diff --git a/apps/code/src/main/platform-adapters/electron-workspace-settings.ts b/apps/code/src/main/platform-adapters/electron-workspace-settings.ts index 4769b46f1d..16d2993b20 100644 --- a/apps/code/src/main/platform-adapters/electron-workspace-settings.ts +++ b/apps/code/src/main/platform-adapters/electron-workspace-settings.ts @@ -6,11 +6,13 @@ import { getAutoSuspendEnabled, getMaxActiveWorktrees, getPreventSleepWhileRunning, + getSubagentModel, getWorktreeLocation, setAutoSuspendAfterDays, setAutoSuspendEnabled, setMaxActiveWorktrees, setPreventSleepWhileRunning, + setSubagentModel, setWorktreeLocation, } from "../services/settingsStore"; @@ -59,4 +61,12 @@ export class ElectronWorkspaceSettings implements IWorkspaceSettings { setPreventSleepWhileRunning(value: boolean): void { setPreventSleepWhileRunning(value); } + + getSubagentModel(): string | null { + return getSubagentModel(); + } + + setSubagentModel(model: string | null): void { + setSubagentModel(model); + } } diff --git a/apps/code/src/main/services/settingsStore.ts b/apps/code/src/main/services/settingsStore.ts index 4ed515d35b..79ef8464e7 100644 --- a/apps/code/src/main/services/settingsStore.ts +++ b/apps/code/src/main/services/settingsStore.ts @@ -14,6 +14,7 @@ interface SettingsSchema { discordPresenceEnabled: boolean; discordPresenceShowTaskTitle: boolean; discordPresenceShowRepoName: boolean; + subagentModel: string | null; } function getDefaultWorktreeLocation(): string { @@ -99,6 +100,10 @@ const schema = { type: "boolean" as const, default: false, }, + subagentModel: { + type: ["string", "null"] as const, + default: null, + }, }; export const settingsStore = new Store({ @@ -114,6 +119,7 @@ export const settingsStore = new Store({ discordPresenceEnabled: false, discordPresenceShowTaskTitle: false, discordPresenceShowRepoName: false, + subagentModel: null, }, }); @@ -192,3 +198,11 @@ export function getPreventSleepWhileRunning(): boolean { export function setPreventSleepWhileRunning(value: boolean): void { settingsStore.set("preventSleepWhileRunning", value); } + +export function getSubagentModel(): string | null { + return settingsStore.get("subagentModel", null); +} + +export function setSubagentModel(model: string | null): void { + settingsStore.set("subagentModel", model); +} diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 173483077a..47ffae3b37 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -1758,6 +1758,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { onProcessSpawned: this.options?.onProcessSpawned, onProcessExited: this.options?.onProcessExited, effort, + subagentModel: meta?.subagentModel, enrichmentDeps: this.enrichment?.deps, enrichedReadCache: this.enrichedReadCache, cloudMode: cloudRun, diff --git a/packages/agent/src/adapters/claude/session/models.ts b/packages/agent/src/adapters/claude/session/models.ts index 4ea33fd269..6e05527cf3 100644 --- a/packages/agent/src/adapters/claude/session/models.ts +++ b/packages/agent/src/adapters/claude/session/models.ts @@ -6,6 +6,8 @@ export const DEFAULT_MODEL = "opus"; // at spawn, so this must stay distinct from the alias form used for DEFAULT_MODEL. export const FALLBACK_MODEL = "claude-opus-4-8"; +export const DEFAULT_SUBAGENT_MODEL = "sonnet"; + // Default thinking level when the user hasn't picked one. Adaptive-only models // like claude-fable-5 reject the SDK's no-effort `thinking: { type: "disabled" }` // shape, so effort-capable models default to high to keep thinking enabled. diff --git a/packages/agent/src/adapters/claude/session/options.test.ts b/packages/agent/src/adapters/claude/session/options.test.ts index 87fd3b2ae0..1249a0c35a 100644 --- a/packages/agent/src/adapters/claude/session/options.test.ts +++ b/packages/agent/src/adapters/claude/session/options.test.ts @@ -193,6 +193,79 @@ describe("buildSessionOptions", () => { }); }); + describe("CLAUDE_CODE_SUBAGENT_MODEL", () => { + const originalSubagentModel = process.env.CLAUDE_CODE_SUBAGENT_MODEL; + + beforeEach(() => { + delete process.env.CLAUDE_CODE_SUBAGENT_MODEL; + }); + + afterEach(() => { + if (originalSubagentModel === undefined) { + delete process.env.CLAUDE_CODE_SUBAGENT_MODEL; + } else { + process.env.CLAUDE_CODE_SUBAGENT_MODEL = originalSubagentModel; + } + }); + + it("defaults subagents to sonnet when nothing overrides", () => { + const options = buildSessionOptions(makeParams()); + + expect(options.env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe("sonnet"); + }); + + it("prefers a pre-set process env value over the default", () => { + process.env.CLAUDE_CODE_SUBAGENT_MODEL = "inherit"; + + const options = buildSessionOptions(makeParams()); + + expect(options.env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe("inherit"); + }); + + it("prefers the host-provided subagent model over process env", () => { + process.env.CLAUDE_CODE_SUBAGENT_MODEL = "inherit"; + + const options = buildSessionOptions({ + ...makeParams(), + subagentModel: "claude-haiku-4-5", + }); + + expect(options.env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe("claude-haiku-4-5"); + }); + + it("prefers the merged settings env value over the host-provided model", () => { + const params = makeParams(); + vi.spyOn(params.settingsManager, "getSettings").mockReturnValue({ + env: { CLAUDE_CODE_SUBAGENT_MODEL: "inherit" }, + }); + + const options = buildSessionOptions({ + ...params, + subagentModel: "claude-haiku-4-5", + }); + + expect(options.env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe("inherit"); + }); + + it.each([ + ["claude-opus-4-7", "opus"], + ["claude-opus-4-8", "opus"], + ["claude-sonnet-4-6", "sonnet"], + ["claude-haiku-4-5", "claude-haiku-4-5"], + ["inherit", "inherit"], + ])( + "maps host-provided value %s to env value %s", + (subagentModel, expected) => { + const options = buildSessionOptions({ + ...makeParams(), + subagentModel, + }); + + expect(options.env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe(expected); + }, + ); + }); + describe("ANTHROPIC_CUSTOM_HEADERS", () => { const originalProjectId = process.env.POSTHOG_PROJECT_ID; const originalCustomHeaders = process.env.ANTHROPIC_CUSTOM_HEADERS; diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index fa71653b2d..c766eeb35c 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -28,7 +28,12 @@ import type { CodeExecutionMode } from "../tools"; import type { EffortLevel } from "../types"; import { APPENDED_INSTRUCTIONS } from "./instructions"; import { loadUserClaudeJsonMcpServers } from "./mcp-config"; -import { DEFAULT_MODEL, FALLBACK_MODEL } from "./models"; +import { + DEFAULT_MODEL, + DEFAULT_SUBAGENT_MODEL, + FALLBACK_MODEL, + toSdkModelId, +} from "./models"; import type { SettingsManager } from "./settings"; export interface ProcessSpawnedInfo { @@ -72,6 +77,8 @@ export interface BuildOptionsParams { onProcessSpawned?: (info: ProcessSpawnedInfo) => void; onProcessExited?: (pid: number) => void; effort?: EffortLevel; + /** Host-provided model for spawned subagents; gateway id or CLI alias. */ + subagentModel?: string; enrichmentDeps?: FileEnrichmentDeps; enrichedReadCache?: EnrichedReadCache; /** Records PostHog product usage from MCP exec calls (deduped, session-wide). */ @@ -134,7 +141,10 @@ function buildMcpServers( }; } -function buildEnvironment(gateway?: GatewayEnv): Record { +function buildEnvironment( + gateway?: GatewayEnv, + subagentModel?: string, +): Record { // Custom HTTP headers reach the model only through the Claude CLI subprocess, // which reads them from this env var (newline-delimited `name: value` lines) // — the SDK has no direct header option. We finalize them here, the single @@ -192,6 +202,11 @@ function buildEnvironment(gateway?: GatewayEnv): Record { MCP_CONNECTION_NONBLOCKING: mcpNonblocking, }), ANTHROPIC_CUSTOM_HEADERS: customHeaders, + CLAUDE_CODE_SUBAGENT_MODEL: toSdkModelId( + subagentModel ?? + process.env.CLAUDE_CODE_SUBAGENT_MODEL ?? + DEFAULT_SUBAGENT_MODEL, + ), }; } @@ -443,7 +458,11 @@ export function buildSessionOptions(params: BuildOptionsParams): Options { params.mcpServers, loadUserClaudeJsonMcpServers(params.cwd, params.logger), ), - env: buildEnvironment(params.gatewayEnv), + env: buildEnvironment( + params.gatewayEnv, + params.settingsManager.getSettings().env?.CLAUDE_CODE_SUBAGENT_MODEL ?? + params.subagentModel, + ), hooks: buildHooks( params.userProvidedOptions?.hooks, params.onModeChange, diff --git a/packages/agent/src/adapters/claude/session/settings.test.ts b/packages/agent/src/adapters/claude/session/settings.test.ts index 5f6a91f425..90549d6b1b 100644 --- a/packages/agent/src/adapters/claude/session/settings.test.ts +++ b/packages/agent/src/adapters/claude/session/settings.test.ts @@ -308,3 +308,39 @@ describe("mergeAvailableModels", () => { ).toEqual(["managed-a"]); }); }); + +describe("user settings env merge", () => { + let configDir: string; + let originalConfigDir: string | undefined; + + beforeEach(async () => { + configDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "user-settings-env-"), + ); + originalConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = configDir; + }); + + afterEach(async () => { + if (originalConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir; + } + await fs.promises.rm(configDir, { recursive: true, force: true }); + }); + + it("merges a user-layer env block into settings", async () => { + await fs.promises.writeFile( + path.join(configDir, "settings.json"), + JSON.stringify({ env: { CLAUDE_CODE_SUBAGENT_MODEL: "sonnet" } }), + ); + + const manager = new SettingsManager(configDir); + await manager.initialize(); + + expect(manager.getSettings().env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe( + "sonnet", + ); + }); +}); diff --git a/packages/agent/src/adapters/claude/types.ts b/packages/agent/src/adapters/claude/types.ts index 3c1973125a..7709bf4eca 100644 --- a/packages/agent/src/adapters/claude/types.ts +++ b/packages/agent/src/adapters/claude/types.ts @@ -167,6 +167,8 @@ export type NewSessionMeta = { allowedDomains?: string[]; /** Model ID to use for this session (e.g. "claude-sonnet-4-6") */ model?: string; + /** Model for spawned subagents (Task tool, workflow fan-outs); gateway id or CLI alias. */ + subagentModel?: string; /** Base branch of the task's repo (e.g. "master"), for the signed-git tools. */ baseBranch?: string; /** diff --git a/packages/host-router/src/routers/agent.router.ts b/packages/host-router/src/routers/agent.router.ts index e62ea18cf9..eefa43ffa0 100644 --- a/packages/host-router/src/routers/agent.router.ts +++ b/packages/host-router/src/routers/agent.router.ts @@ -13,6 +13,7 @@ import { getGatewayModelsOutput, getPreviewConfigOptionsInput, getPreviewConfigOptionsOutput, + getSubagentModelOutput, listSessionsInput, listSessionsOutput, notifySessionContextInput, @@ -23,6 +24,7 @@ import { respondToPermissionInput, sessionResponseSchema, setConfigOptionInput, + setSubagentModelInput, startSessionInput, subscribeSessionInput, } from "@posthog/workspace-server/services/agent/schemas"; @@ -227,4 +229,18 @@ export const agentRouter = router({ .get(AGENT_SERVICE) .getPreviewConfigOptions(input.apiHost, input.adapter), ), + + getSubagentModel: publicProcedure + .output(getSubagentModelOutput) + .query(({ ctx }) => + ctx.container.get(AGENT_SERVICE).getSubagentModel(), + ), + + setSubagentModel: publicProcedure + .input(setSubagentModelInput) + .mutation(({ ctx, input }) => + ctx.container + .get(AGENT_SERVICE) + .setSubagentModel(input.model), + ), }); diff --git a/packages/platform/src/workspace-settings.ts b/packages/platform/src/workspace-settings.ts index 075b59481c..64372d15e3 100644 --- a/packages/platform/src/workspace-settings.ts +++ b/packages/platform/src/workspace-settings.ts @@ -10,6 +10,8 @@ export interface IWorkspaceSettings { setAutoSuspendAfterDays(value: number): void; getPreventSleepWhileRunning(): boolean; setPreventSleepWhileRunning(value: boolean): void; + getSubagentModel(): string | null; + setSubagentModel(model: string | null): void; } export const WORKSPACE_SETTINGS_SERVICE = Symbol.for( diff --git a/packages/ui/src/features/settings/sections/ClaudeCodeSettings.test.tsx b/packages/ui/src/features/settings/sections/ClaudeCodeSettings.test.tsx new file mode 100644 index 0000000000..06aaf36833 --- /dev/null +++ b/packages/ui/src/features/settings/sections/ClaudeCodeSettings.test.tsx @@ -0,0 +1,179 @@ +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, +); + +const mutateMock = vi.fn(); + +let subagentModelResult: { + data: string | null | undefined; + isLoading: boolean; +}; +let previewConfigResult: { data: unknown }; +let mutationResult: { + mutate: typeof mutateMock; + isPending: boolean; + isError: boolean; +}; +let glmEnabled: boolean; + +vi.mock("@posthog/host-router/react", () => ({ + useHostTRPC: () => ({ + agent: { + getSubagentModel: { + queryOptions: () => ({ queryKey: ["subagentModel"] }), + }, + getPreviewConfigOptions: { + queryOptions: () => ({ queryKey: ["previewConfig"] }), + }, + setSubagentModel: { mutationOptions: () => ({}) }, + }, + }), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: (options: { queryKey?: string[] }) => + options.queryKey?.[0] === "subagentModel" + ? subagentModelResult + : previewConfigResult, + useMutation: () => mutationResult, + useQueryClient: () => ({ + cancelQueries: vi.fn(), + getQueryData: vi.fn(), + setQueryData: vi.fn(), + invalidateQueries: vi.fn(), + }), +})); + +vi.mock("@posthog/ui/features/auth/store", () => ({ + useAuthStateValue: (selector: (state: { cloudRegion: string }) => unknown) => + selector({ cloudRegion: "us" }), +})); + +vi.mock("@posthog/ui/features/feature-flags/useFeatureFlag", () => ({ + useFeatureFlag: () => glmEnabled, +})); + +vi.mock("@posthog/ui/features/settings/settingsStore", () => ({ + useSettingsStore: () => ({ + allowBypassPermissions: false, + setAllowBypassPermissions: vi.fn(), + }), +})); + +vi.mock("@posthog/ui/features/settings/sections/PermissionsSettings", () => ({ + PermissionsSettings: () => null, +})); + +vi.mock("@posthog/ui/shell/analytics", () => ({ + track: vi.fn(), +})); + +import { SubagentModelSetting } from "./ClaudeCodeSettings"; + +const CATALOG_OPTIONS = [ + { + id: "model", + name: "Model", + type: "select", + category: "model", + currentValue: "claude-opus-4-8", + options: [ + { value: "claude-opus-4-8", name: "Claude Opus 4.8" }, + { value: "claude-sonnet-5", name: "Claude Sonnet 5" }, + { value: "glm-5", name: "GLM 5" }, + ], + }, +]; + +function renderSetting() { + return render( + + + , + ); +} + +describe("SubagentModelSetting", () => { + beforeEach(() => { + vi.clearAllMocks(); + glmEnabled = false; + subagentModelResult = { data: null, isLoading: false }; + previewConfigResult = { data: CATALOG_OPTIONS }; + mutationResult = { mutate: mutateMock, isPending: false, isError: false }; + }); + + it("shows the sonnet default when nothing is stored", () => { + renderSetting(); + + expect(screen.getByRole("combobox")).toHaveTextContent("Sonnet (default)"); + }); + + it("shows the inherit choice when stored", () => { + subagentModelResult = { data: "inherit", isLoading: false }; + + renderSetting(); + + expect(screen.getByRole("combobox")).toHaveTextContent( + "Inherit main model", + ); + }); + + it("shows a catalog model by display name when stored", () => { + subagentModelResult = { data: "claude-sonnet-5", isLoading: false }; + + renderSetting(); + + expect(screen.getByRole("combobox")).toHaveTextContent("Claude Sonnet 5"); + }); + + it("falls back to the raw stored value when it is not in the catalog", () => { + subagentModelResult = { data: "sonnet", isLoading: false }; + + renderSetting(); + + expect(screen.getByRole("combobox")).toHaveTextContent("sonnet"); + }); + + it("disables the select while the stored value is loading", () => { + subagentModelResult = { data: undefined, isLoading: true }; + + renderSetting(); + + expect(screen.getByRole("combobox")).toBeDisabled(); + }); + + it("disables the select while a mutation is in flight", () => { + mutationResult = { mutate: mutateMock, isPending: true, isError: false }; + + renderSetting(); + + expect(screen.getByRole("combobox")).toBeDisabled(); + }); + + it("surfaces a save failure", () => { + mutationResult = { mutate: mutateMock, isPending: false, isError: true }; + + renderSetting(); + + expect( + screen.getByText("Failed to save subagent model"), + ).toBeInTheDocument(); + }); + + it("renders without a model catalog when the preview query has no data", () => { + previewConfigResult = { data: undefined }; + + renderSetting(); + + expect(screen.getByRole("combobox")).toHaveTextContent("Sonnet (default)"); + }); +}); diff --git a/packages/ui/src/features/settings/sections/ClaudeCodeSettings.tsx b/packages/ui/src/features/settings/sections/ClaudeCodeSettings.tsx index 92f62bbdce..ef0bff3a2f 100644 --- a/packages/ui/src/features/settings/sections/ClaudeCodeSettings.tsx +++ b/packages/ui/src/features/settings/sections/ClaudeCodeSettings.tsx @@ -1,5 +1,14 @@ import { ArrowSquareOut, Check, Copy, Warning } from "@phosphor-icons/react"; -import { ANALYTICS_EVENTS } from "@posthog/shared"; +import { useHostTRPC } from "@posthog/host-router/react"; +import { + ANALYTICS_EVENTS, + flattenSelectOptions, + GLM_MODEL_FLAG, + getCloudUrlFromRegion, +} from "@posthog/shared"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { stripGlmModelOption } from "@posthog/ui/features/sessions/modelOptionFilters"; import { SettingRow } from "@posthog/ui/features/settings/SettingRow"; import { PermissionsSettings } from "@posthog/ui/features/settings/sections/PermissionsSettings"; import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; @@ -12,9 +21,11 @@ import { Flex, IconButton, Link, + Select, Switch, Text, } from "@radix-ui/themes"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useState } from "react"; function CopyableCommand({ command }: { command: string }) { @@ -69,6 +80,132 @@ function SettingDescription({ ); } +const SUBAGENT_MODEL_DEFAULT_VALUE = "__default__"; +const SUBAGENT_MODEL_INHERIT_VALUE = "inherit"; + +export function SubagentModelSetting() { + const hostTRPC = useHostTRPC(); + const glmEnabled = useFeatureFlag(GLM_MODEL_FLAG); + const cloudRegion = useAuthStateValue((state) => state.cloudRegion); + const apiHost = cloudRegion ? getCloudUrlFromRegion(cloudRegion) : null; + + const queryClient = useQueryClient(); + const subagentModelQueryOptions = + hostTRPC.agent.getSubagentModel.queryOptions(); + const { data: storedModelData, isLoading: isStoredModelLoading } = useQuery( + subagentModelQueryOptions, + ); + const { data: previewConfigOptions } = useQuery({ + ...hostTRPC.agent.getPreviewConfigOptions.queryOptions({ + apiHost: apiHost ?? "", + adapter: "claude", + }), + enabled: apiHost !== null, + }); + const setSubagentModel = useMutation( + hostTRPC.agent.setSubagentModel.mutationOptions({ + onMutate: async (input) => { + await queryClient.cancelQueries({ + queryKey: subagentModelQueryOptions.queryKey, + }); + const previous = queryClient.getQueryData( + subagentModelQueryOptions.queryKey, + ); + queryClient.setQueryData( + subagentModelQueryOptions.queryKey, + input.model, + ); + return { previous }; + }, + onError: (_error, _input, context) => { + queryClient.setQueryData( + subagentModelQueryOptions.queryKey, + context?.previous ?? null, + ); + }, + onSettled: () => + queryClient.invalidateQueries({ + queryKey: subagentModelQueryOptions.queryKey, + }), + }), + ); + + const rawModelOption = previewConfigOptions?.find( + (option) => option.category === "model" && option.type === "select", + ); + const modelOption = + rawModelOption?.type === "select" + ? glmEnabled + ? rawModelOption + : stripGlmModelOption(rawModelOption) + : undefined; + const modelItems = + modelOption?.type === "select" + ? flattenSelectOptions(modelOption.options) + : []; + + const storedModel = storedModelData ?? null; + const selectValue = storedModel ?? SUBAGENT_MODEL_DEFAULT_VALUE; + const storedModelHasItem = + !storedModel || + storedModel === SUBAGENT_MODEL_INHERIT_VALUE || + modelItems.some((item) => item.value === storedModel); + + const { mutate: mutateSubagentModel } = setSubagentModel; + const handleChange = useCallback( + (value: string) => { + const next = value === SUBAGENT_MODEL_DEFAULT_VALUE ? null : value; + track(ANALYTICS_EVENTS.SETTING_CHANGED, { + setting_name: "subagent_model", + new_value: next ?? "default", + old_value: storedModel ?? "default", + }); + mutateSubagentModel({ model: next }); + }, + [storedModel, mutateSubagentModel], + ); + + return ( + + + + + + + Sonnet (default) + + + Inherit main model + + {!storedModelHasItem && storedModel && ( + {storedModel} + )} + {modelItems.map((item) => ( + + {item.name} + + ))} + + + {setSubagentModel.isError && ( + + Failed to save subagent model + + )} + + + ); +} + export function ClaudeCodeSettings() { const { allowBypassPermissions, setAllowBypassPermissions } = useSettingsStore(); @@ -104,8 +241,15 @@ export function ClaudeCodeSettings() { return ( + {/* Models */} + Models + + + {/* Extensions */} - Extensions + + Extensions + "/mock/worktrees", + getSubagentModel: vi.fn(() => null as string | null), + setSubagentModel: vi.fn(), }, foldersService: { getFolders: vi.fn().mockResolvedValue([]), @@ -235,6 +237,52 @@ describe("AgentService", () => { vi.unstubAllGlobals(); }); + describe("subagent model", () => { + it("reads the value from workspace settings", () => { + deps.workspaceSettings.getSubagentModel.mockReturnValue( + "claude-haiku-4-5", + ); + + expect(service.getSubagentModel()).toBe("claude-haiku-4-5"); + }); + + it("persists the chosen model to workspace settings", () => { + service.setSubagentModel("claude-sonnet-5"); + + expect(deps.workspaceSettings.setSubagentModel).toHaveBeenCalledWith( + "claude-sonnet-5", + ); + }); + + it("clears the setting on null", () => { + service.setSubagentModel(null); + + expect(deps.workspaceSettings.setSubagentModel).toHaveBeenCalledWith( + null, + ); + }); + + it("threads the stored model into session _meta", async () => { + deps.workspaceSettings.getSubagentModel.mockReturnValue( + "claude-haiku-4-5", + ); + + await service.startSession({ ...baseSessionParams, adapter: "codex" }); + + expect(mockNewSession.mock.calls[0][0]._meta).toMatchObject({ + subagentModel: "claude-haiku-4-5", + }); + }); + + it("omits subagentModel from _meta when unset", async () => { + await service.startSession({ ...baseSessionParams, adapter: "codex" }); + + expect( + mockNewSession.mock.calls[0][0]._meta.subagentModel, + ).toBeUndefined(); + }); + }); + describe("MCP servers", () => { it("marks desktop sessions as local even though they have a taskRunId", async () => { await service.startSession({ diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index 83341d0c1e..c3c403f732 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -905,6 +905,7 @@ If a repository IS genuinely required, attach one in this priority order: plugins, disallowedTools, }); + const subagentModel = this.workspaceSettings.getSubagentModel(); let configOptions: SessionConfigOption[] | undefined; let agentSessionId: string | undefined; @@ -932,6 +933,7 @@ If a repository IS genuinely required, attach one in this priority order: mcpToolApprovals: toolApprovals, ...(permissionMode && { permissionMode }), ...(model != null && { model }), + ...(subagentModel != null && { subagentModel }), ...(jsonSchema && { jsonSchema }), claudeCode: { options: claudeCodeOptions, @@ -1004,6 +1006,7 @@ If a repository IS genuinely required, attach one in this priority order: mcpToolApprovals: toolApprovals, ...(permissionMode && { permissionMode }), ...(model != null && { model }), + ...(subagentModel != null && { subagentModel }), ...(jsonSchema && { jsonSchema }), claudeCode: { options: claudeCodeOptions, @@ -1030,6 +1033,7 @@ If a repository IS genuinely required, attach one in this priority order: mcpToolApprovals: toolApprovals, ...(permissionMode && { permissionMode }), ...(model != null && { model }), + ...(subagentModel != null && { subagentModel }), ...(jsonSchema && { jsonSchema }), claudeCode: { options: claudeCodeOptions, @@ -2127,6 +2131,14 @@ For git operations while detached: }); } + getSubagentModel(): string | null { + return this.workspaceSettings.getSubagentModel(); + } + + setSubagentModel(model: string | null): void { + this.workspaceSettings.setSubagentModel(model); + } + async getPreviewConfigOptions( apiHost: string, adapter: "claude" | "codex" = "claude", diff --git a/packages/workspace-server/src/services/agent/schemas.ts b/packages/workspace-server/src/services/agent/schemas.ts index 493e79943e..6343fd0637 100644 --- a/packages/workspace-server/src/services/agent/schemas.ts +++ b/packages/workspace-server/src/services/agent/schemas.ts @@ -79,6 +79,15 @@ export const startSessionInput = z.object({ export type StartSessionInput = z.infer; +export const setSubagentModelInput = z.object({ + /** Gateway model id or "inherit"; null clears back to the built-in default. */ + model: z.string().min(1).nullable(), +}); + +export type SetSubagentModelInput = z.infer; + +export const getSubagentModelOutput = z.string().nullable(); + export const modelOptionSchema = z.object({ modelId: z.string(), name: z.string(),