Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions backend/internal/adapters/agent/cline/cline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 <agentSessionId>`. Resumes are interactive
// because no prompt is supplied here. ok is false when the hook-derived native
Expand Down
10 changes: 10 additions & 0 deletions backend/internal/adapters/agent/cline/cline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}

Expand Down
31 changes: 18 additions & 13 deletions backend/internal/adapters/agent/copilot/copilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <prompt>` 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 <agentSessionId>`; the
// native session id (a UUID under ~/.copilot/session-state/) is captured by the
// SessionStart hook AO installs (see hooks.go).
Expand Down Expand Up @@ -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 <prompt>]
//
// 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 <prompt>` 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 {
Expand All @@ -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 <prompt>`, 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 <agentSessionId> [-p <prompt>]`.
// session: `copilot [permission flags] --resume <agentSessionId>`.
// ok is false when the hook-derived native session id has not landed yet, so
// callers can fall back to fresh launch behavior.
//
Expand Down
7 changes: 5 additions & 2 deletions backend/internal/adapters/agent/copilot/copilot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
}
}
Expand Down
37 changes: 31 additions & 6 deletions backend/internal/adapters/agent/crush/crush.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -55,12 +56,14 @@ func (p *Plugin) Manifest() adapters.Manifest {
// GetLaunchCommand builds the argv to start an interactive Crush session.
// Shape:
//
// crush [--cwd <WorkspacePath>] [--yolo] [-- <Prompt>]
// crush [--cwd <WorkspacePath>] [--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
Expand All @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still reports PromptDeliveryInCommand for non-empty orchestrator prompts, but GetLaunchCommand no longer ever appends cfg.Prompt. The session manager only calls deliverAfterStartPrompt for PromptDeliveryAfterStart, so POST /sessions with kind=orchestrator, harness=crush, and a prompt will persist the prompt but never deliver it; restore fallback for a persisted prompted orchestrator has the same gap. Please either make any non-empty Crush prompt use after-start delivery, or keep an explicit in-command path for orchestrator prompts.

}

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:
Expand Down
41 changes: 40 additions & 1 deletion backend/internal/adapters/agent/crush/crush_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"reflect"
"testing"

"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)

Expand All @@ -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",
Expand All @@ -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)
Expand Down Expand Up @@ -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"}

Expand Down
16 changes: 16 additions & 0 deletions backend/internal/adapters/agent/kimi/kimi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
//
Expand Down
10 changes: 10 additions & 0 deletions backend/internal/adapters/agent/kimi/kimi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading