From 124fe588f874d9fa033116b43b9e12f93b65be42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Wed, 4 Mar 2026 23:34:26 +0000 Subject: [PATCH] feat: improve TUI agents session resume on restart (#367) --- apps/backend/cmd/mock-agent/main.go | 35 +++- .../backend/cmd/mock-agent/mock_agent_test.go | 52 ++++++ apps/backend/cmd/mock-agent/tui.go | 12 +- apps/backend/internal/agent/agents/copilot.go | 17 +- apps/backend/internal/agent/agents/mock.go | 22 ++- .../internal/agent/agents/mock_test.go | 108 +++++++++++ .../agent/lifecycle/manager_launch.go | 3 + .../lifecycle/manager_passthrough_test.go | 32 ++++ .../agent/lifecycle/manager_startup.go | 5 + .../agent/lifecycle/manager_startup_test.go | 104 +++++++++++ .../backend/internal/agent/lifecycle/types.go | 5 + apps/web/e2e/fixtures/backend.ts | 75 +++++--- apps/web/e2e/helpers/api-client.ts | 8 + apps/web/e2e/tests/diff-update.spec.ts | 6 +- .../web/e2e/tests/session-resume-real.spec.ts | 63 +++++++ apps/web/e2e/tests/session-resume.spec.ts | 169 ++++++++++++++++++ 16 files changed, 671 insertions(+), 45 deletions(-) create mode 100644 apps/backend/internal/agent/agents/mock_test.go create mode 100644 apps/backend/internal/agent/lifecycle/manager_startup_test.go create mode 100644 apps/web/e2e/tests/session-resume-real.spec.ts create mode 100644 apps/web/e2e/tests/session-resume.spec.ts diff --git a/apps/backend/cmd/mock-agent/main.go b/apps/backend/cmd/mock-agent/main.go index 7ea1289bfd..896d8f79fa 100644 --- a/apps/backend/cmd/mock-agent/main.go +++ b/apps/backend/cmd/mock-agent/main.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "os" + "slices" "strings" ) @@ -28,9 +29,17 @@ var mcpServers map[string]mcpServerDef func main() { model := parseModelFlag() + // Override sessionID when resuming a previous session. + resumeID := parseResumeFlag() + if resumeID != "" { + sessionID = resumeID + fmt.Fprintf(os.Stderr, "mock-agent[%d]: resuming session %s\n", os.Getpid(), resumeID) + } + // TUI mode: simple terminal UI for passthrough/PTY testing if parseTUIFlag() { - runTUI(model, parsePromptFlag()) + resumed := resumeID != "" || parseContinueFlag() + runTUI(model, parsePromptFlag(), resumed) return } @@ -90,6 +99,30 @@ func parseModelFromArgs(args []string) string { return "mock-default" } +// parseResumeFlag extracts --resume value from command line args. +func parseResumeFlag() string { + return parseResumeFromArgs(os.Args) +} + +// parseResumeFromArgs extracts --resume value from the given args slice. +func parseResumeFromArgs(args []string) string { + for i, arg := range args[1:] { + if arg == "--resume" && i+1 < len(args)-1 { + return args[i+2] + } + if v, ok := strings.CutPrefix(arg, "--resume="); ok { + return v + } + } + return "" +} + +// parseContinueFlag checks if -c is present in the command line args. +// Used by TUI mode for generic "continue last session" resume. +func parseContinueFlag() bool { + return slices.Contains(os.Args[1:], "-c") +} + // mcpConfigPayload is the JSON structure for --mcp-config. type mcpConfigPayload struct { MCPServers map[string]mcpServerDef `json:"mcpServers"` diff --git a/apps/backend/cmd/mock-agent/mock_agent_test.go b/apps/backend/cmd/mock-agent/mock_agent_test.go index 1f7da9538a..5783d7ed50 100644 --- a/apps/backend/cmd/mock-agent/mock_agent_test.go +++ b/apps/backend/cmd/mock-agent/mock_agent_test.go @@ -227,3 +227,55 @@ func TestDiscoverFiles(t *testing.T) { // Reset global state for other tests workspaceFiles = nil } + +func TestParseResumeFromArgs(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + { + name: "no flag returns empty", + args: []string{"mock-agent"}, + want: "", + }, + { + name: "separate flag and value", + args: []string{"mock-agent", "--resume", "sess-123"}, + want: "sess-123", + }, + { + name: "equals syntax", + args: []string{"mock-agent", "--resume=sess-456"}, + want: "sess-456", + }, + { + name: "flag with other args before", + args: []string{"mock-agent", "--model", "fast", "--resume", "sess-789"}, + want: "sess-789", + }, + { + name: "flag with other args after", + args: []string{"mock-agent", "--resume", "sess-abc", "--verbose"}, + want: "sess-abc", + }, + { + name: "dangling flag without value", + args: []string{"mock-agent", "--resume"}, + want: "", + }, + { + name: "flag combined with --tui", + args: []string{"mock-agent", "--tui", "--resume", "sess-xyz"}, + want: "sess-xyz", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseResumeFromArgs(tt.args) + if got != tt.want { + t.Errorf("parseResumeFromArgs(%v) = %q, want %q", tt.args, got, tt.want) + } + }) + } +} diff --git a/apps/backend/cmd/mock-agent/tui.go b/apps/backend/cmd/mock-agent/tui.go index 4b3ffc6629..cca995ffba 100644 --- a/apps/backend/cmd/mock-agent/tui.go +++ b/apps/backend/cmd/mock-agent/tui.go @@ -11,10 +11,16 @@ import ( // runTUI starts a simple terminal UI mode for passthrough/PTY testing. // It renders a prompt, processes input, shows a response, and waits for more input. // The idle timeout in InteractiveRunner triggers turn completion when output stops. -func runTUI(model, initialPrompt string) { +// When resumed is true (--resume or -c flag was passed), the header shows "(RESUMED)". +func runTUI(model, initialPrompt string, resumed bool) { // Print header - fmt.Print("\033[1;36m╭─ Mock Agent ─╮\033[0m\r\n") - fmt.Print("\033[1;36m╰──────────────╯\033[0m\r\n\r\n") + if resumed { + fmt.Print("\033[1;36m╭─ Mock Agent (RESUMED) ─╮\033[0m\r\n") + fmt.Print("\033[1;36m╰────────────────────────╯\033[0m\r\n\r\n") + } else { + fmt.Print("\033[1;36m╭─ Mock Agent ─╮\033[0m\r\n") + fmt.Print("\033[1;36m╰──────────────╯\033[0m\r\n\r\n") + } // Process initial prompt if provided via --prompt flag if initialPrompt != "" { diff --git a/apps/backend/internal/agent/agents/copilot.go b/apps/backend/internal/agent/agents/copilot.go index 2c6cccfd78..20957f87d9 100644 --- a/apps/backend/internal/agent/agents/copilot.go +++ b/apps/backend/internal/agent/agents/copilot.go @@ -31,14 +31,15 @@ func NewCopilot() *Copilot { StandardPassthrough: StandardPassthrough{ PermSettings: copilotPermSettings, Cfg: PassthroughConfig{ - Supported: true, - Label: "CLI Passthrough", - Description: "Show terminal directly instead of chat interface", - PassthroughCmd: NewCommand("npx", "-y", "@github/copilot"), - ModelFlag: NewParam("--model", "{model}"), - IdleTimeout: 3 * time.Second, - BufferMaxBytes: DefaultBufferMaxBytes, - ResumeFlag: NewParam("--resume"), + Supported: true, + Label: "CLI Passthrough", + Description: "Show terminal directly instead of chat interface", + PassthroughCmd: NewCommand("npx", "-y", "@github/copilot"), + ModelFlag: NewParam("--model", "{model}"), + IdleTimeout: 3 * time.Second, + BufferMaxBytes: DefaultBufferMaxBytes, + ResumeFlag: NewParam("--continue"), + SessionResumeFlag: NewParam("--resume"), }, }, } diff --git a/apps/backend/internal/agent/agents/mock.go b/apps/backend/internal/agent/agents/mock.go index 20b54be7b8..eddda2ff9f 100644 --- a/apps/backend/internal/agent/agents/mock.go +++ b/apps/backend/internal/agent/agents/mock.go @@ -30,14 +30,16 @@ func NewMockAgent() *MockAgent { return &MockAgent{ StandardPassthrough: StandardPassthrough{ Cfg: PassthroughConfig{ - Supported: true, - Label: "TUI Passthrough", - Description: "Terminal UI mode for testing", - PassthroughCmd: NewCommand("mock-agent", "--tui"), - ModelFlag: NewParam("--model", "{model}"), - PromptFlag: NewParam("--prompt", "{prompt}"), - IdleTimeout: 2 * time.Second, - BufferMaxBytes: DefaultBufferMaxBytes, + Supported: true, + Label: "TUI Passthrough", + Description: "Terminal UI mode for testing", + PassthroughCmd: NewCommand("mock-agent", "--tui"), + ModelFlag: NewParam("--model", "{model}"), + PromptFlag: NewParam("--prompt", "{prompt}"), + SessionResumeFlag: NewParam("--resume"), + ResumeFlag: NewParam("-c"), + IdleTimeout: 2 * time.Second, + BufferMaxBytes: DefaultBufferMaxBytes, }, }, supportsMCP: true, @@ -95,11 +97,12 @@ func (a *MockAgent) BuildCommand(opts CommandOptions) Command { } return Cmd(binary). Model(NewParam("--model", "{model}"), opts.Model). + Resume(NewParam("--resume"), opts.SessionID, false). Build() } func (a *MockAgent) Runtime() *RuntimeConfig { - canRecover := false + canRecover := true return &RuntimeConfig{ Cmd: Cmd("mock-agent").Build(), WorkingDir: "{workspace}", @@ -108,6 +111,7 @@ func (a *MockAgent) Runtime() *RuntimeConfig { Protocol: agent.ProtocolClaudeCode, ModelFlag: NewParam("--model", "{model}"), SessionConfig: SessionConfig{ + ResumeFlag: NewParam("--resume"), CanRecover: &canRecover, }, } diff --git a/apps/backend/internal/agent/agents/mock_test.go b/apps/backend/internal/agent/agents/mock_test.go new file mode 100644 index 0000000000..b71f06f58e --- /dev/null +++ b/apps/backend/internal/agent/agents/mock_test.go @@ -0,0 +1,108 @@ +package agents + +import ( + "testing" +) + +func TestMockAgent_BuildCommand_WithSessionID(t *testing.T) { + a := NewMockAgent() + cmd := a.BuildCommand(CommandOptions{Model: "mock-fast", SessionID: "sess-123"}) + args := cmd.Args() + + foundResume := false + for i, arg := range args { + if arg == "--resume" { + foundResume = true + if i+1 >= len(args) { + t.Fatal("--resume flag has no value") + } + if args[i+1] != "sess-123" { + t.Errorf("--resume value = %q, want %q", args[i+1], "sess-123") + } + break + } + } + if !foundResume { + t.Errorf("expected --resume flag in args %v", args) + } +} + +func TestMockAgent_BuildCommand_WithoutSessionID(t *testing.T) { + a := NewMockAgent() + cmd := a.BuildCommand(CommandOptions{Model: "mock-fast"}) + args := cmd.Args() + + for _, arg := range args { + if arg == "--resume" { + t.Errorf("--resume flag should not be present without SessionID, got args %v", args) + } + } +} + +func TestMockAgent_BuildCommand_WithModel(t *testing.T) { + a := NewMockAgent() + cmd := a.BuildCommand(CommandOptions{Model: "mock-fast", SessionID: "sess-456"}) + args := cmd.Args() + + foundModel := false + foundResume := false + for i, arg := range args { + if arg == "--model" && i+1 < len(args) && args[i+1] == "mock-fast" { + foundModel = true + } + if arg == "--resume" && i+1 < len(args) && args[i+1] == "sess-456" { + foundResume = true + } + } + if !foundModel { + t.Errorf("expected --model mock-fast in args %v", args) + } + if !foundResume { + t.Errorf("expected --resume sess-456 in args %v", args) + } +} + +func TestMockAgent_Runtime_CanRecover(t *testing.T) { + a := NewMockAgent() + rt := a.Runtime() + + if !rt.SessionConfig.SupportsRecovery() { + t.Error("expected SupportsRecovery() to return true for mock agent") + } +} + +func TestMockAgent_Runtime_ResumeFlag(t *testing.T) { + a := NewMockAgent() + rt := a.Runtime() + + if rt.SessionConfig.ResumeFlag.IsEmpty() { + t.Error("expected ResumeFlag to be set on mock agent RuntimeConfig") + } + flagArgs := rt.SessionConfig.ResumeFlag.Args() + if len(flagArgs) == 0 || flagArgs[0] != "--resume" { + t.Errorf("expected ResumeFlag args to start with --resume, got %v", flagArgs) + } +} + +func TestMockAgent_PassthroughConfig_ResumeFlags(t *testing.T) { + a := NewMockAgent() + pt := a.PassthroughConfig() + + // Generic resume flag (-c) + if pt.ResumeFlag.IsEmpty() { + t.Error("expected ResumeFlag to be set on PassthroughConfig") + } + resumeArgs := pt.ResumeFlag.Args() + if len(resumeArgs) == 0 || resumeArgs[0] != "-c" { + t.Errorf("expected ResumeFlag = -c, got %v", resumeArgs) + } + + // Session-specific resume flag (--resume) + if pt.SessionResumeFlag.IsEmpty() { + t.Error("expected SessionResumeFlag to be set on PassthroughConfig") + } + sessionArgs := pt.SessionResumeFlag.Args() + if len(sessionArgs) == 0 || sessionArgs[0] != "--resume" { + t.Errorf("expected SessionResumeFlag = --resume, got %v", sessionArgs) + } +} diff --git a/apps/backend/internal/agent/lifecycle/manager_launch.go b/apps/backend/internal/agent/lifecycle/manager_launch.go index 1ebc808ace..fd37ccd1a0 100644 --- a/apps/backend/internal/agent/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/lifecycle/manager_launch.go @@ -389,6 +389,9 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecuti if req.ACPSessionID != "" { execution.ACPSessionID = req.ACPSessionID } + if req.PreviousExecutionID != "" { + execution.isResumedSession = true + } cmds := m.buildAgentCommand(req, profileInfo, agentConfig) execution.AgentCommand = cmds.initial execution.ContinueCommand = cmds.continue_ diff --git a/apps/backend/internal/agent/lifecycle/manager_passthrough_test.go b/apps/backend/internal/agent/lifecycle/manager_passthrough_test.go index bb3c00053b..97d78efe4e 100644 --- a/apps/backend/internal/agent/lifecycle/manager_passthrough_test.go +++ b/apps/backend/internal/agent/lifecycle/manager_passthrough_test.go @@ -166,6 +166,38 @@ func TestBuildPassthroughCommand(t *testing.T) { opts: agents.PassthroughOptions{SessionID: "sess-123"}, wantCmd: []string{"test-cli", "--resume", "sess-123"}, }, + { + name: "mock agent resume with -c flag", + agent: &testAgent{ + id: "mock-agent", + StandardPassthrough: agents.StandardPassthrough{ + Cfg: agents.PassthroughConfig{ + Supported: true, + PassthroughCmd: agents.NewCommand("mock-agent", "--tui"), + ModelFlag: agents.NewParam("--model", "{model}"), + ResumeFlag: agents.NewParam("-c"), + }, + }, + }, + opts: agents.PassthroughOptions{Model: "mock-fast", Resume: true}, + wantCmd: []string{"mock-agent", "--tui", "--model", "mock-fast", "-c"}, + }, + { + name: "mock agent session resume with --resume flag", + agent: &testAgent{ + id: "mock-agent", + StandardPassthrough: agents.StandardPassthrough{ + Cfg: agents.PassthroughConfig{ + Supported: true, + PassthroughCmd: agents.NewCommand("mock-agent", "--tui"), + ModelFlag: agents.NewParam("--model", "{model}"), + SessionResumeFlag: agents.NewParam("--resume"), + }, + }, + }, + opts: agents.PassthroughOptions{Model: "mock-fast", SessionID: "sess-123"}, + wantCmd: []string{"mock-agent", "--tui", "--model", "mock-fast", "--resume", "sess-123"}, + }, } for _, tt := range tests { diff --git a/apps/backend/internal/agent/lifecycle/manager_startup.go b/apps/backend/internal/agent/lifecycle/manager_startup.go index be76e1590a..a9009b3940 100644 --- a/apps/backend/internal/agent/lifecycle/manager_startup.go +++ b/apps/backend/internal/agent/lifecycle/manager_startup.go @@ -28,6 +28,11 @@ func (m *Manager) StartAgentProcess(ctx context.Context, executionID string) err if execution.AgentProfileID != "" && m.profileResolver != nil { profileInfo, err := m.profileResolver.ResolveProfile(ctx, execution.AgentProfileID) if err == nil && profileInfo.CLIPassthrough { + // On resume (e.g., after backend restart), use the resume command path + // so the agent receives resume flags (-c / --resume). + if execution.isResumedSession { + return m.ResumePassthroughSession(ctx, execution.SessionID) + } return m.startPassthroughSession(ctx, execution, profileInfo) } } diff --git a/apps/backend/internal/agent/lifecycle/manager_startup_test.go b/apps/backend/internal/agent/lifecycle/manager_startup_test.go new file mode 100644 index 0000000000..e1801adc56 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/manager_startup_test.go @@ -0,0 +1,104 @@ +package lifecycle + +import ( + "context" + "strings" + "testing" +) + +// mockAgentProfileResolver returns a profile pointing to the mock-agent. +type mockAgentProfileResolver struct { + cliPassthrough bool +} + +func (m *mockAgentProfileResolver) ResolveProfile(_ context.Context, profileID string) (*AgentProfileInfo, error) { + return &AgentProfileInfo{ + ProfileID: profileID, + ProfileName: "Test Profile", + AgentID: "mock-agent", + AgentName: "mock-agent", + Model: "mock-fast", + CLIPassthrough: m.cliPassthrough, + }, nil +} + +func TestStartAgentProcess_NotFound(t *testing.T) { + mgr := newTestManager() + err := mgr.StartAgentProcess(context.Background(), "non-existent") + if err == nil { + t.Fatal("expected error for non-existent execution") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("expected 'not found' error, got: %v", err) + } +} + +func TestStartAgentProcess_NonPassthrough_NoAgentctl(t *testing.T) { + mgr := newTestManager() + // Use a resolver that returns CLIPassthrough=false + mgr.profileResolver = &mockAgentProfileResolver{cliPassthrough: false} + + execution := &AgentExecution{ + ID: "exec-1", + SessionID: "sess-1", + AgentProfileID: "profile-1", + } + mgr.executionStore.Add(execution) + + err := mgr.StartAgentProcess(context.Background(), "exec-1") + if err == nil { + t.Fatal("expected error for missing agentctl") + } + if !strings.Contains(err.Error(), "no agentctl client") { + t.Errorf("expected 'no agentctl client' error, got: %v", err) + } +} + +func TestStartAgentProcess_Passthrough_NotResumed(t *testing.T) { + mgr := newTestManager() + mgr.profileResolver = &mockAgentProfileResolver{cliPassthrough: true} + + execution := &AgentExecution{ + ID: "exec-1", + SessionID: "sess-1", + AgentProfileID: "profile-1", + isResumedSession: false, + } + mgr.executionStore.Add(execution) + + err := mgr.StartAgentProcess(context.Background(), "exec-1") + if err == nil { + t.Fatal("expected error (no interactive runner)") + } + // startPassthroughSession path errors with "interactive runner not available for passthrough mode" + if !strings.Contains(err.Error(), "interactive runner not available") { + t.Errorf("expected 'interactive runner not available' error from startPassthroughSession, got: %v", err) + } +} + +func TestStartAgentProcess_Passthrough_Resumed(t *testing.T) { + mgr := newTestManager() + mgr.profileResolver = &mockAgentProfileResolver{cliPassthrough: true} + + execution := &AgentExecution{ + ID: "exec-1", + SessionID: "sess-1", + AgentProfileID: "profile-1", + isResumedSession: true, + } + mgr.executionStore.Add(execution) + + err := mgr.StartAgentProcess(context.Background(), "exec-1") + if err == nil { + t.Fatal("expected error (no interactive runner)") + } + // ResumePassthroughSession returns "interactive runner not available" (without + // "for passthrough mode"), while startPassthroughSession includes the suffix. + // Assert we hit the resume path specifically. + if !strings.Contains(err.Error(), "interactive runner not available") { + t.Errorf("expected 'interactive runner not available' error, got: %v", err) + } + if strings.Contains(err.Error(), "for passthrough mode") { + t.Errorf("expected ResumePassthroughSession path (no 'for passthrough mode' suffix), got: %v", err) + } +} diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go index d5fdf47d9e..a3cae8303b 100644 --- a/apps/backend/internal/agent/lifecycle/types.go +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -53,6 +53,11 @@ type AgentExecution struct { // Passthrough mode info (CLI passthrough without ACP) PassthroughProcessID string // Process ID in the interactive runner (empty if not in passthrough mode) + // isResumedSession is true when this execution was created as part of a session resume + // (e.g., after backend restart). Used by StartAgentProcess to route passthrough sessions + // to ResumePassthroughSession instead of startPassthroughSession. + isResumedSession bool + // Buffers for accumulating agent response during a prompt messageBuffer strings.Builder thinkingBuffer strings.Builder diff --git a/apps/web/e2e/fixtures/backend.ts b/apps/web/e2e/fixtures/backend.ts index 80293ebf70..818e300615 100644 --- a/apps/web/e2e/fixtures/backend.ts +++ b/apps/web/e2e/fixtures/backend.ts @@ -21,6 +21,8 @@ export type BackendContext = { frontendPort: number; frontendUrl: string; tmpDir: string; + /** Kill the backend process and respawn with the same config (DB, ports, tmpDir persist). */ + restart: () => Promise; }; async function waitForHealth(url: string, timeoutMs: number): Promise { @@ -90,6 +92,41 @@ function killProcessGroup(proc: ChildProcess): Promise { }); } +/** + * Spawn a backend process with the given environment. Returns the child process. + * The process is spawned with `detached: true` so it becomes a process group leader. + */ +function spawnBackendProcess( + env: Record, + debug: boolean, + port: number, +): ChildProcess { + const proc = spawn(KANDEV_BIN, [], { + env: env as unknown as NodeJS.ProcessEnv, + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }); + + const logFile = debug ? fs.createWriteStream(`/tmp/e2e-backend-${port}.log`) : null; + proc.once("exit", () => { + logFile?.end(); + }); + proc.stderr?.on("data", (chunk: Buffer) => { + if (debug) { + process.stderr.write(`[backend:${port}] ${chunk.toString()}`); + logFile?.write(chunk); + } + }); + proc.stdout?.on("data", (chunk: Buffer) => { + if (debug) { + process.stderr.write(`[backend-log:${port}] ${chunk.toString()}`); + logFile?.write(chunk); + } + }); + + return proc; +} + /** * Worker-scoped fixture that spawns an isolated backend process and * a dedicated Next.js frontend. Each Playwright worker gets its own @@ -147,29 +184,10 @@ export const backendFixture = base.extend({ }; const debug = !!process.env.E2E_DEBUG; + const baseUrl = `http://localhost:${backendPort}`; // --- Spawn backend --- - const backendProc: ChildProcess = spawn(KANDEV_BIN, [], { - env: backendEnv as unknown as NodeJS.ProcessEnv, - stdio: ["ignore", "pipe", "pipe"], - detached: true, // Own process group so we can kill backend + agentctl together - }); - - const logFile = debug ? fs.createWriteStream(`/tmp/e2e-backend-${backendPort}.log`) : null; - backendProc.stderr?.on("data", (chunk: Buffer) => { - if (debug) { - process.stderr.write(`[backend:${backendPort}] ${chunk.toString()}`); - logFile?.write(chunk); - } - }); - backendProc.stdout?.on("data", (chunk: Buffer) => { - if (debug) { - process.stderr.write(`[backend-log:${backendPort}] ${chunk.toString()}`); - logFile?.write(chunk); - } - }); - - const baseUrl = `http://localhost:${backendPort}`; + let backendProc = spawnBackendProcess(backendEnv, debug, backendPort); await waitForHealth(`${baseUrl}/health`, HEALTH_TIMEOUT_MS); // Ensure Next.js static assets are available to the standalone server. @@ -204,8 +222,21 @@ export const backendFixture = base.extend({ const frontendUrl = `http://localhost:${frontendPort}`; await waitForHealth(frontendUrl, HEALTH_TIMEOUT_MS); + /** + * Kill the backend process group and respawn with the same config. + * SQLite DB, tmpDir, and all persisted data survive the restart. + * Only in-memory execution state (running agents, WS connections) is lost. + */ + const restart = async () => { + await killProcessGroup(backendProc); + // Brief delay to ensure ports are fully released by the OS + await new Promise((r) => setTimeout(r, 500)); + backendProc = spawnBackendProcess(backendEnv, debug, backendPort); + await waitForHealth(`${baseUrl}/health`, HEALTH_TIMEOUT_MS); + }; + try { - await use({ port: backendPort, baseUrl, frontendPort, frontendUrl, tmpDir }); + await use({ port: backendPort, baseUrl, frontendPort, frontendUrl, tmpDir, restart }); } finally { // Shutdown frontend first (simple process), then backend (process group) await killProcess(frontendProc); diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 49c2cdf56c..942bafde85 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -368,4 +368,12 @@ export class ApiClient { }> { return this.request("GET", "/api/v1/github/status"); } + + // --- Session --- + + async listSessionMessages( + sessionId: string, + ): Promise<{ messages: Array<{ id: string; content: string; author_type: string }> }> { + return this.request("GET", `/api/v1/sessions/${sessionId}/messages`); + } } diff --git a/apps/web/e2e/tests/diff-update.spec.ts b/apps/web/e2e/tests/diff-update.spec.ts index f9f10ab64d..84684d5ea1 100644 --- a/apps/web/e2e/tests/diff-update.spec.ts +++ b/apps/web/e2e/tests/diff-update.spec.ts @@ -109,13 +109,15 @@ test.describe("Diff update on file change", () => { await expect(diffsContainer).toBeVisible({ timeout: 15_000 }); // The diff should now show SECOND_MODIFICATION instead of FIRST_MODIFICATION. + // Use a longer timeout because the workspace polling loop needs to detect the + // file change and push updated git status — can be slow on CI runners. await expect(diffsContainer.getByText("SECOND_MODIFICATION", { exact: true })).toBeVisible({ - timeout: 15_000, + timeout: 30_000, }); // Also verify the additional change on line 3 await expect(diffsContainer.getByText("ALSO_CHANGED", { exact: true })).toBeVisible({ - timeout: 10_000, + timeout: 15_000, }); }); }); diff --git a/apps/web/e2e/tests/session-resume-real.spec.ts b/apps/web/e2e/tests/session-resume-real.spec.ts new file mode 100644 index 0000000000..af7e5d4346 --- /dev/null +++ b/apps/web/e2e/tests/session-resume-real.spec.ts @@ -0,0 +1,63 @@ +import { test } from "../fixtures/test-base"; + +/** + * Real agent session resume tests. + * + * These tests use actual agent binaries (Claude Code, etc.) and incur API costs. + * They are skipped by default and only run when KANDEV_E2E_REAL_AGENTS=1 is set. + * + * Prerequisites: + * - Agent binary installed and on PATH + * - Required API keys set (e.g. ANTHROPIC_API_KEY for Claude Code) + * - Backend NOT running with KANDEV_MOCK_AGENT=only + * + * Run: + * KANDEV_E2E_REAL_AGENTS=1 pnpm --filter @kandev/web e2e -- tests/session-resume-real.spec.ts + */ + +const realAgentsEnabled = process.env.KANDEV_E2E_REAL_AGENTS === "1"; + +test.describe("Session resume (real agents)", () => { + test.skip(!realAgentsEnabled, "Set KANDEV_E2E_REAL_AGENTS=1 to enable"); + + // No retries — real agent calls are expensive + test.describe.configure({ retries: 0 }); + + test("resumable agent: resume preserves conversation context", async () => { + test.setTimeout(180_000); + + // TODO: Implement once mock agent resume tests are stable. + // + // Fixtures available: testPage, apiClient, seedData, backend + // + // Flow: + // 1. Find a resumable agent profile (Claude Code, etc.) + // 2. Create task with a distinctive prompt (e.g. "Remember the word 'pineapple'") + // 3. Wait for agent to respond and reach idle state + // 4. backend.restart() + // 5. Reload page, wait for auto-resume + // 6. Send follow-up: "What word did I ask you to remember?" + // 7. Verify agent responds with "pineapple" (proves context preserved) + test.skip(true, "Scaffold — implement when mock agent tests are stable"); + }); + + test("non-resumable agent: new session created after restart", async () => { + test.setTimeout(180_000); + + // TODO: Implement once mock agent resume tests are stable. + // + // Fixtures available: testPage, apiClient, seedData, backend + // + // Flow: + // 1. Find a non-resumable agent (CanRecover=false) + // 2. Create task with a prompt + // 3. Wait for agent to respond + // 4. backend.restart() + // 5. Reload page + // 6. Verify session is NOT auto-resumed (needs_resume=false) + // 7. Manually start a new session + // 8. Verify agent works without prior context + // 9. Verify previous messages from the old session are still visible + test.skip(true, "Scaffold — implement when mock agent tests are stable"); + }); +}); diff --git a/apps/web/e2e/tests/session-resume.spec.ts b/apps/web/e2e/tests/session-resume.spec.ts new file mode 100644 index 0000000000..b57d2ad832 --- /dev/null +++ b/apps/web/e2e/tests/session-resume.spec.ts @@ -0,0 +1,169 @@ +import { test, expect } from "../fixtures/test-base"; +import type { ApiClient } from "../helpers/api-client"; +import { KanbanPage } from "../pages/kanban-page"; +import { SessionPage } from "../pages/session-page"; +import type { Page } from "@playwright/test"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Create a passthrough (TUI) agent profile for the mock agent. */ +async function createTUIProfile(apiClient: ApiClient, name: string) { + const { agents } = await apiClient.listAgents(); + return apiClient.createAgentProfile(agents[0].id, name, { + model: "mock-fast", + auto_approve: true, + cli_passthrough: true, + }); +} + +/** Navigate to a kanban card by title and open its session page. */ +async function openTaskSession(page: Page, title: string): Promise { + const kanban = new KanbanPage(page); + await kanban.goto(); + + const card = kanban.taskCardByTitle(title); + await expect(card).toBeVisible({ timeout: 15_000 }); + await card.click(); + await expect(page).toHaveURL(/\/s\//, { timeout: 15_000 }); + + const session = new SessionPage(page); + await session.waitForLoad(); + return session; +} + +// --------------------------------------------------------------------------- +// Tests — ACP (normal) mode +// --------------------------------------------------------------------------- + +test.describe("Session resume (ACP mode)", () => { + // These tests restart the backend mid-test, which can be flaky + test.describe.configure({ retries: 1 }); + + test("resume after backend restart preserves messages and accepts new prompts", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + test.setTimeout(90_000); + + // 1. Create task and start agent with a simple scenario + await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Resume ACP Task", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + // 2. Navigate to the session and wait for agent to finish its first turn + const session = await openTaskSession(testPage, "Resume ACP Task"); + await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({ + timeout: 30_000, + }); + await expect(session.idleInput()).toBeVisible({ timeout: 15_000 }); + + // 3. Verify the "Started agent Mock" boot message appeared on initial launch + await expect(session.chat.getByText("Started agent Mock", { exact: false })).toBeVisible({ + timeout: 15_000, + }); + + // 4. Restart the backend — kills the process, respawns with same DB/config + await backend.restart(); + + // 5. Reload the page so SSR fetches from the new backend instance + await testPage.reload(); + await session.waitForLoad(); + + // 6. Previous messages should still be visible (loaded from DB via SSR) + await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({ + timeout: 15_000, + }); + await expect(session.chat.getByText("/e2e:simple-message")).toBeVisible({ timeout: 15_000 }); + + // 7. Wait for auto-resume to complete — useSessionResumption hook detects + // needs_resume=true and relaunches the agent via session.launch + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + + // 8. Verify the "Resumed agent Mock" boot message appeared after resume + await expect(session.chat.getByText("Resumed agent Mock", { exact: false })).toBeVisible({ + timeout: 15_000, + }); + + // 9. Send a follow-up message to verify the agent works after resume + await session.sendMessage("/e2e:simple-message"); + + // 10. The agent should respond to the new prompt + await expect( + session.chat.getByText("simple mock response", { exact: false }).nth(1), + ).toBeVisible({ timeout: 30_000 }); + }); +}); + +// --------------------------------------------------------------------------- +// Tests — TUI (passthrough) mode +// --------------------------------------------------------------------------- + +test.describe("Session resume (TUI passthrough mode)", () => { + test.describe.configure({ retries: 1 }); + + test("resume TUI session after backend restart reconnects with resume flag", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + test.setTimeout(90_000); + + // 1. Create a TUI agent profile + const tuiProfile = await createTUIProfile(apiClient, "TUI Resume"); + + // 2. Create task with TUI agent + await apiClient.createTaskWithAgent(seedData.workspaceId, "TUI Resume Task", tuiProfile.id, { + description: "hello from resume test", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + + // 3. Navigate and wait for TUI terminal to load + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + const card = kanban.taskCardByTitle("TUI Resume Task"); + await expect(card).toBeVisible({ timeout: 15_000 }); + await card.click(); + await expect(testPage).toHaveURL(/\/s\//, { timeout: 15_000 }); + + const session = new SessionPage(testPage); + await session.waitForPassthroughLoad(); + await session.waitForPassthroughLoaded(); + + // 4. Verify initial TUI header + await session.expectPassthroughHasText("Mock Agent"); + + // 5. Wait for the workflow step to advance (idle timeout fires turn complete) + await expect(session.stepperStep("Review")).toHaveAttribute("aria-current", "step", { + timeout: 30_000, + }); + + // 6. Restart the backend + await backend.restart(); + + // 7. Reload the page — forces SSR re-fetch and WS reconnect + await testPage.reload(); + + // 8. Wait for passthrough terminal to reconnect after resume + await session.waitForPassthroughLoad(); + await session.waitForPassthroughLoaded(); + + // 9. The TUI should show the RESUMED header, confirming --resume/-c was passed + await session.expectPassthroughHasText("RESUMED", 30_000); + }); +});