diff --git a/backend/internal/adapters/runtime/tmux/tmux.go b/backend/internal/adapters/runtime/tmux/tmux.go index 8388a5713..892add6b9 100644 --- a/backend/internal/adapters/runtime/tmux/tmux.go +++ b/backend/internal/adapters/runtime/tmux/tmux.go @@ -236,7 +236,7 @@ func (r *Runtime) Attach(ctx context.Context, handle ports.RuntimeHandle, rows, if err != nil { return nil, err } - return ptyexec.Spawn(ctx, argv, nil, rows, cols) + return ptyexec.Spawn(ctx, argv, attachEnv(os.Environ()), rows, cols) } // attachCommand returns the argv to attach a terminal to the session. @@ -249,6 +249,17 @@ func (r *Runtime) attachCommand(handle ports.RuntimeHandle) ([]string, error) { return []string{r.binary, "attach-session", "-t", id}, nil } +func attachEnv(base []string) []string { + env := append([]string(nil), base...) + for i, kv := range env { + if strings.HasPrefix(kv, "TERM=") { + env[i] = "TERM=xterm-256color" + return env + } + } + return append(env, "TERM=xterm-256color") +} + // run wraps runner.Run with a per-call timeout context. func (r *Runtime) run(ctx context.Context, args ...string) ([]byte, error) { cmdCtx, cancel := context.WithTimeout(ctx, r.timeout) diff --git a/backend/internal/adapters/runtime/tmux/tmux_test.go b/backend/internal/adapters/runtime/tmux/tmux_test.go index 04242099d..a6ae8bf68 100644 --- a/backend/internal/adapters/runtime/tmux/tmux_test.go +++ b/backend/internal/adapters/runtime/tmux/tmux_test.go @@ -551,6 +551,18 @@ func TestAttachCommandRejectsInvalidHandle(t *testing.T) { } } +func TestAttachEnvForcesUsableTerm(t *testing.T) { + env := attachEnv([]string{"PATH=/bin", "TERM=dumb", "SHELL=/bin/sh"}) + if got, want := env, []string{"PATH=/bin", "TERM=xterm-256color", "SHELL=/bin/sh"}; !reflect.DeepEqual(got, want) { + t.Fatalf("attachEnv = %#v, want %#v", got, want) + } + + env = attachEnv([]string{"PATH=/bin"}) + if got, want := env, []string{"PATH=/bin", "TERM=xterm-256color"}; !reflect.DeepEqual(got, want) { + t.Fatalf("attachEnv without TERM = %#v, want %#v", got, want) + } +} + // -- commandError tests -- func TestCommandErrorUnwraps(t *testing.T) { diff --git a/backend/internal/cli/doctor.go b/backend/internal/cli/doctor.go index 96492e32a..248730d5e 100644 --- a/backend/internal/cli/doctor.go +++ b/backend/internal/cli/doctor.go @@ -307,7 +307,7 @@ func (c *commandContext) checkTerminalRuntime(ctx context.Context) doctorCheck { func (c *commandContext) checkTmux(ctx context.Context) doctorCheck { path, err := c.deps.LookPath("tmux") if err != nil || path == "" { - return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "tmux", Message: "not found in PATH"} + return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "tmux", Message: "not found in PATH; required on macOS/Linux to start sessions"} } reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) defer cancel() diff --git a/backend/internal/cli/doctor_test.go b/backend/internal/cli/doctor_test.go index daf7c9499..7686dfb76 100644 --- a/backend/internal/cli/doctor_test.go +++ b/backend/internal/cli/doctor_test.go @@ -247,12 +247,18 @@ func TestDoctorJSONOutputIsDecodable(t *testing.T) { clearDoctorGitHubEnv(t) out, errOut, err := executeCLI(t, Deps{ LookPath: func(name string) (string, error) { - if name == "git" { + switch name { + case "git": return "/bin/git", nil + case "tmux": + return "/bin/tmux", nil } return "", errors.New("missing") }, - CommandOutput: func(context.Context, string, ...string) ([]byte, error) { + CommandOutput: func(_ context.Context, name string, _ ...string) ([]byte, error) { + if name == "/bin/tmux" { + return []byte("tmux 3.3a\n"), nil + } return []byte("git version 2.43.0\n"), nil }, ProcessAlive: func(int) bool { return false }, @@ -277,12 +283,18 @@ func TestDoctorTextOutputIsGrouped(t *testing.T) { clearDoctorGitHubEnv(t) out, errOut, err := executeCLI(t, Deps{ LookPath: func(name string) (string, error) { - if name == "git" { + switch name { + case "git": return "/bin/git", nil + case "tmux": + return "/bin/tmux", nil } return "", errors.New("missing") }, - CommandOutput: func(context.Context, string, ...string) ([]byte, error) { + CommandOutput: func(_ context.Context, name string, _ ...string) ([]byte, error) { + if name == "/bin/tmux" { + return []byte("tmux 3.3a\n"), nil + } return []byte("git version 2.43.0\n"), nil }, ProcessAlive: func(int) bool { return false }, diff --git a/backend/internal/cli/dto_drift_e2e_test.go b/backend/internal/cli/dto_drift_e2e_test.go index 5c7e924ac..743a398d7 100644 --- a/backend/internal/cli/dto_drift_e2e_test.go +++ b/backend/internal/cli/dto_drift_e2e_test.go @@ -184,6 +184,7 @@ func TestE2E_SpawnAndProjectAddDTORoundTrip(t *testing.T) { "--branch", "feat/x", "--prompt", "hi", "--issue", "ISS-1", + "--name", "my worker", }) if err := root.Execute(); err != nil { t.Fatalf("spawn execute: %v\noutput: %s", err, out.String()) @@ -205,6 +206,9 @@ func TestE2E_SpawnAndProjectAddDTORoundTrip(t *testing.T) { if got.IssueID != "ISS-1" { t.Errorf("IssueID = %q, want %q", got.IssueID, "ISS-1") } + if got.DisplayName != "my worker" { + t.Errorf("DisplayName = %q, want %q (CLI json:\"displayName\" vs SpawnSessionRequest)", got.DisplayName, "my worker") + } if !bytes.Contains(out.Bytes(), []byte("spawned session")) { t.Errorf("output missing %q; got: %s", "spawned session", out.String()) } diff --git a/backend/internal/cli/spawn.go b/backend/internal/cli/spawn.go index d19d540d0..920b2b363 100644 --- a/backend/internal/cli/spawn.go +++ b/backend/internal/cli/spawn.go @@ -5,6 +5,8 @@ import ( "fmt" "net/url" "runtime" + "strings" + "unicode/utf8" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -12,12 +14,17 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" ) +// maxDisplayNameLen caps the sidebar label set by `--name`. Mirrored by the +// daemon's spawn handler so a direct API call is held to the same limit. +const maxDisplayNameLen = 20 + type spawnOptions struct { project string harness string branch string prompt string issue string + name string claimPR string noTakeover bool } @@ -25,11 +32,12 @@ type spawnOptions struct { // spawnRequest mirrors the daemon's SpawnSessionRequest body for // POST /api/v1/sessions. The CLI keeps its own copy so it need not import httpd. type spawnRequest struct { - ProjectID string `json:"projectId"` - IssueID string `json:"issueId,omitempty"` - Harness string `json:"harness,omitempty"` - Branch string `json:"branch,omitempty"` - Prompt string `json:"prompt,omitempty"` + ProjectID string `json:"projectId"` + IssueID string `json:"issueId,omitempty"` + Harness string `json:"harness,omitempty"` + Branch string `json:"branch,omitempty"` + Prompt string `json:"prompt,omitempty"` + DisplayName string `json:"displayName"` } type spawnResult struct { @@ -52,6 +60,13 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { if opts.project == "" { return usageError{fmt.Errorf("--project is required")} } + name := strings.TrimSpace(opts.name) + if name == "" { + return usageError{fmt.Errorf("--name is required")} + } + if utf8.RuneCountInString(name) > maxDisplayNameLen { + return usageError{fmt.Errorf("--name must be %d characters or fewer", maxDisplayNameLen)} + } if opts.noTakeover && opts.claimPR == "" { return usageError{fmt.Errorf("--no-takeover requires --claim-pr")} } @@ -67,11 +82,12 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { } } req := spawnRequest{ - ProjectID: opts.project, - IssueID: opts.issue, - Harness: opts.harness, - Branch: opts.branch, - Prompt: opts.prompt, + ProjectID: opts.project, + IssueID: opts.issue, + Harness: opts.harness, + Branch: opts.branch, + Prompt: opts.prompt, + DisplayName: name, } var res spawnResult if err := ctx.postJSON(cmd.Context(), "sessions", req, &res); err != nil { @@ -125,6 +141,7 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { f.StringVar(&opts.branch, "branch", "", "Branch for the session worktree (default: ao//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 (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)") return cmd diff --git a/backend/internal/cli/spawn_test.go b/backend/internal/cli/spawn_test.go index 2abfcfaa4..68bf78b44 100644 --- a/backend/internal/cli/spawn_test.go +++ b/backend/internal/cli/spawn_test.go @@ -66,7 +66,7 @@ func TestSpawnClaimPRWiring(t *testing.T) { t.Cleanup(srv.Close) writeRunFileFor(t, cfg, srv) - out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--claim-pr", "142", "--no-takeover") + out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--name", "worker", "--claim-pr", "142", "--no-takeover") if err != nil { t.Fatalf("spawn claim-pr failed: %v stderr=%s", err, errOut) } @@ -108,7 +108,7 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) { t.Cleanup(srv.Close) writeRunFileFor(t, cfg, srv) - _, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--claim-pr", "142") + _, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--name", "worker", "--claim-pr", "142") if err == nil { t.Fatal("expected spawn claim failure") } @@ -126,8 +126,26 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) { } func TestSpawnNoTakeoverRequiresClaimPR(t *testing.T) { - _, _, err := executeCLI(t, Deps{}, "spawn", "--project", "demo", "--no-takeover") + _, _, err := executeCLI(t, Deps{}, "spawn", "--project", "demo", "--name", "worker", "--no-takeover") if err == nil || ExitCode(err) != 2 || !strings.Contains(err.Error(), "--no-takeover requires --claim-pr") { t.Fatalf("err=%v exit=%d", err, ExitCode(err)) } } + +// TestSpawnCommand_RequiresName asserts `ao spawn` rejects a missing --name +// before touching the network, mirroring the --project guard. +func TestSpawnCommand_RequiresName(t *testing.T) { + _, _, err := executeCLI(t, Deps{}, "spawn", "--project", "demo") + 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) { + _, _, err := executeCLI(t, Deps{}, "spawn", "--project", "demo", "--name", strings.Repeat("x", 21)) + if err == nil || ExitCode(err) != 2 || !strings.Contains(err.Error(), "20 characters or fewer") { + t.Fatalf("err=%v exit=%d, want 20 characters or fewer", err, ExitCode(err)) + } +} diff --git a/backend/internal/domain/projectconfig.go b/backend/internal/domain/projectconfig.go index 04cdb2425..934819e4c 100644 --- a/backend/internal/domain/projectconfig.go +++ b/backend/internal/domain/projectconfig.go @@ -48,7 +48,7 @@ type ProjectConfig struct { // Reviewers names the agent(s) that review a worker's PR when a review is // triggered. It is configured independently of the Worker override; an empty - // list falls back to the worker's own harness (see ResolveReviewerHarness). + // list falls back to claude-code (see ResolveReviewerHarness). Reviewers []ReviewerConfig `json:"reviewers,omitempty"` } @@ -64,15 +64,11 @@ type ReviewerConfig struct { const FallbackReviewerHarness = ReviewerClaudeCode // ResolveReviewerHarness picks the reviewer harness for a worker. A configured -// reviewer wins; otherwise it reuses the worker's own harness when that harness -// is also a supported reviewer, falling back to claude-code. -func (c ProjectConfig) ResolveReviewerHarness(workerHarness AgentHarness) ReviewerHarness { +// reviewer wins; otherwise claude-code is used. +func (c ProjectConfig) ResolveReviewerHarness(_ AgentHarness) ReviewerHarness { if len(c.Reviewers) > 0 { return c.Reviewers[0].Harness } - if h := ReviewerHarness(workerHarness); h.IsKnown() { - return h - } return FallbackReviewerHarness } diff --git a/backend/internal/domain/projectconfig_test.go b/backend/internal/domain/projectconfig_test.go index 415646af4..b1b515945 100644 --- a/backend/internal/domain/projectconfig_test.go +++ b/backend/internal/domain/projectconfig_test.go @@ -83,13 +83,12 @@ func TestResolveReviewerHarness(t *testing.T) { t.Fatalf("configured reviewer = %q, want claude-code", got) } - // No reviewer configured: reuse the worker harness when it is also a - // supported reviewer (claude-code is). + // No reviewer configured: always use claude-code. if got := (ProjectConfig{}).ResolveReviewerHarness(HarnessClaudeCode); got != ReviewerClaudeCode { t.Fatalf("default = %q, want reviewer claude-code", got) } - // A worker harness that is not a supported reviewer falls back to claude-code. + // A worker harness that is not claude-code also falls back to claude-code. if got := (ProjectConfig{}).ResolveReviewerHarness(HarnessAider); got != FallbackReviewerHarness { t.Fatalf("fallback = %q, want %q", got, FallbackReviewerHarness) } diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 9f0f79783..0e22f30ca 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -2470,6 +2470,9 @@ components: properties: branch: type: string + displayName: + maxLength: 20 + type: string harness: enum: - claude-code diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index fca0790d2..ab859ef7a 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -146,6 +146,10 @@ type SpawnSessionRequest struct { Harness domain.AgentHarness `json:"harness,omitempty" enum:"claude-code,codex,aider,opencode,grok,droid,amp,agy,crush,cursor,qwen,copilot,goose,auggie,continue,devin,cline,kimi,kiro,kilocode,vibe,pi,autohand"` Branch string `json:"branch,omitempty"` Prompt string `json:"prompt,omitempty" maxLength:"4096"` + // DisplayName is the sidebar label for the session, capped at 20 characters. + // `ao spawn --name` always sets it; other clients (e.g. the desktop new-task + // dialog) may omit it and fall back to the session id in the read model. + DisplayName string `json:"displayName,omitempty" maxLength:"20"` } // SessionResponse is the { session } body shared by session create/get. diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index d10e72d4b..348f65ff1 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strconv" "strings" + "unicode/utf8" "github.com/go-chi/chi/v5" @@ -22,8 +23,9 @@ import ( ) const ( - maxPromptLen = 4096 - maxMessageLen = 4096 + maxPromptLen = 4096 + maxMessageLen = 4096 + maxDisplayNameLen = 20 ) var errPreviewFileNotFound = errors.New("preview file not found") @@ -120,10 +122,19 @@ func (c *SessionsController) spawn(w http.ResponseWriter, r *http.Request) { envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "PROMPT_TOO_LONG", "prompt is too long", nil) return } + // displayName is optional at the API (the desktop new-task dialog omits it + // and the read model falls back to the session id). `ao spawn` makes it + // required CLI-side. When present, it is held to the same length cap here so + // a direct API call cannot exceed it. + displayName := strings.TrimSpace(in.DisplayName) + if utf8.RuneCountInString(displayName) > maxDisplayNameLen { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "DISPLAY_NAME_TOO_LONG", "displayName must be 20 characters or fewer", nil) + return + } if in.Kind == "" { in.Kind = domain.KindWorker } - sess, err := c.Svc.Spawn(r.Context(), ports.SpawnConfig{ProjectID: in.ProjectID, IssueID: in.IssueID, Kind: in.Kind, Harness: in.Harness, Branch: in.Branch, Prompt: in.Prompt}) + sess, err := c.Svc.Spawn(r.Context(), ports.SpawnConfig{ProjectID: in.ProjectID, IssueID: in.IssueID, Kind: in.Kind, Harness: in.Harness, Branch: in.Branch, Prompt: in.Prompt, DisplayName: displayName}) if err != nil { envelope.WriteError(w, r, err) return diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go index ab5455245..ae0fe53da 100644 --- a/backend/internal/httpd/controllers/sessions_test.go +++ b/backend/internal/httpd/controllers/sessions_test.go @@ -61,7 +61,7 @@ func (f *fakeSessionService) Spawn(_ context.Context, cfg ports.SpawnConfig) (do return domain.Session{}, f.spawnErr } now := time.Now().UTC() - s := domain.Session{SessionRecord: domain.SessionRecord{ID: domain.SessionID(string(cfg.ProjectID) + "-2"), ProjectID: cfg.ProjectID, IssueID: cfg.IssueID, Kind: cfg.Kind, Harness: cfg.Harness, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, CreatedAt: now, UpdatedAt: now}, Status: domain.StatusIdle} + s := domain.Session{SessionRecord: domain.SessionRecord{ID: domain.SessionID(string(cfg.ProjectID) + "-2"), ProjectID: cfg.ProjectID, IssueID: cfg.IssueID, Kind: cfg.Kind, Harness: cfg.Harness, DisplayName: cfg.DisplayName, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, CreatedAt: now, UpdatedAt: now}, Status: domain.StatusIdle} f.sessions[s.ID] = s return s, nil } @@ -269,7 +269,7 @@ func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) { t.Fatalf("list leaked prompt: %s", body) } - body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions", `{"projectId":"ao","issueId":"ISS-1","kind":"worker","harness":"codex","prompt":"fix"}`) + body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions", `{"projectId":"ao","issueId":"ISS-1","kind":"worker","harness":"codex","prompt":"fix","displayName":"my worker"}`) if status != http.StatusCreated { t.Fatalf("POST session = %d, want 201; body=%s", status, body) } @@ -280,6 +280,9 @@ func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) { if spawned.Session.ID != "ao-2" || spawned.Session.IssueID != "ISS-1" || spawned.Session.Harness != "codex" { t.Fatalf("spawned = %#v", spawned) } + if spawned.Session.DisplayName != "my worker" { + t.Fatalf("spawned displayName = %q, want %q", spawned.Session.DisplayName, "my worker") + } body, status, _ = doRequest(t, srv, "GET", "/api/v1/sessions/ao-2", "") if status != http.StatusOK { @@ -679,6 +682,18 @@ func TestSessionsAPI_SpawnBranchNotFetchedReturnsTypedError(t *testing.T) { assertErrorCode(t, body, status, http.StatusBadRequest, "BRANCH_NOT_FETCHED") } +// TestSessionsAPI_SpawnRejectsOverlongDisplayName asserts the spawn endpoint +// caps displayName at 20 characters even though the field itself is optional +// (the desktop new-task dialog omits it). `ao spawn` enforces the same limit +// CLI-side before the request is sent. +func TestSessionsAPI_SpawnRejectsOverlongDisplayName(t *testing.T) { + srv := newSessionTestServer(t, newFakeSessionService()) + + overlong := strings.Repeat("x", 21) + body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions", `{"projectId":"ao","harness":"codex","displayName":"`+overlong+`"}`) + assertErrorCode(t, body, status, http.StatusBadRequest, "DISPLAY_NAME_TOO_LONG") +} + func TestSessionsAPI_RenameNotFound(t *testing.T) { srv := newSessionTestServer(t, newFakeSessionService()) diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index 359cd5f8c..f88e1b783 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -164,6 +164,9 @@ var ( // conflict markers for manual resolution. Adapters wrap this sentinel via // fmt.Errorf so callers can match it with errors.Is. ErrPreservedConflict = errors.New("workspace: preserved apply produced conflicts") + // ErrRuntimePrerequisite reports a missing host prerequisite for the selected + // runtime before a session can be created. + ErrRuntimePrerequisite = errors.New("runtime: prerequisite missing") ) // WorkspaceConfig is the spec for creating or restoring a session's workspace. diff --git a/backend/internal/ports/session.go b/backend/internal/ports/session.go index 07a403a2e..035ad249e 100644 --- a/backend/internal/ports/session.go +++ b/backend/internal/ports/session.go @@ -14,11 +14,11 @@ var ErrSessionNotFound = errors.New("session not found") type SpawnConfig struct { ProjectID domain.ProjectID IssueID domain.IssueID - // IssueContext is optional pre-fetched tracker context for the task prompt. - // Standing rules stay in SystemPrompt; issue facts belong to the user task. - IssueContext string - Kind domain.SessionKind - Harness domain.AgentHarness - Branch string - Prompt string + Kind domain.SessionKind + Harness domain.AgentHarness + Branch string + Prompt string + // DisplayName is the user-facing sidebar label. Empty falls back to the + // session id in the read model (e.g. orchestrator sessions). + DisplayName string } diff --git a/backend/internal/review/review.go b/backend/internal/review/review.go index 46d47b67c..c6c88f336 100644 --- a/backend/internal/review/review.go +++ b/backend/internal/review/review.go @@ -375,8 +375,7 @@ func (e *Engine) List(ctx stdctx.Context, workerID domain.SessionID) (SessionRev } // reviewerHarness resolves which harness reviews the worker's PR: a configured -// reviewer wins, otherwise the worker's own harness is reused (falling back to -// claude-code), per domain.ResolveReviewerHarness. +// reviewer wins, otherwise claude-code is used, per domain.ResolveReviewerHarness. func (e *Engine) reviewerHarness(ctx stdctx.Context, worker domain.SessionRecord) (domain.ReviewerHarness, error) { var cfg domain.ProjectConfig if e.projects != nil { diff --git a/backend/internal/service/session/service.go b/backend/internal/service/session/service.go index 740c9e43c..32d338897 100644 --- a/backend/internal/service/session/service.go +++ b/backend/internal/service/session/service.go @@ -481,6 +481,8 @@ func toAPIError(err error) error { return apierr.Invalid("INVALID_BRANCH", err.Error(), nil) case errors.Is(err, ports.ErrAgentBinaryNotFound): return apierr.Invalid("AGENT_BINARY_NOT_FOUND", err.Error(), nil) + case errors.Is(err, ports.ErrRuntimePrerequisite): + return apierr.Invalid("RUNTIME_PREREQUISITE_MISSING", err.Error(), nil) default: return err } diff --git a/backend/internal/service/session/service_test.go b/backend/internal/service/session/service_test.go index 405e17964..c5b1b1ca6 100644 --- a/backend/internal/service/session/service_test.go +++ b/backend/internal/service/session/service_test.go @@ -534,6 +534,7 @@ func TestToAPIErrorMapsWorkspaceBranchSentinels(t *testing.T) { {"not fetched", fmt.Errorf("spawn mer-1: workspace: %w: \"x\" has no local head", ports.ErrWorkspaceBranchNotFetched), apierr.KindInvalid, "BRANCH_NOT_FETCHED"}, {"invalid branch", fmt.Errorf("spawn mer-1: workspace: %w: \"bad!!\" (exit 1)", ports.ErrWorkspaceBranchInvalid), apierr.KindInvalid, "INVALID_BRANCH"}, {"agent binary not found", fmt.Errorf("spawn mer-1: %w", ports.ErrAgentBinaryNotFound), apierr.KindInvalid, "AGENT_BINARY_NOT_FOUND"}, + {"runtime prerequisite missing", fmt.Errorf("spawn: %w: tmux required on macOS/Linux but not in PATH", ports.ErrRuntimePrerequisite), apierr.KindInvalid, "RUNTIME_PREREQUISITE_MISSING"}, {"unknown harness", fmt.Errorf("spawn: %w: %q", sessionmanager.ErrUnknownHarness, "bogus"), apierr.KindInvalid, "UNKNOWN_HARNESS"}, {"missing harness", fmt.Errorf("spawn: %w: configure project worker.agent or pass --harness", sessionmanager.ErrMissingHarness), apierr.KindInvalid, "AGENT_REQUIRED"}, } diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index b1ac8744d..587dfb9a4 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -96,6 +96,8 @@ type Store interface { // presence of any row is the marker; preserved_ref may be empty for clean // worktrees. ListSessionWorktrees(ctx context.Context, id domain.SessionID) ([]domain.SessionWorktreeRecord, error) + // DeleteSessionWorktrees clears the "shutdown-saved" restore marker. + DeleteSessionWorktrees(ctx context.Context, id domain.SessionID) error } // Manager coordinates internal session spawn, restore, kill, and cleanup over @@ -202,6 +204,10 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess return domain.SessionRecord{}, fmt.Errorf("spawn: %w: %q", ErrUnknownHarness, cfg.Harness) } + if err := m.validateRuntimePrerequisites(); err != nil { + return domain.SessionRecord{}, fmt.Errorf("spawn: %w", err) + } + prompt, systemPrompt, err := m.buildSpawnTexts(ctx, cfg) if err != nil { return domain.SessionRecord{}, fmt.Errorf("spawn: prompt: %w", err) @@ -242,19 +248,19 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess // post-create commands (e.g. `pnpm install`) before the agent launches. if err := m.provisionWorkspace(ctx, project, ws.Path); err != nil { _ = m.workspace.Destroy(ctx, ws) - m.markSpawnFailedTerminated(ctx, id) + m.rollbackSpawnSeedRow(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: provision: %w", id, err) } agent, ok := m.agents.Agent(cfg.Harness) if !ok { _ = m.workspace.Destroy(ctx, ws) - m.markSpawnFailedTerminated(ctx, id) + m.rollbackSpawnSeedRow(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: no agent adapter for harness %q", id, cfg.Harness) } if err := m.prepareWorkspace(ctx, agent, id, ws.Path); err != nil { _ = m.workspace.Destroy(ctx, ws) - m.markSpawnFailedTerminated(ctx, id) + m.rollbackSpawnSeedRow(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err) } agentConfig := effectiveAgentConfig(cfg.Kind, project.Config) @@ -270,7 +276,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess }) if err != nil { _ = m.workspace.Destroy(ctx, ws) - m.markSpawnFailedTerminated(ctx, id) + m.rollbackSpawnSeedRow(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: launch command: %w", id, err) } // Pre-flight: confirm argv[0] actually exists on PATH (or as an absolute @@ -279,7 +285,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess // unresolved binary would leak through as a "live" session that never ran. if err := m.validateAgentBinary(argv); err != nil { _ = m.workspace.Destroy(ctx, ws) - m.markSpawnFailedTerminated(ctx, id) + m.rollbackSpawnSeedRow(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err) } handle, err := m.runtime.Create(ctx, ports.RuntimeConfig{ @@ -290,7 +296,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess }) if err != nil { _ = m.workspace.Destroy(ctx, ws) - m.markSpawnFailedTerminated(ctx, id) + m.rollbackSpawnSeedRow(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: runtime: %w", id, err) } @@ -450,6 +456,12 @@ func (m *Manager) Kill(ctx context.Context, id domain.SessionID) (bool, error) { return false, fmt.Errorf("kill %s: %w", id, err) } + // Clear the restore marker so the next boot's RestoreAll cannot resurrect a + // killed session (#2319). Best-effort: teardown below still matters. + if err := m.store.DeleteSessionWorktrees(ctx, id); err != nil { + m.logger.Warn("kill: delete restore marker failed", "sessionID", id, "error", err) + } + // Only tear down what exists. A session may have lost its handle after a // crash or never acquired one if spawn failed partway. if handle.ID != "" { @@ -847,6 +859,12 @@ func (m *Manager) RestoreAll(ctx context.Context) error { m.logger.Error("restore-all: relaunch failed", "sessionID", rec.ID, "error", err) } } + + // One-shot: drop the consumed marker so it never outlives one restart + // (#2319). A still-live session re-acquires it at the next quit. + if err := m.store.DeleteSessionWorktrees(ctx, rec.ID); err != nil { + m.logger.Warn("restore-all: delete restore marker failed", "sessionID", rec.ID, "error", err) + } } return nil } @@ -928,13 +946,14 @@ func (m *Manager) cleanupRecords(ctx context.Context, project domain.ProjectID) func seedRecord(cfg ports.SpawnConfig, now time.Time) domain.SessionRecord { return domain.SessionRecord{ - ProjectID: cfg.ProjectID, - IssueID: cfg.IssueID, - Kind: cfg.Kind, - CreatedAt: now, - UpdatedAt: now, - Harness: cfg.Harness, - Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, + ProjectID: cfg.ProjectID, + IssueID: cfg.IssueID, + Kind: cfg.Kind, + CreatedAt: now, + UpdatedAt: now, + Harness: cfg.Harness, + DisplayName: cfg.DisplayName, + Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, } } @@ -1300,6 +1319,16 @@ func (m *Manager) validateAgentBinary(argv []string) error { return nil } +func (m *Manager) validateRuntimePrerequisites() error { + if runtime.GOOS == "windows" { + return nil + } + if path, err := m.lookPath("tmux"); err != nil || path == "" { + return fmt.Errorf("%w: tmux required on macOS/Linux but not in PATH", ports.ErrRuntimePrerequisite) + } + return nil +} + func runtimeHandle(meta domain.SessionMetadata) ports.RuntimeHandle { return ports.RuntimeHandle{ID: meta.RuntimeHandleID} } diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 6474712f5..778bfb242 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -8,6 +8,7 @@ import ( "log/slog" "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -112,6 +113,10 @@ func (f *fakeStore) UpsertSessionWorktree(_ context.Context, row domain.SessionW func (f *fakeStore) ListSessionWorktrees(_ context.Context, id domain.SessionID) ([]domain.SessionWorktreeRecord, error) { return f.worktrees[id], nil } +func (f *fakeStore) DeleteSessionWorktrees(_ context.Context, id domain.SessionID) error { + delete(f.worktrees, id) + return nil +} type fakeLCM struct { store *fakeStore @@ -465,8 +470,8 @@ func TestSpawn_RollsBackOnRuntimeFailure(t *testing.T) { if ws.destroyed != 1 { t.Fatal("workspace should roll back") } - if !st.sessions["mer-1"].IsTerminated { - t.Fatal("orphaned spawn should be terminated") + if rec, present := st.sessions["mer-1"]; present { + t.Fatalf("seed row must be deleted before a runtime handle is live, got %+v", rec) } } @@ -569,6 +574,29 @@ func TestKill_OtherWorkspaceErrorStillFails(t *testing.T) { t.Fatalf("kill err = %v, want workspace error surfaced", err) } } + +// TestKill_DeletesRestoreMarker covers issue #2319 (a): a user kill is explicit +// terminal intent and must delete the session_worktrees "shutdown-saved" marker. +// A session that carried a marker (e.g. it survived a prior reopen cycle) and is +// then killed must not keep that marker, or the next boot's RestoreAll would +// resurrect it. +func TestKill_DeletesRestoreMarker(t *testing.T) { + m, st, _, _ := newManager() + st.sessions["mer-1"] = mkLive("mer-1") + // The session carries a leftover shutdown-saved marker from a prior cycle. + st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__"}} + + if _, err := m.Kill(ctx, "mer-1"); err != nil { + t.Fatalf("kill err = %v", err) + } + rows, err := st.ListSessionWorktrees(ctx, "mer-1") + if err != nil { + t.Fatal(err) + } + if len(rows) != 0 { + t.Fatalf("kill must delete the restore marker, got %d rows", len(rows)) + } +} func TestRestore_ReopensTerminal(t *testing.T) { m, st, rt, _ := newManager() seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"}) @@ -1248,6 +1276,9 @@ func TestSpawn_RejectsMissingAgentBinary(t *testing.T) { rt := &fakeRuntime{} ws := &fakeWorkspace{} notFound := func(name string) (string, error) { + if name == "tmux" { + return "/bin/tmux", nil + } return "", fmt.Errorf("exec: %q: not found", name) } m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: notFound}) @@ -1262,8 +1293,39 @@ func TestSpawn_RejectsMissingAgentBinary(t *testing.T) { if ws.destroyed != 1 { t.Fatal("workspace must be torn down when the pre-launch binary check fails") } - if !st.sessions["mer-1"].IsTerminated { - t.Fatal("the orphan row should be marked terminated after the failed spawn") + if rec, present := st.sessions["mer-1"]; present { + t.Fatalf("seed row must be deleted before a runtime handle is live, got %+v", rec) + } +} + +func TestSpawn_RejectsMissingTmuxBeforeSessionRow(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows uses ConPTY, not tmux") + } + st := newFakeStore() + st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()} + rt := &fakeRuntime{} + ws := &fakeWorkspace{} + lookPath := func(name string) (string, error) { + if name == "tmux" { + return "", fmt.Errorf("exec: %q: not found", name) + } + return "/bin/true", nil + } + m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) + + _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) + if !errors.Is(err, ports.ErrRuntimePrerequisite) || !strings.Contains(err.Error(), "tmux required") { + t.Fatalf("err = %v, want missing tmux prerequisite", err) + } + if len(st.sessions) != 0 { + t.Fatalf("no session row should be created before runtime prerequisites pass, got %d", len(st.sessions)) + } + if ws.lastCfg.SessionID != "" || ws.destroyed != 0 { + t.Fatal("workspace must not be created when tmux is missing") + } + if rt.created != 0 { + t.Fatal("runtime must not be created when tmux is missing") } } @@ -1693,6 +1755,81 @@ func TestRestoreAll_SkipsSessionsKilledBeforeShutdown(t *testing.T) { } } +// TestRestoreAll_DeletesMarkerAfterRelaunch covers issue #2319 (b): the +// shutdown-saved marker is one-shot. After RestoreAll relaunches a session, its +// session_worktrees marker is deleted, so a second RestoreAll (with no fresh +// marker) does NOT relaunch it again. +func TestRestoreAll_DeletesMarkerAfterRelaunch(t *testing.T) { + m, st, rt, _ := newLifecycleManager() + + st.sessions["mer-1"] = domain.SessionRecord{ + ID: "mer-1", + ProjectID: "mer", + Kind: domain.KindWorker, + Harness: domain.HarnessClaudeCode, + IsTerminated: true, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"}, + Activity: domain.Activity{State: domain.ActivityExited}, + } + st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__"}} + + if err := m.RestoreAll(ctx); err != nil { + t.Fatalf("RestoreAll err = %v", err) + } + if rt.created != 1 { + t.Fatalf("first RestoreAll must relaunch once, runtime.Create called %d times", rt.created) + } + rows, err := st.ListSessionWorktrees(ctx, "mer-1") + if err != nil { + t.Fatal(err) + } + if len(rows) != 0 { + t.Fatalf("RestoreAll must delete the one-shot marker, got %d rows", len(rows)) + } +} + +// TestRestoreAll_KilledSessionNotResurrectedOnSecondBoot covers issue #2319 (c), +// the killed-session-resurrection scenario. A terminated session WITH a marker +// is relaunched exactly once; on a second RestoreAll (no new marker) it stays +// terminated and is not relaunched again. +func TestRestoreAll_KilledSessionNotResurrectedOnSecondBoot(t *testing.T) { + m, st, rt, _ := newLifecycleManager() + + st.sessions["mer-1"] = domain.SessionRecord{ + ID: "mer-1", + ProjectID: "mer", + Kind: domain.KindWorker, + Harness: domain.HarnessClaudeCode, + IsTerminated: true, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"}, + Activity: domain.Activity{State: domain.ActivityExited}, + } + st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__"}} + + // First boot: marker present, session relaunches once. + if err := m.RestoreAll(ctx); err != nil { + t.Fatalf("first RestoreAll err = %v", err) + } + if rt.created != 1 { + t.Fatalf("first RestoreAll must relaunch once, runtime.Create called %d times", rt.created) + } + + // Simulate the user killing the relaunched session before the next quit, so + // it has no fresh marker, then a second boot. + if _, err := m.Kill(ctx, "mer-1"); err != nil { + t.Fatalf("kill err = %v", err) + } + if err := m.RestoreAll(ctx); err != nil { + t.Fatalf("second RestoreAll err = %v", err) + } + if rt.created != 1 { + t.Fatalf("killed session must NOT be resurrected on second boot, runtime.Create total = %d, want 1", rt.created) + } + if !st.sessions["mer-1"].IsTerminated { + t.Error("killed session must remain terminated after second RestoreAll") + } +} + // TestRestoreAll_AppliesPreservedRef: when the session_worktrees row has a // non-empty preserved_ref, RestoreAll calls ApplyPreserved after workspace // restore but before relaunching. diff --git a/frontend/assets/icon.png b/frontend/assets/icon.png index 7c65c0786..59b25614a 100644 Binary files a/frontend/assets/icon.png and b/frontend/assets/icon.png differ diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 48fd32e24..20e5b9850 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -883,6 +883,7 @@ export interface components { }; SpawnSessionRequest: { branch?: string; + displayName?: string; /** @enum {string} */ harness?: "claude-code" | "codex" | "aider" | "opencode" | "grok" | "droid" | "amp" | "agy" | "crush" | "cursor" | "qwen" | "copilot" | "goose" | "auggie" | "continue" | "devin" | "cline" | "kimi" | "kiro" | "kilocode" | "vibe" | "pi" | "autohand"; issueId?: string; diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 8e88420a2..c408e41c9 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -5,6 +5,7 @@ import { dialog, ipcMain, net, + nativeImage, Notification as ElectronNotification, protocol, shell, @@ -156,6 +157,16 @@ function windowIconPath(): string | undefined { return existsSync(candidate) ? candidate : undefined; } +function applyRuntimeAppIcon(): void { + if (process.platform !== "darwin") return; + const iconPath = windowIconPath(); + if (!iconPath) return; + const icon = nativeImage.createFromPath(iconPath); + if (!icon.isEmpty()) { + app.dock.setIcon(icon); + } +} + function setDaemonStatus(nextStatus: DaemonStatus): void { daemonStatus = nextStatus; mainWindow?.webContents.send("daemon:status", daemonStatus); @@ -952,6 +963,7 @@ app.whenReady().then(async () => { } registerRendererProtocol(); + applyRuntimeAppIcon(); createWindow(); void startDaemon(); initAutoUpdates(); diff --git a/frontend/src/renderer/components/NewTaskDialog.test.tsx b/frontend/src/renderer/components/NewTaskDialog.test.tsx index 456a2919e..d7e480f5a 100644 --- a/frontend/src/renderer/components/NewTaskDialog.test.tsx +++ b/frontend/src/renderer/components/NewTaskDialog.test.tsx @@ -1,15 +1,18 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { NewTaskDialog } from "./NewTaskDialog"; -const { postMock } = vi.hoisted(() => ({ +const { getMock, postMock } = vi.hoisted(() => ({ + getMock: vi.fn(), postMock: vi.fn(), })); vi.mock("../lib/api-client", () => ({ apiClient: { - POST: postMock, + GET: (...args: unknown[]) => getMock(...args), + POST: (...args: unknown[]) => postMock(...args), }, apiErrorMessage: (error: unknown, fallback = "Request failed") => { if (typeof error === "object" && error !== null && "message" in error) { @@ -19,27 +22,48 @@ vi.mock("../lib/api-client", () => ({ }, })); +function renderDialog() { + const onCreated = vi.fn(); + const onOpenChange = vi.fn(); + render( + + + , + ); + return { onCreated, onOpenChange }; +} + +function spawnBody() { + return (postMock.mock.calls[0][1] as { body: Record }).body; +} + beforeEach(() => { - postMock.mockReset(); - postMock.mockResolvedValue({ data: { session: { id: "task-1" } }, error: undefined }); + getMock.mockReset().mockResolvedValue({ + data: { status: "ok", project: { id: "proj-1", config: { worker: { agent: "claude-code" } } } }, + error: undefined, + }); + postMock.mockReset().mockResolvedValue({ data: { session: { id: "task-1" } }, error: undefined }); }); -describe("NewTaskDialog", () => { - it("starts a worker task with the entered title and brief", async () => { - const onCreated = vi.fn(); - const onOpenChange = vi.fn(); - render(); +afterEach(() => vi.restoreAllMocks()); - await userEvent.type(screen.getByLabelText("Title"), "Fix fallback renderer"); - await userEvent.type(screen.getByLabelText("Brief"), "Restore the fallback renderer after WebGL init fails."); - await userEvent.click(screen.getByRole("button", { name: "Start task" })); +describe("NewTaskDialog", () => { + it("preselects the project's default agent and omits harness so the daemon applies it", async () => { + const { onCreated, onOpenChange } = renderDialog(); + const user = userEvent.setup(); + + await screen.findByText("claude-code"); + + await user.type(screen.getByLabelText("Title"), "Fix fallback renderer"); + await user.type(screen.getByLabelText("Brief"), "Restore the fallback renderer after WebGL init fails."); + await user.click(screen.getByRole("button", { name: "Start task" })); await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1)); expect(postMock).toHaveBeenCalledWith("/api/v1/sessions", { body: { projectId: "proj-1", kind: "worker", - harness: "codex", + harness: undefined, issueId: "Fix fallback renderer", prompt: "Restore the fallback renderer after WebGL init fails.", branch: undefined, @@ -49,10 +73,28 @@ describe("NewTaskDialog", () => { expect(onOpenChange).toHaveBeenCalledWith(false); }); - it("requires both title and brief", async () => { - render(); + it("sends the chosen harness when the user overrides the default, including agents beyond the legacy four", async () => { + renderDialog(); + const user = userEvent.setup(); + await screen.findByText("claude-code"); - await userEvent.click(screen.getByRole("button", { name: "Start task" })); + await user.type(screen.getByLabelText("Title"), "T"); + await user.type(screen.getByLabelText("Brief"), "B"); + + await user.click(screen.getByRole("combobox", { name: "Agent" })); + await user.click(await screen.findByRole("option", { name: "cursor" })); + + await user.click(screen.getByRole("button", { name: "Start task" })); + + await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1)); + expect(spawnBody().harness).toBe("cursor"); + }); + + it("requires both title and brief", async () => { + renderDialog(); + const user = userEvent.setup(); + + await user.click(screen.getByRole("button", { name: "Start task" })); expect(await screen.findByText("Title and brief are required.")).toBeInTheDocument(); expect(postMock).not.toHaveBeenCalled(); diff --git a/frontend/src/renderer/components/NewTaskDialog.tsx b/frontend/src/renderer/components/NewTaskDialog.tsx index 385eb4076..cef37d5b8 100644 --- a/frontend/src/renderer/components/NewTaskDialog.tsx +++ b/frontend/src/renderer/components/NewTaskDialog.tsx @@ -1,12 +1,16 @@ import * as Dialog from "@radix-ui/react-dialog"; +import { useQuery } from "@tanstack/react-query"; import { Loader2, X } from "lucide-react"; import { type FormEvent, useEffect, useId, useState } from "react"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; +import { RequiredAgentField } from "./CreateProjectAgentSheet"; +import type { components } from "../../api/schema"; import { apiClient, apiErrorMessage } from "../lib/api-client"; import type { AgentProvider } from "../types/workspace"; +type Project = components["schemas"]["Project"]; + type NewTaskDialogProps = { open: boolean; projectId?: string; @@ -14,35 +18,51 @@ type NewTaskDialogProps = { onOpenChange: (open: boolean) => void; }; -const AGENTS: Array<{ value: AgentProvider; label: string }> = [ - { value: "codex", label: "Codex" }, - { value: "claude-code", label: "Claude Code" }, - { value: "opencode", label: "OpenCode" }, - { value: "aider", label: "Aider" }, -]; - export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewTaskDialogProps) { const titleId = useId(); const promptId = useId(); const branchId = useId(); + const agentId = useId(); const [title, setTitle] = useState(""); const [prompt, setPrompt] = useState(""); const [branch, setBranch] = useState(""); - const [agent, setAgent] = useState("codex"); + const [agent, setAgent] = useState(""); + const [agentTouched, setAgentTouched] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(); + const projectQuery = useQuery({ + queryKey: ["project", projectId], + enabled: open && Boolean(projectId), + queryFn: async () => { + const { data, error: apiError } = await apiClient.GET("/api/v1/projects/{id}", { + params: { path: { id: projectId as string } }, + }); + if (apiError) throw new Error(apiErrorMessage(apiError)); + if (data?.status !== "ok") throw new Error("Project config is unavailable."); + return data.project as Project; + }, + }); + const defaultWorkerAgent = projectQuery.data?.config?.worker?.agent ?? ""; + useEffect(() => { if (!open) { setTitle(""); setPrompt(""); setBranch(""); - setAgent("codex"); + setAgent(""); + setAgentTouched(false); setError(undefined); setIsSubmitting(false); } }, [open]); + useEffect(() => { + if (open && !agentTouched) { + setAgent(defaultWorkerAgent); + } + }, [open, agentTouched, defaultWorkerAgent]); + const submit = async (event: FormEvent) => { event.preventDefault(); if (!projectId || isSubmitting) return; @@ -62,7 +82,7 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT body: { projectId, kind: "worker", - harness: agent, + harness: agentTouched && agent ? (agent as AgentProvider) : undefined, issueId: cleanTitle, prompt: cleanPrompt, branch: cleanBranch || undefined, @@ -130,21 +150,16 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT
-
- - -
+ { + setAgent(value); + setAgentTouched(true); + }} + />