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:
parent
5e8c8defbd
commit
72b757b203
10
README.md
10
README.md
|
|
@ -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`,
|
||||
`crush`, `cline`, `droid`, `devin`, `auggie`, `continue`, `kiro`, `kilocode`,
|
||||
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
|
||||
`git worktree` (`backend/internal/adapters/workspace/gitworktree/`), launched
|
||||
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
|
||||
# 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"
|
||||
|
||||
# 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_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_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`. |
|
||||
| `GITHUB_TOKEN` | _(unset)_ | Used by the GitHub SCM and tracker adapters. Falls back to `gh auth token`. |
|
||||
|
||||
|
|
|
|||
|
|
@ -222,6 +222,8 @@ func TestE2E_SpawnAndProjectAddDTORoundTrip(t *testing.T) {
|
|||
"--path", "/repo/mer",
|
||||
"--id", "demo",
|
||||
"--name", "Demo",
|
||||
"--worker-agent", "codex",
|
||||
"--orchestrator-agent", "claude-code",
|
||||
"--as-workspace",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
|
|
@ -238,6 +240,15 @@ func TestE2E_SpawnAndProjectAddDTORoundTrip(t *testing.T) {
|
|||
if got.Name == nil || *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 {
|
||||
t.Errorf("AsWorkspace = false, want true (CLI json:\"asWorkspace\" vs AddInput)")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ type projectAddOptions struct {
|
|||
path string
|
||||
id string
|
||||
name string
|
||||
workerAgent string
|
||||
orchestratorAgent string
|
||||
asWorkspace bool
|
||||
}
|
||||
|
||||
|
|
@ -40,6 +42,7 @@ type addProjectRequest struct {
|
|||
Path string `json:"path"`
|
||||
ProjectID *string `json:"projectId,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Config *projectConfig `json:"config,omitempty"`
|
||||
AsWorkspace bool `json:"asWorkspace,omitempty"`
|
||||
}
|
||||
|
||||
|
|
@ -58,7 +61,7 @@ type projectDetails struct {
|
|||
Path string `json:"path"`
|
||||
Repo string `json:"repo"`
|
||||
DefaultBranch string `json:"defaultBranch"`
|
||||
DefaultHarness string `json:"agent,omitempty"`
|
||||
Agent string `json:"agent,omitempty"`
|
||||
Config *projectConfig `json:"config,omitempty"`
|
||||
WorkspaceRepos []workspaceRepoDetails `json:"workspaceRepos,omitempty"`
|
||||
ResolveError string `json:"resolveError,omitempty"`
|
||||
|
|
@ -226,6 +229,12 @@ func newProjectAddCommand(ctx *commandContext) *cobra.Command {
|
|||
if 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
|
||||
if err := ctx.postJSON(cmd.Context(), "projects", req, &res); err != nil {
|
||||
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.id, "id", "", "Project id (default: derived by the daemon from the path)")
|
||||
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)")
|
||||
return cmd
|
||||
}
|
||||
|
|
@ -443,7 +454,7 @@ func writeProjectDetails(cmd *cobra.Command, res projectGetResult) error {
|
|||
{label: "path", value: p.Path},
|
||||
{label: "repo", value: p.Repo},
|
||||
{label: "default branch", value: p.DefaultBranch},
|
||||
{label: "default harness", value: p.DefaultHarness},
|
||||
{label: "agent", value: p.Agent},
|
||||
{label: "config", value: formatProjectConfig(p.Config)},
|
||||
{label: "resolve error", value: p.ResolveError},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ func TestProjectGet_Success(t *testing.T) {
|
|||
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)
|
||||
}
|
||||
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) {
|
||||
t.Fatalf("output missing %q:\n%s", want, out)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
|
|||
Use: "spawn",
|
||||
Short: "Spawn a worker agent session in a registered project",
|
||||
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`.",
|
||||
Args: noArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
|
|
@ -120,7 +120,7 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
|
|||
return pflag.NormalizedName(name)
|
||||
})
|
||||
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.prompt, "prompt", "", "Initial prompt for the agent")
|
||||
f.StringVar(&opts.issue, "issue", "", "Issue id to associate with the session")
|
||||
|
|
|
|||
|
|
@ -29,8 +29,9 @@ const (
|
|||
// DefaultShutdownTimeout is the hard cap on graceful shutdown. After this
|
||||
// the process exits even if connections are still draining.
|
||||
DefaultShutdownTimeout = 10 * time.Second
|
||||
// DefaultAgent is the agent adapter id the daemon wires when AO_AGENT is
|
||||
// unset. It matches the claude-code adapter's manifest id.
|
||||
// DefaultAgent is the compatibility value used when AO_AGENT is unset. The
|
||||
// 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"
|
||||
// DefaultTelemetryPostHogHost is the default PostHog ingestion host when
|
||||
// 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.
|
||||
// It is created on first use by the storage layer.
|
||||
DataDir string
|
||||
// Agent is the id of the agent adapter the daemon wires into the Session
|
||||
// Manager (see DefaultAgent). Selected by AO_AGENT; startSession fails fast
|
||||
// if no adapter with this id is registered.
|
||||
// Agent is the compatibility agent adapter id selected by AO_AGENT;
|
||||
// startSession fails fast if no adapter with this id is registered.
|
||||
Agent string
|
||||
// AllowedOrigins are the browser origins granted CORS read access (see
|
||||
// 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_RUN_FILE running.json path (default ~/.ao/running.json)
|
||||
// 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_TELEMETRY_EVENTS local event capture off|on (default off)
|
||||
// AO_TELEMETRY_METRICS local metric capture off|on (default off)
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ func Run() error {
|
|||
|
||||
// Wire the controller-facing session service over the same store + LCM, the
|
||||
// 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.
|
||||
sessionSvc, reviewSvc, err := startSession(cfg, runtimeAdapter, store, lcStack.LCM, messenger, telemetrySink, log)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -60,12 +60,9 @@ func (l *lifecycleStack) Stop() {
|
|||
|
||||
// startSession builds the controller-facing session service: a session manager
|
||||
// over the real zellij runtime, a per-session gitworktree workspace, the shared
|
||||
// store + LCM, the per-session agent resolver (AO_AGENT default), and the
|
||||
// agent messenger. The returned service is mounted at httpd APIDeps.Sessions.
|
||||
// store + LCM, the per-session agent resolver, and the agent messenger. The
|
||||
// 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) {
|
||||
// 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
|
||||
if defaultAgent == "" {
|
||||
defaultAgent = config.DefaultAgent
|
||||
|
|
@ -94,7 +91,6 @@ func startSession(cfg config.Config, runtime *zellij.Runtime, store *sqlite.Stor
|
|||
Messenger: messenger,
|
||||
Lifecycle: lcm,
|
||||
DataDir: cfg.DataDir,
|
||||
DefaultHarness: domain.AgentHarness(defaultAgent),
|
||||
Logger: log,
|
||||
})
|
||||
scmProvider, err := newGitHubSCMProvider(log)
|
||||
|
|
@ -179,20 +175,15 @@ func buildAgentRegistry() (*adapters.Registry, error) {
|
|||
|
||||
// agentRegistry adapts the generic adapter Registry to ports.AgentResolver: it
|
||||
// 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
|
||||
// daemon's configured default (AO_AGENT), so a spawn that names no harness still
|
||||
// gets a real agent.
|
||||
// asserts that adapter drives an agent. Empty harnesses are invalid at the
|
||||
// session manager boundary and deliberately do not resolve here.
|
||||
type agentRegistry struct {
|
||||
reg *adapters.Registry
|
||||
defaultHarness domain.AgentHarness
|
||||
}
|
||||
|
||||
var _ ports.AgentResolver = agentRegistry{}
|
||||
|
||||
func (a agentRegistry) Agent(harness domain.AgentHarness) (ports.Agent, bool) {
|
||||
if harness == "" {
|
||||
harness = a.defaultHarness
|
||||
}
|
||||
adapter, ok := a.reg.Get(string(harness))
|
||||
if !ok {
|
||||
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
|
||||
// Manager consumes (sessionmanager.Deps.Agents): a registry of the shipped
|
||||
// adapters plus the configured default harness. It fails fast if the default
|
||||
// does not resolve, so a typo'd AO_AGENT surfaces at startup. The session lane
|
||||
// plugs this in when it mounts the controller-facing session service at the
|
||||
// httpd APIDeps.Sessions slot.
|
||||
// adapters. It still validates AO_AGENT at startup for compatibility with the
|
||||
// config surface, but worker/orchestrator spawns must provide a resolved
|
||||
// harness before calling Agent.
|
||||
func buildAgentResolver(defaultAgent string, log *slog.Logger) (ports.AgentResolver, error) {
|
||||
if defaultAgent == "" {
|
||||
defaultAgent = config.DefaultAgent
|
||||
|
|
@ -215,8 +205,8 @@ func buildAgentResolver(defaultAgent string, log *slog.Logger) (ports.AgentResol
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolver := agentRegistry{reg: reg, defaultHarness: domain.AgentHarness(defaultAgent)}
|
||||
if _, ok := resolver.Agent(""); !ok {
|
||||
resolver := agentRegistry{reg: reg}
|
||||
if _, ok := resolver.Agent(domain.AgentHarness(defaultAgent)); !ok {
|
||||
return nil, fmt.Errorf("configured default agent %q is not a registered adapter", defaultAgent)
|
||||
}
|
||||
ids := make([]string, 0)
|
||||
|
|
|
|||
|
|
@ -79,8 +79,7 @@ func TestWiring_WriteFlowsToBroadcaster(t *testing.T) {
|
|||
|
||||
// TestWiring_AgentResolverResolvesRealAdapters asserts buildAgentResolver wires a
|
||||
// real registry-backed per-session resolver: each harness resolves to the
|
||||
// matching registered adapter, an empty harness falls back to the AO_AGENT
|
||||
// default, and an unknown harness misses.
|
||||
// matching registered adapter, while empty and unknown harnesses miss.
|
||||
func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) {
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
resolver, err := buildAgentResolver("", log) // empty default → claude-code
|
||||
|
|
@ -114,7 +113,6 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) {
|
|||
{domain.HarnessVibe, "vibe"},
|
||||
{domain.HarnessPi, "pi"},
|
||||
{domain.HarnessAutohand, "autohand"},
|
||||
{"", config.DefaultAgent}, // empty harness falls back to the AO_AGENT default
|
||||
} {
|
||||
agent, ok := resolver.Agent(tc.harness)
|
||||
if !ok {
|
||||
|
|
@ -131,6 +129,9 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) {
|
|||
if _, ok := resolver.Agent("definitely-not-an-agent"); ok {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -89,7 +89,15 @@ func newStack(t *testing.T) *stack {
|
|||
t.Fatal(err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
msg := &captureMessenger{}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
case errors.Is(err, sessionmanager.ErrUnknownHarness):
|
||||
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):
|
||||
return apierr.Conflict("BRANCH_CHECKED_OUT_ELSEWHERE", err.Error(), nil)
|
||||
case errors.Is(err, ports.ErrWorkspaceBranchNotFetched):
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
{"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"},
|
||||
{"missing harness", fmt.Errorf("spawn: %w: configure project worker.agent or pass --harness", sessionmanager.ErrMissingHarness), apierr.KindInvalid, "AGENT_REQUIRED"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ var (
|
|||
// adapter. The API maps it to a 400 so a typo'd `--harness` is a validation
|
||||
// error, not an opaque 500.
|
||||
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.
|
||||
|
|
@ -86,10 +89,6 @@ type Manager struct {
|
|||
messenger ports.AgentMessenger
|
||||
lcm lifecycleRecorder
|
||||
dataDir string
|
||||
// defaultHarness is the daemon's configured default agent (AO_AGENT). A spawn
|
||||
// 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
|
||||
// they don't need real binaries on PATH. Returns ports.ErrAgentBinaryNotFound
|
||||
|
|
@ -113,11 +112,6 @@ type Deps struct {
|
|||
// DataDir is exported to spawned agents as AO_DATA_DIR so their hook
|
||||
// commands can open the same store.
|
||||
DataDir string
|
||||
// DefaultHarness is the daemon's configured default agent (AO_AGENT), used to
|
||||
// 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.
|
||||
// Production wiring leaves this nil and the manager defaults to
|
||||
|
|
@ -143,7 +137,6 @@ func New(d Deps) *Manager {
|
|||
messenger: d.Messenger,
|
||||
lcm: d.Lifecycle,
|
||||
dataDir: d.DataDir,
|
||||
defaultHarness: d.DefaultHarness,
|
||||
clock: d.Clock,
|
||||
lookPath: d.LookPath,
|
||||
executable: d.Executable,
|
||||
|
|
@ -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,
|
||||
// so a project can default workers to one agent and orchestrators to another.
|
||||
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 == "" {
|
||||
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
|
||||
|
|
@ -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;
|
||||
// otherwise the project's role override for the session kind applies; otherwise
|
||||
// it stays empty so the daemon's global default (AO_AGENT) is used downstream.
|
||||
// otherwise the project's role override for the session kind applies. Empty is
|
||||
// invalid for new worker/orchestrator launches and is rejected by Spawn.
|
||||
func effectiveHarness(explicit domain.AgentHarness, kind domain.SessionKind, cfg domain.ProjectConfig) domain.AgentHarness {
|
||||
if explicit != "" {
|
||||
return explicit
|
||||
|
|
@ -319,6 +308,13 @@ func effectiveHarness(explicit domain.AgentHarness, kind domain.SessionKind, cfg
|
|||
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
|
||||
// project's base agent config; set override fields win.
|
||||
func effectiveAgentConfig(kind domain.SessionKind, cfg domain.ProjectConfig) ports.AgentConfig {
|
||||
|
|
|
|||
|
|
@ -223,6 +223,7 @@ func (m *fakeMessenger) Send(_ context.Context, _ domain.SessionID, msg string)
|
|||
|
||||
func newManager() (*Manager, *fakeStore, *fakeRuntime, *fakeWorkspace) {
|
||||
st := newFakeStore()
|
||||
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
||||
rt := &fakeRuntime{}
|
||||
ws := &fakeWorkspace{}
|
||||
// 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})
|
||||
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) {
|
||||
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")
|
||||
}
|
||||
|
||||
// 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"}
|
||||
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)
|
||||
}
|
||||
if !agent.lastConfig.IsZero() {
|
||||
|
|
@ -284,30 +292,38 @@ func TestSpawn_ResolvesProjectConfig(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestSpawn_PersistsResolvedDefaultHarness locks the fix for the mislabelled
|
||||
// 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) {
|
||||
func TestSpawn_RejectsMissingRoleHarness(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 },
|
||||
DefaultHarness: domain.HarnessClaudeCode,
|
||||
})
|
||||
|
||||
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil {
|
||||
t.Fatal(err)
|
||||
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); !errors.Is(err, ErrMissingHarness) {
|
||||
t.Fatalf("worker err = %v, want ErrMissingHarness", err)
|
||||
}
|
||||
if got := st.sessions["mer-1"].Harness; got != domain.HarnessClaudeCode {
|
||||
t.Fatalf("unspecified harness = %q, want resolved default %q", got, domain.HarnessClaudeCode)
|
||||
if len(st.sessions) != 0 {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -561,7 +577,10 @@ func TestSpawn_ForwardsResolvedAgentConfigPermissions(t *testing.T) {
|
|||
st := newFakeStore()
|
||||
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: domain.ProjectConfig{
|
||||
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{}
|
||||
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) {
|
||||
st := newFakeStore()
|
||||
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
||||
st.num = 1
|
||||
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator}
|
||||
agent := &recordingAgent{}
|
||||
|
|
@ -646,6 +666,7 @@ func TestSpawnWorker_AppendsActiveOrchestratorContact(t *testing.T) {
|
|||
|
||||
func TestSpawnWorker_SkipsTerminatedOrchestratorContact(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
||||
st.num = 1
|
||||
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator, IsTerminated: true}
|
||||
agent := &recordingAgent{}
|
||||
|
|
@ -666,6 +687,7 @@ func TestSpawnWorker_SkipsTerminatedOrchestratorContact(t *testing.T) {
|
|||
|
||||
func TestSpawnOrchestrator_UsesCoordinatorPrompt(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
||||
agent := &recordingAgent{}
|
||||
rt := &fakeRuntime{}
|
||||
ws := &fakeWorkspace{}
|
||||
|
|
@ -878,6 +900,7 @@ func TestRollbackSpawn_FallsBackToKillForLiveRow(t *testing.T) {
|
|||
// reaper later mistakes for a live session.
|
||||
func TestSpawn_RejectsMissingAgentBinary(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
||||
rt := &fakeRuntime{}
|
||||
ws := &fakeWorkspace{}
|
||||
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.
|
||||
func pathPinManager(executable func() (string, error)) (*Manager, *fakeStore, *fakeRuntime, *bytes.Buffer) {
|
||||
st := newFakeStore()
|
||||
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
||||
rt := &fakeRuntime{}
|
||||
logBuf := &bytes.Buffer{}
|
||||
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
||||
|
|
@ -1019,6 +1043,7 @@ func TestSpawn_ProjectPATHIsPinBase(t *testing.T) {
|
|||
m, st, rt, _ := pathPinManager(func() (string, error) { return daemonExe, nil })
|
||||
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: domain.ProjectConfig{
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -149,6 +149,10 @@ describe("ProjectSettingsForm", () => {
|
|||
path: "/repo/project-one",
|
||||
repo: "",
|
||||
defaultBranch: "main",
|
||||
config: {
|
||||
worker: { agent: "codex" },
|
||||
orchestrator: { agent: "claude-code" },
|
||||
},
|
||||
},
|
||||
},
|
||||
error: undefined,
|
||||
|
|
@ -165,4 +169,35 @@ describe("ProjectSettingsForm", () => {
|
|||
expect(await screen.findByText("invalid permissions")).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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useState } from "react";
|
|||
import type { components } from "../../api/schema";
|
||||
import { apiClient, apiErrorMessage } from "../lib/api-client";
|
||||
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
|
||||
import { RequiredAgentField } from "./CreateProjectAgentSheet";
|
||||
import { DashboardSubhead } from "./DashboardSubhead";
|
||||
import { Button } from "./ui/button";
|
||||
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 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 = [
|
||||
{ value: "default", label: "Default" },
|
||||
{ value: "accept-edits", label: "Accept edits" },
|
||||
|
|
@ -78,6 +76,8 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
|||
reviewerHarness: config.reviewers?.[0]?.harness ?? "",
|
||||
});
|
||||
const [savedAt, setSavedAt] = useState<number | null>(null);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const missingRequiredAgent = form.workerAgent === "" || form.orchestratorAgent === "";
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
|
|
@ -87,8 +87,8 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
|||
...config,
|
||||
defaultBranch: form.defaultBranch || undefined,
|
||||
sessionPrefix: form.sessionPrefix || undefined,
|
||||
worker: blankToUndefined({ ...config.worker, agent: form.workerAgent || undefined }),
|
||||
orchestrator: blankToUndefined({ ...config.orchestrator, agent: form.orchestratorAgent || undefined }),
|
||||
worker: { ...config.worker, agent: form.workerAgent },
|
||||
orchestrator: { ...config.orchestrator, agent: form.orchestratorAgent },
|
||||
agentConfig: blankToUndefined({
|
||||
...config.agentConfig,
|
||||
model: form.model || undefined,
|
||||
|
|
@ -104,6 +104,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
|||
},
|
||||
onSuccess: () => {
|
||||
setSavedAt(Date.now());
|
||||
setValidationError(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
|
||||
onSaved();
|
||||
},
|
||||
|
|
@ -114,6 +115,12 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
|||
className="mx-auto flex max-w-2xl flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setSavedAt(null);
|
||||
if (missingRequiredAgent) {
|
||||
setValidationError("Worker and orchestrator agents are required.");
|
||||
return;
|
||||
}
|
||||
setValidationError(null);
|
||||
mutation.mutate();
|
||||
}}
|
||||
>
|
||||
|
|
@ -159,20 +166,25 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
|||
<CardTitle className="text-[13px]">Agents</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<Field label="Default worker agent" htmlFor="workerAgent">
|
||||
<AgentSelect
|
||||
<RequiredAgentField
|
||||
id="workerAgent"
|
||||
value={form.workerAgent}
|
||||
placeholder="Select worker agent"
|
||||
label="Default worker agent"
|
||||
invalid={validationError !== null && form.workerAgent === ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, workerAgent: v }))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Default orchestrator agent" htmlFor="orchestratorAgent">
|
||||
<AgentSelect
|
||||
<RequiredAgentField
|
||||
id="orchestratorAgent"
|
||||
value={form.orchestratorAgent}
|
||||
placeholder="Select orchestrator agent"
|
||||
label="Default orchestrator agent"
|
||||
invalid={validationError !== null && form.orchestratorAgent === ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, orchestratorAgent: v }))}
|
||||
/>
|
||||
</Field>
|
||||
{missingRequiredAgent && (
|
||||
<p className="text-[12px] leading-5 text-error">Worker and orchestrator agents are required.</p>
|
||||
)}
|
||||
<Field label="Model override" htmlFor="model">
|
||||
<input
|
||||
id="model"
|
||||
|
|
@ -211,6 +223,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
|||
<Button type="submit" variant="primary" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
{validationError && <span className="text-[12px] text-error">{validationError}</span>}
|
||||
{mutation.isError && (
|
||||
<span className="text-[12px] text-error">
|
||||
{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 }) {
|
||||
return (
|
||||
<Select value={value || "__default__"} onValueChange={(v) => onChange(v === "__default__" ? "" : v)}>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,16 @@ const workspace: WorkspaceSummary = {
|
|||
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({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
|
|
@ -35,7 +44,7 @@ function renderSidebar(onRemoveProject = vi.fn().mockResolvedValue(undefined)) {
|
|||
<SidebarProvider>
|
||||
<Sidebar
|
||||
daemonStatus={{ state: "running" }}
|
||||
onCreateProject={vi.fn()}
|
||||
onCreateProject={onCreateProject}
|
||||
onRemoveProject={onRemoveProject}
|
||||
workspaces={[workspace]}
|
||||
/>
|
||||
|
|
@ -45,6 +54,11 @@ function renderSidebar(onRemoveProject = vi.fn().mockResolvedValue(undefined)) {
|
|||
return onRemoveProject;
|
||||
}
|
||||
|
||||
async function chooseOption(trigger: HTMLElement, optionName: string) {
|
||||
await userEvent.click(trigger);
|
||||
await userEvent.click(await screen.findByRole("option", { name: optionName }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
navigateMock.mockReset();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
|
@ -97,6 +111,30 @@ describe("Sidebar", () => {
|
|||
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", () => {
|
||||
renderSidebar();
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
Sun,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import {
|
||||
attentionZone,
|
||||
isOrchestratorSession,
|
||||
|
|
@ -55,6 +55,7 @@ import { OrchestratorIcon } from "./icons";
|
|||
import aoLogo from "../assets/ao-logo.png";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useUiStore } from "../stores/ui-store";
|
||||
import { CreateProjectAgentSheet, type CreateProjectAgentSelection } from "./CreateProjectAgentSheet";
|
||||
|
||||
// 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
|
||||
|
|
@ -74,7 +75,7 @@ type SidebarProps = {
|
|||
underTopbar?: boolean;
|
||||
workspaceError?: string;
|
||||
workspaces: WorkspaceSummary[];
|
||||
onCreateProject: (input: { path: string }) => Promise<void>;
|
||||
onCreateProject: (input: { path: string; workerAgent: string; orchestratorAgent: string }) => Promise<void>;
|
||||
onRemoveProject: (projectId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
|
|
@ -591,15 +592,70 @@ function ProjectItem({
|
|||
}
|
||||
|
||||
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 [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [isChoosingPath, setIsChoosingPath] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const choosePath = async () => {
|
||||
setError(null);
|
||||
setIsChoosingPath(true);
|
||||
try {
|
||||
const selectedPath = await aoBridge.app.chooseDirectory();
|
||||
if (selectedPath) await onCreateProject({ path: selectedPath });
|
||||
const path = await aoBridge.app.chooseDirectory();
|
||||
if (path) setSelectedPath(path);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not add project");
|
||||
} 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 (
|
||||
<>
|
||||
<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={isChoosingPath}
|
||||
onClick={choosePath}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="h-[13px] w-[13px]" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{isChoosingPath ? "Opening…" : "New project"}</TooltipContent>
|
||||
</Tooltip>
|
||||
{children({ choosePath: () => void choosePath(), disabled: isChoosingPath || isCreating, label })}
|
||||
<CreateProjectAgentSheet
|
||||
error={error}
|
||||
isCreating={isCreating}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSelectedPath(null);
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
onSubmit={createProject}
|
||||
open={selectedPath !== null}
|
||||
path={selectedPath}
|
||||
/>
|
||||
{error && (
|
||||
<span className="sr-only" role="status">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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.
|
||||
export type ShellContextValue = {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { useWorkspaceQuery, workspaceQueryKey, workspaceQueryOptions } from "../
|
|||
import { apiClient, apiErrorMessage } from "../lib/api-client";
|
||||
import { captureRendererEvent, captureRendererException } from "../lib/telemetry";
|
||||
import { ShellProvider } from "../lib/shell-context";
|
||||
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
|
||||
import { readStoredTheme, type Theme, useUiStore } from "../stores/ui-store";
|
||||
import type { WorkspaceSummary } from "../types/workspace";
|
||||
|
||||
|
|
@ -51,9 +52,17 @@ function ShellLayout() {
|
|||
);
|
||||
|
||||
const createProject = useCallback(
|
||||
async (input: { path: string }) => {
|
||||
async (input: { path: string; workerAgent: string; orchestratorAgent: string }) => {
|
||||
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) {
|
||||
const failure = new Error(apiErrorMessage(error));
|
||||
void captureRendererException(failure, { source: "project-add" });
|
||||
|
|
@ -70,9 +79,20 @@ function ShellLayout() {
|
|||
};
|
||||
void captureRendererEvent("ao.renderer.project_add_succeeded", { project_id: workspace.id });
|
||||
updateWorkspaces((current) => [workspace, ...current.filter((item) => item.id !== 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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue