package runtime_test import ( "context" "errors" "strings" "testing" "time" agentruntime "github.com/kandev/kandev/internal/agent/runtime" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" v1 "github.com/kandev/kandev/pkg/api/v1" ) // fakeBackend is the runtime contract test stand-in for *lifecycle.Manager. // It records every call so the test can assert the facade translates // Runtime verbs into Backend verbs faithfully. type fakeBackend struct { executions map[string]*lifecycle.AgentExecution launchReq *lifecycle.LaunchRequest launchExec *lifecycle.AgentExecution launchErr error startCalls []string startErr error promptCalls []promptCall promptResult *lifecycle.PromptResult promptErr error stopCalls []stopCall stopErr error mcpCalls []mcpCall mcpErr error } type promptCall struct { executionID string prompt string dispatchOnly bool } type stopCall struct { executionID string reason string force bool } type mcpCall struct { executionID string mode string } type fakeOwnerAdmission struct { calls int err error } func (f *fakeOwnerAdmission) AdmitExecution(context.Context, lifecycle.ExecutionOwner) error { f.calls++ return f.err } func (f *fakeBackend) Launch(_ context.Context, req *lifecycle.LaunchRequest) (*lifecycle.AgentExecution, error) { f.launchReq = req if f.launchErr != nil { return nil, f.launchErr } if f.launchExec != nil { f.executions[f.launchExec.ID] = f.launchExec } return f.launchExec, nil } // StartAgentProcess is intentionally present in the test backend even before // Runtime.Start is wired. The failing contract test below proves the facade // does not expose the required start-and-prompt seam yet. func (f *fakeBackend) StartAgentProcess(_ context.Context, executionID string) error { f.startCalls = append(f.startCalls, executionID) if f.launchReq != nil && f.launchReq.TaskDescription != "" { f.promptCalls = append(f.promptCalls, promptCall{ executionID: executionID, prompt: f.launchReq.TaskDescription, }) } return f.startErr } func (f *fakeBackend) PromptAgent(_ context.Context, executionID, prompt string, _ []v1.MessageAttachment, dispatchOnly bool) (*lifecycle.PromptResult, error) { f.promptCalls = append(f.promptCalls, promptCall{executionID: executionID, prompt: prompt, dispatchOnly: dispatchOnly}) if f.promptErr != nil { return nil, f.promptErr } return f.promptResult, nil } func (f *fakeBackend) StopAgentWithReason(_ context.Context, executionID, reason string, force bool) error { f.stopCalls = append(f.stopCalls, stopCall{executionID: executionID, reason: reason, force: force}) return f.stopErr } func (f *fakeBackend) GetExecution(executionID string) (*lifecycle.AgentExecution, bool) { exec, ok := f.executions[executionID] return exec, ok } func (f *fakeBackend) SetMcpMode(_ context.Context, executionID, mode string) error { f.mcpCalls = append(f.mcpCalls, mcpCall{executionID: executionID, mode: mode}) return f.mcpErr } func newFakeBackend() *fakeBackend { return &fakeBackend{executions: map[string]*lifecycle.AgentExecution{}} } func TestRuntime_Launch_TranslatesSpecToLaunchRequest(t *testing.T) { t.Parallel() now := time.Now().UTC() backend := newFakeBackend() backend.launchExec = &lifecycle.AgentExecution{ ID: "exec-1", SessionID: "sess-1", StartedAt: now, } rt := agentruntime.New(backend) ref, err := rt.Launch(context.Background(), agentruntime.LaunchSpec{ AgentProfileID: "claude-acp-default", ExecutorID: "local_pc", Workspace: agentruntime.WorkspaceRef{ Path: "/work/foo", RepositoryID: "repo-1", IsEphemeral: true, }, Prompt: "do the thing", PriorACPSession: "acp-prior", McpMode: "config", Metadata: map[string]any{ "source": "engine", }, }) if err != nil { t.Fatalf("Launch returned error: %v", err) } if ref.ID != "exec-1" || ref.SessionID != "sess-1" { t.Fatalf("unexpected ExecutionRef: %+v", ref) } if !ref.StartedAt.Equal(now) { t.Fatalf("StartedAt mismatch: got %v want %v", ref.StartedAt, now) } // AgentctlURL is empty when the execution has no agentctl client wired // (which is the case for the fakeBackend). The field must be present on // the ref — callers that don't read it are unaffected. if ref.AgentctlURL != "" { t.Errorf("expected empty AgentctlURL for execution with no client, got %q", ref.AgentctlURL) } if backend.launchReq == nil { t.Fatal("backend.Launch was not called") } got := backend.launchReq if got.AgentProfileID != "claude-acp-default" { t.Errorf("AgentProfileID = %q", got.AgentProfileID) } if got.ExecutorType != "local_pc" { t.Errorf("ExecutorType = %q", got.ExecutorType) } if got.WorkspacePath != "/work/foo" { t.Errorf("WorkspacePath = %q", got.WorkspacePath) } if got.RepositoryID != "repo-1" { t.Errorf("RepositoryID = %q", got.RepositoryID) } if !got.IsEphemeral { t.Errorf("IsEphemeral = false, want true") } if got.TaskDescription != "do the thing" { t.Errorf("TaskDescription = %q", got.TaskDescription) } if got.ACPSessionID != "acp-prior" { t.Errorf("ACPSessionID = %q", got.ACPSessionID) } if got.McpMode != "config" { t.Errorf("McpMode = %q", got.McpMode) } if got.Metadata["source"] != "engine" { t.Errorf("Metadata[source] = %v", got.Metadata["source"]) } } func TestRuntime_StartDispatchesInitialPromptOnce(t *testing.T) { t.Parallel() backend := newFakeBackend() backend.launchExec = &lifecycle.AgentExecution{ID: "exec-start", SessionID: "session-start"} rt := agentruntime.New(backend) starter, ok := rt.(interface { Start(context.Context, agentruntime.LaunchSpec) (agentruntime.ExecutionRef, error) }) if !ok { t.Fatal("runtime does not expose Start") } if _, err := starter.Start(context.Background(), agentruntime.LaunchSpec{Prompt: "initial"}); err != nil { t.Fatalf("Start returned error: %v", err) } if len(backend.startCalls) != 1 || backend.startCalls[0] != "exec-start" { t.Fatalf("start calls = %#v, want one call for exec-start", backend.startCalls) } if len(backend.promptCalls) != 1 || backend.promptCalls[0].prompt != "initial" { t.Fatalf("prompt calls = %#v, want one initial prompt", backend.promptCalls) } } func TestRuntime_StartRollsBackRegistrationFailure(t *testing.T) { t.Parallel() backend := newFakeBackend() backend.launchExec = &lifecycle.AgentExecution{ID: "exec-rollback", SessionID: "session-rollback"} backend.startErr = errors.New("startup failed") rt := agentruntime.New(backend) starter, ok := rt.(interface { Start(context.Context, agentruntime.LaunchSpec) (agentruntime.ExecutionRef, error) }) if !ok { t.Fatal("runtime does not expose Start") } if _, err := starter.Start(context.Background(), agentruntime.LaunchSpec{}); err == nil { t.Fatal("Start returned nil error after startup failure") } if len(backend.stopCalls) != 1 || backend.stopCalls[0].executionID != "exec-rollback" { t.Fatalf("stop calls = %#v, want one rollback for exec-rollback", backend.stopCalls) } } func TestRuntime_RunOwnerAdmissionRejectsStaleAttempt(t *testing.T) { t.Parallel() backend := newFakeBackend() backend.launchExec = &lifecycle.AgentExecution{ID: "exec-owner"} admission := &fakeOwnerAdmission{err: errors.New("run attempt is stale")} rt := agentruntime.New(backend) _, err := rt.Launch(context.Background(), agentruntime.LaunchSpec{ Owner: lifecycle.ExecutionOwner{ Kind: lifecycle.ExecutionOwnerRun, WorkspaceID: "workspace-owner", RunID: "run-owner", RunSessionID: "session-owner", Attempt: 3, }, OwnerAdmission: admission, }) if err == nil || !strings.Contains(err.Error(), "stale") { t.Fatalf("Launch error = %v, want stale-owner rejection", err) } if admission.calls != 1 { t.Fatalf("admission calls = %d, want 1", admission.calls) } if backend.launchReq != nil { t.Fatal("backend launch ran after owner admission rejected the attempt") } } func TestRuntime_TaskOwnerDoesNotRequireRunAdmission(t *testing.T) { t.Parallel() backend := newFakeBackend() backend.launchExec = &lifecycle.AgentExecution{ID: "exec-task-owner"} rt := agentruntime.New(backend) if _, err := rt.Launch(context.Background(), agentruntime.LaunchSpec{ Owner: lifecycle.ExecutionOwner{ Kind: lifecycle.ExecutionOwnerTask, TaskID: "task-owner", SessionID: "session-owner", }, }); err != nil { t.Fatalf("task-owned Launch returned error: %v", err) } } func TestRuntime_Launch_PassesThroughLaunchRequestMetadata(t *testing.T) { t.Parallel() backend := newFakeBackend() backend.launchExec = &lifecycle.AgentExecution{ID: "exec-2"} base := &lifecycle.LaunchRequest{ TaskID: "task-A", SessionID: "sess-A", UseWorktree: true, Repositories: []lifecycle.RepoLaunchSpec{ {RepositoryID: "r1", BaseBranch: "main"}, }, } rt := agentruntime.New(backend) if _, err := rt.Launch(context.Background(), agentruntime.LaunchSpec{ AgentProfileID: "p1", Prompt: "hello", Metadata: map[string]any{ "launch_request": base, }, }); err != nil { t.Fatalf("Launch returned error: %v", err) } got := backend.launchReq if got == nil { t.Fatal("backend.Launch not called") } if got.TaskID != "task-A" || got.SessionID != "sess-A" { t.Errorf("base fields lost: TaskID=%q SessionID=%q", got.TaskID, got.SessionID) } if !got.UseWorktree { t.Errorf("UseWorktree lost from base launch_request") } if len(got.Repositories) != 1 || got.Repositories[0].RepositoryID != "r1" { t.Errorf("Repositories lost from base launch_request: %+v", got.Repositories) } // Spec fields override base. if got.AgentProfileID != "p1" { t.Errorf("AgentProfileID overlay lost: %q", got.AgentProfileID) } if got.TaskDescription != "hello" { t.Errorf("TaskDescription overlay lost: %q", got.TaskDescription) } // launch_request key itself does not leak into LaunchRequest.Metadata. if _, found := got.Metadata["launch_request"]; found { t.Errorf("launch_request key leaked into LaunchRequest.Metadata") } } func TestRuntime_Launch_PropagatesError(t *testing.T) { t.Parallel() backend := newFakeBackend() backend.launchErr = errors.New("boom") rt := agentruntime.New(backend) if _, err := rt.Launch(context.Background(), agentruntime.LaunchSpec{}); err == nil { t.Fatal("expected error") } } func TestRuntime_Resume_DelegatesToPromptAgent(t *testing.T) { t.Parallel() backend := newFakeBackend() rt := agentruntime.New(backend) if err := rt.Resume(context.Background(), "exec-1", "next prompt"); err != nil { t.Fatalf("Resume returned: %v", err) } if len(backend.promptCalls) != 1 { t.Fatalf("expected 1 prompt call, got %d", len(backend.promptCalls)) } got := backend.promptCalls[0] if got.executionID != "exec-1" || got.prompt != "next prompt" || got.dispatchOnly != false { t.Errorf("unexpected prompt call: %+v", got) } } func TestRuntime_Resume_RequiresExecutionID(t *testing.T) { t.Parallel() rt := agentruntime.New(newFakeBackend()) if err := rt.Resume(context.Background(), "", "prompt"); err == nil { t.Fatal("expected error for empty executionID") } } func TestRuntime_Resume_PropagatesError(t *testing.T) { t.Parallel() backend := newFakeBackend() backend.promptErr = errors.New("nope") rt := agentruntime.New(backend) if err := rt.Resume(context.Background(), "exec-1", "p"); err == nil { t.Fatal("expected error") } } func TestRuntime_Stop_DelegatesToBackend(t *testing.T) { t.Parallel() backend := newFakeBackend() rt := agentruntime.New(backend) if err := rt.Stop(context.Background(), "exec-1", "user_cancelled"); err != nil { t.Fatalf("Stop returned: %v", err) } if len(backend.stopCalls) != 1 { t.Fatalf("expected 1 stop call, got %d", len(backend.stopCalls)) } got := backend.stopCalls[0] if got.executionID != "exec-1" || got.reason != "user_cancelled" || got.force { t.Errorf("unexpected stop call: %+v", got) } } func TestRuntime_Stop_ClassifiesMissingExecution(t *testing.T) { t.Parallel() backend := newFakeBackend() backend.stopErr = lifecycle.ErrExecutionNotFound rt := agentruntime.New(backend) err := rt.Stop(context.Background(), "missing", "cleanup") if !errors.Is(err, agentruntime.ErrNotFound) { t.Fatalf("Stop error = %v, want runtime.ErrNotFound", err) } if !errors.Is(err, lifecycle.ErrExecutionNotFound) { t.Fatalf("Stop error = %v, want lifecycle.ErrExecutionNotFound", err) } } func TestRuntime_Stop_RequiresExecutionID(t *testing.T) { t.Parallel() rt := agentruntime.New(newFakeBackend()) if err := rt.Stop(context.Background(), "", "reason"); err == nil { t.Fatal("expected error for empty executionID") } } func TestRuntime_GetExecution_ReturnsSnapshot(t *testing.T) { t.Parallel() finishedAt := time.Now().UTC() exit := 0 backend := newFakeBackend() backend.executions["exec-1"] = &lifecycle.AgentExecution{ ID: "exec-1", SessionID: "sess-1", TaskID: "task-1", AgentProfileID: "p1", WorkspacePath: "/w", Status: v1.AgentStatus("ready"), StartedAt: finishedAt.Add(-time.Minute), FinishedAt: &finishedAt, ExitCode: &exit, ACPSessionID: "acp-1", } backend.executions["exec-1"].SetMetadataForTesting(map[string]interface{}{"k": "v"}) rt := agentruntime.New(backend) got, err := rt.GetExecution(context.Background(), "exec-1") if err != nil { t.Fatalf("GetExecution returned: %v", err) } if got.ID != "exec-1" || got.SessionID != "sess-1" || got.TaskID != "task-1" { t.Errorf("snapshot identifiers wrong: %+v", got) } if got.AgentProfileID != "p1" || got.WorkspacePath != "/w" { t.Errorf("snapshot fields wrong: %+v", got) } if got.Status != v1.AgentStatus("ready") { t.Errorf("Status mismatch: %q", got.Status) } if got.FinishedAt == nil || !got.FinishedAt.Equal(finishedAt) { t.Errorf("FinishedAt mismatch") } if got.ExitCode == nil || *got.ExitCode != 0 { t.Errorf("ExitCode mismatch") } if got.ACPSessionID != "acp-1" { t.Errorf("ACPSessionID mismatch: %q", got.ACPSessionID) } if got.Metadata["k"] != "v" { t.Errorf("Metadata mismatch: %+v", got.Metadata) } // AgentctlURL is empty when the execution has no agentctl client wired. if got.AgentctlURL != "" { t.Errorf("expected empty AgentctlURL for execution with no client, got %q", got.AgentctlURL) } } func TestRuntime_GetExecution_NotFound(t *testing.T) { t.Parallel() rt := agentruntime.New(newFakeBackend()) _, err := rt.GetExecution(context.Background(), "missing") if !errors.Is(err, agentruntime.ErrNotFound) { t.Fatalf("expected ErrNotFound, got %v", err) } } func TestRuntime_GetExecution_RequiresExecutionID(t *testing.T) { t.Parallel() rt := agentruntime.New(newFakeBackend()) if _, err := rt.GetExecution(context.Background(), ""); err == nil { t.Fatal("expected error") } } func TestRuntime_SetMcpMode_DelegatesToBackend(t *testing.T) { t.Parallel() backend := newFakeBackend() rt := agentruntime.New(backend) if err := rt.SetMcpMode(context.Background(), "exec-1", "config"); err != nil { t.Fatalf("SetMcpMode returned: %v", err) } if len(backend.mcpCalls) != 1 { t.Fatalf("expected 1 mcp call, got %d", len(backend.mcpCalls)) } got := backend.mcpCalls[0] if got.executionID != "exec-1" || got.mode != "config" { t.Errorf("unexpected mcp call: %+v", got) } } func TestRuntime_SetMcpMode_RequiresExecutionID(t *testing.T) { t.Parallel() rt := agentruntime.New(newFakeBackend()) if err := rt.SetMcpMode(context.Background(), "", "config"); err == nil { t.Fatal("expected error") } } func TestRuntime_SubscribeEvents_UnsupportedInPhase1(t *testing.T) { t.Parallel() // Phase 1 leaves SubscribeEvents best-effort: the lifecycle manager // publishes events through the global event bus rather than // per-execution channels, so the facade returns ErrUnsupported. // Phase 2/3 may add a per-execution channel. rt := agentruntime.New(newFakeBackend()) ch, err := rt.SubscribeEvents(context.Background(), "exec-1") if !errors.Is(err, agentruntime.ErrUnsupported) { t.Fatalf("expected ErrUnsupported, got %v", err) } if ch != nil { t.Fatalf("expected nil channel, got %v", ch) } }