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 3b89098d0..2d672e961 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, 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" @@ -1292,7 +1304,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 6fc718810..be26fbf7d 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, 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" @@ -1183,7 +1195,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 841d08db6..cf28910c9 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", @@ -204,7 +204,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 7924f29dc..e3bb5c8ca 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", @@ -188,7 +188,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 e4191639b..9b774e8ad 100644 --- a/src-tauri/crates/berdctl/src/main.rs +++ b/src-tauri/crates/berdctl/src/main.rs @@ -403,6 +403,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(&[ @@ -628,8 +651,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 @@ -644,6 +668,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`). @@ -674,9 +704,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 c4e27e45a..074462b74 100644 --- a/src/features/berdctl/__tests__/commands/commands.test.ts +++ b/src/features/berdctl/__tests__/commands/commands.test.ts @@ -1,5 +1,8 @@ import { usePendingAutoModeStore } from "@/features/chat/lib/autoModelSelection"; -import { useRuntimeConfigStore } from "@/shared/runtime-config/runtimeConfigStore"; +import { + useRuntimeConfigStore, + INITIAL_RUNTIME_CONFIG_RESULT, +} from "@/shared/runtime-config/runtimeConfigStore"; import { DEFAULT_RUNTIME_CONFIG } from "@/shared/runtime-config/schema"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -363,7 +366,11 @@ async function expectCommandError( beforeEach(() => { usePendingAutoModeStore.setState({ modeId: undefined }); - useRuntimeConfigStore.setState({ config: DEFAULT_RUNTIME_CONFIG }); + useRuntimeConfigStore.setState({ + config: DEFAULT_RUNTIME_CONFIG, + loaded: false, + result: INITIAL_RUNTIME_CONFIG_RESULT, + }); mocks.refreshNewChat.mockReset().mockResolvedValue(undefined); resetSessionTargetCoordinatorsForTests(); localStorage.removeItem("goose:chat-workspace-metadata"); @@ -699,6 +706,309 @@ describe("command safety metadata", () => { }); describe("sessions.create", () => { + function configureAutoModes() { + const 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 }], + })), + }, + }, + }; + useRuntimeConfigStore.setState({ + config, + loaded: true, + result: { + status: "ready", + source: "endpoint", + config, + autoRoutingRevision: "a".repeat(64), + }, + }); + } + + 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("rejects an explicit Auto mode as retryable config_unavailable before the initial load", async () => { + // Store default: initial app-default snapshot; the refresh is a no-op. + const error = await expectCommandError( + dispatchCommand( + "sessions", + { action: "create", prompt: "Investigate", auto_mode_id: "balanced" }, + ctx, + ), + "config_unavailable", + ); + expect(error.message).toContain("retry"); + expect(mocks.acpCreateSession).not.toHaveBeenCalled(); + expect( + useChatStore.getState().queuedMessageBySession["session-new"], + ).toBeUndefined(); + expect(useChatSessionStore.getState().sessions).toHaveLength(0); + }); + + it("rejects an explicit Auto mode as config_unavailable when the refresh fails", async () => { + mocks.refreshNewChat.mockImplementationOnce(async () => { + useRuntimeConfigStore.setState({ + loaded: true, + result: { + status: "unavailable", + source: "endpoint", + reason: "endpointUnavailable", + message: "Runtime configuration refresh unavailable", + }, + config: DEFAULT_RUNTIME_CONFIG, + }); + }); + await expectCommandError( + dispatchCommand( + "sessions", + { action: "create", prompt: "Investigate", auto_mode_id: "balanced" }, + ctx, + ), + "config_unavailable", + ); + expect(mocks.acpCreateSession).not.toHaveBeenCalled(); + expect(useChatSessionStore.getState().sessions).toHaveLength(0); + }); + + it("rejects as selection_superseded when the selected mode changes before creation", async () => { + configureAutoModes(); + let releaseCwd = () => {}; + mocks.resolveSessionCwd.mockImplementationOnce(async () => { + await new Promise((resolve) => { + releaseCwd = resolve; + }); + return "/resolved/cwd"; + }); + const pending = dispatchCommand( + "sessions", + { action: "create", prompt: "Investigate", auto_mode_id: "balanced" }, + ctx, + ); + await vi.waitFor(() => + expect(mocks.resolveSessionCwd).toHaveBeenCalledOnce(), + ); + // A concurrent refresh replaces the config without the selected mode. + const config = { + ...DEFAULT_RUNTIME_CONFIG, + goose: { + ...DEFAULT_RUNTIME_CONFIG.goose, + autoRouting: { + url: "https://router.example", + defaultModeId: "custom-mode", + modes: [ + { + id: "custom-mode", + displayName: "Custom routing", + modelProviderId: "openai", + fallback: { + modelId: "custom-mode-fallback", + effort: "medium" as const, + }, + models: [ + { modelId: "custom-mode-fallback", routerModel: "custom-mode" }, + ], + }, + ], + }, + }, + }; + useRuntimeConfigStore.setState({ + config, + loaded: true, + result: { + status: "ready", + source: "endpoint", + config, + autoRoutingRevision: "f".repeat(64), + }, + }); + releaseCwd(); + await expectCommandError(pending, "selection_superseded"); + expect(mocks.acpCreateSession).not.toHaveBeenCalled(); + expect( + useChatStore.getState().queuedMessageBySession["session-new"], + ).toBeUndefined(); + expect(useChatSessionStore.getState().sessions).toHaveLength(0); + }); + + 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([ { @@ -867,6 +1177,7 @@ describe("sessions.create", () => { session_id: "session-new", title: DEFAULT_CHAT_TITLE, harness_id: "goose", + auto_mode_id: null, send_status: "dispatched", }); @@ -2436,6 +2747,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", @@ -2467,6 +2779,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"); @@ -5344,6 +5669,7 @@ describe("info", () => { ctx, ); + expect(mocks.refreshNewChat).not.toHaveBeenCalled(); expect(result).toEqual({ harnesses: [ { @@ -5371,14 +5697,56 @@ describe("info", () => { harnesses: [ { harness_id: "goose", + auto_modes: [], models: [ { model_id: "model-a", name: "model-a", provider: modelProvider }, ], + // Without a refreshed authoritative config, the empty mode list is + // uncertainty, not a proven configuration. + warning: + "configured Auto modes may be unavailable; the mode list may be incomplete or stale", }, ], }); }); + it("list_models keeps serving other harnesses when the Goose Auto configuration refresh fails", async () => { + mocks.readinessFromReport.mockReturnValue( + new Map([ + ["goose", "ready"], + ["codex-acp", "ready"], + ]), + ); + seedModelCache("codex-acp", ["gpt-6"]); + mocks.refreshNewChat.mockRejectedValueOnce( + new Error("goose provider sync failed"), + ); + + const result = await dispatchCommand( + "info", + { action: "list_models" }, + ctx, + ); + + const harnesses = result as { + harnesses: Array<{ + harness_id: string; + models: Array<{ model_id: string }>; + warning?: string; + }>; + }; + expect(harnesses.harnesses.map((h) => h.harness_id)).toEqual([ + "goose", + "codex-acp", + ]); + const goose = harnesses.harnesses.find((h) => h.harness_id === "goose"); + expect(goose?.warning).toContain("goose provider sync failed"); + // The unaffected harness still serves its cached models with no warning. + const codex = harnesses.harnesses.find((h) => h.harness_id === "codex-acp"); + expect(codex?.models.map((m) => m.model_id)).toEqual(["gpt-6"]); + expect(codex).not.toHaveProperty("warning"); + }); + it("list_models covers every ready harness when harness_id is omitted", async () => { mocks.readinessFromReport.mockReturnValue( new Map([ diff --git a/src/features/berdctl/commands/impl/createSession.ts b/src/features/berdctl/commands/impl/createSession.ts index c1ebab2fb..bd435b8fa 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,54 @@ Result: import("../runtime/providers"), ]); await refreshRuntimeConfigForNewChat(); + const harnessId = args.harness_id ?? GOOSE_PROVIDER_ID; + const runtimeState = useRuntimeConfigStore.getState(); + const autoRouting = runtimeState.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).", + ); + } + // Startup, failed, and app-default loads expose defaults without proving + // a configured mode was removed (same rule as Auto dispatch). Report the + // uncertainty as retryable instead of claiming the mode is unknown. + if ( + !runtimeState.loaded || + runtimeState.result.status !== "ready" || + runtimeState.result.source === "appDefault" + ) { + throw new CommandError( + "config_unavailable", + "Auto mode configuration is not available right now; retry, then list configured modes with `berdctl info models --harness-id goose`.", + ); + } + 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); + // Bind the selected mode to this config snapshot: a concurrent refresh + // may replace the routing while the later validations below run, which + // would otherwise persist a stale mode over a different live target. + // Re-verified immediately before the session is created. + const selectedAutoRouting = autoModeId + ? { + revision: + runtimeState.result.status === "ready" + ? runtimeState.result.autoRoutingRevision + : undefined, + routing: JSON.stringify(autoRouting), + } + : null; // The validation legs are independent I/O; overlap them. const [project, , models, persona] = await Promise.all([ args.project_id ? findProjectOrThrow(args.project_id) : null, @@ -205,6 +262,24 @@ Result: : await resolveSessionCwd(project); let session: ChatSession; try { + // A concurrent refresh replaced the routing snapshot the selection was + // validated against; fall back would silently discard the caller's + // requested mode, so reject with no session instead. + if (selectedAutoRouting) { + const current = useRuntimeConfigStore.getState(); + if ( + (current.result.status === "ready" + ? current.result.autoRoutingRevision + : undefined) !== selectedAutoRouting.revision || + JSON.stringify(current.config.goose.autoRouting) !== + selectedAutoRouting.routing + ) { + throw new CommandError( + "selection_superseded", + "The Auto mode selection changed while the chat was being prepared; no session was created. Retry with a currently configured --auto-mode-id.", + ); + } + } // Past the broker deadline the agent was already told this call failed; // do not create a session it cannot see. The workspace plan may already // have created a branch/worktree, so the catch below rolls it back. @@ -253,6 +328,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 ba1620b39..31d5c8c28 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..34ad25675 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,16 +50,22 @@ 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, execute: async (args): Promise => { + let gooseConfigWarning: string | null = null; const [ { getProviderModelSelectionHint }, { useProviderModelCacheStore }, { GOOSE_PROVIDER_ID }, + { refreshRuntimeConfigForNewChat }, + { useRuntimeConfigStore }, { findReadyHarnessOrThrow, listHarnessStatuses, @@ -68,6 +76,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 +85,30 @@ Result: : (await listHarnessStatuses()).filter( (harness) => harness.readiness === "ready", ); + if (targets.some((harness) => harness.id === GOOSE_PROVIDER_ID)) { + try { + await refreshRuntimeConfigForNewChat(); + } catch (error) { + // A Goose-specific sync failure must not abort discovery for the + // other ready harnesses; surface it on the Goose entry instead. + gooseConfigWarning = `auto mode configuration refresh failed: ${ + error instanceof Error ? error.message : String(error) + }`; + } + } + const runtimeState = useRuntimeConfigStore.getState(); + const autoRouting = runtimeState.config.goose.autoRouting; + if ( + gooseConfigWarning === null && + (!runtimeState.loaded || + runtimeState.result.status !== "ready" || + runtimeState.result.source === "appDefault") + ) { + // Startup, failed, and app-default loads expose defaults without + // proving the saved Auto modes are gone (same rule as Auto dispatch). + gooseConfigWarning = + "configured Auto modes may be unavailable; the mode list may be incomplete or stale"; + } const harnesses = await Promise.all( targets.map(async (harness) => { const models = @@ -91,11 +125,25 @@ Result: harness.id === GOOSE_PROVIDER_ID ? null : useProviderModelCacheStore.getState().getError(harness.id); + const warningParts = [ + hint, + staleError ? `list may be stale: ${staleError}` : null, + harness.id === GOOSE_PROVIDER_ID ? gooseConfigWarning : null, + ].filter((entry): entry is string => entry !== null); const warning = - hint ?? (staleError ? `list may be stale: ${staleError}` : null); + warningParts.length > 0 ? warningParts.join("; ") : null; 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 6c3924932..1096c6124 100644 --- a/src/features/berdctl/commands/runtime/sessions.ts +++ b/src/features/berdctl/commands/runtime/sessions.ts @@ -137,6 +137,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, diff --git a/src/features/berdctl/commands/types.ts b/src/features/berdctl/commands/types.ts index 131d8d18c..d61c6d2e9 100644 --- a/src/features/berdctl/commands/types.ts +++ b/src/features/berdctl/commands/types.ts @@ -79,6 +79,8 @@ export const COMMAND_ERROR_CODES = [ "harness_not_found", "harness_not_ready", "model_not_found", + "config_unavailable", + "selection_superseded", "blocked_unsaved_changes", "backend_read_failed", "backend_archive_failed",