feat: require explicit project agents (#355)

* feat: require explicit project agents

* chore: format with prettier [skip ci]

* test: configure integration project agents

* fix: center project agent selection dialog

* fix: keep agent selects neutral before validation

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Adil Shaikh 2026-06-21 06:07:53 +05:30 committed by GitHub
parent 5e8c8defbd
commit 72b757b203
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 540 additions and 206 deletions

View File

@ -21,7 +21,8 @@ progress (what's shipped vs. in flight) see [`docs/STATUS.md`](docs/STATUS.md).
`opencode`, `aider`, `amp`, `goose`, `copilot`, `grok`, `qwen`, `kimi`, `opencode`, `aider`, `amp`, `goose`, `copilot`, `grok`, `qwen`, `kimi`,
`crush`, `cline`, `droid`, `devin`, `auggie`, `continue`, `kiro`, `kilocode`, `crush`, `cline`, `droid`, `devin`, `auggie`, `continue`, `kiro`, `kilocode`,
and more), registered through a shared registry with common and more), registered through a shared registry with common
activity-dispatch / hook utilities. The default is set by `AO_AGENT`. activity-dispatch / hook utilities. Worker and orchestrator defaults are set
per project.
- **Isolated workspaces.** Worker and orchestrator sessions spawn into their own - **Isolated workspaces.** Worker and orchestrator sessions spawn into their own
`git worktree` (`backend/internal/adapters/workspace/gitworktree/`), launched `git worktree` (`backend/internal/adapters/workspace/gitworktree/`), launched
inside a `zellij` runtime adapter (`backend/internal/adapters/runtime/`) so inside a `zellij` runtime adapter (`backend/internal/adapters/runtime/`) so
@ -92,9 +93,10 @@ go build -o /tmp/ao ./cmd/ao
# Register a local git repo as a project. The id defaults to the lowercased # Register a local git repo as a project. The id defaults to the lowercased
# base of --path; pass --id explicitly when the directory name doesn't match. # base of --path; pass --id explicitly when the directory name doesn't match.
/tmp/ao project add --path /path/to/your/repo --id your-repo --name your-repo /tmp/ao project add --path /path/to/your/repo --id your-repo --name your-repo \
--worker-agent codex --orchestrator-agent codex
# Spawn a worker session running the default agent. # Spawn a worker session running the project's worker agent.
/tmp/ao spawn --project your-repo --prompt "Refactor the auth module" /tmp/ao spawn --project your-repo --prompt "Refactor the auth module"
# Inspect what's running. # Inspect what's running.
@ -167,7 +169,7 @@ exposing it beyond loopback would be a security regression.
| `AO_SHUTDOWN_TIMEOUT` | `10s` | Graceful-shutdown hard cap. | | `AO_SHUTDOWN_TIMEOUT` | `10s` | Graceful-shutdown hard cap. |
| `AO_RUN_FILE` | `<UserConfigDir>/agent-orchestrator/running.json` | PID + port handshake path. | | `AO_RUN_FILE` | `<UserConfigDir>/agent-orchestrator/running.json` | PID + port handshake path. |
| `AO_DATA_DIR` | `<UserConfigDir>/agent-orchestrator/data` | SQLite DB, WAL files, managed state. | | `AO_DATA_DIR` | `<UserConfigDir>/agent-orchestrator/data` | SQLite DB, WAL files, managed state. |
| `AO_AGENT` | `claude-code` | Default agent adapter id used by `ao spawn`. | | `AO_AGENT` | `claude-code` | Compatibility agent adapter id validated at daemon startup. |
| `AO_SESSION_ID` | _(unset)_ | Set inside spawned sessions; read by `ao send` and `ao hooks`. | | `AO_SESSION_ID` | _(unset)_ | Set inside spawned sessions; read by `ao send` and `ao hooks`. |
| `GITHUB_TOKEN` | _(unset)_ | Used by the GitHub SCM and tracker adapters. Falls back to `gh auth token`. | | `GITHUB_TOKEN` | _(unset)_ | Used by the GitHub SCM and tracker adapters. Falls back to `gh auth token`. |

View File

