diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts index eaf7be6f185..92052a4ab03 100644 --- a/src/common/orpc/schemas/stream.ts +++ b/src/common/orpc/schemas/stream.ts @@ -1081,6 +1081,11 @@ export const SendMessageOptionsSchema = z.object({ * composer's concrete level and doubles as the fallback. */ autoThinkingLevel: z.boolean().optional(), + /** + * Auto choices to save for the agent instead of this send's own flags: a plan handoff sends + * with routing off but keeps the target agent's Auto choices for its later sends. + */ + savedAutoRouting: z.object({ model: z.boolean(), thinkingLevel: z.boolean() }).optional(), /** * When true, workspace-specific agent definitions are disabled. * Only built-in and global agents are loaded. Useful for "unbricking" when diff --git a/src/common/orpc/schemas/workspaceAiSettings.ts b/src/common/orpc/schemas/workspaceAiSettings.ts index 43a6ce3d543..a67d8b8dc36 100644 --- a/src/common/orpc/schemas/workspaceAiSettings.ts +++ b/src/common/orpc/schemas/workspaceAiSettings.ts @@ -21,6 +21,16 @@ export const WorkspaceAISettingsSchema = z.object({ }), }); +// Only sends persist the auto-routing flags, so the picker update routes keep the base schema. +export const WorkspaceAgentAISettingsSchema = WorkspaceAISettingsSchema.extend({ + autoModelRouting: z.boolean().optional().meta({ + description: "Composer model set to Auto on the last send. Absent = off.", + }), + autoThinkingLevel: z.boolean().optional().meta({ + description: "Composer thinking level set to Auto on the last send. Absent = off.", + }), +}); + /** * Per-agent workspace AI overrides. * @@ -29,5 +39,5 @@ export const WorkspaceAISettingsSchema = z.object({ */ export const WorkspaceAISettingsByAgentSchema = z.record( z.string().min(1), - WorkspaceAISettingsSchema + WorkspaceAgentAISettingsSchema ); diff --git a/src/node/services/agentTaskReawakenAi.test.ts b/src/node/services/agentTaskReawakenAi.test.ts index c60eff7e0d0..77c4de35f7f 100644 --- a/src/node/services/agentTaskReawakenAi.test.ts +++ b/src/node/services/agentTaskReawakenAi.test.ts @@ -96,6 +96,16 @@ describe("computeReawakenInputsKey", () => { } expect(key(makeConfig(), "other-context")).not.toBe(base); }); + + it("ignores Auto flags, which planning does not read", () => { + const bucket = { model: MODEL_A, thinkingLevel: "medium" as const }; + const flagged = { ...bucket, autoModelRouting: true, autoThinkingLevel: true }; + for (const side of ["child", "parent"] as const) { + expect(key(makeConfig({ [side]: { aiSettingsByAgent: { exec: flagged } } }))).toBe( + key(makeConfig({ [side]: { aiSettingsByAgent: { exec: bucket } } })) + ); + } + }); }); describe("planReawakenAi", () => { @@ -220,4 +230,28 @@ describe("applyAgentTaskTurnAiSnapshot", () => { expect(workspace.taskModelString).toBe(MODEL_B); expect(workspace.taskThinkingLevel).toBe("high"); }); + + it("keeps the child's saved Auto choices", () => { + const workspace: Workspace = { + id: "child", + name: "child", + path: "/repo/child", + aiSettingsByAgent: { + exec: { model: MODEL_C, thinkingLevel: "low", autoModelRouting: true }, + }, + }; + applyAgentTaskTurnAiSnapshot(workspace, { + agentId: "exec", + taskModelString: MODEL_B, + canonicalModel: MODEL_B, + thinkingLevel: "high", + reasoningMode: "standard", + }); + expect(workspace.aiSettingsByAgent?.exec).toEqual({ + model: MODEL_B, + thinkingLevel: "high", + reasoningMode: "standard", + autoModelRouting: true, + }); + }); }); diff --git a/src/node/services/agentTaskReawakenAi.ts b/src/node/services/agentTaskReawakenAi.ts index 5672351cbe1..0a80a6ef848 100644 --- a/src/node/services/agentTaskReawakenAi.ts +++ b/src/node/services/agentTaskReawakenAi.ts @@ -41,6 +41,14 @@ import { } from "@/node/services/taskWorkspaceSeam"; type LoadedConfig = ReturnType; +type AgentSettingsBucket = NonNullable[string]; + +/** Planning never reads the composer's Auto flags, so a flag-only change must not refuse a commit. */ +function withoutAutoRoutingFlags(bucket: AgentSettingsBucket | undefined) { + if (bucket == null) return null; + const { autoModelRouting, autoThinkingLevel, ...settings } = bucket; + return settings; +} /** AI settings one reawakened execution runs with; mirrors creation's conventions. */ export interface AgentTaskTurnAiSnapshot { @@ -131,7 +139,7 @@ export function computeReawakenInputsKey( agentId, parentWorkspaceId: child.parentWorkspaceId ?? null, taskAiPins: child.taskAiPins ?? null, - bucket: child.aiSettingsByAgent?.[agentId] ?? null, + bucket: withoutAutoRoutingFlags(child.aiSettingsByAgent?.[agentId]), aiSettings: child.aiSettings ?? null, taskModelString: child.taskModelString ?? null, taskThinkingLevel: child.taskThinkingLevel ?? null, @@ -141,7 +149,15 @@ export function computeReawakenInputsKey( ? { agentId: parent.agentId ?? null, agentType: parent.agentType ?? null, - aiSettingsByAgent: parent.aiSettingsByAgent ?? null, + aiSettingsByAgent: + parent.aiSettingsByAgent != null + ? Object.fromEntries( + Object.entries(parent.aiSettingsByAgent).map(([id, bucket]) => [ + id, + withoutAutoRoutingFlags(bucket), + ]) + ) + : null, aiSettings: parent.aiSettings ?? null, } : null, @@ -310,12 +326,16 @@ export function applyAgentTaskTurnAiSnapshot( thinkingLevel: snapshot.thinkingLevel, reasoningMode: snapshot.reasoningMode, }; + const previous = workspace.aiSettingsByAgent?.[snapshot.agentId]; workspace.aiSettingsByAgent = { ...(workspace.aiSettingsByAgent ?? {}), [snapshot.agentId]: { model: bucketModel, thinkingLevel: snapshot.thinkingLevel, reasoningMode: snapshot.reasoningMode, + // The snapshot has no Auto choices; the child's composer keeps them for its next sends. + ...(previous?.autoModelRouting === true ? { autoModelRouting: true } : {}), + ...(previous?.autoThinkingLevel === true ? { autoThinkingLevel: true } : {}), }, }; workspace.taskModelString = snapshot.taskModelString; diff --git a/src/node/services/workspaceService.aiSettings.test.ts b/src/node/services/workspaceService.aiSettings.test.ts index b134a973aad..9c409b2e869 100644 --- a/src/node/services/workspaceService.aiSettings.test.ts +++ b/src/node/services/workspaceService.aiSettings.test.ts @@ -87,6 +87,8 @@ describe("WorkspaceService sendMessage AI settings persistence", () => { agentId, model: "openai:gpt-5.2", thinkingLevel: "high", + autoModelRouting: true, + autoThinkingLevel: true, ...(skip ? { skipAiSettingsPersistence: true } : {}), }, synthetic ? { synthetic: true } : undefined @@ -114,7 +116,12 @@ describe("WorkspaceService sendMessage AI settings persistence", () => { aiSettings: remembered.aiSettings, aiSettingsByAgent: { ...remembered.aiSettingsByAgent, - [agentId]: { model: "openai:gpt-5.2", thinkingLevel: "high" }, + [agentId]: { + model: "openai:gpt-5.2", + thinkingLevel: "high", + autoModelRouting: true, + autoThinkingLevel: true, + }, }, } ); @@ -492,6 +499,65 @@ describe("WorkspaceService maybePersistAISettingsFromOptions", () => { }); }); + const saveWithAutoChoices = () => + saveWorkspaces(harness.config, projectPath, [ + { + id: "ws", + path: workspacePath, + name: "ws", + aiSettingsByAgent: { + exec: { + model: "openai:gpt-4o-mini", + thinkingLevel: "off", + autoModelRouting: true, + autoThinkingLevel: true, + }, + }, + }, + ]); + + test.each([ + [{}, {}], + // A plan handoff runs its send without Auto but keeps the agent's Auto choices. + [{ savedAutoRouting: { model: true, thinkingLevel: false } }, { autoModelRouting: true }], + [{ autoModelRouting: true, savedAutoRouting: { model: false, thinkingLevel: false } }, {}], + ])("a send saves the Auto choices it carries: %j", async (sendFlags, savedFlags) => { + await saveWithAutoChoices(); + const svc = workspaceService as unknown as { + maybePersistAISettingsFromOptions: (workspaceId: string, options: unknown) => Promise; + }; + + await svc.maybePersistAISettingsFromOptions("ws", { + agentId: "exec", + model: "openai:gpt-4o-mini", + thinkingLevel: "off", + ...sendFlags, + }); + + expect(readEntry()?.aiSettingsByAgent?.exec).toEqual({ + model: "openai:gpt-4o-mini", + thinkingLevel: "off", + ...savedFlags, + }); + }); + + test("a picker update keeps the saved Auto choices", async () => { + await saveWithAutoChoices(); + + const result = await workspaceService.updateAgentAISettings("ws", "exec", { + model: "openai:gpt-5.2", + thinkingLevel: "high", + }); + + expect(result.success).toBe(true); + expect(readEntry()?.aiSettingsByAgent?.exec).toEqual({ + model: "openai:gpt-5.2", + thinkingLevel: "high", + autoModelRouting: true, + autoThinkingLevel: true, + }); + }); + test("persists AI settings for sub-agent workspaces so auto-resume can use latest model", async () => { interface WorkspaceServiceTestAccess { maybePersistAISettingsFromOptions: (workspaceId: string, options: unknown) => Promise; diff --git a/src/node/services/workspaceService.fork.test.ts b/src/node/services/workspaceService.fork.test.ts index de99bae87fa..8fd56efdba8 100644 --- a/src/node/services/workspaceService.fork.test.ts +++ b/src/node/services/workspaceService.fork.test.ts @@ -1059,10 +1059,22 @@ describe("WorkspaceService fork", () => { generateStableIdSpy.mockRestore(); } }); - test("fork marks the new workspace as pending auto-title when a continue message is queued", async () => { + test("fork keeps the parent's AI selection and marks the new workspace as pending auto-title when a continue message is queued", async () => { const sourceWorkspaceId = "source-workspace"; const newWorkspaceId = "forked-workspace"; const sourceProjectPath = path.join(tempDir, "project"); + const sourceAISelection = { + agentId: "plan", + aiSettings: { model: "anthropic:claude-sonnet-4-5", thinkingLevel: "medium" as const }, + aiSettingsByAgent: { + plan: { + model: "openai:gpt-5.2", + thinkingLevel: "high" as const, + autoModelRouting: true, + autoThinkingLevel: true, + }, + }, + }; const sourceMetadata: FrontendWorkspaceMetadata = { id: sourceWorkspaceId, name: "source-branch", @@ -1071,6 +1083,7 @@ describe("WorkspaceService fork", () => { projectName: "project", runtimeConfig: { type: "local" }, namedWorkspacePath: path.join(sourceProjectPath, "source-branch"), + ...sourceAISelection, }; const forkedWorkspacePath = path.join(sourceProjectPath, "source-branch-1"); @@ -1135,6 +1148,11 @@ describe("WorkspaceService fork", () => { (metadata) => metadata.id === newWorkspaceId ); expect(persistedMetadata?.pendingAutoTitle).toBe(true); + expect({ + agentId: persistedMetadata?.agentId, + aiSettings: persistedMetadata?.aiSettings, + aiSettingsByAgent: persistedMetadata?.aiSettingsByAgent, + }).toEqual(sourceAISelection); } finally { orchestrateForkSpy.mockRestore(); copyPlanSpy.mockRestore(); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 64e5c7cdd78..30ac213dafe 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -377,6 +377,7 @@ import type { WorkspaceGoalDefaultsOverrideSchema, WorkspaceHeartbeatSettingsSchema, } from "@/common/orpc/schemas"; +import type { WorkspaceAgentAISettingsSchema } from "@/common/orpc/schemas/workspaceAiSettings"; import { SendMessageOptionsSchema } from "@/common/orpc/schemas"; import { type AgentMessageDispatchMode, @@ -634,12 +635,32 @@ const AUTO_NEW_WORKSPACE_BASE_NAME = "workspace"; // Shared type for workspace-scoped AI settings (model + thinking) type WorkspaceAISettings = z.infer; +type WorkspaceAgentAISettings = z.infer; type WorkspaceHeartbeatSettings = z.infer; // intervalMs: null clears the per-workspace override so the global default applies (#5692). type WorkspaceHeartbeatSettingsUpdate = Omit, "intervalMs"> & { intervalMs?: number | null; }; type WorkspaceGoalDefaultsOverride = z.infer; + +// Optional fields the update omits keep their stored value: older clients omit reasoningMode, +// and the picker update routes never carry the auto-routing flags, which only sends persist. +function mergeAgentAISettings( + prev: WorkspaceAgentAISettings | undefined, + next: WorkspaceAgentAISettings +): WorkspaceAgentAISettings { + const reasoningMode = next.reasoningMode ?? prev?.reasoningMode; + const autoModelRouting = next.autoModelRouting ?? prev?.autoModelRouting; + const autoThinkingLevel = next.autoThinkingLevel ?? prev?.autoThinkingLevel; + return { + model: next.model, + thinkingLevel: next.thinkingLevel, + ...(reasoningMode != null ? { reasoningMode } : {}), + ...(autoModelRouting === true ? { autoModelRouting } : {}), + ...(autoThinkingLevel === true ? { autoThinkingLevel } : {}), + }; +} + interface HeartbeatWorkspaceConfigEntry { normalizedWorkspaceId: string; /** Project/workspace paths so editConfig transforms can re-find the FRESH entry. */ @@ -13239,7 +13260,7 @@ export class WorkspaceService private extractWorkspaceAISettingsFromSendOptions( options: SendMessageOptions | undefined - ): WorkspaceAISettings | null { + ): WorkspaceAgentAISettings | null { const rawModel = options?.model; if (typeof rawModel !== "string" || rawModel.trim().length === 0) { return null; @@ -13263,7 +13284,15 @@ export class WorkspaceService // preserves any previously stored value instead of wiping it. const reasoningMode = options?.reasoningMode; - return { model, thinkingLevel, ...(reasoningMode != null ? { reasoningMode } : {}) }; + return { + model, + thinkingLevel, + ...(reasoningMode != null ? { reasoningMode } : {}), + // Clients send the auto-routing flags only when on, so an absent flag means off. + autoModelRouting: options?.savedAutoRouting?.model ?? options?.autoModelRouting === true, + autoThinkingLevel: + options?.savedAutoRouting?.thinkingLevel ?? options?.autoThinkingLevel === true, + }; } /** @@ -13331,7 +13360,7 @@ export class WorkspaceService private async persistWorkspaceAISettingsForAgent( workspaceId: string, agentId: string, - aiSettings: WorkspaceAISettings | null, + aiSettings: WorkspaceAgentAISettings | null, options?: { emitMetadata?: boolean; disableWorkspaceAgents?: boolean; @@ -13393,11 +13422,7 @@ export class WorkspaceService const aiSettingsChanged = options?.pinsOnly !== true && aiSettings != null && - (prev?.model !== aiSettings.model || - prev?.thinkingLevel !== aiSettings.thinkingLevel || - // Absent reasoningMode preserves the previous value (see write below), - // so only an explicit different value counts as a change. - (aiSettings.reasoningMode != null && prev?.reasoningMode !== aiSettings.reasoningMode)); + !isDeepStrictEqual(prev, mergeAgentAISettings(prev, aiSettings)); const selectedAgentChanged = options?.pinsOnly !== true && options?.persistSelectedAgentId === true && @@ -13424,12 +13449,12 @@ export class WorkspaceService } const prev = workspaceEntry.aiSettingsByAgent?.[normalizedAgentId]; + const nextAgentSettings = + aiSettings != null && options?.pinsOnly !== true + ? mergeAgentAISettings(prev, aiSettings) + : undefined; const aiSettingsChanged = - options?.pinsOnly !== true && - aiSettings != null && - (prev?.model !== aiSettings.model || - prev?.thinkingLevel !== aiSettings.thinkingLevel || - (aiSettings.reasoningMode != null && prev?.reasoningMode !== aiSettings.reasoningMode)); + nextAgentSettings != null && !isDeepStrictEqual(prev, nextAgentSettings); const selectedAgentChanged = options?.pinsOnly !== true && options?.persistSelectedAgentId === true && @@ -13445,16 +13470,10 @@ export class WorkspaceService workspaceEntry.taskAiPins = nextPins; } - if (aiSettings != null && options?.pinsOnly !== true) { - // Callers that omit reasoningMode (older clients, thinking-only updates) - // must not wipe a previously persisted value — self-healing merge. - const mergedReasoningMode = aiSettings.reasoningMode ?? prev?.reasoningMode; + if (nextAgentSettings != null) { workspaceEntry.aiSettingsByAgent = { ...(workspaceEntry.aiSettingsByAgent ?? {}), - [normalizedAgentId]: { - ...aiSettings, - ...(mergedReasoningMode != null ? { reasoningMode: mergedReasoningMode } : {}), - }, + [normalizedAgentId]: nextAgentSettings, }; } @@ -14094,6 +14113,9 @@ export class WorkspaceService namedWorkspacePath, // Preserve sub-project cwd/prompt context when forking via /fork. subProjectPath: sourceMetadata.subProjectPath, + aiSettings: sourceMetadata.aiSettings, + aiSettingsByAgent: sourceMetadata.aiSettingsByAgent, + agentId: sourceMetadata.agentId, // Forks with a continue message stay pending until the first accepted user send // can generate a more specific title, unless the user edits the title first. pendingAutoTitle: pendingAutoTitle === true ? true : undefined, diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index b6f08e560a2..93e745a8577 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -502,7 +502,7 @@ function createWorkspaceQuickPickItem( detailParts.push(`Created: ${new Date(workspace.createdAt).toLocaleDateString()}`); } - const aiByAgent = + const aiByAgent: typeof workspace.aiSettingsByAgent = workspace.aiSettingsByAgent ?? (workspace.aiSettings ? {