From 37f38f6df5e9c0513516d4b44ca963b7736be564 Mon Sep 17 00:00:00 2001 From: Kyle De Freitas Date: Sun, 25 Oct 2026 18:51:23 -0400 Subject: [PATCH] feat(berdctl): select Auto modes for child chats --- distro/skills/berd-help/references/berdctl.md | 25 +++ .../crates/berdctl/api-surface-feedback.json | 16 +- src-tauri/crates/berdctl/api-surface.json | 16 +- .../crates/berdctl/cli-surface-feedback.json | 6 +- src-tauri/crates/berdctl/cli-surface.json | 6 +- src-tauri/crates/berdctl/src/main.rs | 39 +++- .../__tests__/commands/commands.test.ts | 209 ++++++++++++++++++ .../berdctl/commands/impl/createSession.ts | 42 +++- .../berdctl/commands/impl/getSession.ts | 3 +- .../berdctl/commands/impl/listModels.ts | 25 ++- .../berdctl/commands/runtime/sessions.ts | 1 + 11 files changed, 368 insertions(+), 20 deletions(-) diff --git a/distro/skills/berd-help/references/berdctl.md b/distro/skills/berd-help/references/berdctl.md index 40178d539..6c2681254 100644 --- a/distro/skills/berd-help/references/berdctl.md +++ b/distro/skills/berd-help/references/berdctl.md @@ -23,3 +23,28 @@ organized as `berdctl ` (for example `berdctl session list`, recall. Use `berdctl session send` for an ordinary message from another session. Use `berdctl session notify` for an automated event such as a monitor update; it appears as a collapsed activity entry and still reaches the agent as context. Both commands support queueing, steering, source attribution, and idempotent delivery. + +## Child sessions with Auto routing + +Discover configured Goose Auto modes with +`berdctl info models --harness-id goose --json`. The Goose entry's `auto_modes` +array contains each mode's `id`, display `name`, and `is_default`; these ids are +not concrete `models[].model_id` values. + +Create a child/sub-session with an explicit configured mode: + +```sh +berdctl session create --prompt "Investigate this task" --harness-id goose \ + --auto-mode-id --json +``` + +For Auto Balanced, use the id of the configured Balanced mode, not its display +name. Auto selects a model for each fresh prompt, rather than pinning one model +for the whole child session. Do not combine `--auto-mode-id` with `--model-id` +or a non-Goose harness. `--agent-id` may supply persona instructions while Auto +selects the model. Creation and session metadata report `auto_mode_id`. + +There is no automatic inference from the parent or active UI session. Without +`--auto-mode-id`, existing creation behavior is unchanged: the configured Auto +default applies only when harness, model, and agent are all omitted. Pass the +mode explicitly when delegating so the child's intended routing is unambiguous. diff --git a/src-tauri/crates/berdctl/api-surface-feedback.json b/src-tauri/crates/berdctl/api-surface-feedback.json index d0753599a..f0ec46cf0 100644 --- a/src-tauri/crates/berdctl/api-surface-feedback.json +++ b/src-tauri/crates/berdctl/api-surface-feedback.json @@ -6,7 +6,7 @@ "description": "Manage the user's chat sessions: create (fire-and-forget, on any installed agent harness), send, notify, open, list, get, rename, move, move to group, clear project, send to a live voice Spokesperson, dismiss voice handoffs, fork, archive.", "actions": { "create": { - "description": "Create a new chat session on any installed agent harness and send the prompt in it. Fire-and-forget: returns the session id immediately and the session runs in the background without changing what the user sees; the user can open it themselves. Use --from to give the delegating session or tool a concise visible label on the initial message. Only check on it later (action \"get\") if the user asks.", + "description": "Create a new chat session on any installed agent harness and send the prompt in it. Fire-and-forget: returns the session id immediately and the session runs in the background without changing what the user sees; the user can open it themselves. Use --from to give the delegating session or tool a concise visible label on the initial message. Use --auto-mode-id to select a configured Goose Auto mode explicitly; no parent or active UI session selection is inferred. Only check on it later (action \"get\") if the user asks.", "fields": [ { "name": "prompt", @@ -28,6 +28,13 @@ "kind": "string", "description": "Id of the model to use (from `berdctl info models`)." }, + { + "name": "auto_mode_id", + "required": false, + "kind": "string", + "description": "Configured Goose Auto mode id (from `berdctl info models --harness-id goose`). Routes each fresh prompt; cannot combine with --model-id or a non-Goose harness. With --agent-id, keeps persona instructions while Auto selects the model.", + "min": 1 + }, { "name": "agent_id", "required": false, @@ -75,6 +82,11 @@ "description": "Id of the model to use (from `berdctl info models`).", "type": "string" }, + "auto_mode_id": { + "description": "Configured Goose Auto mode id (from `berdctl info models --harness-id goose`). Routes each fresh prompt; cannot combine with --model-id or a non-Goose harness. With --agent-id, keeps persona instructions while Auto selects the model.", + "type": "string", + "minLength": 1 + }, "agent_id": { "description": "Id of the agent (persona) to use (from `berdctl agent list`).", "type": "string" @@ -1445,7 +1457,7 @@ } }, "list_models": { - "description": "List the models available per agent harness (same source as the app's model picker); omit harness_id to cover every ready harness in one call. Use a model_id (and its harness_id) when creating a session.", + "description": "List the models available per agent harness (same source as the app's model picker); omit harness_id to cover every ready harness in one call. Goose also lists configured Auto modes separately in auto_modes. Use --auto-mode-id for those ids, or --model-id for a concrete model when creating a session.", "fields": [ { "name": "harness_id", diff --git a/src-tauri/crates/berdctl/api-surface.json b/src-tauri/crates/berdctl/api-surface.json index 973fd7d2f..e4ab418a8 100644 --- a/src-tauri/crates/berdctl/api-surface.json +++ b/src-tauri/crates/berdctl/api-surface.json @@ -6,7 +6,7 @@ "description": "Manage the user's chat sessions: create (fire-and-forget, on any installed agent harness), send, notify, open, list, get, rename, move, move to group, clear project, send to a live voice Spokesperson, dismiss voice handoffs, fork, archive.", "actions": { "create": { - "description": "Create a new chat session on any installed agent harness and send the prompt in it. Fire-and-forget: returns the session id immediately and the session runs in the background without changing what the user sees; the user can open it themselves. Use --from to give the delegating session or tool a concise visible label on the initial message. Only check on it later (action \"get\") if the user asks.", + "description": "Create a new chat session on any installed agent harness and send the prompt in it. Fire-and-forget: returns the session id immediately and the session runs in the background without changing what the user sees; the user can open it themselves. Use --from to give the delegating session or tool a concise visible label on the initial message. Use --auto-mode-id to select a configured Goose Auto mode explicitly; no parent or active UI session selection is inferred. Only check on it later (action \"get\") if the user asks.", "fields": [ { "name": "prompt", @@ -28,6 +28,13 @@ "kind": "string", "description": "Id of the model to use (from `berdctl info models`)." }, + { + "name": "auto_mode_id", + "required": false, + "kind": "string", + "description": "Configured Goose Auto mode id (from `berdctl info models --harness-id goose`). Routes each fresh prompt; cannot combine with --model-id or a non-Goose harness. With --agent-id, keeps persona instructions while Auto selects the model.", + "min": 1 + }, { "name": "agent_id", "required": false, @@ -75,6 +82,11 @@ "description": "Id of the model to use (from `berdctl info models`).", "type": "string" }, + "auto_mode_id": { + "description": "Configured Goose Auto mode id (from `berdctl info models --harness-id goose`). Routes each fresh prompt; cannot combine with --model-id or a non-Goose harness. With --agent-id, keeps persona instructions while Auto selects the model.", + "type": "string", + "minLength": 1 + }, "agent_id": { "description": "Id of the agent (persona) to use (from `berdctl agent list`).", "type": "string" @@ -1336,7 +1348,7 @@ } }, "list_models": { - "description": "List the models available per agent harness (same source as the app's model picker); omit harness_id to cover every ready harness in one call. Use a model_id (and its harness_id) when creating a session.", + "description": "List the models available per agent harness (same source as the app's model picker); omit harness_id to cover every ready harness in one call. Goose also lists configured Auto modes separately in auto_modes. Use --auto-mode-id for those ids, or --model-id for a concrete model when creating a session.", "fields": [ { "name": "harness_id", diff --git a/src-tauri/crates/berdctl/cli-surface-feedback.json b/src-tauri/crates/berdctl/cli-surface-feedback.json index 87b91e67b..31ff864c6 100644 --- a/src-tauri/crates/berdctl/cli-surface-feedback.json +++ b/src-tauri/crates/berdctl/cli-surface-feedback.json @@ -8,7 +8,7 @@ "create": { "action": "create", "about": "Create a new chat session and send a prompt in it (fire-and-forget)", - "afterHelp": "Examples:\n berdctl session create --prompt \"Triage the failing nightly build\" \\\n --harness-id claude-acp --from \"the release orchestrator\" --json\n berdctl session create --prompt \"Implement the fix\" \\\n --project-id --startup-name my-feature\n\nResult:\n {\"session_id\": \"...\", \"title\": \"...\", \"harness_id\": \"...\",\n \"send_status\": \"dispatched\"}\n The session runs in the background; the user's view does not change. Check\n progress later with `berdctl session get --session-id `." + "afterHelp": "Examples:\n berdctl session create --prompt \"Triage the failing nightly build\" \\\n --harness-id claude-acp --from \"the release orchestrator\" --json\n berdctl session create --prompt \"Implement the fix\" \\\n --project-id --startup-name my-feature\n\n berdctl session create --prompt \"Investigate\" \\\n --harness-id goose --auto-mode-id --json\n\nResult:\n {\"session_id\": \"...\", \"title\": \"...\", \"harness_id\": \"...\",\n \"auto_mode_id\": \"...\"|null, \"send_status\": \"dispatched\"}\n The session runs in the background; the user's view does not change. Check\n progress later with `berdctl session get --session-id `." }, "send": { "action": "send", @@ -33,7 +33,7 @@ "get": { "action": "get", "about": "Read one session's metadata and optionally its latest messages", - "afterHelp": "Example:\n berdctl session get --session-id --messages 5 --json\n\nResult:\n {\"session_id\": \"...\", \"title\": \"...\", \"harness_id\": \"...\",\n \"model_id\": \"...\"|null, \"agent_id\": \"...\"|null,\n \"project_id\": \"...\"|null, \"working_dir\": \"...\"|null,\n \"created_at\": \"...\", \"updated_at\": \"...\", \"archived\": false,\n \"is_running\": false, \"is_open_in_window\": false,\n \"chat_state\": \"idle\", \"message_count\": 12,\n \"messages\": [{\"role\": \"user\"|\"assistant\"|null, \"text\": \"...\"}, ...]}\n \"messages\" is present only when --messages > 0; each message text is\n truncated to 2000 chars." + "afterHelp": "Example:\n berdctl session get --session-id --messages 5 --json\n\nResult:\n {\"session_id\": \"...\", \"title\": \"...\", \"harness_id\": \"...\",\n \"model_id\": \"...\"|null, \"agent_id\": \"...\"|null, \"auto_mode_id\": \"...\"|null,\n \"project_id\": \"...\"|null, \"working_dir\": \"...\"|null,\n \"created_at\": \"...\", \"updated_at\": \"...\", \"archived\": false,\n \"is_running\": false, \"is_open_in_window\": false,\n \"chat_state\": \"idle\", \"message_count\": 12,\n \"messages\": [{\"role\": \"user\"|\"assistant\"|null, \"text\": \"...\"}, ...]}\n \"messages\" is present only when --messages > 0; each message text is\n truncated to 2000 chars." }, "rename": { "action": "rename", @@ -214,7 +214,7 @@ "models": { "action": "list_models", "about": "List the models available per agent harness", - "afterHelp": "Example:\n berdctl info models --harness-id goose --json\n\nResult:\n {\"harnesses\": [{\"harness_id\": \"...\",\n \"models\": [{\"model_id\": \"...\", \"name\": \"...\",\n \"provider\": \"...\"?}],\n \"warning\": \"...\"?}]}\n Use a model_id (with its harness) as --model-id when creating a session.\n \"warning\" appears when a stale cached list was served or when the harness\n manages its model outside the app." + "afterHelp": "Example:\n berdctl info models --harness-id goose --json\n\nResult:\n {\"harnesses\": [{\"harness_id\": \"...\",\n \"models\": [{\"model_id\": \"...\", \"name\": \"...\",\n \"provider\": \"...\"?}],\n \"auto_modes\": [{\"id\": \"...\", \"name\": \"...\", \"is_default\": true}]?,\n \"warning\": \"...\"?}]}\n Use a model_id (with its harness) as --model-id when creating a session.\n Goose auto_modes ids are not model ids; pass one as --auto-mode-id to route\n each fresh prompt. auto_modes is omitted for non-Goose harnesses.\n \"warning\" appears when a stale cached list was served or when the harness\n manages its model outside the app." }, "context": { "action": "get_context", diff --git a/src-tauri/crates/berdctl/cli-surface.json b/src-tauri/crates/berdctl/cli-surface.json index 948797105..18a60bd5c 100644 --- a/src-tauri/crates/berdctl/cli-surface.json +++ b/src-tauri/crates/berdctl/cli-surface.json @@ -8,7 +8,7 @@ "create": { "action": "create", "about": "Create a new chat session and send a prompt in it (fire-and-forget)", - "afterHelp": "Examples:\n berdctl session create --prompt \"Triage the failing nightly build\" \\\n --harness-id claude-acp --from \"the release orchestrator\" --json\n berdctl session create --prompt \"Implement the fix\" \\\n --project-id --startup-name my-feature\n\nResult:\n {\"session_id\": \"...\", \"title\": \"...\", \"harness_id\": \"...\",\n \"send_status\": \"dispatched\"}\n The session runs in the background; the user's view does not change. Check\n progress later with `berdctl session get --session-id `." + "afterHelp": "Examples:\n berdctl session create --prompt \"Triage the failing nightly build\" \\\n --harness-id claude-acp --from \"the release orchestrator\" --json\n berdctl session create --prompt \"Implement the fix\" \\\n --project-id --startup-name my-feature\n\n berdctl session create --prompt \"Investigate\" \\\n --harness-id goose --auto-mode-id --json\n\nResult:\n {\"session_id\": \"...\", \"title\": \"...\", \"harness_id\": \"...\",\n \"auto_mode_id\": \"...\"|null, \"send_status\": \"dispatched\"}\n The session runs in the background; the user's view does not change. Check\n progress later with `berdctl session get --session-id `." }, "send": { "action": "send", @@ -33,7 +33,7 @@ "get": { "action": "get", "about": "Read one session's metadata and optionally its latest messages", - "afterHelp": "Example:\n berdctl session get --session-id --messages 5 --json\n\nResult:\n {\"session_id\": \"...\", \"title\": \"...\", \"harness_id\": \"...\",\n \"model_id\": \"...\"|null, \"agent_id\": \"...\"|null,\n \"project_id\": \"...\"|null, \"working_dir\": \"...\"|null,\n \"created_at\": \"...\", \"updated_at\": \"...\", \"archived\": false,\n \"is_running\": false, \"is_open_in_window\": false,\n \"chat_state\": \"idle\", \"message_count\": 12,\n \"messages\": [{\"role\": \"user\"|\"assistant\"|null, \"text\": \"...\"}, ...]}\n \"messages\" is present only when --messages > 0; each message text is\n truncated to 2000 chars." + "afterHelp": "Example:\n berdctl session get --session-id --messages 5 --json\n\nResult:\n {\"session_id\": \"...\", \"title\": \"...\", \"harness_id\": \"...\",\n \"model_id\": \"...\"|null, \"agent_id\": \"...\"|null, \"auto_mode_id\": \"...\"|null,\n \"project_id\": \"...\"|null, \"working_dir\": \"...\"|null,\n \"created_at\": \"...\", \"updated_at\": \"...\", \"archived\": false,\n \"is_running\": false, \"is_open_in_window\": false,\n \"chat_state\": \"idle\", \"message_count\": 12,\n \"messages\": [{\"role\": \"user\"|\"assistant\"|null, \"text\": \"...\"}, ...]}\n \"messages\" is present only when --messages > 0; each message text is\n truncated to 2000 chars." }, "rename": { "action": "rename", @@ -198,7 +198,7 @@ "models": { "action": "list_models", "about": "List the models available per agent harness", - "afterHelp": "Example:\n berdctl info models --harness-id goose --json\n\nResult:\n {\"harnesses\": [{\"harness_id\": \"...\",\n \"models\": [{\"model_id\": \"...\", \"name\": \"...\",\n \"provider\": \"...\"?}],\n \"warning\": \"...\"?}]}\n Use a model_id (with its harness) as --model-id when creating a session.\n \"warning\" appears when a stale cached list was served or when the harness\n manages its model outside the app." + "afterHelp": "Example:\n berdctl info models --harness-id goose --json\n\nResult:\n {\"harnesses\": [{\"harness_id\": \"...\",\n \"models\": [{\"model_id\": \"...\", \"name\": \"...\",\n \"provider\": \"...\"?}],\n \"auto_modes\": [{\"id\": \"...\", \"name\": \"...\", \"is_default\": true}]?,\n \"warning\": \"...\"?}]}\n Use a model_id (with its harness) as --model-id when creating a session.\n Goose auto_modes ids are not model ids; pass one as --auto-mode-id to route\n each fresh prompt. auto_modes is omitted for non-Goose harnesses.\n \"warning\" appears when a stale cached list was served or when the harness\n manages its model outside the app." }, "context": { "action": "get_context", diff --git a/src-tauri/crates/berdctl/src/main.rs b/src-tauri/crates/berdctl/src/main.rs index a363d943f..9642d6602 100644 --- a/src-tauri/crates/berdctl/src/main.rs +++ b/src-tauri/crates/berdctl/src/main.rs @@ -416,6 +416,29 @@ mod tests { ); } + #[test] + fn session_create_maps_auto_mode_id_onto_the_wire() { + let (command, args) = wire_of(&[ + "berdctl", + "session", + "create", + "--prompt", + "hi", + "--harness-id", + "goose", + "--auto-mode-id", + "custom-balanced", + ]); + assert_eq!(command, "sessions"); + assert_eq!( + Value::Object(args), + serde_json::json!({ + "action": "create", "prompt": "hi", "harness_id": "goose", + "auto_mode_id": "custom-balanced", + }) + ); + } + #[test] fn project_create_collects_repeated_working_dirs() { let (command, args) = wire_of(&[ @@ -641,8 +664,9 @@ mod tests { it. Fire-and-forget: returns the session id immediately and the session runs in the background without changing what the user sees; the user can open it themselves. Use --from to give the delegating session or tool a concise visible -label on the initial message. Only check on it later (action "get") if the user -asks. +label on the initial message. Use --auto-mode-id to select a configured Goose +Auto mode explicitly; no parent or active UI session selection is inferred. Only +check on it later (action "get") if the user asks. Usage: berdctl session create [OPTIONS] --prompt @@ -657,6 +681,12 @@ Options: --model-id Id of the model to use (from `berdctl info models`). + --auto-mode-id + Configured Goose Auto mode id (from `berdctl info models --harness-id + goose`). Routes each fresh prompt; cannot combine with --model-id or a + non-Goose harness. With --agent-id, keeps persona instructions while + Auto selects the model. + --agent-id Id of the agent (persona) to use (from `berdctl agent list`). @@ -687,9 +717,12 @@ Examples: berdctl session create --prompt "Implement the fix" \ --project-id --startup-name my-feature + berdctl session create --prompt "Investigate" \ + --harness-id goose --auto-mode-id --json + Result: {"session_id": "...", "title": "...", "harness_id": "...", - "send_status": "dispatched"} + "auto_mode_id": "..."|null, "send_status": "dispatched"} The session runs in the background; the user's view does not change. Check progress later with `berdctl session get --session-id `. "#; diff --git a/src/features/berdctl/__tests__/commands/commands.test.ts b/src/features/berdctl/__tests__/commands/commands.test.ts index fa81e6af1..c2cb5c262 100644 --- a/src/features/berdctl/__tests__/commands/commands.test.ts +++ b/src/features/berdctl/__tests__/commands/commands.test.ts @@ -711,6 +711,198 @@ describe("command safety metadata", () => { }); describe("sessions.create", () => { + function configureAutoModes() { + useRuntimeConfigStore.setState({ + config: { + ...DEFAULT_RUNTIME_CONFIG, + goose: { + ...DEFAULT_RUNTIME_CONFIG.goose, + autoRouting: { + url: "https://router.example", + defaultModeId: "balanced", + modes: ["balanced", "custom-mode"].map((id) => ({ + id, + displayName: + id === "balanced" ? "Auto Balanced" : "Custom routing", + modelProviderId: "openai", + fallback: { + modelId: `${id}-fallback`, + effort: "medium" as const, + }, + models: [{ modelId: `${id}-fallback`, routerModel: id }], + })), + }, + }, + }, + }); + } + + it.each([ + ["balanced", undefined], + ["custom-mode", "goose"], + ])("creates with refreshed explicit mode %s on harness %s", async (modeId, harnessId) => { + usePendingAutoModeStore.setState({ modeId: "unrelated-ui-mode" }); + mocks.refreshNewChat.mockImplementationOnce(async () => + configureAutoModes(), + ); + const result = await dispatchCommand( + "sessions", + { + action: "create", + prompt: "Investigate", + auto_mode_id: modeId, + ...(harnessId ? { harness_id: harnessId } : {}), + }, + ctx, + ); + expect(result).toMatchObject({ auto_mode_id: modeId, harness_id: "goose" }); + expect(mocks.acpCreateSession).toHaveBeenCalledWith( + "openai", + "/resolved/cwd", + expect.objectContaining({ modelId: `${modeId}-fallback` }), + ); + expect( + useChatSessionStore.getState().getSession("session-new"), + ).toMatchObject({ + autoModeId: modeId, + executionTarget: { + harnessId: "goose", + modelProviderId: "openai", + modelId: `${modeId}-fallback`, + }, + }); + }); + + it.each([ + { auto_mode_id: "missing" }, + { auto_mode_id: "balanced", model_id: "model-a" }, + { auto_mode_id: "balanced", harness_id: "codex-acp" }, + ])("rejects invalid Auto selection before workspace/session side effects: %j", async (selection) => { + mocks.refreshNewChat.mockImplementationOnce(async () => + configureAutoModes(), + ); + const error = await expectCommandError( + dispatchCommand( + "sessions", + { + action: "create", + prompt: "Investigate", + project_id: "p-1", + startup_name: "child", + ...selection, + }, + ctx, + ), + "invalid_args", + ); + expect(error.message).toContain("--auto-mode-id"); + expect(mocks.refreshNewChat).toHaveBeenCalledOnce(); + expect(mocks.planProjectChatWorkspaces).not.toHaveBeenCalled(); + expect(mocks.planProjectChatWorkspacesAsIs).not.toHaveBeenCalled(); + expect(mocks.resolveSessionCwd).not.toHaveBeenCalled(); + expect(mocks.acpCreateSession).not.toHaveBeenCalled(); + }); + + it("keeps explicit persona instructions while Auto owns model selection", async () => { + configureAutoModes(); + mocks.listPersonas.mockResolvedValue([ + { + id: "reviewer", + displayName: "Reviewer", + systemPrompt: "Review carefully.", + provider: "codex-acp", + model: "persona-model", + isBuiltin: false, + writable: true, + }, + ]); + await dispatchCommand( + "sessions", + { + action: "create", + prompt: "Review", + auto_mode_id: "balanced", + agent_id: "reviewer", + }, + ctx, + ); + expect(mocks.acpCreateSession).toHaveBeenCalledWith( + "openai", + "/resolved/cwd", + expect.objectContaining({ + personaId: "reviewer", + modelId: "balanced-fallback", + }), + ); + expect( + useChatSessionStore.getState().getSession("session-new"), + ).toMatchObject({ + autoModeId: "balanced", + personaId: "reviewer", + }); + expect( + useChatStore.getState().queuedMessageBySession["session-new"]?.[0], + ).toMatchObject({ + payload: { + persona: { kind: "persona", id: "reviewer", name: "Reviewer" }, + }, + }); + }); + + it("does not apply an Auto default when only an explicit Goose harness is supplied", async () => { + configureAutoModes(); + await dispatchCommand( + "sessions", + { action: "create", prompt: "Hi", harness_id: "goose" }, + ctx, + ); + expect( + useChatSessionStore.getState().getSession("session-new")?.autoModeId, + ).toBeNull(); + }); + + it("discovers refreshed modes separately from concrete models only for Goose", async () => { + mocks.refreshNewChat.mockImplementationOnce(async () => + configureAutoModes(), + ); + const result = await dispatchCommand( + "info", + { action: "list_models" }, + ctx, + ); + expect(result).toMatchObject({ + harnesses: expect.arrayContaining([ + expect.objectContaining({ + harness_id: "goose", + auto_modes: [ + { id: "balanced", name: "Auto Balanced", is_default: true }, + { id: "custom-mode", name: "Custom routing", is_default: false }, + ], + }), + ]), + }); + const harnesses = ( + result as { + harnesses: Array<{ + harness_id: string; + auto_modes?: unknown; + models: Array<{ model_id: string }>; + }>; + } + ).harnesses; + for (const harness of harnesses) { + if (harness.harness_id !== "goose") + expect(harness).not.toHaveProperty("auto_modes"); + expect(harness.models.map((model) => model.model_id)).not.toContain( + "balanced", + ); + expect(harness.models.map((model) => model.model_id)).not.toContain( + "custom-mode", + ); + } + expect(mocks.refreshNewChat).toHaveBeenCalledOnce(); + }); + it("preserves an explicit persona instead of applying the refreshed Auto default", async () => { mocks.listPersonas.mockResolvedValue([ { @@ -879,6 +1071,7 @@ describe("sessions.create", () => { session_id: "session-new", title: DEFAULT_CHAT_TITLE, harness_id: "goose", + auto_mode_id: null, send_status: "dispatched", }); @@ -2448,6 +2641,7 @@ describe("sessions.get", () => { harness_id: "codex-acp", model_id: "gpt-6", agent_id: null, + auto_mode_id: null, project_id: "p-1", working_dir: "/work", created_at: "2026-04-01T00:00:00.000Z", @@ -2461,6 +2655,19 @@ describe("sessions.get", () => { expect(mocks.lastSessionMessages).not.toHaveBeenCalled(); }); + it("reports the selected Auto mode in session metadata", async () => { + mockSessionFound(); + useChatSessionStore + .getState() + .patchSession("session-1", { autoModeId: "balanced" }); + const result = await dispatchCommand( + "sessions", + { action: "get", session_id: "session-1" }, + ctx, + ); + expect(result).toMatchObject({ auto_mode_id: "balanced" }); + }); + it("reports whether the session chat is running", async () => { mockSessionFound(); useChatStore.getState().setChatState("session-1", "streaming"); @@ -5292,6 +5499,7 @@ describe("info", () => { ctx, ); + expect(mocks.refreshNewChat).not.toHaveBeenCalled(); expect(result).toEqual({ harnesses: [ { @@ -5319,6 +5527,7 @@ describe("info", () => { harnesses: [ { harness_id: "goose", + auto_modes: [], models: [ { model_id: "model-a", name: "model-a", provider: modelProvider }, ], diff --git a/src/features/berdctl/commands/impl/createSession.ts b/src/features/berdctl/commands/impl/createSession.ts index c1ebab2fb..fac5405b1 100644 --- a/src/features/berdctl/commands/impl/createSession.ts +++ b/src/features/berdctl/commands/impl/createSession.ts @@ -23,6 +23,15 @@ const createSessionSchema = z .string() .optional() .describe("Id of the model to use (from `berdctl info models`)."), + auto_mode_id: z + .string() + .min(1) + .optional() + .describe( + "Configured Goose Auto mode id (from `berdctl info models --harness-id goose`). " + + "Routes each fresh prompt; cannot combine with --model-id or a non-Goose harness. " + + "With --agent-id, keeps persona instructions while Auto selects the model.", + ), agent_id: z .string() .optional() @@ -62,6 +71,7 @@ interface CreateSessionResult { session_id: string; title: string; harness_id: string; + auto_mode_id: string | null; send_status: "dispatched"; } @@ -77,6 +87,8 @@ export const createSessionCommand = defineCommand({ "background without changing what the user sees; the user can open it themselves. " + "Use --from to give the delegating session or tool a concise visible label on " + "the initial message. " + + "Use --auto-mode-id to select a configured Goose Auto mode explicitly; no parent " + + "or active UI session selection is inferred. " + 'Only check on it later (action "get") if the user asks.', helpFooter: `Examples: berdctl session create --prompt "Triage the failing nightly build" \\ @@ -84,9 +96,12 @@ export const createSessionCommand = defineCommand({ berdctl session create --prompt "Implement the fix" \\ --project-id --startup-name my-feature + berdctl session create --prompt "Investigate" \\ + --harness-id goose --auto-mode-id --json + Result: {"session_id": "...", "title": "...", "harness_id": "...", - "send_status": "dispatched"} + "auto_mode_id": "..."|null, "send_status": "dispatched"} The session runs in the background; the user's view does not change. Check progress later with \`berdctl session get --session-id \`.`, schema: createSessionSchema, @@ -129,12 +144,28 @@ Result: import("../runtime/providers"), ]); await refreshRuntimeConfigForNewChat(); + const harnessId = args.harness_id ?? GOOSE_PROVIDER_ID; + const autoRouting = + useRuntimeConfigStore.getState().config.goose.autoRouting; + if (args.auto_mode_id !== undefined) { + if (args.model_id !== undefined || harnessId !== GOOSE_PROVIDER_ID) { + throw new CommandError( + "invalid_args", + "--auto-mode-id requires Goose and cannot be combined with --model-id; omit --model-id and use --harness-id goose (or omit --harness-id).", + ); + } + if (!autoRouting?.modes.some((mode) => mode.id === args.auto_mode_id)) { + throw new CommandError( + "invalid_args", + `Unknown Auto mode "${args.auto_mode_id}"; list configured modes with \`berdctl info models --harness-id goose\` and pass an auto_modes id as --auto-mode-id.`, + ); + } + } const autoModeId = - args.harness_id || args.model_id || args.agent_id + args.auto_mode_id ?? + (args.harness_id || args.model_id || args.agent_id ? null - : useRuntimeConfigStore.getState().config.goose.autoRouting - ?.defaultModeId; - const harnessId = args.harness_id ?? GOOSE_PROVIDER_ID; + : autoRouting?.defaultModeId); // The validation legs are independent I/O; overlap them. const [project, , models, persona] = await Promise.all([ args.project_id ? findProjectOrThrow(args.project_id) : null, @@ -253,6 +284,7 @@ Result: session_id: session.id, title: session.title, harness_id: harnessId, + auto_mode_id: session.autoModeId ?? null, send_status: "dispatched" as const, }; }, diff --git a/src/features/berdctl/commands/impl/getSession.ts b/src/features/berdctl/commands/impl/getSession.ts index 27e70517a..55f494a66 100644 --- a/src/features/berdctl/commands/impl/getSession.ts +++ b/src/features/berdctl/commands/impl/getSession.ts @@ -28,6 +28,7 @@ interface SessionMetadataResult { harness_id: string; model_id: string | null; agent_id: string | null; + auto_mode_id: string | null; project_id: string | null; working_dir: string | null; created_at: string; @@ -63,7 +64,7 @@ export const getSessionCommand = defineCommand({ Result: {"session_id": "...", "title": "...", "harness_id": "...", - "model_id": "..."|null, "agent_id": "..."|null, + "model_id": "..."|null, "agent_id": "..."|null, "auto_mode_id": "..."|null, "project_id": "..."|null, "working_dir": "..."|null, "created_at": "...", "updated_at": "...", "archived": false, "is_running": false, "is_open_in_window": false, diff --git a/src/features/berdctl/commands/impl/listModels.ts b/src/features/berdctl/commands/impl/listModels.ts index adb3d39d9..54d51df34 100644 --- a/src/features/berdctl/commands/impl/listModels.ts +++ b/src/features/berdctl/commands/impl/listModels.ts @@ -26,6 +26,7 @@ interface ListModelsResult { harnesses: Array<{ harness_id: string; models: ModelEntry[]; + auto_modes?: Array<{ id: string; name: string; is_default: boolean }>; /** Present when a stale cached list was served (the last refresh failed) * or when the harness manages its model outside the app. */ warning?: string; @@ -40,7 +41,8 @@ export const listModelsCommand = defineCommand({ description: "List the models available per agent harness (same source as the app's " + "model picker); omit harness_id to cover every ready harness in one " + - "call. Use a model_id (and its harness_id) when creating a session.", + "call. Goose also lists configured Auto modes separately in auto_modes. Use " + + "--auto-mode-id for those ids, or --model-id for a concrete model when creating a session.", helpFooter: `Example: berdctl info models --harness-id goose --json @@ -48,8 +50,11 @@ Result: {"harnesses": [{"harness_id": "...", "models": [{"model_id": "...", "name": "...", "provider": "..."?}], + "auto_modes": [{"id": "...", "name": "...", "is_default": true}]?, "warning": "..."?}]} Use a model_id (with its harness) as --model-id when creating a session. + Goose auto_modes ids are not model ids; pass one as --auto-mode-id to route + each fresh prompt. auto_modes is omitted for non-Goose harnesses. "warning" appears when a stale cached list was served or when the harness manages its model outside the app.`, schema: listModelsSchema, @@ -58,6 +63,8 @@ Result: { getProviderModelSelectionHint }, { useProviderModelCacheStore }, { GOOSE_PROVIDER_ID }, + { refreshRuntimeConfigForNewChat }, + { useRuntimeConfigStore }, { findReadyHarnessOrThrow, listHarnessStatuses, @@ -68,6 +75,8 @@ Result: import("@/features/providers/modelSelectionHints"), import("@/features/providers/stores/providerModelCacheStore"), import("@/shared/api/acpPersonaHandoff"), + import("@/shared/runtime-config/newChatRuntimeConfig"), + import("@/shared/runtime-config/runtimeConfigStore"), import("../runtime/providers"), ]); const targets = args.harness_id @@ -75,6 +84,11 @@ Result: : (await listHarnessStatuses()).filter( (harness) => harness.readiness === "ready", ); + if (targets.some((harness) => harness.id === GOOSE_PROVIDER_ID)) { + await refreshRuntimeConfigForNewChat(); + } + const autoRouting = + useRuntimeConfigStore.getState().config.goose.autoRouting; const harnesses = await Promise.all( targets.map(async (harness) => { const models = @@ -96,6 +110,15 @@ Result: return { harness_id: harness.id, models, + ...(harness.id === GOOSE_PROVIDER_ID + ? { + auto_modes: (autoRouting?.modes ?? []).map((mode) => ({ + id: mode.id, + name: mode.displayName, + is_default: mode.id === autoRouting?.defaultModeId, + })), + } + : {}), ...(warning ? { warning } : {}), }; }), diff --git a/src/features/berdctl/commands/runtime/sessions.ts b/src/features/berdctl/commands/runtime/sessions.ts index b5bb0b9de..f2a7c8f4f 100644 --- a/src/features/berdctl/commands/runtime/sessions.ts +++ b/src/features/berdctl/commands/runtime/sessions.ts @@ -128,6 +128,7 @@ export function sessionMetadata(session: ChatSession) { harness_id: session.executionTarget?.harnessId ?? "goose", model_id: session.executionTarget?.modelId ?? null, agent_id: session.personaId ?? null, + auto_mode_id: session.autoModeId ?? null, project_id: session.projectId ?? null, working_dir: session.workingDir ?? null, created_at: session.createdAt,