From f255c750711d37dcbce21dc036b6e0912efa8c2a Mon Sep 17 00:00:00 2001 From: haoqixu Date: Tue, 22 Sep 2026 20:34:32 +0800 Subject: [PATCH] fix(coding-agent): preserve thinking levels across model switches --- packages/coding-agent/docs/settings.md | 10 +- .../coding-agent/src/core/agent-session.ts | 21 +- .../agent-session-model-extension.test.ts | 9 +- .../agent-session-thinking-level.test.ts | 295 ++++++++++++++++++ 4 files changed, 324 insertions(+), 11 deletions(-) create mode 100644 packages/coding-agent/test/suite/agent-session-thinking-level.test.ts diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index bbbf0b0ab..1dfcf83bd 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -11,7 +11,7 @@ This reference lists user-configurable settings, their types, defaults, and purp | `defaultProvider` | string | Automatic | Startup AI provider. | | `defaultModel` | string | Automatic | Startup model ID. | | `defaultThinkingLevel` | `"off" \| "minimal" \| "low" \| "medium" \| "high" \| "xhigh" \| "max"` | `"medium"` | Startup thinking level. | -| `modelThinkingLevels` | object | None | Per-model startup thinking levels keyed by exact `provider/modelId`. | +| `modelThinkingLevels` | object | None | Per-model thinking defaults applied at startup and on model switches, keyed by exact `provider/modelId`. Configure from `/settings` → Default thinking level per model or edit manually. | | `thinkingBudgets` | object | Built-in budgets | Token budgets for `minimal`, `low`, `medium`, and `high` thinking levels. | | `enabledModels` | `string[]` | All available models | Model patterns used for startup selection and model cycling. Uses the same format as `--models`. | | `hideThinkingBlock` | boolean | `false` | Hide thinking blocks in the transcript. | @@ -20,6 +20,14 @@ This reference lists user-configurable settings, their types, defaults, and purp Cache warming runs only when the model declares a cache lifetime and Pi estimates at least $0.05 in avoided cache-miss cost. Refresh usage counts toward session totals but does not enter model context. `/session` shows the next decision; extensions can override it with `cache_warming_decision`. See [Prompt Cache Lifetimes](models.md#prompt-cache-lifetimes). +### Thinking level on model switches + +Model switches keep your current thinking level. A target model's `modelThinkingLevels` entry overrides it; when cycling, an explicit `:` suffix in `--models` or `enabledModels` has highest priority. + +Unsupported levels are temporarily adjusted to a supported level and restored on a capable model: `high` → non-thinking model (`off`) → thinking model (`high`). Choosing a different level or applying a model override sets the level carried into later switches. Selecting the displayed level leaves it unchanged. + +Reopening or forking a session carries forward the thinking level saved at that point. + See [Choose a Model](models.md) for model selection and thinking controls. ## Interaction diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 4aaf3c248..b7fa52101 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -77,7 +77,7 @@ import { prepareCompaction, shouldCompact, } from "./compaction/index.ts"; -import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OPTIONS } from "./defaults.ts"; +import { THINKING_LEVEL_OPTIONS } from "./defaults.ts"; import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.ts"; import { createToolHtmlRenderer } from "./export-html/tool-renderer.ts"; import { @@ -360,6 +360,8 @@ export class AgentSession { readonly settingsManager: SettingsManager; private _scopedModels: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; + /** Thinking level to restore after a capability clamp; not persisted. */ + private _thinkingLevelBeforeClamp: ThinkingLevel | undefined; // Event subscription state private _unsubscribeAgent?: () => void; @@ -2404,7 +2406,7 @@ export class AgentSession { } // Apply thinking level for the new model. - // Per-model thinking level overrides take priority over the global default. + // Per-model thinking level overrides take priority over the cached pre-clamp or current level. // Model persistence does not implicitly rewrite the global thinking default. this.setThinkingLevel(thinkingLevel); @@ -2471,8 +2473,8 @@ export class AgentSession { } // Apply thinking level for the new model. - // - Explicit scoped model thinking level overrides defaults - // - Per-model thinking level overrides take priority over the global default + // - Explicit scoped model thinking level overrides per-model defaults + // - Per-model thinking level overrides take priority over the cached pre-clamp or current level // setThinkingLevel clamps to model capabilities. // Model persistence does not implicitly rewrite the global thinking default. this.setThinkingLevel(thinkingLevel); @@ -2522,7 +2524,7 @@ export class AgentSession { * Set thinking level. * Clamps to model capabilities based on available thinking levels. * Saves the clamped level to the session transcript only if the level actually changes. - * Persists the requested level to global defaults only when options.persist is true. + * Persists the supplied level to global defaults only when options.persist is true. */ setThinkingLevel(level: ThinkingLevel, options: ModelMutationOptions = {}): void { const availableLevels = this.getAvailableThinkingLevels(); @@ -2532,6 +2534,10 @@ export class AgentSession { const previousLevel = this.agent.state.thinkingLevel; const isChanging = effectiveLevel !== previousLevel; + // Remember only values lost to clamping. Reapplying the current level is a no-op. + if (isChanging || effectiveLevel !== level) { + this._thinkingLevelBeforeClamp = effectiveLevel !== level ? level : undefined; + } this.agent.state.thinkingLevel = effectiveLevel; if (options.persist) { @@ -2583,16 +2589,19 @@ export class AgentSession { private _getThinkingLevelForModelSwitch(targetModel?: Model, explicitLevel?: ThinkingLevel): ThinkingLevel { if (explicitLevel !== undefined) { + // Overrides replace the inherited level, including any pending clamp restoration. + this._thinkingLevelBeforeClamp = undefined; return explicitLevel; } // Per-model default takes priority when switching to a model that has one if (targetModel) { const perModel = this.settingsManager.getModelThinkingLevel(targetModel.provider, targetModel.id); if (perModel !== undefined) { + this._thinkingLevelBeforeClamp = undefined; return perModel; } } - return this.settingsManager.getDefaultThinkingLevel() ?? this.thinkingLevel ?? DEFAULT_THINKING_LEVEL; + return this._thinkingLevelBeforeClamp ?? this.thinkingLevel; } private _clampThinkingLevel(level: ThinkingLevel, _availableLevels: ThinkingLevel[]): ThinkingLevel { diff --git a/packages/coding-agent/test/suite/agent-session-model-extension.test.ts b/packages/coding-agent/test/suite/agent-session-model-extension.test.ts index a9f6a8d0e..a839a58d8 100644 --- a/packages/coding-agent/test/suite/agent-session-model-extension.test.ts +++ b/packages/coding-agent/test/suite/agent-session-model-extension.test.ts @@ -111,7 +111,7 @@ describe("AgentSession model and extension characterization", () => { expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("low"); }); - it("applies per-model thinking level override on model switch", async () => { + it("inherits the current level after leaving a model with a thinking override", async () => { const harness = await createHarness({ models: [ { id: "faux-1", name: "One", reasoning: true }, @@ -124,7 +124,7 @@ describe("AgentSession model and extension characterization", () => { // Set a per-model override for faux-2 harness.settingsManager.setModelThinkingLevel("faux", "faux-2", "low"); - // Session starts on faux-1 with default thinking + // Manually select high on faux-1; the global startup default remains medium. harness.session.setThinkingLevel("high"); expect(harness.session.thinkingLevel).toBe("high"); @@ -133,10 +133,11 @@ describe("AgentSession model and extension characterization", () => { await harness.session.setModel(model2); expect(harness.session.thinkingLevel).toBe("low"); - // Switch back to faux-1 → no per-model override, uses global default + // faux-1 has no override, so it inherits faux-2's low, not the earlier high or startup medium. const model1 = harness.getModel("faux-1")!; await harness.session.setModel(model1); - expect(harness.session.thinkingLevel).toBe("medium"); + expect(harness.session.thinkingLevel).toBe("low"); + expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("medium"); }); it("falls back to current session thinking level when no per-model or global default is configured", async () => { diff --git a/packages/coding-agent/test/suite/agent-session-thinking-level.test.ts b/packages/coding-agent/test/suite/agent-session-thinking-level.test.ts new file mode 100644 index 000000000..8b89f10bc --- /dev/null +++ b/packages/coding-agent/test/suite/agent-session-thinking-level.test.ts @@ -0,0 +1,295 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { ExtensionAPI } from "../../src/core/extensions/types.ts"; +import { createAgentSession } from "../../src/core/sdk.ts"; +import { SessionManager } from "../../src/core/session-manager.ts"; +import { createTestResourceLoader } from "../utilities.ts"; +import { createHarness, type Harness } from "./harness.ts"; + +describe("thinking level on model switches", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + while (harnesses.length > 0) harnesses.pop()?.cleanup(); + }); + + it.each([ + { method: "set", reasoning: true }, + { method: "set", reasoning: false }, + { method: "cycle", reasoning: true }, + { method: "cycle", reasoning: false }, + { method: "scoped", reasoning: true }, + { method: "scoped", reasoning: false }, + ])("retains the level with $method and target reasoning=$reasoning", async ({ method, reasoning }) => { + const harness = await createHarness({ + models: [ + { id: "faux-1", reasoning: true }, + { id: "faux-2", reasoning }, + ], + settings: { defaultThinkingLevel: "low" }, + }); + harnesses.push(harness); + const { session } = harness; + const first = harness.getModel("faux-1")!; + const second = harness.getModel("faux-2")!; + if (method === "scoped") session.setScopedModels([{ model: first }, { model: second }]); + + session.setThinkingLevel("high"); + if (method === "set") await session.setModel(second); + else await session.cycleModel("forward"); + expect(session.model?.id).toBe(second.id); + expect(session.thinkingLevel).toBe(reasoning ? "high" : "off"); + + if (method === "set") await session.setModel(first); + else await session.cycleModel("backward"); + expect(session.model?.id).toBe(first.id); + expect(session.thinkingLevel).toBe("high"); + expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("low"); + + const expectedLevels = reasoning ? ["high"] : ["high", "off", "high"]; + expect(harness.eventsOfType("thinking_level_changed").map((event) => event.level)).toEqual(expectedLevels); + expect( + harness.sessionManager + .getEntries() + .filter((entry) => entry.type === "thinking_level_change") + .map((entry) => entry.thinkingLevel), + ).toEqual(expectedLevels); + }); + + it("keeps the original value through multiple capability clamps", async () => { + const harness = await createHarness({ + models: [ + { id: "faux-1", reasoning: true }, + { id: "faux-2", reasoning: false }, + { id: "faux-3", reasoning: true }, + ], + }); + harnesses.push(harness); + const { session } = harness; + const first = harness.getModel()!; + first.thinkingLevelMap = { max: "max" }; + session.setThinkingLevel("max"); + + await session.setModel(harness.getModel("faux-2")!); + expect(session.thinkingLevel).toBe("off"); + await session.setModel(harness.getModel("faux-3")!); + expect(session.thinkingLevel).toBe("high"); + await session.setModel(harness.getModel("faux-2")!); + expect(session.thinkingLevel).toBe("off"); + await session.setModel(first); + expect(session.thinkingLevel).toBe("max"); + expect(Reflect.get(session, "_thinkingLevelBeforeClamp")).toBeUndefined(); + }); + + it.each([false, true])("remembers a clamped setter value with persist=%s", async (persist) => { + const harness = await createHarness({ + models: [ + { id: "faux-1", reasoning: true }, + { id: "faux-2", reasoning: true }, + ], + settings: { defaultThinkingLevel: "low" }, + }); + harnesses.push(harness); + const { session } = harness; + const second = harness.getModel("faux-2")!; + second.thinkingLevelMap = { max: "max" }; + + session.setThinkingLevel("high"); + session.setThinkingLevel("max", { persist }); + expect(session.thinkingLevel).toBe("high"); + expect(harness.eventsOfType("thinking_level_changed")).toHaveLength(1); + await session.setModel(second); + expect(session.thinkingLevel).toBe("max"); + expect(harness.settingsManager.getDefaultThinkingLevel()).toBe(persist ? "max" : "low"); + }); + + it.each(["set", "cycle"])( + "discards the cached value after a different supported selection via %s", + async (method) => { + const harness = await createHarness({ + models: [ + { id: "faux-1", reasoning: true }, + { id: "faux-2", reasoning: true }, + ], + }); + harnesses.push(harness); + const { session } = harness; + const first = harness.getModel()!; + first.thinkingLevelMap = { max: "max" }; + session.setThinkingLevel("max"); + await session.setModel(harness.getModel("faux-2")!); + expect(session.thinkingLevel).toBe("high"); + + if (method === "set") session.setThinkingLevel("low"); + else expect(session.cycleThinkingLevel()).toBe("off"); + await session.setModel(first); + expect(session.thinkingLevel).toBe(method === "set" ? "low" : "off"); + }, + ); + + it.each([ + { source: "session", persist: false }, + { source: "session", persist: true }, + { source: "extension", persist: false }, + ])("same-value $source setter preserves the clamp cache with persist=$persist", async ({ source, persist }) => { + let extensionApi: ExtensionAPI | undefined; + const harness = await createHarness({ + models: [ + { id: "faux-1", reasoning: true }, + { id: "faux-2", reasoning: false }, + ], + settings: { defaultThinkingLevel: "medium" }, + extensionFactories: [ + (pi) => { + extensionApi = pi; + }, + ], + }); + harnesses.push(harness); + const { session } = harness; + session.setThinkingLevel("high"); + await session.setModel(harness.getModel("faux-2")!); + if (source === "session") session.setThinkingLevel("off", { persist }); + else { + if (!extensionApi) throw new Error("Expected extension API"); + extensionApi.setThinkingLevel("off"); + } + expect(harness.eventsOfType("thinking_level_changed")).toHaveLength(2); + await session.setModel(harness.getModel("faux-1")!); + expect(session.thinkingLevel).toBe("high"); + expect(harness.settingsManager.getDefaultThinkingLevel()).toBe(persist ? "off" : "medium"); + }); + + it.each([ + { source: "per-model", level: "low" }, + { source: "per-model", level: "off" }, + { source: "scoped", level: "low" }, + { source: "scoped", level: "off" }, + ] as const)("$source override $level replaces the cached level", async ({ source, level }) => { + const harness = await createHarness({ + models: [ + { id: "faux-1", reasoning: true }, + { id: "faux-2", reasoning: false }, + { id: "faux-3", reasoning: true }, + ], + settings: { + defaultThinkingLevel: "medium", + modelThinkingLevels: { "faux/faux-3": source === "scoped" ? "minimal" : level }, + }, + }); + harnesses.push(harness); + const { session } = harness; + const first = harness.getModel()!; + const second = harness.getModel("faux-2")!; + const third = harness.getModel("faux-3")!; + if (source === "scoped") { + session.setScopedModels([{ model: first }, { model: second }, { model: third, thinkingLevel: level }]); + } + session.setThinkingLevel("high"); + await session.setModel(second); + expect(session.thinkingLevel).toBe("off"); + if (source === "scoped") await session.cycleModel(); + else await session.setModel(third); + expect(session.thinkingLevel).toBe(level); + await session.setModel(first); + expect(session.thinkingLevel).toBe(level); + expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("medium"); + }); + + it("caches the model override itself when that override is clamped", async () => { + const harness = await createHarness({ + models: [ + { id: "faux-1", reasoning: true }, + { id: "faux-2", reasoning: false }, + ], + }); + harnesses.push(harness); + const { session } = harness; + const second = harness.getModel("faux-2")!; + session.setThinkingLevel("high"); + await session.setModel(second); + harness.settingsManager.setModelThinkingLevel("faux", second.id, "low"); + await session.setModel(second); + expect(session.thinkingLevel).toBe("off"); + await session.setModel(harness.getModel()!); + expect(session.thinkingLevel).toBe("low"); + }); + + it.each([ + { source: "explicit", reasoning: false }, + { source: "explicit", reasoning: true }, + { source: "global", reasoning: false }, + { source: "global", reasoning: true }, + { source: "per-model", reasoning: false }, + { source: "per-model", reasoning: true }, + { source: "built-in", reasoning: false }, + { source: "built-in", reasoning: true }, + ])("inherits the effective $source startup level with reasoning=$reasoning", async ({ source, reasoning }) => { + const harness = await createHarness({ + models: [ + { id: "faux-1", reasoning }, + { id: "faux-2", reasoning: true }, + ], + settings: { + defaultThinkingLevel: source === "built-in" ? undefined : "max", + modelThinkingLevels: source === "per-model" ? { "faux/faux-1": "low" } : undefined, + }, + }); + harnesses.push(harness); + const second = harness.getModel("faux-2")!; + second.thinkingLevelMap = { max: "max" }; + const { session } = await createAgentSession({ + cwd: harness.tempDir, + model: harness.getModel(), + thinkingLevel: source === "explicit" ? "minimal" : undefined, + modelRuntime: harness.session.modelRuntime, + settingsManager: harness.settingsManager, + sessionManager: SessionManager.inMemory(harness.tempDir), + resourceLoader: createTestResourceLoader(), + }); + try { + const expected = !reasoning + ? "off" + : source === "explicit" + ? "minimal" + : source === "per-model" + ? "low" + : source === "global" + ? "high" + : "medium"; + expect(session.thinkingLevel).toBe(expected); + await session.setModel(second); + expect(session.thinkingLevel).toBe(expected); + } finally { + session.dispose(); + } + }); + + it("does not restore the runtime clamp cache when reopening a session", async () => { + const harness = await createHarness({ + models: [ + { id: "faux-1", reasoning: true }, + { id: "faux-2", reasoning: false }, + ], + settings: { defaultThinkingLevel: "high" }, + }); + harnesses.push(harness); + harness.session.setThinkingLevel("high"); + await harness.session.setModel(harness.getModel("faux-2")!); + harness.sessionManager.appendMessage({ role: "user", content: "hello", timestamp: Date.now() }); + + const { session } = await createAgentSession({ + cwd: harness.tempDir, + modelRuntime: harness.session.modelRuntime, + settingsManager: harness.settingsManager, + sessionManager: harness.sessionManager, + resourceLoader: createTestResourceLoader(), + }); + try { + expect(session.thinkingLevel).toBe("off"); + await session.setModel(harness.getModel("faux-1")!); + expect(session.thinkingLevel).toBe("off"); + } finally { + session.dispose(); + } + }); +});