fix(coding-agent): preserve thinking levels across model switches

This commit is contained in:
haoqixu 2026-09-22 20:34:32 +08:00
parent 1b347794e2
commit f255c75071
4 changed files with 324 additions and 11 deletions

View file

@ -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 `:<thinking>` 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

View file

@ -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<any>; 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<any>, 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 {

View file

@ -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 () => {

View file

@ -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();
}
});
});