From 1dfd7f05e98f27ee7c299f6eb4d9424fd156943c Mon Sep 17 00:00:00 2001 From: nikhil achale Date: Thu, 9 Jul 2026 00:31:27 +0530 Subject: [PATCH 1/4] feat: add prompt readiness hints for interactive agents - Implemented PromptReadinessHints method in the Cline, Crush, Kimi, and Kiro agents to provide initial delay, patterns, poll interval, timeout, and line count for waiting on the interactive prompt before injecting the first task. - Updated GetPromptDeliveryStrategy methods to accommodate the new behavior for agents that require readiness hints. - Enhanced session manager to wait for prompt readiness before delivering the first task prompt. - Added tests for the new readiness hints functionality in respective agent test files and session manager tests. --- .../internal/adapters/agent/cline/cline.go | 20 ++++ .../adapters/agent/cline/cline_test.go | 10 ++ .../internal/adapters/agent/crush/crush.go | 40 ++++++-- .../adapters/agent/crush/crush_test.go | 29 +++++- backend/internal/adapters/agent/kimi/kimi.go | 16 ++++ .../internal/adapters/agent/kimi/kimi_test.go | 10 ++ backend/internal/adapters/agent/kiro/kiro.go | 37 +++++-- .../internal/adapters/agent/kiro/kiro_test.go | 52 +++++++++- backend/internal/ports/agent.go | 18 ++++ backend/internal/session_manager/manager.go | 88 ++++++++++++++++- .../internal/session_manager/manager_test.go | 96 +++++++++++++++++++ 11 files changed, 395 insertions(+), 21 deletions(-) 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/crush/crush.go b/backend/internal/adapters/agent/crush/crush.go index 3971e573c4..49bfdf361e 100644 --- a/backend/internal/adapters/agent/crush/crush.go +++ b/backend/internal/adapters/agent/crush/crush.go @@ -10,10 +10,12 @@ import ( "context" "strings" "sync" + "time" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -55,12 +57,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 +88,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 - if cfg.Prompt != "" { - cmd = append(cmd, "--", cfg.Prompt) + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that prompted workers 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 != "" && cfg.Kind != domain.KindOrchestrator { + 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..5b12ee0143 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,32 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } } +func TestGetPromptDeliveryStrategyPromptedWorkerIsAfterStart(t *testing.T) { + plugin := &Plugin{} + + got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{ + Kind: domain.KindWorker, + 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/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..898e699f72 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,79 @@ 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: + 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..5ef4eb11d3 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,76 @@ 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{} + 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 }, + }) + + 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) + } +} + func TestSpawn_AfterStartPromptFailureCleansUpSpawn(t *testing.T) { st := newFakeStore() st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()} From 4370cf2bd95469c66a6c51d098aa8e3a9a77e7cb Mon Sep 17 00:00:00 2001 From: nikhil achale Date: Thu, 9 Jul 2026 00:56:27 +0530 Subject: [PATCH 2/4] feat: enhance Copilot integration with interactive prompt support --- .../adapters/agent/copilot/copilot.go | 31 +++++++++++-------- .../adapters/agent/copilot/copilot_test.go | 7 +++-- .../integration/lifecycle_sqlite_test.go | 3 ++ 3 files changed, 26 insertions(+), 15 deletions(-) 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/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 { From 1f14debfb84d6fec46d0ead62264671b87c7c32c Mon Sep 17 00:00:00 2001 From: nikhil achale Date: Thu, 9 Jul 2026 16:17:01 +0530 Subject: [PATCH 3/4] refactor: update prompt delivery strategy tests for session types --- .../internal/adapters/agent/crush/crush.go | 5 ++- .../adapters/agent/crush/crush_test.go | 34 +++++++++++++------ 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/backend/internal/adapters/agent/crush/crush.go b/backend/internal/adapters/agent/crush/crush.go index 49bfdf361e..ea460f5c96 100644 --- a/backend/internal/adapters/agent/crush/crush.go +++ b/backend/internal/adapters/agent/crush/crush.go @@ -15,7 +15,6 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -91,13 +90,13 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that prompted workers receive their task +// 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 != "" && cfg.Kind != domain.KindOrchestrator { + if cfg.Prompt != "" { return ports.PromptDeliveryAfterStart, nil } return ports.PromptDeliveryInCommand, nil diff --git a/backend/internal/adapters/agent/crush/crush_test.go b/backend/internal/adapters/agent/crush/crush_test.go index 5b12ee0143..45ad0257c6 100644 --- a/backend/internal/adapters/agent/crush/crush_test.go +++ b/backend/internal/adapters/agent/crush/crush_test.go @@ -103,19 +103,31 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } } -func TestGetPromptDeliveryStrategyPromptedWorkerIsAfterStart(t *testing.T) { - plugin := &Plugin{} - - got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{ - Kind: domain.KindWorker, - Prompt: "fix this", - }) - if err != nil { - t.Fatal(err) +func TestGetPromptDeliveryStrategyPromptedSessionsAreAfterStart(t *testing.T) { + tests := []struct { + name string + kind domain.SessionKind + }{ + {name: "worker", kind: domain.KindWorker}, + {name: "orchestrator", kind: domain.KindOrchestrator}, } - if got != ports.PromptDeliveryAfterStart { - t.Fatalf("unexpected prompt delivery strategy: got %v, want %v", got, ports.PromptDeliveryAfterStart) + 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) + } + }) } } From baa86c589ce0080358b120d135cf67683d30afea Mon Sep 17 00:00:00 2001 From: nikhil achale Date: Thu, 9 Jul 2026 17:21:31 +0530 Subject: [PATCH 4/4] feat: add logging for prompt readiness timeout and fallback delivery --- backend/internal/session_manager/manager.go | 10 ++++++++++ backend/internal/session_manager/manager_test.go | 12 ++++++++++++ 2 files changed, 22 insertions(+) diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 898e699f72..76d3cf3c46 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -1911,6 +1911,16 @@ func (m *Manager) waitForPromptReadiness(ctx context.Context, agent ports.Agent, 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: } diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 5ef4eb11d3..d9facbfd15 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -659,6 +659,7 @@ func TestSpawn_AfterStartPromptFallsBackWhenReadinessTimesOut(t *testing.T) { ws := &fakeWorkspace{} msg := &fakeMessenger{} agent := &recordingAgent{} + var logBuf bytes.Buffer m := New(Deps{ Runtime: rt, Agents: singleAgent{agent: readinessAgent{ @@ -674,6 +675,7 @@ func TestSpawn_AfterStartPromptFallsBackWhenReadinessTimesOut(t *testing.T) { 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 { @@ -685,6 +687,16 @@ func TestSpawn_AfterStartPromptFallsBackWhenReadinessTimesOut(t *testing.T) { 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) {