feat: improve TUI agents session resume on restart (#367)

This commit is contained in:
Carlos Florêncio
2026-03-04 23:34:26 +00:00
committed by GitHub
parent b0a85e6471
commit 124fe588f8
16 changed files with 671 additions and 45 deletions
+34 -1
View File
@@ -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"`
@@ -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)
}
})
}
}
+9 -3
View File
@@ -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 != "" {
@@ -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"),
},
},
}
+13 -9
View File
@@ -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,
},
}
@@ -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)
}
}
@@ -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_
@@ -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 {
@@ -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)
}
}
@@ -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)
}
}
@@ -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
+53 -22
View File
@@ -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<void>;
};
async function waitForHealth(url: string, timeoutMs: number): Promise<void> {
@@ -90,6 +92,41 @@ function killProcessGroup(proc: ChildProcess): Promise<void> {
});
}
/**
* 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<string, string>,
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<object, { backend: BackendContext }>({
};
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<object, { backend: BackendContext }>({
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);
+8
View File
@@ -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`);
}
}
+4 -2
View File
@@ -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,
});
});
});
@@ -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");
});
});
+169
View File
@@ -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<SessionPage> {
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);
});
});