diff --git a/backend/internal/adapters/agent/cline/cline.go b/backend/internal/adapters/agent/cline/cline.go index f47a17388f..a7f21f684b 100644 --- a/backend/internal/adapters/agent/cline/cline.go +++ b/backend/internal/adapters/agent/cline/cline.go @@ -16,6 +16,7 @@ import ( "context" "strings" "sync" + "time" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" @@ -80,6 +81,25 @@ func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, _ ports.LaunchCo return ports.PromptDeliveryAfterStart, nil } +// PromptReadinessHints waits briefly for Cline's interactive prompt before AO +// injects the worker's first task. +func (p *Plugin) PromptReadinessHints(ctx context.Context, _ ports.LaunchConfig) (ports.PromptReadinessHints, error) { + if err := ctx.Err(); err != nil { + return ports.PromptReadinessHints{}, err + } + return ports.PromptReadinessHints{ + InitialDelay: 750 * time.Millisecond, + Patterns: []string{ + "Type a message", + "What can I help", + ">", + }, + PollInterval: 200 * time.Millisecond, + Timeout: 8 * time.Second, + Lines: 80, + }, nil +} + // GetRestoreCommand rebuilds the argv that continues an existing Cline session: // `cline [approval flags] --id `. Resumes are interactive // because no prompt is supplied here. ok is false when the hook-derived native diff --git a/backend/internal/adapters/agent/cline/cline_test.go b/backend/internal/adapters/agent/cline/cline_test.go index 3054162b3d..c3f5572c27 100644 --- a/backend/internal/adapters/agent/cline/cline_test.go +++ b/backend/internal/adapters/agent/cline/cline_test.go @@ -129,6 +129,16 @@ func TestGetPromptDeliveryStrategyIsAfterStart(t *testing.T) { } } +func TestPromptReadinessHints(t *testing.T) { + hints, err := (&Plugin{}).PromptReadinessHints(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatal(err) + } + if hints.Timeout <= 0 || len(hints.Patterns) == 0 { + t.Fatalf("hints = %#v, want bounded readiness patterns", hints) + } +} + func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { plugin := &Plugin{} diff --git a/backend/internal/adapters/agent/copilot/copilot.go b/backend/internal/adapters/agent/copilot/copilot.go index 043023e1bb..4406ab34fb 100644 --- a/backend/internal/adapters/agent/copilot/copilot.go +++ b/backend/internal/adapters/agent/copilot/copilot.go @@ -7,8 +7,10 @@ // suggest/explain extension. // // Launch runs the CLI in interactive mode so AO can keep a durable terminal -// pane attached to the session. Permission modes map onto the CLI's allow flags -// (`--allow-tool`, `--allow-all-tools`, `--allow-all`). +// pane attached to the session. When AO has an initial task, it uses Copilot's +// `--interactive ` mode so the task executes immediately instead of +// waiting in the terminal input buffer. Permission modes map onto the CLI's allow +// flags (`--allow-tool`, `--allow-all-tools`, `--allow-all`). // Restore continues an existing session via `--resume `; the // native session id (a UUID under ~/.copilot/session-state/) is captured by the // SessionStart hook AO installs (see hooks.go). @@ -66,12 +68,13 @@ func (p *Plugin) Manifest() adapters.Manifest { // GetLaunchCommand builds the argv to start a new interactive Copilot session: // -// copilot [permission flags] +// copilot [permission flags] [--interactive ] // -// The prompt is delivered after the process starts; using `-p` runs Copilot in -// programmatic mode and exits when done, which leaves AO's terminal pane blank -// or dead. Copilot CLI does not have a documented system-prompt-injection flag, -// so SystemPrompt/SystemPromptFile are ignored. +// `--interactive ` keeps the durable Copilot terminal session open while +// automatically submitting the initial task. `-p` is deliberately avoided +// because it runs Copilot in programmatic mode and exits when done, which leaves +// AO's terminal pane blank or dead. Copilot CLI does not have a documented +// system-prompt-injection flag, so SystemPrompt/SystemPromptFile are ignored. func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { binary, err := p.copilotBinary(ctx) if err != nil { @@ -80,23 +83,25 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( cmd = []string{binary} appendApprovalFlags(&cmd, cfg.Permissions) + if cfg.Prompt != "" { + cmd = append(cmd, "--interactive", cfg.Prompt) + } return cmd, nil } -// GetPromptDeliveryStrategy reports that Copilot receives its prompt after the -// interactive process starts. This overrides the agentbase.Base default -// (in-command) because Copilot's `-p` programmatic mode exits when done, which -// would leave AO's terminal pane dead. +// GetPromptDeliveryStrategy reports that Copilot receives its prompt in the +// launch command. This uses `--interactive `, not `-p`, so Copilot starts +// executing immediately while keeping the interactive terminal session alive. func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { if err := ctx.Err(); err != nil { return "", err } - return ports.PromptDeliveryAfterStart, nil + return ports.PromptDeliveryInCommand, nil } // GetRestoreCommand rebuilds the argv that continues an existing Copilot -// session: `copilot [permission flags] --resume [-p ]`. +// session: `copilot [permission flags] --resume `. // ok is false when the hook-derived native session id has not landed yet, so // callers can fall back to fresh launch behavior. // diff --git a/backend/internal/adapters/agent/copilot/copilot_test.go b/backend/internal/adapters/agent/copilot/copilot_test.go index 711e372093..f7340858ad 100644 --- a/backend/internal/adapters/agent/copilot/copilot_test.go +++ b/backend/internal/adapters/agent/copilot/copilot_test.go @@ -33,7 +33,7 @@ func TestGetLaunchCommandBuildsArgv(t *testing.T) { t.Fatal(err) } - want := []string{"copilot", "--allow-all"} + want := []string{"copilot", "--allow-all", "--interactive", "-fix this"} if !reflect.DeepEqual(cmd, want) { t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) } @@ -49,6 +49,9 @@ func TestGetLaunchCommandOmitsPromptWhenEmpty(t *testing.T) { if contains(cmd, "-p") { t.Fatalf("command %#v unexpectedly contains -p", cmd) } + if contains(cmd, "--interactive") { + t.Fatalf("command %#v unexpectedly contains --interactive", cmd) + } } func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { @@ -123,7 +126,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { if err != nil { t.Fatal(err) } - if got != ports.PromptDeliveryAfterStart { + if got != ports.PromptDeliveryInCommand { t.Fatalf("unexpected strategy: %q", got) } } diff --git a/backend/internal/adapters/agent/crush/crush.go b/backend/internal/adapters/agent/crush/crush.go index 3971e573c4..ea460f5c96 100644 --- a/backend/internal/adapters/agent/crush/crush.go +++ b/backend/internal/adapters/agent/crush/crush.go @@ -10,6 +10,7 @@ import ( "context" "strings" "sync" + "time" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" @@ -55,12 +56,14 @@ func (p *Plugin) Manifest() adapters.Manifest { // GetLaunchCommand builds the argv to start an interactive Crush session. // Shape: // -// crush [--cwd ] [--yolo] [-- ] +// crush [--cwd ] [--yolo] // // The session runs in the worktree (cwd is set by the runtime). Crush doesn't // have native system prompt support, so cfg.SystemPrompt / SystemPromptFile are -// intentionally ignored. The initial task prompt is delivered as a positional -// argument after `--`. The --yolo flag corresponds to bypass-permissions mode. +// intentionally ignored. Worker task prompts are delivered after startup so AO +// keeps the interactive TUI; Crush's documented `run` command is intentionally +// not used here because it is non-interactive. The --yolo flag corresponds to +// bypass-permissions mode. // // We intentionally do not pass --session on launch: cfg.SessionID is the // AO-internal id, not a Crush-native session id. Letting Crush mint its own @@ -84,12 +87,34 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( cmd = append(cmd, "--yolo") } - // Prompt is passed after `--` so a leading "-" is not read as a flag + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that prompted sessions receive their task +// after the interactive Crush UI starts. +func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } if cfg.Prompt != "" { - cmd = append(cmd, "--", cfg.Prompt) + return ports.PromptDeliveryAfterStart, nil } + return ports.PromptDeliveryInCommand, nil +} - return cmd, nil +// PromptReadinessHints waits for Crush's ready prompt before AO injects the +// worker's first task. +func (p *Plugin) PromptReadinessHints(ctx context.Context, _ ports.LaunchConfig) (ports.PromptReadinessHints, error) { + if err := ctx.Err(); err != nil { + return ports.PromptReadinessHints{}, err + } + return ports.PromptReadinessHints{ + InitialDelay: 500 * time.Millisecond, + Patterns: []string{"Ready..."}, + PollInterval: 200 * time.Millisecond, + Timeout: 8 * time.Second, + Lines: 80, + }, nil } // GetRestoreCommand rebuilds the argv that continues an existing Crush session: diff --git a/backend/internal/adapters/agent/crush/crush_test.go b/backend/internal/adapters/agent/crush/crush_test.go index b7cb1b26e3..45ad0257c6 100644 --- a/backend/internal/adapters/agent/crush/crush_test.go +++ b/backend/internal/adapters/agent/crush/crush_test.go @@ -5,6 +5,7 @@ import ( "reflect" "testing" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -13,6 +14,7 @@ func TestGetLaunchCommandBuildsCrossPlatformArgv(t *testing.T) { cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ Permissions: ports.PermissionModeBypassPermissions, + Kind: domain.KindWorker, Prompt: "fix this", WorkspacePath: "/tmp/workspace", SessionID: "test-session-id", @@ -27,7 +29,6 @@ func TestGetLaunchCommandBuildsCrossPlatformArgv(t *testing.T) { "crush", "--cwd", "/tmp/workspace", "--yolo", - "--", "fix this", } if !reflect.DeepEqual(cmd, want) { t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) @@ -102,6 +103,44 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } } +func TestGetPromptDeliveryStrategyPromptedSessionsAreAfterStart(t *testing.T) { + tests := []struct { + name string + kind domain.SessionKind + }{ + {name: "worker", kind: domain.KindWorker}, + {name: "orchestrator", kind: domain.KindOrchestrator}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := &Plugin{} + + got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{ + Kind: tt.kind, + Prompt: "fix this", + }) + if err != nil { + t.Fatal(err) + } + + if got != ports.PromptDeliveryAfterStart { + t.Fatalf("unexpected prompt delivery strategy: got %v, want %v", got, ports.PromptDeliveryAfterStart) + } + }) + } +} + +func TestPromptReadinessHints(t *testing.T) { + hints, err := (&Plugin{}).PromptReadinessHints(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatal(err) + } + if hints.Timeout <= 0 || len(hints.Patterns) == 0 { + t.Fatalf("hints = %#v, want bounded readiness patterns", hints) + } +} + func TestGetRestoreCommand(t *testing.T) { plugin := &Plugin{resolvedBinary: "crush"} diff --git a/backend/internal/adapters/agent/kimi/kimi.go b/backend/internal/adapters/agent/kimi/kimi.go index 168b1b8221..51ed6c081c 100644 --- a/backend/internal/adapters/agent/kimi/kimi.go +++ b/backend/internal/adapters/agent/kimi/kimi.go @@ -21,6 +21,7 @@ import ( "context" "strings" "sync" + "time" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" @@ -88,6 +89,21 @@ func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, _ ports.LaunchCo return ports.PromptDeliveryAfterStart, nil } +// PromptReadinessHints waits for Kimi's interactive prompt before AO injects +// the worker's first task. +func (p *Plugin) PromptReadinessHints(ctx context.Context, _ ports.LaunchConfig) (ports.PromptReadinessHints, error) { + if err := ctx.Err(); err != nil { + return ports.PromptReadinessHints{}, err + } + return ports.PromptReadinessHints{ + InitialDelay: 750 * time.Millisecond, + Patterns: []string{"│ >"}, + PollInterval: 200 * time.Millisecond, + Timeout: 8 * time.Second, + Lines: 80, + }, nil +} + // GetRestoreCommand rebuilds the argv that continues an existing Kimi session // when a native Kimi session id is known: // diff --git a/backend/internal/adapters/agent/kimi/kimi_test.go b/backend/internal/adapters/agent/kimi/kimi_test.go index 286e4acd0c..1eecb45b94 100644 --- a/backend/internal/adapters/agent/kimi/kimi_test.go +++ b/backend/internal/adapters/agent/kimi/kimi_test.go @@ -49,6 +49,16 @@ func TestGetPromptDeliveryStrategy(t *testing.T) { } } +func TestPromptReadinessHints(t *testing.T) { + hints, err := (&Plugin{}).PromptReadinessHints(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatalf("err: %v", err) + } + if hints.Timeout <= 0 || len(hints.Patterns) == 0 { + t.Fatalf("hints = %#v, want bounded readiness patterns", hints) + } +} + // Kimi prompt mode is non-interactive, so AO launches the TUI and lets the // session manager inject the task after startup. Because the prompt is not // carried with `-p`, approval flags remain valid for prompted workers. diff --git a/backend/internal/adapters/agent/kiro/kiro.go b/backend/internal/adapters/agent/kiro/kiro.go index 0e386efcd7..a245127143 100644 --- a/backend/internal/adapters/agent/kiro/kiro.go +++ b/backend/internal/adapters/agent/kiro/kiro.go @@ -1,4 +1,4 @@ -// Package kiro implements the Kiro (AWS) agent adapter: launching new headless +// Package kiro implements the Kiro (AWS) agent adapter: launching interactive // sessions, resuming hook-tracked sessions, installing workspace-local hooks, // and reading hook-derived session info. // @@ -8,9 +8,9 @@ // approval flow. See https://kiro.dev/docs/cli/headless/ and // https://kiro.dev/docs/cli/reference/cli-commands/. // -// Launch delivers the initial prompt as a positional argument after `--` so a -// leading "-" is not parsed as a flag. Permission/approval modes map onto -// Kiro's tool-trust flags (`--trust-all-tools`, `--trust-tools=`). +// Worker prompts are delivered after startup so AO keeps Kiro's interactive +// TUI. Permission/approval modes map onto Kiro's tool-trust flags +// (`--trust-all-tools`, `--trust-tools=`). // Restore uses `kiro-cli chat --resume-id ` with the native session id // captured from a Kiro hook payload. // @@ -22,6 +22,7 @@ import ( "context" "strings" "sync" + "time" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" @@ -63,8 +64,10 @@ func (p *Plugin) Manifest() adapters.Manifest { // `kiro-cli chat --agent ao [trust flags] [-- ]`. // // The prompt is passed as a positional argument after `--` so a leading "-" is -// not read as a flag. Kiro runs interactively for both workers and orchestrators; -// standing instructions come from the generated custom agent. +// not read as a flag for non-worker launches. Worker prompts are sent after +// startup so AO keeps the interactive TUI and avoids Kiro's current positional +// input submission gap. Kiro runs interactively for both workers and +// orchestrators; standing instructions come from the generated custom agent. func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { binary, err := p.kiroBinary(ctx) if err != nil { @@ -75,7 +78,7 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( appendApprovalFlags(&cmd, cfg.Permissions) prompt := cfg.Prompt - if prompt != "" { + if prompt != "" && cfg.Kind == domain.KindOrchestrator { cmd = append(cmd, "--", prompt) } @@ -90,15 +93,33 @@ func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.Launch if err := ctx.Err(); err != nil { return "", err } - if cfg.Prompt != "" { + if cfg.Prompt != "" && cfg.Kind == domain.KindOrchestrator { return ports.PromptDeliveryInCommand, nil } + if cfg.Prompt != "" && cfg.Kind != domain.KindOrchestrator { + return ports.PromptDeliveryAfterStart, nil + } if cfg.Kind == domain.KindOrchestrator { return ports.PromptDeliveryCustomAgent, nil } return ports.PromptDeliveryInCommand, nil } +// PromptReadinessHints waits for Kiro's interactive prompt before AO injects +// the worker's first task. +func (p *Plugin) PromptReadinessHints(ctx context.Context, _ ports.LaunchConfig) (ports.PromptReadinessHints, error) { + if err := ctx.Err(); err != nil { + return ports.PromptReadinessHints{}, err + } + return ports.PromptReadinessHints{ + InitialDelay: 500 * time.Millisecond, + Patterns: []string{"ask a question or describe a task"}, + PollInterval: 200 * time.Millisecond, + Timeout: 8 * time.Second, + Lines: 80, + }, nil +} + // GetRestoreCommand rebuilds the argv that continues an existing Kiro session. // ok is false when the hook-derived native session id has not landed yet, so // callers can fall back to fresh launch behavior. diff --git a/backend/internal/adapters/agent/kiro/kiro_test.go b/backend/internal/adapters/agent/kiro/kiro_test.go index e891ed4ef9..35d67e15e0 100644 --- a/backend/internal/adapters/agent/kiro/kiro_test.go +++ b/backend/internal/adapters/agent/kiro/kiro_test.go @@ -33,6 +33,7 @@ func TestGetLaunchCommandBuildsInteractiveArgv(t *testing.T) { cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ Permissions: ports.PermissionModeBypassPermissions, + Kind: domain.KindWorker, Prompt: "-fix this", }) if err != nil { @@ -43,7 +44,6 @@ func TestGetLaunchCommandBuildsInteractiveArgv(t *testing.T) { "kiro-cli", "chat", "--agent", "ao", "--trust-all-tools", - "--", "-fix this", } if !reflect.DeepEqual(cmd, want) { t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) @@ -87,10 +87,11 @@ func TestGetLaunchCommandPromptlessWorkerStaysInteractive(t *testing.T) { } } -func TestGetLaunchCommandPromptTakesPrecedenceOverSystemPrompt(t *testing.T) { +func TestGetLaunchCommandPromptedWorkerKeepsPromptOutOfArgv(t *testing.T) { plugin := &Plugin{resolvedBinary: "kiro-cli"} cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Kind: domain.KindWorker, Prompt: "fix the failing test", SystemPrompt: "standing role instructions", }) @@ -101,7 +102,27 @@ func TestGetLaunchCommandPromptTakesPrecedenceOverSystemPrompt(t *testing.T) { want := []string{ "kiro-cli", "chat", "--agent", "ao", - "--", "fix the failing test", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandPromptedOrchestratorCarriesPrompt(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kiro-cli"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Kind: domain.KindOrchestrator, + Prompt: "do the explicit task", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{ + "kiro-cli", "chat", + "--agent", "ao", + "--", "do the explicit task", } if !reflect.DeepEqual(cmd, want) { t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) @@ -184,6 +205,21 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } } +func TestGetPromptDeliveryStrategyPromptedWorkerIsAfterStart(t *testing.T) { + plugin := &Plugin{} + + got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{ + Kind: domain.KindWorker, + Prompt: "do this task", + }) + if err != nil { + t.Fatal(err) + } + if got != ports.PromptDeliveryAfterStart { + t.Fatalf("unexpected strategy: %q", got) + } +} + func TestGetPromptDeliveryStrategyOrchestratorUsesCustomAgent(t *testing.T) { plugin := &Plugin{resolvedBinary: "kiro-cli"} @@ -211,6 +247,16 @@ func TestGetPromptDeliveryStrategyPromptedOrchestratorUsesCommand(t *testing.T) } } +func TestPromptReadinessHints(t *testing.T) { + hints, err := (&Plugin{}).PromptReadinessHints(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatal(err) + } + if hints.Timeout <= 0 || len(hints.Patterns) == 0 { + t.Fatalf("hints = %#v, want bounded readiness patterns", hints) + } +} + func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { plugin := &Plugin{} diff --git a/backend/internal/integration/lifecycle_sqlite_test.go b/backend/internal/integration/lifecycle_sqlite_test.go index fcfe64d11c..5acf5331e8 100644 --- a/backend/internal/integration/lifecycle_sqlite_test.go +++ b/backend/internal/integration/lifecycle_sqlite_test.go @@ -42,6 +42,9 @@ func (s *stubRuntime) IsAlive(_ context.Context, h ports.RuntimeHandle) (bool, e } return true, nil } +func (s *stubRuntime) GetOutput(_ context.Context, _ ports.RuntimeHandle, _ int) (string, error) { + return "", nil +} // wasDestroyed reports whether Destroy was called with the given handle ID. func (s *stubRuntime) wasDestroyed(handleID string) bool { diff --git a/backend/internal/ports/agent.go b/backend/internal/ports/agent.go index 391c334e71..057a38ab2f 100644 --- a/backend/internal/ports/agent.go +++ b/backend/internal/ports/agent.go @@ -3,6 +3,7 @@ package ports import ( "context" "errors" + "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) @@ -70,6 +71,23 @@ type AgentBinaryResolver interface { ResolveBinary(ctx context.Context) (path string, err error) } +// AgentPromptReadinessProvider is an optional capability for interactive +// adapters that receive their first task after startup. It lets AO wait until a +// terminal UI is ready before injecting text through the runtime. +type AgentPromptReadinessProvider interface { + PromptReadinessHints(ctx context.Context, cfg LaunchConfig) (PromptReadinessHints, error) +} + +// PromptReadinessHints describes when an after-start prompt should be sent. +// Empty hints mean "send immediately" to preserve existing adapter behavior. +type PromptReadinessHints struct { + InitialDelay time.Duration + Patterns []string + PollInterval time.Duration + Timeout time.Duration + Lines int +} + // AgentResolver maps a session's harness onto the Agent adapter that drives it, // so the Session Manager can spawn (and restore) a different agent per session // without depending on the concrete adapter registry. ok=false means no adapter diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 4581ca771f..76d3cf3c46 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -72,6 +72,7 @@ type lifecycleRecorder interface { type runtimeController interface { Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) Destroy(ctx context.Context, handle ports.RuntimeHandle) error + GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) // IsAlive reports whether the handle's runtime session still exists. Used by // Reconcile on boot to adopt crash-surviving sessions and reap leaked ones. IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) @@ -316,7 +317,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess return domain.SessionRecord{}, fmt.Errorf("spawn %s: completed: %w", id, err) } if delivery == ports.PromptDeliveryAfterStart && prompt != "" { - if err := m.messenger.Send(ctx, id, prompt); err != nil { + if err := m.deliverAfterStartPrompt(ctx, agent, launchCfg, handle, id, prompt); err != nil { _ = m.runtime.Destroy(ctx, handle) m.destroySpawnWorkspace(ctx, ws, workspaceProject) m.markSpawnFailedTerminatedWithoutWorkspace(ctx, id) @@ -757,7 +758,17 @@ func (m *Manager) relaunchRestoredSession(ctx context.Context, rec domain.Sessio return domain.SessionRecord{}, fmt.Errorf("restore %s: completed: %w", rec.ID, err) } if delivery == ports.PromptDeliveryAfterStart && rec.Metadata.Prompt != "" { - if err := m.messenger.Send(ctx, rec.ID, rec.Metadata.Prompt); err != nil { + launchCfg := ports.LaunchConfig{ + DataDir: m.dataDir, + SessionID: string(rec.ID), + WorkspacePath: ws.Path, + Kind: rec.Kind, + Prompt: rec.Metadata.Prompt, + SystemPrompt: systemPrompt, + Config: agentConfig, + Permissions: agentConfig.Permissions, + } + if err := m.deliverAfterStartPrompt(ctx, agent, launchCfg, handle, rec.ID, rec.Metadata.Prompt); err != nil { _ = m.runtime.Destroy(ctx, handle) _ = m.lcm.MarkTerminated(ctx, rec.ID) return domain.SessionRecord{}, fmt.Errorf("restore %s: deliver prompt: %w", rec.ID, err) @@ -1853,6 +1864,89 @@ func (m *Manager) prepareWorkspace(ctx context.Context, agent ports.Agent, id do return nil } +func (m *Manager) deliverAfterStartPrompt(ctx context.Context, agent ports.Agent, cfg ports.LaunchConfig, handle ports.RuntimeHandle, id domain.SessionID, prompt string) error { + if err := m.waitForPromptReadiness(ctx, agent, cfg, handle); err != nil { + return err + } + return m.messenger.Send(ctx, id, prompt) +} + +func (m *Manager) waitForPromptReadiness(ctx context.Context, agent ports.Agent, cfg ports.LaunchConfig, handle ports.RuntimeHandle) error { + provider, ok := agent.(ports.AgentPromptReadinessProvider) + if !ok { + return nil + } + hints, err := provider.PromptReadinessHints(ctx, cfg) + if err != nil { + return fmt.Errorf("prompt readiness: %w", err) + } + if hints.InitialDelay > 0 { + if err := sleepContext(ctx, hints.InitialDelay); err != nil { + return err + } + } + if len(hints.Patterns) == 0 || hints.Timeout <= 0 { + return nil + } + poll := hints.PollInterval + if poll <= 0 { + poll = 200 * time.Millisecond + } + lines := hints.Lines + if lines <= 0 { + lines = 80 + } + + deadline := time.NewTimer(hints.Timeout) + defer deadline.Stop() + ticker := time.NewTicker(poll) + defer ticker.Stop() + + for { + output, err := m.runtime.GetOutput(ctx, handle, lines) + if err == nil && promptOutputContains(output, hints.Patterns) { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-deadline.C: + // Prompt readiness is best-effort: a missing terminal marker must not + // block spawn forever or be treated as confirmed readiness. Fall back + // to delivering the prompt and make the degraded path observable. + m.logger.Warn("prompt readiness timed out; falling back to after-start prompt delivery", + "sessionID", cfg.SessionID, + "kind", string(cfg.Kind), + "timeout", hints.Timeout.String(), + "pollInterval", poll.String(), + "lines", lines, + ) + return nil + case <-ticker.C: + } + } +} + +func promptOutputContains(output string, patterns []string) bool { + for _, pattern := range patterns { + if pattern != "" && strings.Contains(output, pattern) { + return true + } + } + return false +} + +func sleepContext(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + // restoreArgv builds the argv to relaunch a torn-down session: the agent's // native resume command when it can continue the session, else a fresh launch. // The agent signals via ok=false (e.g. no native session id captured yet). diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 61cecd88b7..d9facbfd15 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -163,6 +163,9 @@ type fakeRuntime struct { destroyErr error created, destroyed int lastCfg ports.RuntimeConfig + outputs []string + outputCalls int + outputErr error // aliveByHandle maps a RuntimeHandle.ID to its liveness; missing = false. aliveByHandle map[string]bool aliveErr error @@ -188,6 +191,20 @@ func (r *fakeRuntime) IsAlive(_ context.Context, handle ports.RuntimeHandle) (bo } return r.aliveByHandle[handle.ID], nil } +func (r *fakeRuntime) GetOutput(_ context.Context, _ ports.RuntimeHandle, _ int) (string, error) { + r.outputCalls++ + if r.outputErr != nil { + return "", r.outputErr + } + if len(r.outputs) == 0 { + return "", nil + } + out := r.outputs[0] + if len(r.outputs) > 1 { + r.outputs = r.outputs[1:] + } + return out, nil +} type fakeAgent struct{} @@ -250,6 +267,15 @@ func (a afterStartAgent) GetPromptDeliveryStrategy(context.Context, ports.Launch return ports.PromptDeliveryAfterStart, nil } +type readinessAgent struct { + afterStartAgent + hints ports.PromptReadinessHints +} + +func (a readinessAgent) PromptReadinessHints(context.Context, ports.LaunchConfig) (ports.PromptReadinessHints, error) { + return a.hints, nil +} + type promptStrategyErrorAgent struct { *recordingAgent err error @@ -591,6 +617,88 @@ func TestSpawn_DeliversPromptAfterStartWhenAgentRequestsIt(t *testing.T) { } } +func TestSpawn_AfterStartPromptWaitsForReadinessHint(t *testing.T) { + st := newFakeStore() + st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()} + rt := &fakeRuntime{outputs: []string{"booting", "agent Ready..."}} + ws := &fakeWorkspace{} + msg := &fakeMessenger{} + agent := &recordingAgent{} + m := New(Deps{ + Runtime: rt, + Agents: singleAgent{agent: readinessAgent{ + afterStartAgent: afterStartAgent{recordingAgent: agent}, + hints: ports.PromptReadinessHints{ + Patterns: []string{"Ready..."}, + PollInterval: time.Millisecond, + Timeout: 50 * time.Millisecond, + }, + }}, + Workspace: ws, + Store: st, + Messenger: msg, + Lifecycle: &fakeLCM{store: st}, + LookPath: func(string) (string, error) { return "/bin/true", nil }, + }) + + if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "fix the button"}); err != nil { + t.Fatal(err) + } + if rt.outputCalls != 2 { + t.Fatalf("GetOutput calls = %d, want 2", rt.outputCalls) + } + if len(msg.msgs) != 1 || msg.msgs[0] != "fix the button" { + t.Fatalf("delivered prompts = %#v, want one original prompt", msg.msgs) + } +} + +func TestSpawn_AfterStartPromptFallsBackWhenReadinessTimesOut(t *testing.T) { + st := newFakeStore() + st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()} + rt := &fakeRuntime{outputs: []string{"still booting"}} + ws := &fakeWorkspace{} + msg := &fakeMessenger{} + agent := &recordingAgent{} + var logBuf bytes.Buffer + m := New(Deps{ + Runtime: rt, + Agents: singleAgent{agent: readinessAgent{ + afterStartAgent: afterStartAgent{recordingAgent: agent}, + hints: ports.PromptReadinessHints{ + Patterns: []string{"Ready..."}, + PollInterval: time.Millisecond, + Timeout: time.Millisecond, + }, + }}, + Workspace: ws, + Store: st, + Messenger: msg, + Lifecycle: &fakeLCM{store: st}, + LookPath: func(string) (string, error) { return "/bin/true", nil }, + Logger: slog.New(slog.NewTextHandler(&logBuf, nil)), + }) + + if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "fix the button"}); err != nil { + t.Fatal(err) + } + if rt.outputCalls == 0 { + t.Fatal("GetOutput was not called") + } + if len(msg.msgs) != 1 || msg.msgs[0] != "fix the button" { + t.Fatalf("delivered prompts = %#v, want fallback prompt delivery", msg.msgs) + } + logText := logBuf.String() + if !strings.Contains(logText, "prompt readiness timed out") { + t.Fatalf("log = %q, want readiness timeout warning", logText) + } + if !strings.Contains(logText, "falling back to after-start prompt delivery") { + t.Fatalf("log = %q, want fallback delivery context", logText) + } + if !strings.Contains(logText, "sessionID=mer-1") { + t.Fatalf("log = %q, want session id", logText) + } +} + func TestSpawn_AfterStartPromptFailureCleansUpSpawn(t *testing.T) { st := newFakeStore() st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}