feat: improve TUI agents session resume on restart (#367)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user