@ -222,6 +222,8 @@ func TestE2E_SpawnAndProjectAddDTORoundTrip(t *testing.T) {
"--path", "/repo/mer", "--path", "/repo/mer",
"--id", "demo", "--id", "demo",
"--name", "Demo", "--name", "Demo",
"--worker-agent", "codex",
"--orchestrator-agent", "claude-code",
"--as-workspace", "--as-workspace",
}) })
if err := root.Execute(); err != nil { if err := root.Execute(); err != nil {
@ -238,6 +240,15 @@ func TestE2E_SpawnAndProjectAddDTORoundTrip(t *testing.T) {
if got.Name == nil || *got.Name != "Demo" { if got.Name == nil || *got.Name != "Demo" {
t.Errorf("Name = %v, want %q", got.Name, "Demo") t.Errorf("Name = %v, want %q", got.Name, "Demo")
} }
if got.Config == nil {
t.Fatal("Config = nil, want role agent config")
}
if got.Config.Worker.Harness != domain.HarnessCodex {
t.Errorf("Config.Worker.Harness = %q, want codex", got.Config.Worker.Harness)
}
if got.Config.Orchestrator.Harness != domain.HarnessClaudeCode {
t.Errorf("Config.Orchestrator.Harness = %q, want claude-code", got.Config.Orchestrator.Harness)
}
if !got.AsWorkspace { if !got.AsWorkspace {
t.Errorf("AsWorkspace = false, want true (CLI json:\"asWorkspace\" vs AddInput)") t.Errorf("AsWorkspace = false, want true (CLI json:\"asWorkspace\" vs AddInput)")
} }

View File

@ -15,10 +15,12 @@ import (
) )
type projectAddOptions struct { type projectAddOptions struct {
path string path string
id string id string
name string name string
asWorkspace bool workerAgent string
orchestratorAgent string
asWorkspace bool
} }
type projectListOptions struct { type projectListOptions struct {
@ -37,10 +39,11 @@ type projectRemoveOptions struct {
// addProjectRequest mirrors the daemon's project AddInput body for // addProjectRequest mirrors the daemon's project AddInput body for
// POST /api/v1/projects. projectId and name are optional (pointers omit them). // POST /api/v1/projects. projectId and name are optional (pointers omit them).
type addProjectRequest struct { type addProjectRequest struct {
Path string `json:"path"` Path string `json:"path"`
ProjectID *string `json:"projectId,omitempty"` ProjectID *string `json:"projectId,omitempty"`
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
AsWorkspace bool `json:"asWorkspace,omitempty"` Config *projectConfig `json:"config,omitempty"`
AsWorkspace bool `json:"asWorkspace,omitempty"`
} }
type projectSummary struct { type projectSummary struct {
@ -58,7 +61,7 @@ type projectDetails struct {
Path string `json:"path"` Path string `json:"path"`
Repo string `json:"repo"` Repo string `json:"repo"`
DefaultBranch string `json:"defaultBranch"` DefaultBranch string `json:"defaultBranch"`
DefaultHarness string `json:"agent,omitempty"` Agent string `json:"agent,omitempty"`
Config *projectConfig `json:"config,omitempty"` Config *projectConfig `json:"config,omitempty"`
WorkspaceRepos []workspaceRepoDetails `json:"workspaceRepos,omitempty"` WorkspaceRepos []workspaceRepoDetails `json:"workspaceRepos,omitempty"`
ResolveError string `json:"resolveError,omitempty"` ResolveError string `json:"resolveError,omitempty"`
@ -226,6 +229,12 @@ func newProjectAddCommand(ctx *commandContext) *cobra.Command {
if opts.name != "" { if opts.name != "" {
req.Name = &opts.name req.Name = &opts.name
} }
if opts.workerAgent != "" || opts.orchestratorAgent != "" {
req.Config = &projectConfig{
Worker: roleOverride{Agent: opts.workerAgent},
Orchestrator: roleOverride{Agent: opts.orchestratorAgent},
}
}
var res projectResult var res projectResult
if err := ctx.postJSON(cmd.Context(), "projects", req, &res); err != nil { if err := ctx.postJSON(cmd.Context(), "projects", req, &res); err != nil {
return err return err
@ -238,6 +247,8 @@ func newProjectAddCommand(ctx *commandContext) *cobra.Command {
f.StringVar(&opts.path, "path", "", "Absolute path to the local git repo (required)") f.StringVar(&opts.path, "path", "", "Absolute path to the local git repo (required)")
f.StringVar(&opts.id, "id", "", "Project id (default: derived by the daemon from the path)") f.StringVar(&opts.id, "id", "", "Project id (default: derived by the daemon from the path)")
f.StringVar(&opts.name, "name", "", "Display name") f.StringVar(&opts.name, "name", "", "Display name")
f.StringVar(&opts.workerAgent, "worker-agent", "", "Default worker session agent")
f.StringVar(&opts.orchestratorAgent, "orchestrator-agent", "", "Default orchestrator session agent")
f.BoolVar(&opts.asWorkspace, "as-workspace", false, "Register a parent folder as a workspace project (root-as-repo plus direct child repos)") f.BoolVar(&opts.asWorkspace, "as-workspace", false, "Register a parent folder as a workspace project (root-as-repo plus direct child repos)")
return cmd return cmd
} }
@ -443,7 +454,7 @@ func writeProjectDetails(cmd *cobra.Command, res projectGetResult) error {
{label: "path", value: p.Path}, {label: "path", value: p.Path},
{label: "repo", value: p.Repo}, {label: "repo", value: p.Repo},
{label: "default branch", value: p.DefaultBranch}, {label: "default branch", value: p.DefaultBranch},
{label: "default harness", value: p.DefaultHarness}, {label: "agent", value: p.Agent},
{label: "config", value: formatProjectConfig(p.Config)}, {label: "config", value: formatProjectConfig(p.Config)},
{label: "resolve error", value: p.ResolveError}, {label: "resolve error", value: p.ResolveError},
} }

View File

@ -107,7 +107,7 @@ func TestProjectGet_Success(t *testing.T) {
if capture.method != http.MethodGet || capture.path != "/api/v1/projects/demo" { if capture.method != http.MethodGet || capture.path != "/api/v1/projects/demo" {
t.Fatalf("request = %s %s, want GET /api/v1/projects/demo", capture.method, capture.path) t.Fatalf("request = %s %s, want GET /api/v1/projects/demo", capture.method, capture.path)
} }
for _, want := range []string{"Project demo (ok)", "name: Demo", "path: /repo/demo", "default branch: main", "default harness: codex"} { for _, want := range []string{"Project demo (ok)", "name: Demo", "path: /repo/demo", "default branch: main", "agent: codex"} {
if !strings.Contains(out, want) { if !strings.Contains(out, want) {
t.Fatalf("output missing %q:\n%s", want, out) t.Fatalf("output missing %q:\n%s", want, out)
} }

View File

@ -44,7 +44,7 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
Use: "spawn", Use: "spawn",
Short: "Spawn a worker agent session in a registered project", Short: "Spawn a worker agent session in a registered project",
Long: "Spawn a worker agent session in a registered project.\n\n" + Long: "Spawn a worker agent session in a registered project.\n\n" +
"The session runs the chosen agent (default: the daemon's AO_AGENT) in a\n" + "The session runs the chosen agent in a\n" +
"fresh git worktree. Register the project first with `ao project add`.", "fresh git worktree. Register the project first with `ao project add`.",
Args: noArgs, Args: noArgs,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
@ -120,7 +120,7 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
return pflag.NormalizedName(name) return pflag.NormalizedName(name)
}) })
f.StringVar(&opts.project, "project", "", "Project id to spawn the session in (required)") f.StringVar(&opts.project, "project", "", "Project id to spawn the session in (required)")
f.StringVar(&opts.harness, "harness", "", "Agent harness / --agent: claude-code, codex, aider, opencode, grok, droid, amp, agy, crush, cursor, qwen, copilot, goose, auggie, continue, devin, cline, kimi, kiro, kilocode, vibe, pi, autohand (default: the daemon's AO_AGENT)") f.StringVar(&opts.harness, "harness", "", "Agent harness / --agent: claude-code, codex, aider, opencode, grok, droid, amp, agy, crush, cursor, qwen, copilot, goose, auggie, continue, devin, cline, kimi, kiro, kilocode, vibe, pi, autohand (default: project worker.agent; required if the project has none)")
f.StringVar(&opts.branch, "branch", "", "Branch for the session worktree (default: ao/<session-id>/root)") 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.prompt, "prompt", "", "Initial prompt for the agent")
f.StringVar(&opts.issue, "issue", "", "Issue id to associate with the session") f.StringVar(&opts.issue, "issue", "", "Issue id to associate with the session")

View File

@ -29,8 +29,9 @@ const (
// DefaultShutdownTimeout is the hard cap on graceful shutdown. After this // DefaultShutdownTimeout is the hard cap on graceful shutdown. After this
// the process exits even if connections are still draining. // the process exits even if connections are still draining.
DefaultShutdownTimeout = 10 * time.Second DefaultShutdownTimeout = 10 * time.Second
// DefaultAgent is the agent adapter id the daemon wires when AO_AGENT is // DefaultAgent is the compatibility value used when AO_AGENT is unset. The
// unset. It matches the claude-code adapter's manifest id. // daemon validates it at startup, but worker/orchestrator spawns resolve from
// explicit requests or project role config instead of falling back to it.
DefaultAgent = "claude-code" DefaultAgent = "claude-code"
// DefaultTelemetryPostHogHost is the default PostHog ingestion host when // DefaultTelemetryPostHogHost is the default PostHog ingestion host when
// remote telemetry is enabled and AO_TELEMETRY_POSTHOG_HOST is unset. // remote telemetry is enabled and AO_TELEMETRY_POSTHOG_HOST is unset.
@ -85,9 +86,8 @@ type Config struct {
// DataDir is the directory holding durable SQLite state: DB and WAL files. // DataDir is the directory holding durable SQLite state: DB and WAL files.
// It is created on first use by the storage layer. // It is created on first use by the storage layer.
DataDir string DataDir string
// Agent is the id of the agent adapter the daemon wires into the Session // Agent is the compatibility agent adapter id selected by AO_AGENT;
// Manager (see DefaultAgent). Selected by AO_AGENT; startSession fails fast // startSession fails fast if no adapter with this id is registered.
// if no adapter with this id is registered.
Agent string Agent string
// AllowedOrigins are the browser origins granted CORS read access (see // AllowedOrigins are the browser origins granted CORS read access (see
// DefaultAllowedOrigins). Overridden by AO_ALLOWED_ORIGINS. // DefaultAllowedOrigins). Overridden by AO_ALLOWED_ORIGINS.
@ -113,7 +113,7 @@ func (c Config) Addr() string {
// AO_SHUTDOWN_TIMEOUT shutdown deadline (Go duration > 0, default 10s) // AO_SHUTDOWN_TIMEOUT shutdown deadline (Go duration > 0, default 10s)
// AO_RUN_FILE running.json path (default ~/.ao/running.json) // AO_RUN_FILE running.json path (default ~/.ao/running.json)
// AO_DATA_DIR durable state dir (default ~/.ao/data) // AO_DATA_DIR durable state dir (default ~/.ao/data)
// AO_AGENT agent adapter id (default claude-code) // AO_AGENT compatibility agent id (default claude-code)
// AO_ALLOWED_ORIGINS CORS origins, comma-separated (default DefaultAllowedOrigins) // AO_ALLOWED_ORIGINS CORS origins, comma-separated (default DefaultAllowedOrigins)
// AO_TELEMETRY_EVENTS local event capture off|on (default off) // AO_TELEMETRY_EVENTS local event capture off|on (default off)
// AO_TELEMETRY_METRICS local metric capture off|on (default off) // AO_TELEMETRY_METRICS local metric capture off|on (default off)

View File

@ -116,7 +116,7 @@ func Run() error {
// Wire the controller-facing session service over the same store + LCM, the // Wire the controller-facing session service over the same store + LCM, the
// zellij runtime, a gitworktree workspace, the per-session agent resolver // zellij runtime, a gitworktree workspace, the per-session agent resolver
// (AO_AGENT default, validated here), and the agent messenger, then mount it // (AO_AGENT validated here for compatibility), and the agent messenger, then mount it
// on the API. // on the API.
sessionSvc, reviewSvc, err := startSession(cfg, runtimeAdapter, store, lcStack.LCM, messenger, telemetrySink, log) sessionSvc, reviewSvc, err := startSession(cfg, runtimeAdapter, store, lcStack.LCM, messenger, telemetrySink, log)
if err != nil { if err != nil {

View File

@ -60,12 +60,9 @@ func (l *lifecycleStack) Stop() {
// startSession builds the controller-facing session service: a session manager // startSession builds the controller-facing session service: a session manager
// over the real zellij runtime, a per-session gitworktree workspace, the shared // over the real zellij runtime, a per-session gitworktree workspace, the shared
// store + LCM, the per-session agent resolver (AO_AGENT default), and the // store + LCM, the per-session agent resolver, and the agent messenger. The
// agent messenger. The returned service is mounted at httpd APIDeps.Sessions. // returned service is mounted at httpd APIDeps.Sessions.
func startSession(cfg config.Config, runtime *zellij.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, telemetry ports.EventSink, log *slog.Logger) (*sessionsvc.Service, reviewsvc.Manager, error) { func startSession(cfg config.Config, runtime *zellij.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, telemetry ports.EventSink, log *slog.Logger) (*sessionsvc.Service, reviewsvc.Manager, error) {
// Resolve the default agent once and share it with both the resolver (which
// launches it for an unspecified harness) and the session manager (which
// persists it onto the seed row), so the stored harness matches what runs.
defaultAgent := cfg.Agent defaultAgent := cfg.Agent
if defaultAgent == "" { if defaultAgent == "" {
defaultAgent = config.DefaultAgent defaultAgent = config.DefaultAgent
@ -87,15 +84,14 @@ func startSession(cfg config.Config, runtime *zellij.Runtime, store *sqlite.Stor
return nil, nil, fmt.Errorf("session workspace: %w", err) return nil, nil, fmt.Errorf("session workspace: %w", err)
} }
mgr := sessionmanager.New(sessionmanager.Deps{ mgr := sessionmanager.New(sessionmanager.Deps{
Runtime: runtime, Runtime: runtime,
Agents: agents, Agents: agents,
Workspace: ws, Workspace: ws,
Store: store, Store: store,
Messenger: messenger, Messenger: messenger,
Lifecycle: lcm, Lifecycle: lcm,
DataDir: cfg.DataDir, DataDir: cfg.DataDir,
DefaultHarness: domain.AgentHarness(defaultAgent), Logger: log,
Logger: log,
}) })
scmProvider, err := newGitHubSCMProvider(log) scmProvider, err := newGitHubSCMProvider(log)
if err != nil { if err != nil {
@ -179,20 +175,15 @@ func buildAgentRegistry() (*adapters.Registry, error) {
// agentRegistry adapts the generic adapter Registry to ports.AgentResolver: it // agentRegistry adapts the generic adapter Registry to ports.AgentResolver: it
// maps a session's harness onto the registered adapter of the same id and // maps a session's harness onto the registered adapter of the same id and
// asserts that adapter drives an agent. An empty harness falls back to the // asserts that adapter drives an agent. Empty harnesses are invalid at the
// daemon's configured default (AO_AGENT), so a spawn that names no harness still // session manager boundary and deliberately do not resolve here.
// gets a real agent.
type agentRegistry struct { type agentRegistry struct {
reg *adapters.Registry reg *adapters.Registry
defaultHarness domain.AgentHarness
} }
var _ ports.AgentResolver = agentRegistry{} var _ ports.AgentResolver = agentRegistry{}
func (a agentRegistry) Agent(harness domain.AgentHarness) (ports.Agent, bool) { func (a agentRegistry) Agent(harness domain.AgentHarness) (ports.Agent, bool) {
if harness == "" {
harness = a.defaultHarness
}
adapter, ok := a.reg.Get(string(harness)) adapter, ok := a.reg.Get(string(harness))
if !ok { if !ok {
return nil, false return nil, false
@ -203,10 +194,9 @@ func (a agentRegistry) Agent(harness domain.AgentHarness) (ports.Agent, bool) {
// buildAgentResolver constructs the per-session agent resolver the Session // buildAgentResolver constructs the per-session agent resolver the Session
// Manager consumes (sessionmanager.Deps.Agents): a registry of the shipped // Manager consumes (sessionmanager.Deps.Agents): a registry of the shipped
// adapters plus the configured default harness. It fails fast if the default // adapters. It still validates AO_AGENT at startup for compatibility with the
// does not resolve, so a typo'd AO_AGENT surfaces at startup. The session lane // config surface, but worker/orchestrator spawns must provide a resolved
// plugs this in when it mounts the controller-facing session service at the // harness before calling Agent.
// httpd APIDeps.Sessions slot.
func buildAgentResolver(defaultAgent string, log *slog.Logger) (ports.AgentResolver, error) { func buildAgentResolver(defaultAgent string, log *slog.Logger) (ports.AgentResolver, error) {
if defaultAgent == "" { if defaultAgent == "" {
defaultAgent = config.DefaultAgent defaultAgent = config.DefaultAgent
@ -215,8 +205,8 @@ func buildAgentResolver(defaultAgent string, log *slog.Logger) (ports.AgentResol
if err != nil { if err != nil {
return nil, err return nil, err
} }
resolver := agentRegistry{reg: reg, defaultHarness: domain.AgentHarness(defaultAgent)} resolver := agentRegistry{reg: reg}
if _, ok := resolver.Agent(""); !ok { if _, ok := resolver.Agent(domain.AgentHarness(defaultAgent)); !ok {
return nil, fmt.Errorf("configured default agent %q is not a registered adapter", defaultAgent) return nil, fmt.Errorf("configured default agent %q is not a registered adapter", defaultAgent)
} }
ids := make([]string, 0) ids := make([]string, 0)

View File

@ -79,8 +79,7 @@ func TestWiring_WriteFlowsToBroadcaster(t *testing.T) {
// TestWiring_AgentResolverResolvesRealAdapters asserts buildAgentResolver wires a // TestWiring_AgentResolverResolvesRealAdapters asserts buildAgentResolver wires a
// real registry-backed per-session resolver: each harness resolves to the // real registry-backed per-session resolver: each harness resolves to the
// matching registered adapter, an empty harness falls back to the AO_AGENT // matching registered adapter, while empty and unknown harnesses miss.
// default, and an unknown harness misses.
func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) { func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil)) log := slog.New(slog.NewTextHandler(io.Discard, nil))
resolver, err := buildAgentResolver("", log) // empty default → claude-code resolver, err := buildAgentResolver("", log) // empty default → claude-code
@ -114,7 +113,6 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) {
{domain.HarnessVibe, "vibe"}, {domain.HarnessVibe, "vibe"},
{domain.HarnessPi, "pi"}, {domain.HarnessPi, "pi"},
{domain.HarnessAutohand, "autohand"}, {domain.HarnessAutohand, "autohand"},
{"", config.DefaultAgent}, // empty harness falls back to the AO_AGENT default
} { } {
agent, ok := resolver.Agent(tc.harness) agent, ok := resolver.Agent(tc.harness)
if !ok { if !ok {
@ -131,6 +129,9 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) {
if _, ok := resolver.Agent("definitely-not-an-agent"); ok { if _, ok := resolver.Agent("definitely-not-an-agent"); ok {
t.Fatal("unknown harness resolved to an agent; want a miss") t.Fatal("unknown harness resolved to an agent; want a miss")
} }
if _, ok := resolver.Agent(""); ok {
t.Fatal("empty harness resolved to an agent; want a miss")
}
} }
// TestWiring_StartSessionBuildsSessionService asserts the daemon's startSession // TestWiring_StartSessionBuildsSessionService asserts the daemon's startSession

View File

@ -89,7 +89,15 @@ func newStack(t *testing.T) *stack {
t.Fatal(err) t.Fatal(err)
} }
t.Cleanup(func() { _ = store.Close() }) t.Cleanup(func() { _ = store.Close() })
if err := store.UpsertProject(ctx, domain.ProjectRecord{ID: "mer", Path: "/repo/mer", RegisteredAt: time.Now()}); err != nil { if err := store.UpsertProject(ctx, domain.ProjectRecord{
ID: "mer",
Path: "/repo/mer",
RegisteredAt: time.Now(),
Config: domain.ProjectConfig{
Worker: domain.RoleOverride{Harness: domain.HarnessClaudeCode},
Orchestrator: domain.RoleOverride{Harness: domain.HarnessClaudeCode},
},
}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
msg := &captureMessenger{} msg := &captureMessenger{}

View File

@ -452,6 +452,8 @@ func toAPIError(err error) error {
return apierr.Invalid("PROJECT_NOT_RESOLVABLE", "Project is not registered or has no repo — register it with `ao project add`", nil) return apierr.Invalid("PROJECT_NOT_RESOLVABLE", "Project is not registered or has no repo — register it with `ao project add`", nil)
case errors.Is(err, sessionmanager.ErrUnknownHarness): case errors.Is(err, sessionmanager.ErrUnknownHarness):
return apierr.Invalid("UNKNOWN_HARNESS", err.Error(), nil) return apierr.Invalid("UNKNOWN_HARNESS", err.Error(), nil)
case errors.Is(err, sessionmanager.ErrMissingHarness):
return apierr.Invalid("AGENT_REQUIRED", err.Error(), nil)
case errors.Is(err, ports.ErrWorkspaceBranchCheckedOutElsewhere): case errors.Is(err, ports.ErrWorkspaceBranchCheckedOutElsewhere):
return apierr.Conflict("BRANCH_CHECKED_OUT_ELSEWHERE", err.Error(), nil) return apierr.Conflict("BRANCH_CHECKED_OUT_ELSEWHERE", err.Error(), nil)
case errors.Is(err, ports.ErrWorkspaceBranchNotFetched): case errors.Is(err, ports.ErrWorkspaceBranchNotFetched):

View File

@ -432,6 +432,7 @@ func TestToAPIErrorMapsWorkspaceBranchSentinels(t *testing.T) {
{"invalid branch", fmt.Errorf("spawn mer-1: workspace: %w: \"bad!!\" (exit 1)", ports.ErrWorkspaceBranchInvalid), apierr.KindInvalid, "INVALID_BRANCH"}, {"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"}, {"agent binary not found", fmt.Errorf("spawn mer-1: %w", ports.ErrAgentBinaryNotFound), apierr.KindInvalid, "AGENT_BINARY_NOT_FOUND"},
{"unknown harness", fmt.Errorf("spawn: %w: %q", sessionmanager.ErrUnknownHarness, "bogus"), apierr.KindInvalid, "UNKNOWN_HARNESS"}, {"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"},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {

View File

@ -32,6 +32,9 @@ var (
// adapter. The API maps it to a 400 so a typo'd `--harness` is a validation // adapter. The API maps it to a 400 so a typo'd `--harness` is a validation
// error, not an opaque 500. // error, not an opaque 500.
ErrUnknownHarness = errors.New("session: unknown agent harness") ErrUnknownHarness = errors.New("session: unknown agent harness")
// ErrMissingHarness means neither the spawn request nor the project's role
// config selected an agent. Worker/orchestrator spawns must be explicit.
ErrMissingHarness = errors.New("session: agent harness required")
) )
// Env vars a spawned process reads to learn who it is. // Env vars a spawned process reads to learn who it is.
@ -86,11 +89,7 @@ type Manager struct {
messenger ports.AgentMessenger messenger ports.AgentMessenger
lcm lifecycleRecorder lcm lifecycleRecorder
dataDir string dataDir string
// defaultHarness is the daemon's configured default agent (AO_AGENT). A spawn clock func() time.Time
// that names no harness resolves to it before the seed row is written, so the
// stored/returned harness matches the agent the resolver actually launches.
defaultHarness domain.AgentHarness
clock func() time.Time
// lookPath is exec.LookPath in production; tests substitute a stub so // lookPath is exec.LookPath in production; tests substitute a stub so
// they don't need real binaries on PATH. Returns ports.ErrAgentBinaryNotFound // they don't need real binaries on PATH. Returns ports.ErrAgentBinaryNotFound
// when the binary is missing so the sentinel propagates through toAPIError. // when the binary is missing so the sentinel propagates through toAPIError.
@ -113,12 +112,7 @@ type Deps struct {
// DataDir is exported to spawned agents as AO_DATA_DIR so their hook // DataDir is exported to spawned agents as AO_DATA_DIR so their hook
// commands can open the same store. // commands can open the same store.
DataDir string DataDir string
// DefaultHarness is the daemon's configured default agent (AO_AGENT), used to Clock func() time.Time
// resolve a spawn that names no harness. Wiring passes config.DefaultAgent;
// left empty, an unspecified harness stays empty (the resolver still defaults
// it at launch, but the record won't reflect the real agent).
DefaultHarness domain.AgentHarness
Clock func() time.Time
// LookPath overrides exec.LookPath for the pre-launch agent-binary check. // LookPath overrides exec.LookPath for the pre-launch agent-binary check.
// Production wiring leaves this nil and the manager defaults to // Production wiring leaves this nil and the manager defaults to
// exec.LookPath; tests inject a stub so they need not seed real binaries. // exec.LookPath; tests inject a stub so they need not seed real binaries.
@ -136,18 +130,17 @@ type Deps struct {
// time.Now when Deps.Clock is nil. // time.Now when Deps.Clock is nil.
func New(d Deps) *Manager { func New(d Deps) *Manager {
m := &Manager{ m := &Manager{
runtime: d.Runtime, runtime: d.Runtime,
agents: d.Agents, agents: d.Agents,
workspace: d.Workspace, workspace: d.Workspace,
store: d.Store, store: d.Store,
messenger: d.Messenger, messenger: d.Messenger,
lcm: d.Lifecycle, lcm: d.Lifecycle,
dataDir: d.DataDir, dataDir: d.DataDir,
defaultHarness: d.DefaultHarness, clock: d.Clock,
clock: d.Clock, lookPath: d.LookPath,
lookPath: d.LookPath, executable: d.Executable,
executable: d.Executable, logger: d.Logger,
logger: d.Logger,
} }
if m.clock == nil { if m.clock == nil {
// UTC so spawn-stamped CreatedAt/UpdatedAt match every other session // UTC so spawn-stamped CreatedAt/UpdatedAt match every other session
@ -179,12 +172,8 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
// A per-project role override picks the harness when the spawn names none, // A per-project role override picks the harness when the spawn names none,
// so a project can default workers to one agent and orchestrators to another. // so a project can default workers to one agent and orchestrators to another.
cfg.Harness = effectiveHarness(cfg.Harness, cfg.Kind, project.Config) cfg.Harness = effectiveHarness(cfg.Harness, cfg.Kind, project.Config)
// Resolve an unspecified harness to the daemon default BEFORE the seed row is
// written, so the stored/returned harness matches the agent the resolver
// launches (otherwise a default-agent session persists an empty harness and
// the UI can't tell which agent is running).
if cfg.Harness == "" { if cfg.Harness == "" {
cfg.Harness = m.defaultHarness return domain.SessionRecord{}, fmt.Errorf("spawn: %w: configure project %s.agent or pass --harness", ErrMissingHarness, roleConfigName(cfg.Kind))
} }
// Reject an unknown harness before any durable state is created. Doing this // Reject an unknown harness before any durable state is created. Doing this
@ -307,8 +296,8 @@ func (m *Manager) loadProject(ctx context.Context, projectID domain.ProjectID) (
} }
// effectiveHarness resolves the harness for a spawn: an explicit harness wins; // effectiveHarness resolves the harness for a spawn: an explicit harness wins;
// otherwise the project's role override for the session kind applies; otherwise // otherwise the project's role override for the session kind applies. Empty is
// it stays empty so the daemon's global default (AO_AGENT) is used downstream. // invalid for new worker/orchestrator launches and is rejected by Spawn.
func effectiveHarness(explicit domain.AgentHarness, kind domain.SessionKind, cfg domain.ProjectConfig) domain.AgentHarness { func effectiveHarness(explicit domain.AgentHarness, kind domain.SessionKind, cfg domain.ProjectConfig) domain.AgentHarness {
if explicit != "" { if explicit != "" {
return explicit return explicit
@ -319,6 +308,13 @@ func effectiveHarness(explicit domain.AgentHarness, kind domain.SessionKind, cfg
return "" return ""
} }
func roleConfigName(kind domain.SessionKind) string {
if kind == domain.KindOrchestrator {
return "orchestrator"
}
return "worker"
}
// effectiveAgentConfig merges the role override's agent config over the // effectiveAgentConfig merges the role override's agent config over the
// project's base agent config; set override fields win. // project's base agent config; set override fields win.
func effectiveAgentConfig(kind domain.SessionKind, cfg domain.ProjectConfig) ports.AgentConfig { func effectiveAgentConfig(kind domain.SessionKind, cfg domain.ProjectConfig) ports.AgentConfig {

View File

@ -223,6 +223,7 @@ func (m *fakeMessenger) Send(_ context.Context, _ domain.SessionID, msg string)
func newManager() (*Manager, *fakeStore, *fakeRuntime, *fakeWorkspace) { func newManager() (*Manager, *fakeStore, *fakeRuntime, *fakeWorkspace) {
st := newFakeStore() st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
rt := &fakeRuntime{} rt := &fakeRuntime{}
ws := &fakeWorkspace{} ws := &fakeWorkspace{}
// Stub lookPath so the pre-launch agent-binary check passes; the fakeAgent // Stub lookPath so the pre-launch agent-binary check passes; the fakeAgent
@ -231,6 +232,12 @@ func newManager() (*Manager, *fakeStore, *fakeRuntime, *fakeWorkspace) {
m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
return m, st, rt, ws return m, st, rt, ws
} }
func testRoleAgents() domain.ProjectConfig {
return domain.ProjectConfig{
Worker: domain.RoleOverride{Harness: domain.HarnessClaudeCode},
Orchestrator: domain.RoleOverride{Harness: domain.HarnessClaudeCode},
}
}
func seedTerminal(st *fakeStore, id domain.SessionID, meta domain.SessionMetadata) { func seedTerminal(st *fakeStore, id domain.SessionID, meta domain.SessionMetadata) {
st.sessions[id] = domain.SessionRecord{ID: id, ProjectID: "mer", Metadata: meta, IsTerminated: true, Activity: domain.Activity{State: domain.ActivityExited}} st.sessions[id] = domain.SessionRecord{ID: id, ProjectID: "mer", Metadata: meta, IsTerminated: true, Activity: domain.Activity{State: domain.ActivityExited}}
} }
@ -273,10 +280,11 @@ func TestSpawn_ResolvesProjectConfig(t *testing.T) {
t.Fatal("runtime env missing AO_SESSION_ID") t.Fatal("runtime env missing AO_SESSION_ID")
} }
// A project with no stored config yields a zero AgentConfig (adapter defaults). // A project with no stored config yields a zero AgentConfig (adapter defaults)
// when the spawn explicitly names its agent.
st.projects["bare"] = domain.ProjectRecord{ID: "bare"} st.projects["bare"] = domain.ProjectRecord{ID: "bare"}
agent.lastConfig = ports.AgentConfig{Model: "stale"} agent.lastConfig = ports.AgentConfig{Model: "stale"}
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "bare", Kind: domain.KindWorker}); err != nil { if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "bare", Kind: domain.KindWorker, Harness: domain.HarnessCodex}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !agent.lastConfig.IsZero() { if !agent.lastConfig.IsZero() {
@ -284,30 +292,38 @@ func TestSpawn_ResolvesProjectConfig(t *testing.T) {
} }
} }
// TestSpawn_PersistsResolvedDefaultHarness locks the fix for the mislabelled func TestSpawn_RejectsMissingRoleHarness(t *testing.T) {
// agent: a spawn that names no harness must persist the daemon's default agent
// (so the API/UI report what actually runs), while an explicit harness wins.
func TestSpawn_PersistsResolvedDefaultHarness(t *testing.T) {
st := newFakeStore() st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer"} st.projects["mer"] = domain.ProjectRecord{ID: "mer"}
m := New(Deps{ m := New(Deps{
Runtime: &fakeRuntime{}, Agents: fakeAgents{}, Workspace: &fakeWorkspace{}, Store: st, Runtime: &fakeRuntime{}, Agents: fakeAgents{}, Workspace: &fakeWorkspace{}, Store: st,
Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st},
LookPath: func(string) (string, error) { return "/bin/true", nil }, LookPath: func(string) (string, error) { return "/bin/true", nil },
DefaultHarness: domain.HarnessClaudeCode,
}) })
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil { if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); !errors.Is(err, ErrMissingHarness) {
t.Fatal(err) t.Fatalf("worker err = %v, want ErrMissingHarness", err)
} }
if got := st.sessions["mer-1"].Harness; got != domain.HarnessClaudeCode { if len(st.sessions) != 0 {
t.Fatalf("unspecified harness = %q, want resolved default %q", got, domain.HarnessClaudeCode) t.Fatalf("missing worker harness must not create a session row, got %d", len(st.sessions))
} }
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindOrchestrator}); !errors.Is(err, ErrMissingHarness) {
t.Fatalf("orchestrator err = %v, want ErrMissingHarness", err)
}
}
func TestSpawn_ExplicitHarnessWinsWithoutProjectRoleHarness(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer"}
m := New(Deps{
Runtime: &fakeRuntime{}, Agents: fakeAgents{}, Workspace: &fakeWorkspace{}, Store: st,
Messenger: &fakeMessenger{}, 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, Harness: domain.HarnessCodex}); err != nil { if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessCodex}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := st.sessions["mer-2"].Harness; got != domain.HarnessCodex { if got := st.sessions["mer-1"].Harness; got != domain.HarnessCodex {
t.Fatalf("explicit harness = %q, want %q", got, domain.HarnessCodex) t.Fatalf("explicit harness = %q, want %q", got, domain.HarnessCodex)
} }
} }
@ -561,7 +577,10 @@ func TestSpawn_ForwardsResolvedAgentConfigPermissions(t *testing.T) {
st := newFakeStore() st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: domain.ProjectConfig{ st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: domain.ProjectConfig{
AgentConfig: domain.AgentConfig{Permissions: domain.PermissionModeAuto}, AgentConfig: domain.AgentConfig{Permissions: domain.PermissionModeAuto},
Worker: domain.RoleOverride{AgentConfig: domain.AgentConfig{Permissions: domain.PermissionModeBypassPermissions}}, Worker: domain.RoleOverride{
Harness: domain.HarnessClaudeCode,
AgentConfig: domain.AgentConfig{Permissions: domain.PermissionModeBypassPermissions},
},
}} }}
agent := &recordingAgent{} agent := &recordingAgent{}
lookPath := func(string) (string, error) { return "/bin/true", nil } lookPath := func(string) (string, error) { return "/bin/true", nil }
@ -610,6 +629,7 @@ func TestRestore_ForwardsResolvedAgentConfigPermissions(t *testing.T) {
func TestSpawnWorker_AppendsActiveOrchestratorContact(t *testing.T) { func TestSpawnWorker_AppendsActiveOrchestratorContact(t *testing.T) {
st := newFakeStore() st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
st.num = 1 st.num = 1
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator} st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator}
agent := &recordingAgent{} agent := &recordingAgent{}
@ -646,6 +666,7 @@ func TestSpawnWorker_AppendsActiveOrchestratorContact(t *testing.T) {
func TestSpawnWorker_SkipsTerminatedOrchestratorContact(t *testing.T) { func TestSpawnWorker_SkipsTerminatedOrchestratorContact(t *testing.T) {
st := newFakeStore() st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
st.num = 1 st.num = 1
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator, IsTerminated: true} st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator, IsTerminated: true}
agent := &recordingAgent{} agent := &recordingAgent{}
@ -666,6 +687,7 @@ func TestSpawnWorker_SkipsTerminatedOrchestratorContact(t *testing.T) {
func TestSpawnOrchestrator_UsesCoordinatorPrompt(t *testing.T) { func TestSpawnOrchestrator_UsesCoordinatorPrompt(t *testing.T) {
st := newFakeStore() st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
agent := &recordingAgent{} agent := &recordingAgent{}
rt := &fakeRuntime{} rt := &fakeRuntime{}
ws := &fakeWorkspace{} ws := &fakeWorkspace{}
@ -878,6 +900,7 @@ func TestRollbackSpawn_FallsBackToKillForLiveRow(t *testing.T) {
// reaper later mistakes for a live session. // reaper later mistakes for a live session.
func TestSpawn_RejectsMissingAgentBinary(t *testing.T) { func TestSpawn_RejectsMissingAgentBinary(t *testing.T) {
st := newFakeStore() st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
rt := &fakeRuntime{} rt := &fakeRuntime{}
ws := &fakeWorkspace{} ws := &fakeWorkspace{}
notFound := func(name string) (string, error) { notFound := func(name string) (string, error) {
@ -927,6 +950,7 @@ func TestSpawn_RejectsUnknownHarness(t *testing.T) {
// buffer capturing its log output, for the hook PATH pin tests. // buffer capturing its log output, for the hook PATH pin tests.
func pathPinManager(executable func() (string, error)) (*Manager, *fakeStore, *fakeRuntime, *bytes.Buffer) { func pathPinManager(executable func() (string, error)) (*Manager, *fakeStore, *fakeRuntime, *bytes.Buffer) {
st := newFakeStore() st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
rt := &fakeRuntime{} rt := &fakeRuntime{}
logBuf := &bytes.Buffer{} logBuf := &bytes.Buffer{}
lookPath := func(string) (string, error) { return "/bin/true", nil } lookPath := func(string) (string, error) { return "/bin/true", nil }
@ -1018,7 +1042,8 @@ func TestSpawn_ProjectPATHIsPinBase(t *testing.T) {
daemonExe := filepath.Join(t.TempDir(), "ao") daemonExe := filepath.Join(t.TempDir(), "ao")
m, st, rt, _ := pathPinManager(func() (string, error) { return daemonExe, nil }) m, st, rt, _ := pathPinManager(func() (string, error) { return daemonExe, nil })
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: domain.ProjectConfig{ st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: domain.ProjectConfig{
Env: map[string]string{"PATH": "/proj/bin"}, Env: map[string]string{"PATH": "/proj/bin"},
Worker: domain.RoleOverride{Harness: domain.HarnessClaudeCode},
}} }}
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil { if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil {
t.Fatal(err) t.Fatal(err)

View File

@ -0,0 +1,145 @@
import * as Dialog from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { useEffect, useState } from "react";
import { AGENT_OPTIONS } from "../lib/agent-options";
import { Button } from "./ui/button";
import { Label } from "./ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
export type CreateProjectAgentSelection = {
workerAgent: string;
orchestratorAgent: string;
};
type CreateProjectAgentSheetProps = {
error?: string | null;
isCreating: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (selection: CreateProjectAgentSelection) => Promise<void>;
open: boolean;
path: string | null;
};
export function CreateProjectAgentSheet({
error,
isCreating,
onOpenChange,
onSubmit,
open,
path,
}: CreateProjectAgentSheetProps) {
const [workerAgent, setWorkerAgent] = useState("");
const [orchestratorAgent, setOrchestratorAgent] = useState("");
const canSubmit = workerAgent !== "" && orchestratorAgent !== "" && !isCreating;
useEffect(() => {
if (!open) {
setWorkerAgent("");
setOrchestratorAgent("");
}
}, [open, path]);
return (
<Dialog.Root open={open} onOpenChange={(next) => !isCreating && onOpenChange(next)}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/55 data-[state=open]:animate-overlay-in" />
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-[min(420px,calc(100vw-32px))] -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-popover p-0 text-popover-foreground shadow-xl data-[state=open]:animate-modal-in">
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div className="min-w-0">
<Dialog.Title className="text-[15px] font-semibold text-foreground">Project agents</Dialog.Title>
<Dialog.Description className="mt-1 break-all text-[12px] text-muted-foreground">
{path ?? ""}
</Dialog.Description>
</div>
<Dialog.Close asChild>
<button
type="button"
className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground transition hover:bg-surface hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
aria-label="Close project agents dialog"
disabled={isCreating}
>
<X className="size-4" aria-hidden="true" />
</button>
</Dialog.Close>
</div>
<form
className="space-y-4 px-5 py-4"
onSubmit={(event) => {
event.preventDefault();
if (!canSubmit) return;
void onSubmit({ workerAgent, orchestratorAgent });
}}
>
<div className="grid gap-3 sm:grid-cols-2">
<RequiredAgentField
id="newProjectWorkerAgent"
label="Worker agent"
placeholder="Select worker agent"
value={workerAgent}
onChange={setWorkerAgent}
/>
<RequiredAgentField
id="newProjectOrchestratorAgent"
label="Orchestrator agent"
placeholder="Select orchestrator agent"
value={orchestratorAgent}
onChange={setOrchestratorAgent}
/>
</div>
{error && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12px] leading-5 text-destructive">
{error}
</div>
)}
<div className="flex items-center justify-end gap-2 pt-1">
<Button type="button" variant="ghost" disabled={isCreating} onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!canSubmit}>
{isCreating ? "Creating..." : "Create and start"}
</Button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
export function RequiredAgentField({
id,
invalid = false,
label,
onChange,
placeholder,
value,
}: {
id: string;
invalid?: boolean;
label: string;
onChange: (value: string) => void;
placeholder: string;
value: string;
}) {
return (
<div className="flex flex-col gap-1.5">
<Label htmlFor={id} className="text-[12px] font-medium text-muted-foreground">
{label}
</Label>
<Select value={value} onValueChange={onChange}>
<SelectTrigger id={id} className="h-8 w-full text-[13px]" aria-invalid={invalid || undefined}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{AGENT_OPTIONS.map((agent) => (
<SelectItem key={agent} value={agent}>
{agent}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@ -149,6 +149,10 @@ describe("ProjectSettingsForm", () => {
path: "/repo/project-one", path: "/repo/project-one",
repo: "", repo: "",
defaultBranch: "main", defaultBranch: "main",
config: {
worker: { agent: "codex" },
orchestrator: { agent: "claude-code" },
},
}, },
}, },
error: undefined, error: undefined,
@ -165,4 +169,35 @@ describe("ProjectSettingsForm", () => {
expect(await screen.findByText("invalid permissions")).toBeInTheDocument(); expect(await screen.findByText("invalid permissions")).toBeInTheDocument();
expect(screen.queryByText("Saved.")).not.toBeInTheDocument(); expect(screen.queryByText("Saved.")).not.toBeInTheDocument();
}); });
it("requires worker and orchestrator agents for existing projects missing role config", async () => {
getMock.mockResolvedValue({
data: {
status: "ok",
project: {
id: "proj-1",
name: "Project One",
kind: "single_repo",
path: "/repo/project-one",
repo: "",
defaultBranch: "main",
config: {},
},
},
error: undefined,
});
renderSettings();
expect(await screen.findByText("Worker and orchestrator agents are required.")).toBeInTheDocument();
expect(screen.getByRole("combobox", { name: "Default worker agent" })).toHaveTextContent("Select worker agent");
expect(screen.getByRole("combobox", { name: "Default orchestrator agent" })).toHaveTextContent(
"Select orchestrator agent",
);
await userEvent.click(screen.getByRole("button", { name: "Save changes" }));
expect(await screen.findAllByText("Worker and orchestrator agents are required.")).toHaveLength(2);
expect(putMock).not.toHaveBeenCalled();
});
}); });

View File

@ -3,6 +3,7 @@ import { useState } from "react";
import type { components } from "../../api/schema"; import type { components } from "../../api/schema";
import { apiClient, apiErrorMessage } from "../lib/api-client"; import { apiClient, apiErrorMessage } from "../lib/api-client";
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { RequiredAgentField } from "./CreateProjectAgentSheet";
import { DashboardSubhead } from "./DashboardSubhead"; import { DashboardSubhead } from "./DashboardSubhead";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
@ -12,9 +13,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
type Project = components["schemas"]["Project"]; type Project = components["schemas"]["Project"];
type ProjectConfig = components["schemas"]["ProjectConfig"]; type ProjectConfig = components["schemas"]["ProjectConfig"];
// Agents the daemon registers. Empty = "use the daemon default".
const AGENT_OPTIONS = ["claude-code", "codex", "opencode", "amp", "goose", "kiro"] as const;
const PERMISSION_MODE_OPTIONS = [ const PERMISSION_MODE_OPTIONS = [
{ value: "default", label: "Default" }, { value: "default", label: "Default" },
{ value: "accept-edits", label: "Accept edits" }, { value: "accept-edits", label: "Accept edits" },
@ -78,6 +76,8 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
reviewerHarness: config.reviewers?.[0]?.harness ?? "", reviewerHarness: config.reviewers?.[0]?.harness ?? "",
}); });
const [savedAt, setSavedAt] = useState<number | null>(null); const [savedAt, setSavedAt] = useState<number | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const missingRequiredAgent = form.workerAgent === "" || form.orchestratorAgent === "";
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
@ -87,8 +87,8 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
...config, ...config,
defaultBranch: form.defaultBranch || undefined, defaultBranch: form.defaultBranch || undefined,
sessionPrefix: form.sessionPrefix || undefined, sessionPrefix: form.sessionPrefix || undefined,
worker: blankToUndefined({ ...config.worker, agent: form.workerAgent || undefined }), worker: { ...config.worker, agent: form.workerAgent },
orchestrator: blankToUndefined({ ...config.orchestrator, agent: form.orchestratorAgent || undefined }), orchestrator: { ...config.orchestrator, agent: form.orchestratorAgent },
agentConfig: blankToUndefined({ agentConfig: blankToUndefined({
...config.agentConfig, ...config.agentConfig,
model: form.model || undefined, model: form.model || undefined,
@ -104,6 +104,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
}, },
onSuccess: () => { onSuccess: () => {
setSavedAt(Date.now()); setSavedAt(Date.now());
setValidationError(null);
void queryClient.invalidateQueries({ queryKey: ["project", projectId] }); void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
onSaved(); onSaved();
}, },
@ -114,6 +115,12 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
className="mx-auto flex max-w-2xl flex-col gap-4" className="mx-auto flex max-w-2xl flex-col gap-4"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
setSavedAt(null);
if (missingRequiredAgent) {
setValidationError("Worker and orchestrator agents are required.");
return;
}
setValidationError(null);
mutation.mutate(); mutation.mutate();
}} }}
> >
@ -159,20 +166,25 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
<CardTitle className="text-[13px]">Agents</CardTitle> <CardTitle className="text-[13px]">Agents</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent className="flex flex-col gap-4">
<Field label="Default worker agent" htmlFor="workerAgent"> <RequiredAgentField
<AgentSelect id="workerAgent"
id="workerAgent" value={form.workerAgent}
value={form.workerAgent} placeholder="Select worker agent"
onChange={(v) => setForm((f) => ({ ...f, workerAgent: v }))} label="Default worker agent"
/> invalid={validationError !== null && form.workerAgent === ""}
</Field> onChange={(v) => setForm((f) => ({ ...f, workerAgent: v }))}
<Field label="Default orchestrator agent" htmlFor="orchestratorAgent"> />
<AgentSelect <RequiredAgentField
id="orchestratorAgent" id="orchestratorAgent"
value={form.orchestratorAgent} value={form.orchestratorAgent}
onChange={(v) => setForm((f) => ({ ...f, orchestratorAgent: v }))} placeholder="Select orchestrator agent"
/> label="Default orchestrator agent"
</Field> invalid={validationError !== null && form.orchestratorAgent === ""}
onChange={(v) => setForm((f) => ({ ...f, orchestratorAgent: v }))}
/>
{missingRequiredAgent && (
<p className="text-[12px] leading-5 text-error">Worker and orchestrator agents are required.</p>
)}
<Field label="Model override" htmlFor="model"> <Field label="Model override" htmlFor="model">
<input <input
id="model" id="model"
@ -211,6 +223,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
<Button type="submit" variant="primary" disabled={mutation.isPending}> <Button type="submit" variant="primary" disabled={mutation.isPending}>
{mutation.isPending ? "Saving…" : "Save changes"} {mutation.isPending ? "Saving…" : "Save changes"}
</Button> </Button>
{validationError && <span className="text-[12px] text-error">{validationError}</span>}
{mutation.isError && ( {mutation.isError && (
<span className="text-[12px] text-error"> <span className="text-[12px] text-error">
{mutation.error instanceof Error ? mutation.error.message : "Save failed"} {mutation.error instanceof Error ? mutation.error.message : "Save failed"}
@ -250,25 +263,6 @@ function PermissionModeSelect({
); );
} }
function AgentSelect({ id, value, onChange }: { id: string; value: string; onChange: (value: string) => void }) {
// "" sentinel → daemon default; Select can't hold an empty value, so map it.
return (
<Select value={value || "__default__"} onValueChange={(v) => onChange(v === "__default__" ? "" : v)}>
<SelectTrigger id={id} className="h-8 w-full text-[13px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Daemon default</SelectItem>
{AGENT_OPTIONS.map((agent) => (
<SelectItem key={agent} value={agent}>
{agent}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function ReviewerSelect({ id, value, onChange }: { id: string; value: string; onChange: (value: string) => void }) { function ReviewerSelect({ id, value, onChange }: { id: string; value: string; onChange: (value: string) => void }) {
return ( return (
<Select value={value || "__default__"} onValueChange={(v) => onChange(v === "__default__" ? "" : v)}> <Select value={value || "__default__"} onValueChange={(v) => onChange(v === "__default__" ? "" : v)}>

View File

@ -26,7 +26,16 @@ const workspace: WorkspaceSummary = {
sessions: [], sessions: [],
}; };
function renderSidebar(onRemoveProject = vi.fn().mockResolvedValue(undefined)) { type CreateProjectHandler = (input: { path: string; workerAgent: string; orchestratorAgent: string }) => Promise<void>;
type RemoveProjectHandler = (projectId: string) => Promise<void>;
function renderSidebar({
onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler,
onRemoveProject = vi.fn().mockResolvedValue(undefined) as RemoveProjectHandler,
}: {
onCreateProject?: CreateProjectHandler;
onRemoveProject?: RemoveProjectHandler;
} = {}) {
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
}); });
@ -35,7 +44,7 @@ function renderSidebar(onRemoveProject = vi.fn().mockResolvedValue(undefined)) {
<SidebarProvider> <SidebarProvider>
<Sidebar <Sidebar
daemonStatus={{ state: "running" }} daemonStatus={{ state: "running" }}
onCreateProject={vi.fn()} onCreateProject={onCreateProject}
onRemoveProject={onRemoveProject} onRemoveProject={onRemoveProject}
workspaces={[workspace]} workspaces={[workspace]}
/> />
@ -45,6 +54,11 @@ function renderSidebar(onRemoveProject = vi.fn().mockResolvedValue(undefined)) {
return onRemoveProject; return onRemoveProject;
} }
async function chooseOption(trigger: HTMLElement, optionName: string) {
await userEvent.click(trigger);
await userEvent.click(await screen.findByRole("option", { name: optionName }));
}
beforeEach(() => { beforeEach(() => {
navigateMock.mockReset(); navigateMock.mockReset();
vi.spyOn(window, "confirm").mockReturnValue(true); vi.spyOn(window, "confirm").mockReturnValue(true);
@ -97,6 +111,30 @@ describe("Sidebar", () => {
expect(navigateMock).toHaveBeenCalledWith({ to: "/projects/$projectId", params: { projectId: "proj-1" } }); expect(navigateMock).toHaveBeenCalledWith({ to: "/projects/$projectId", params: { projectId: "proj-1" } });
}); });
it("requires explicit worker and orchestrator agents when creating a project", async () => {
const user = userEvent.setup();
const onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler;
window.ao!.app.chooseDirectory = vi.fn().mockResolvedValue("/repo/new-project");
renderSidebar({ onCreateProject });
await user.click(screen.getByLabelText("New project"));
expect(await screen.findByText("/repo/new-project")).toBeInTheDocument();
const dialog = screen.getByRole("dialog", { name: "Project agents" });
expect(dialog).toHaveClass("left-1/2", "top-1/2", "-translate-x-1/2", "-translate-y-1/2");
await chooseOption(screen.getByRole("combobox", { name: "Worker agent" }), "codex");
await chooseOption(screen.getByRole("combobox", { name: "Orchestrator agent" }), "claude-code");
await user.click(screen.getByRole("button", { name: "Create and start" }));
await waitFor(() =>
expect(onCreateProject).toHaveBeenCalledWith({
path: "/repo/new-project",
workerAgent: "codex",
orchestratorAgent: "claude-code",
}),
);
});
it("always shows action icons and reserves padding for them", () => { it("always shows action icons and reserves padding for them", () => {
renderSidebar(); renderSidebar();

View File

@ -12,7 +12,7 @@ import {
Sun, Sun,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useState, type ReactNode } from "react";
import { import {
attentionZone, attentionZone,
isOrchestratorSession, isOrchestratorSession,
@ -55,6 +55,7 @@ import { OrchestratorIcon } from "./icons";
import aoLogo from "../assets/ao-logo.png"; import aoLogo from "../assets/ao-logo.png";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { useUiStore } from "../stores/ui-store"; import { useUiStore } from "../stores/ui-store";
import { CreateProjectAgentSheet, type CreateProjectAgentSelection } from "./CreateProjectAgentSheet";
// The macOS hiddenInset traffic lights and the fixed TitlebarNav overlay live // The macOS hiddenInset traffic lights and the fixed TitlebarNav overlay live
// in the full-width topbar's left inset (_shell renders the bar above the // in the full-width topbar's left inset (_shell renders the bar above the
@ -74,7 +75,7 @@ type SidebarProps = {
underTopbar?: boolean; underTopbar?: boolean;
workspaceError?: string; workspaceError?: string;
workspaces: WorkspaceSummary[]; workspaces: WorkspaceSummary[];
onCreateProject: (input: { path: string }) => Promise<void>; onCreateProject: (input: { path: string; workerAgent: string; orchestratorAgent: string }) => Promise<void>;
onRemoveProject: (projectId: string) => Promise<void>; onRemoveProject: (projectId: string) => Promise<void>;
}; };
@ -591,15 +592,70 @@ function ProjectItem({
} }
function CreateProjectButton({ onCreateProject }: Pick<SidebarProps, "onCreateProject">) { function CreateProjectButton({ onCreateProject }: Pick<SidebarProps, "onCreateProject">) {
return (
<CreateProjectFlow onCreateProject={onCreateProject}>
{({ disabled, choosePath, label }) => (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label="New project"
className="grid h-[18px] w-[18px] place-items-center rounded-[4px] text-passive transition-colors hover:bg-interactive-hover hover:text-muted-foreground"
disabled={disabled}
onClick={choosePath}
type="button"
>
<Plus className="h-[13px] w-[13px]" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
)}
</CreateProjectFlow>
);
}
function CreateProjectListItem({ onCreateProject }: Pick<SidebarProps, "onCreateProject">) {
return (
<CreateProjectFlow onCreateProject={onCreateProject}>
{({ disabled, choosePath, label }) => (
<SidebarMenuItem className="mb-px group-data-[collapsible=icon]:mb-0">
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label="New project"
className="grid h-9 w-full place-items-center rounded-[5px] text-passive transition-colors hover:bg-interactive-hover hover:text-muted-foreground"
disabled={disabled}
onClick={choosePath}
type="button"
>
<Plus className="h-[13px] w-[13px]" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</SidebarMenuItem>
)}
</CreateProjectFlow>
);
}
function CreateProjectFlow({
children,
onCreateProject,
}: Pick<SidebarProps, "onCreateProject"> & {
children: (state: { choosePath: () => void; disabled: boolean; label: string }) => ReactNode;
}) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [isChoosingPath, setIsChoosingPath] = useState(false); const [isChoosingPath, setIsChoosingPath] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const choosePath = async () => { const choosePath = async () => {
setError(null); setError(null);
setIsChoosingPath(true); setIsChoosingPath(true);
try { try {
const selectedPath = await aoBridge.app.chooseDirectory(); const path = await aoBridge.app.chooseDirectory();
if (selectedPath) await onCreateProject({ path: selectedPath }); if (path) setSelectedPath(path);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Could not add project"); setError(err instanceof Error ? err.message : "Could not add project");
} finally { } finally {
@ -607,22 +663,38 @@ function CreateProjectButton({ onCreateProject }: Pick<SidebarProps, "onCreatePr
} }
}; };
const createProject = async (selection: CreateProjectAgentSelection) => {
if (!selectedPath) return;
setError(null);
setIsCreating(true);
try {
await onCreateProject({ path: selectedPath, ...selection });
setSelectedPath(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not add project");
} finally {
setIsCreating(false);
}
};
const label = isChoosingPath ? "Opening..." : isCreating ? "Creating..." : "New project";
return ( return (
<> <>
<Tooltip> {children({ choosePath: () => void choosePath(), disabled: isChoosingPath || isCreating, label })}
<TooltipTrigger asChild> <CreateProjectAgentSheet
<button error={error}
aria-label="New project" isCreating={isCreating}
className="grid h-[18px] w-[18px] place-items-center rounded-[4px] text-passive transition-colors hover:bg-interactive-hover hover:text-muted-foreground" onOpenChange={(open) => {
disabled={isChoosingPath} if (!open) {
onClick={choosePath} setSelectedPath(null);
type="button" setError(null);
> }
<Plus className="h-[13px] w-[13px]" aria-hidden="true" /> }}
</button> onSubmit={createProject}
</TooltipTrigger> open={selectedPath !== null}
<TooltipContent>{isChoosingPath ? "Opening…" : "New project"}</TooltipContent> path={selectedPath}
</Tooltip> />
{error && ( {error && (
<span className="sr-only" role="status"> <span className="sr-only" role="status">
{error} {error}
@ -631,45 +703,3 @@ function CreateProjectButton({ onCreateProject }: Pick<SidebarProps, "onCreatePr
</> </>
); );
} }
function CreateProjectListItem({ onCreateProject }: Pick<SidebarProps, "onCreateProject">) {
const [error, setError] = useState<string | null>(null);
const [isChoosingPath, setIsChoosingPath] = useState(false);
const choosePath = async () => {
setError(null);
setIsChoosingPath(true);
try {
const selectedPath = await aoBridge.app.chooseDirectory();
if (selectedPath) await onCreateProject({ path: selectedPath });
} catch (err) {
setError(err instanceof Error ? err.message : "Could not add project");
} finally {
setIsChoosingPath(false);
}
};
return (
<SidebarMenuItem className="mb-px group-data-[collapsible=icon]:mb-0">
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label="New project"
className="grid h-9 w-full place-items-center rounded-[5px] text-passive transition-colors hover:bg-interactive-hover hover:text-muted-foreground"
disabled={isChoosingPath}
onClick={choosePath}
type="button"
>
<Plus className="h-[13px] w-[13px]" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>{isChoosingPath ? "Opening…" : "New project"}</TooltipContent>
</Tooltip>
{error && (
<span className="sr-only" role="status">
{error}
</span>
)}
</SidebarMenuItem>
);
}

View File

@ -0,0 +1,25 @@
export const AGENT_OPTIONS = [
"claude-code",
"codex",
"aider",
"opencode",
"grok",
"droid",
"amp",
"agy",
"crush",
"cursor",
"qwen",
"copilot",
"goose",
"auggie",
"continue",
"devin",
"cline",
"kimi",
"kiro",
"kilocode",
"vibe",
"pi",
"autohand",
] as const;

View File

@ -6,7 +6,7 @@ import type { useDaemonStatus } from "../hooks/useDaemonStatus";
// it lives in the shell and is handed down here rather than re-run per route. // it lives in the shell and is handed down here rather than re-run per route.
export type ShellContextValue = { export type ShellContextValue = {
daemonStatus: ReturnType<typeof useDaemonStatus>; daemonStatus: ReturnType<typeof useDaemonStatus>;
createProject: (input: { path: string }) => Promise<void>; createProject: (input: { path: string; workerAgent: string; orchestratorAgent: string }) => Promise<void>;
}; };
const ShellContext = createContext<ShellContextValue | null>(null); const ShellContext = createContext<ShellContextValue | null>(null);

View File

@ -10,6 +10,7 @@ import { useWorkspaceQuery, workspaceQueryKey, workspaceQueryOptions } from "../
import { apiClient, apiErrorMessage } from "../lib/api-client"; import { apiClient, apiErrorMessage } from "../lib/api-client";
import { captureRendererEvent, captureRendererException } from "../lib/telemetry"; import { captureRendererEvent, captureRendererException } from "../lib/telemetry";
import { ShellProvider } from "../lib/shell-context"; import { ShellProvider } from "../lib/shell-context";
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { readStoredTheme, type Theme, useUiStore } from "../stores/ui-store"; import { readStoredTheme, type Theme, useUiStore } from "../stores/ui-store";
import type { WorkspaceSummary } from "../types/workspace"; import type { WorkspaceSummary } from "../types/workspace";
@ -51,9 +52,17 @@ function ShellLayout() {
); );
const createProject = useCallback( const createProject = useCallback(
async (input: { path: string }) => { async (input: { path: string; workerAgent: string; orchestratorAgent: string }) => {
void captureRendererEvent("ao.renderer.project_add_requested"); void captureRendererEvent("ao.renderer.project_add_requested");
const { data, error } = await apiClient.POST("/api/v1/projects", { body: { path: input.path } }); const { data, error } = await apiClient.POST("/api/v1/projects", {
body: {
path: input.path,
config: {
worker: { agent: input.workerAgent },
orchestrator: { agent: input.orchestratorAgent },
},
},
});
if (error) { if (error) {
const failure = new Error(apiErrorMessage(error)); const failure = new Error(apiErrorMessage(error));
void captureRendererException(failure, { source: "project-add" }); void captureRendererException(failure, { source: "project-add" });
@ -70,9 +79,20 @@ function ShellLayout() {
}; };
void captureRendererEvent("ao.renderer.project_add_succeeded", { project_id: workspace.id }); void captureRendererEvent("ao.renderer.project_add_succeeded", { project_id: workspace.id });
updateWorkspaces((current) => [workspace, ...current.filter((item) => item.id !== workspace.id)]); updateWorkspaces((current) => [workspace, ...current.filter((item) => item.id !== workspace.id)]);
void navigate({ to: "/projects/$projectId", params: { projectId: workspace.id } }); try {
const sessionId = await spawnOrchestrator(workspace.id);
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId: workspace.id, sessionId },
});
} catch (spawnError) {
void navigate({ to: "/projects/$projectId", params: { projectId: workspace.id } });
const message = spawnError instanceof Error ? spawnError.message : "Could not start orchestrator";
throw new Error(`Project added, but orchestrator did not start: ${message}`);
}
}, },
[navigate, updateWorkspaces], [navigate, queryClient, updateWorkspaces],
); );
const removeProject = useCallback( const removeProject = useCallback(