Skip to content
Merged
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
40 changes: 7 additions & 33 deletions backend/internal/cli/spawn.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ type spawnRequest struct {
Harness string `json:"harness,omitempty"`
Branch string `json:"branch,omitempty"`
Prompt string `json:"prompt,omitempty"`
DisplayName string `json:"displayName,omitempty"`
DisplayName string `json:"displayName"`
}

type spawnResult struct {
Expand Down Expand Up @@ -72,7 +72,11 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
if opts.noTakeover && opts.claimPR == "" {
return usageError{fmt.Errorf("--no-takeover requires --claim-pr")}
}
if explicitName := strings.TrimSpace(opts.name); utf8.RuneCountInString(explicitName) > maxDisplayNameLen {
name := strings.TrimSpace(opts.name)
if name == "" {
Comment thread
harshitsinghbhandari marked this conversation as resolved.
return usageError{fmt.Errorf("--name is required")}
}
if utf8.RuneCountInString(name) > maxDisplayNameLen {
return usageError{fmt.Errorf("--name must be %d characters or fewer", maxDisplayNameLen)}
}

Expand All @@ -88,7 +92,6 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
}
opts.harness = harness

name := resolveSpawnDisplayName(opts.name, opts.prompt)
if !opts.skipAgentCheck {
if err := ctx.preflightSpawnAgentAuth(cmd.Context(), cmd, opts.harness); err != nil {
return err
Expand Down Expand Up @@ -161,7 +164,7 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
f.StringVar(&opts.branch, "branch", "", "Branch for the session worktree (default: ao/<session-id>/root)")
f.StringVar(&opts.prompt, "prompt", "", "Initial prompt for the agent")
f.StringVar(&opts.issue, "issue", "", "Issue id to associate with the session")
f.StringVar(&opts.name, "name", "", "Display name shown in the sidebar (default: derived from --prompt, max 20 characters)")
f.StringVar(&opts.name, "name", "", "Display name shown in the sidebar (required, max 20 characters)")
f.StringVar(&opts.claimPR, "claim-pr", "", "Immediately claim an existing PR for the spawned session")
f.BoolVar(&opts.noTakeover, "no-takeover", false, "Refuse if another active session owns the claimed PR (requires --claim-pr)")
f.BoolVar(&opts.skipAgentCheck, "skip-agent-check", false, "Skip advisory agent catalog install/auth preflight before spawning")
Expand Down Expand Up @@ -306,35 +309,6 @@ func resolveSpawnHarness(explicit string, project projectDetails) (string, error
return "", usageError{fmt.Errorf("agent could not be resolved; pass --agent or configure `ao project set-config %s --worker-agent <agent>`", project.ID)}
}

func resolveSpawnDisplayName(explicit, prompt string) string {
if name := strings.TrimSpace(explicit); name != "" {
return name
}
return deriveDisplayNameFromPrompt(prompt)
}

func deriveDisplayNameFromPrompt(prompt string) string {
fields := strings.Fields(strings.TrimSpace(prompt))
if len(fields) == 0 {
return ""
}
var b strings.Builder
for _, field := range fields {
next := strings.Trim(field, " \t\r\n.,;:!?()[]{}\"'")
if next == "" {
continue
}
if b.Len() > 0 {
next = " " + next
}
if utf8.RuneCountInString(b.String()+next) > maxDisplayNameLen {
break
}
b.WriteString(next)
}
return b.String()
}

func (c *commandContext) preflightSpawnAgentAuth(ctx context.Context, cmd *cobra.Command, agentID string) error {
inv, err := c.fetchAgentInventory(ctx, true)
if err != nil {
Expand Down
41 changes: 25 additions & 16 deletions backend/internal/cli/spawn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func TestSpawnCommand_MissingProjectContext(t *testing.T) {
t.Cleanup(srv.Close)
writeRunFileFor(t, cfg, srv)

_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--agent", "codex")
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--agent", "codex", "--name", "worker")
if err == nil {
t.Fatal("expected an error when project context is missing")
}
Expand Down Expand Up @@ -157,6 +157,15 @@ func TestSpawnNoTakeoverRequiresClaimPR(t *testing.T) {
}
}

// TestSpawnCommand_RequiresName asserts `ao spawn` rejects a missing --name
// without contacting the daemon.
func TestSpawnCommand_RequiresName(t *testing.T) {
_, _, err := executeCLI(t, Deps{}, "spawn", "--project", "demo", "--agent", "codex")
if err == nil || ExitCode(err) != 2 || !strings.Contains(err.Error(), "--name is required") {
t.Fatalf("err=%v exit=%d, want --name is required", err, ExitCode(err))
}
}

// TestSpawnCommand_RejectsOverlongName asserts `ao spawn` rejects a --name
// longer than 20 characters without contacting the daemon.
func TestSpawnCommand_RejectsOverlongName(t *testing.T) {
Expand Down Expand Up @@ -191,14 +200,14 @@ func TestSpawnResolvesProjectFromEnvAndDefaultAgent(t *testing.T) {
writeRunFileFor(t, cfg, srv)
t.Setenv("AO_PROJECT_ID", "demo")

out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--prompt", "Fix failing tests in auth")
out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--prompt", "Fix failing tests in auth", "--name", "worker")
if err != nil {
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
}
if !strings.Contains(out, "spawned session demo-11") {
t.Fatalf("output missing spawn: %s", out)
}
if req.ProjectID != "demo" || req.Harness != "codex" || req.DisplayName != "Fix failing tests in" {
if req.ProjectID != "demo" || req.Harness != "codex" || req.DisplayName != "worker" {
t.Fatalf("spawn request = %#v", req)
}
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/sessions"}
Expand Down Expand Up @@ -234,7 +243,7 @@ func TestSpawnResolvesProjectFromAOSessionID(t *testing.T) {
writeRunFileFor(t, cfg, srv)
t.Setenv("AO_SESSION_ID", "demo-1")

_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--prompt", "Fix tests")
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--prompt", "Fix tests", "--name", "worker")
if err != nil {
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
}
Expand Down Expand Up @@ -265,7 +274,7 @@ func TestSpawnAOSessionIDFailureRequiresProject(t *testing.T) {
writeRunFileFor(t, cfg, srv)
t.Setenv("AO_SESSION_ID", "missing")

_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--agent", "codex")
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--agent", "codex", "--name", "worker")
if err == nil || !strings.Contains(err.Error(), `project could not be resolved from AO_SESSION_ID "missing"; pass --project`) {
t.Fatalf("err=%v, want AO_SESSION_ID project error", err)
}
Expand Down Expand Up @@ -313,7 +322,7 @@ func TestSpawnResolvesProjectFromCWD(t *testing.T) {
t.Cleanup(srv.Close)
writeRunFileFor(t, cfg, srv)

_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--prompt", "Fix tests")
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--prompt", "Fix tests", "--name", "worker")
if err != nil {
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
}
Expand Down Expand Up @@ -348,7 +357,7 @@ func TestSpawnStaleUnauthorizedAgentRefreshesProbesThenAllows(t *testing.T) {
t.Cleanup(srv.Close)
writeRunFileFor(t, cfg, srv)

_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex", "--name", "worker")
if err != nil {
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
}
Expand Down Expand Up @@ -390,7 +399,7 @@ func TestSpawnFreshUnauthorizedWarnsAndAllows(t *testing.T) {
t.Cleanup(srv.Close)
writeRunFileFor(t, cfg, srv)

_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex", "--name", "worker")
if err != nil {
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
}
Expand Down Expand Up @@ -433,7 +442,7 @@ func TestSpawnUnavailableFreshProbeWarnsAndAllows(t *testing.T) {
t.Cleanup(srv.Close)
writeRunFileFor(t, cfg, srv)

_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex", "--name", "worker")
if err != nil {
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
}
Expand Down Expand Up @@ -467,7 +476,7 @@ func TestSpawnUnsupportedAgentRefreshesThenBlocks(t *testing.T) {
t.Cleanup(srv.Close)
writeRunFileFor(t, cfg, srv)

_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "unknown")
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "unknown", "--name", "worker")
if err == nil || !strings.Contains(err.Error(), "agent \"unknown\" is not supported") {
t.Fatalf("err=%v, want unsupported", err)
}
Expand Down Expand Up @@ -497,7 +506,7 @@ func TestSpawnNotInstalledAgentRefreshesThenBlocks(t *testing.T) {
t.Cleanup(srv.Close)
writeRunFileFor(t, cfg, srv)

_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex", "--name", "worker")
if err == nil || !strings.Contains(err.Error(), "agent \"codex\" needs install") {
t.Fatalf("err=%v, want needs install", err)
}
Expand Down Expand Up @@ -533,7 +542,7 @@ func TestSpawnStaleNotInstalledFreshInstalledWarnsAndAllows(t *testing.T) {
t.Cleanup(srv.Close)
writeRunFileFor(t, cfg, srv)

_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex", "--name", "worker")
if err != nil {
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
}
Expand Down Expand Up @@ -576,7 +585,7 @@ func TestSpawnUnavailableFreshProbeForNotInstalledWarnsAndAllows(t *testing.T) {
t.Cleanup(srv.Close)
writeRunFileFor(t, cfg, srv)

_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex", "--name", "worker")
if err != nil {
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
}
Expand Down Expand Up @@ -613,7 +622,7 @@ func TestSpawnFreshProbeServerErrorBlocks(t *testing.T) {
t.Cleanup(srv.Close)
writeRunFileFor(t, cfg, srv)

_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex", "--name", "worker")
if err == nil || !strings.Contains(err.Error(), "probe failed (PROBE_FAILED) [request req-1]") {
t.Fatalf("err=%v, want probe server error", err)
}
Expand Down Expand Up @@ -645,7 +654,7 @@ func TestSpawnSkipAgentCheckBypassesOnlyPreflight(t *testing.T) {
t.Cleanup(srv.Close)
writeRunFileFor(t, cfg, srv)

_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "unsupported", "--skip-agent-check")
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "unsupported", "--skip-agent-check", "--name", "worker")
if err != nil {
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
}
Expand Down Expand Up @@ -680,7 +689,7 @@ func TestSpawnUnknownAuthRefreshesWarnsAndAllows(t *testing.T) {
t.Cleanup(srv.Close)
writeRunFileFor(t, cfg, srv)

_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex", "--name", "worker")
if err != nil {
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
}
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/session_manager/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2083,7 +2083,7 @@ func TestSpawnOrchestrator_UsesCoordinatorPrompt(t *testing.T) {
systemPrompt := agent.lastLaunch.SystemPrompt
for _, want := range []string{
"You are the human-facing orchestrator for project mer",
`ao spawn --project mer --prompt "<clear worker task>"`,
`ao spawn --project mer --name "<label>" --prompt "<clear worker task>"`,
"Before running `ao spawn`, count the `--name` label yourself",
"coordination-only by default",
"always spawn or redirect a worker session",
Expand Down
6 changes: 3 additions & 3 deletions backend/internal/session_manager/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,9 @@ Your job is to coordinate work, not to perform implementation. Keep the project
- `+"`ao status`"+` - inspect project, session, PR, and review state.
- `+"`ao session ls --project %s`"+` - list sessions for this project.
- `+"`ao session get <worker-session-id>`"+` - inspect a worker session's details.
- `+"`ao spawn --project %s --prompt \"<clear worker task>\"`"+` - spawn a freeform worker.
- `+"`ao spawn --project %s --issue <issue-id>`"+` - spawn a worker for an issue.
- Add `+"`--name \"<label>\"`"+` when you want an explicit sidebar label; labels must be 20 characters or fewer.
- `+"`ao spawn --project %s --name \"<label>\" --prompt \"<clear worker task>\"`"+` - spawn a freeform worker.
- `+"`ao spawn --project %s --name \"<label>\" --issue <issue-id>`"+` - spawn a worker for an issue.
- `+"`--name`"+` is required: a deliberate sidebar label so the user can see what each worker is working on at a glance; labels must be 20 characters or fewer.
- Before running `+"`ao spawn`"+`, count the `+"`--name`"+` label yourself. It must be 20 characters or fewer. If your first label is longer, shorten it before executing the command.
- Add `+"`--agent <name>`"+` when a worker must use a specific agent.
- `+"`ao send --session <session-id> --message \"<message>\"`"+` - message a worker.
Expand Down
Loading