From 30ec0139a290ce62817addecabc6b7e771e28132 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 04:09:59 +0000 Subject: [PATCH] refactor(reasoning): unify thinking precedence resolution --- src/agents/model-catalog.test-helpers.ts | 15 +++ src/agents/model-selection.test.ts | 102 +++++++++++++++++- src/agents/model-selection.ts | 57 +++++++--- .../reply/directive-handling.levels.test.ts | 62 ++++++----- .../reply/directive-handling.levels.ts | 15 +-- src/auto-reply/reply/get-reply-directives.ts | 16 +-- src/auto-reply/reply/model-selection.test.ts | 24 +++-- src/auto-reply/reply/model-selection.ts | 25 +++-- src/commands/agent.ts | 40 ++++--- src/sessions/thinking-level.test.ts | 82 ++++++++++++++ src/sessions/thinking-level.ts | 33 ++++++ 11 files changed, 382 insertions(+), 89 deletions(-) create mode 100644 src/agents/model-catalog.test-helpers.ts create mode 100644 src/sessions/thinking-level.test.ts create mode 100644 src/sessions/thinking-level.ts diff --git a/src/agents/model-catalog.test-helpers.ts b/src/agents/model-catalog.test-helpers.ts new file mode 100644 index 00000000000..6ad1859c287 --- /dev/null +++ b/src/agents/model-catalog.test-helpers.ts @@ -0,0 +1,15 @@ +import type { ModelCatalogEntry } from "./model-catalog.js"; + +type RequiredModelCatalogFields = Pick; + +export function makeModelCatalogEntry( + required: RequiredModelCatalogFields, + optional?: Omit, +): ModelCatalogEntry { + return { + provider: required.provider, + id: required.id, + name: required.name, + ...optional, + }; +} diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 6e95a1e622b..5db17a55c0b 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { resetLogger, setLoggerOverride } from "../logging/logger.js"; +import { makeModelCatalogEntry } from "./model-catalog.test-helpers.js"; import { buildAllowedModelSet, inferUniqueProviderFromConfiguredModels, @@ -11,6 +12,8 @@ import { modelKey, resolveAllowedModelRef, resolveConfiguredModelRef, + resolveModelThinkingDefault, + supportsReasoningModel, resolveThinkingDefault, resolveModelRefFromString, } from "./model-selection.js"; @@ -257,8 +260,16 @@ describe("model-selection", () => { } as OpenClawConfig; const catalog = [ - { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, - { provider: "openai", id: "gpt-5.2", name: "gpt-5.2" }, + makeModelCatalogEntry({ + provider: "anthropic", + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5", + }), + makeModelCatalogEntry({ + provider: "openai", + id: "gpt-5.2", + name: "gpt-5.2", + }), ]; const result = buildAllowedModelSet({ @@ -270,7 +281,11 @@ describe("model-selection", () => { expect(result.allowAny).toBe(false); expect(result.allowedKeys.has("anthropic/claude-sonnet-4-6")).toBe(true); expect(result.allowedCatalog).toEqual([ - { provider: "anthropic", id: "claude-sonnet-4-6", name: "claude-sonnet-4-6" }, + makeModelCatalogEntry({ + provider: "anthropic", + id: "claude-sonnet-4-6", + name: "claude-sonnet-4-6", + }), ]); }); }); @@ -289,8 +304,16 @@ describe("model-selection", () => { } as OpenClawConfig; const catalog = [ - { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, - { provider: "openai", id: "gpt-5.2", name: "gpt-5.2" }, + makeModelCatalogEntry({ + provider: "anthropic", + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5", + }), + makeModelCatalogEntry({ + provider: "openai", + id: "gpt-5.2", + name: "gpt-5.2", + }), ]; const result = resolveAllowedModelRef({ @@ -504,6 +527,75 @@ describe("model-selection", () => { ).toBe("high"); }); }); + + describe("supportsReasoningModel", () => { + it("detects reasoning support from provider/model catalog entry", () => { + expect( + supportsReasoningModel({ + provider: "openrouter", + model: "x-ai/grok-4.1-fast", + catalog: [ + { + provider: "openrouter", + id: "x-ai/grok-4.1-fast", + name: "Grok 4.1 Fast", + reasoning: true, + }, + ], + }), + ).toBe(true); + }); + + it("returns false when model is missing from catalog", () => { + expect( + supportsReasoningModel({ + provider: "openrouter", + model: "x-ai/grok-4.1-fast", + catalog: [], + }), + ).toBe(false); + }); + }); + + describe("resolveModelThinkingDefault", () => { + it("returns model-derived low thinking when reasoning is supported", () => { + const cfg = {} as OpenClawConfig; + expect( + resolveModelThinkingDefault({ + cfg, + provider: "openrouter", + model: "x-ai/grok-4.1-fast", + catalog: [ + { + provider: "openrouter", + id: "x-ai/grok-4.1-fast", + name: "Grok 4.1 Fast", + reasoning: true, + }, + ], + }), + ).toBe("low"); + }); + + it("returns undefined when no model-level default applies", () => { + const cfg = { agents: { defaults: { thinkingDefault: "high" } } } as OpenClawConfig; + expect( + resolveModelThinkingDefault({ + cfg, + provider: "openai", + model: "gpt-4o-mini", + catalog: [ + { + provider: "openai", + id: "gpt-4o-mini", + name: "GPT-4o mini", + reasoning: false, + }, + ], + }), + ).toBeUndefined(); + }); + }); }); describe("normalizeModelSelection", () => { diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index e7f6ae9757f..41454b606a4 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -525,6 +525,24 @@ export function resolveThinkingDefault(params: { model: string; catalog?: ModelCatalogEntry[]; }): ThinkLevel { + return ( + resolveModelThinkingDefault({ + cfg: params.cfg, + provider: params.provider, + model: params.model, + catalog: params.catalog, + }) ?? + params.cfg.agents?.defaults?.thinkingDefault ?? + "off" + ); +} + +export function resolveModelThinkingDefault(params: { + cfg: OpenClawConfig; + provider: string; + model: string; + catalog?: ModelCatalogEntry[]; +}): ThinkLevel | undefined { const perModelThinking = params.cfg.agents?.defaults?.models?.[modelKey(params.provider, params.model)]?.params ?.thinking; @@ -538,17 +556,30 @@ export function resolveThinkingDefault(params: { ) { return perModelThinking; } - const configured = params.cfg.agents?.defaults?.thinkingDefault; - if (configured) { - return configured; - } - const candidate = params.catalog?.find( - (entry) => entry.provider === params.provider && entry.id === params.model, - ); - if (candidate?.reasoning) { + if ( + supportsReasoningModel({ + provider: params.provider, + model: params.model, + catalog: params.catalog, + }) + ) { return "low"; } - return "off"; + return undefined; +} + +export function supportsReasoningModel(params: { + provider: string; + model: string; + catalog?: ModelCatalogEntry[]; +}): boolean { + const key = modelKey(params.provider, params.model); + const candidate = params.catalog?.find( + (entry) => + (entry.provider === params.provider && entry.id === params.model) || + (entry.provider === key && entry.id === params.model), + ); + return candidate?.reasoning === true; } /** Default reasoning level when session/directive do not set it: "on" if model supports reasoning, else "off". */ @@ -557,13 +588,7 @@ export function resolveReasoningDefault(params: { model: string; catalog?: ModelCatalogEntry[]; }): "on" | "off" { - const key = modelKey(params.provider, params.model); - const candidate = params.catalog?.find( - (entry) => - (entry.provider === params.provider && entry.id === params.model) || - (entry.provider === key && entry.id === params.model), - ); - return candidate?.reasoning === true ? "on" : "off"; + return supportsReasoningModel(params) ? "on" : "off"; } /** diff --git a/src/auto-reply/reply/directive-handling.levels.test.ts b/src/auto-reply/reply/directive-handling.levels.test.ts index 204d2685005..c653d6d1862 100644 --- a/src/auto-reply/reply/directive-handling.levels.test.ts +++ b/src/auto-reply/reply/directive-handling.levels.test.ts @@ -2,35 +2,49 @@ import { describe, expect, it, vi } from "vitest"; import { resolveCurrentDirectiveLevels } from "./directive-handling.levels.js"; describe("resolveCurrentDirectiveLevels", () => { - it("prefers resolved model default over agent thinkingDefault", async () => { - const resolveDefaultThinkingLevel = vi.fn().mockResolvedValue("high"); - - const result = await resolveCurrentDirectiveLevels({ + it.each([ + { + name: "uses session override first", + sessionEntry: { thinkingLevel: "minimal" }, + agentCfg: { thinkingDefault: "low" }, + modelDefault: "high", + expectedLevel: "minimal", + expectedModelCalls: 0, + }, + { + name: "uses model default when no session override", sessionEntry: {}, - agentCfg: { - thinkingDefault: "low", - }, - resolveDefaultThinkingLevel, - }); - - expect(result.currentThinkLevel).toBe("high"); - expect(resolveDefaultThinkingLevel).toHaveBeenCalledTimes(1); - }); - - it("keeps session thinking override without consulting defaults", async () => { - const resolveDefaultThinkingLevel = vi.fn().mockResolvedValue("high"); + agentCfg: { thinkingDefault: "low" }, + modelDefault: "high", + expectedLevel: "high", + expectedModelCalls: 1, + }, + { + name: "falls back to global default when model default missing", + sessionEntry: {}, + agentCfg: { thinkingDefault: "low" }, + modelDefault: undefined, + expectedLevel: "low", + expectedModelCalls: 1, + }, + { + name: "falls back to off when no defaults are set", + sessionEntry: {}, + agentCfg: {}, + modelDefault: undefined, + expectedLevel: "off", + expectedModelCalls: 1, + }, + ])("$name", async (testCase) => { + const resolveDefaultThinkingLevel = vi.fn().mockResolvedValue(testCase.modelDefault); const result = await resolveCurrentDirectiveLevels({ - sessionEntry: { - thinkingLevel: "minimal", - }, - agentCfg: { - thinkingDefault: "low", - }, + sessionEntry: testCase.sessionEntry, + agentCfg: testCase.agentCfg, resolveDefaultThinkingLevel, }); - expect(result.currentThinkLevel).toBe("minimal"); - expect(resolveDefaultThinkingLevel).not.toHaveBeenCalled(); + expect(result.currentThinkLevel).toBe(testCase.expectedLevel); + expect(resolveDefaultThinkingLevel).toHaveBeenCalledTimes(testCase.expectedModelCalls); }); }); diff --git a/src/auto-reply/reply/directive-handling.levels.ts b/src/auto-reply/reply/directive-handling.levels.ts index ee7b1108e83..9626e54239f 100644 --- a/src/auto-reply/reply/directive-handling.levels.ts +++ b/src/auto-reply/reply/directive-handling.levels.ts @@ -1,3 +1,4 @@ +import { resolveThinkingLevelByPrecedence } from "../../sessions/thinking-level.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js"; export async function resolveCurrentDirectiveLevels(params: { @@ -14,16 +15,18 @@ export async function resolveCurrentDirectiveLevels(params: { }; resolveDefaultThinkingLevel: () => Promise; }): Promise<{ - currentThinkLevel: ThinkLevel | undefined; + currentThinkLevel: ThinkLevel; currentVerboseLevel: VerboseLevel | undefined; currentReasoningLevel: ReasoningLevel; currentElevatedLevel: ElevatedLevel | undefined; }> { - const resolvedDefaultThinkLevel = - (params.sessionEntry?.thinkingLevel as ThinkLevel | undefined) ?? - (await params.resolveDefaultThinkingLevel()) ?? - (params.agentCfg?.thinkingDefault as ThinkLevel | undefined); - const currentThinkLevel = resolvedDefaultThinkLevel; + const currentThinkLevel = ( + await resolveThinkingLevelByPrecedence({ + sessionThinkLevel: params.sessionEntry?.thinkingLevel as ThinkLevel | undefined, + resolveModelDefaultThinkingLevel: params.resolveDefaultThinkingLevel, + globalDefaultThinkLevel: params.agentCfg?.thinkingDefault as ThinkLevel | undefined, + }) + ).level; const currentVerboseLevel = (params.sessionEntry?.verboseLevel as VerboseLevel | undefined) ?? (params.agentCfg?.verboseDefault as VerboseLevel | undefined); diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts index 4c9da28deae..53aa21680d0 100644 --- a/src/auto-reply/reply/get-reply-directives.ts +++ b/src/auto-reply/reply/get-reply-directives.ts @@ -4,6 +4,7 @@ import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; import type { SkillCommandSpec } from "../../agents/skills.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; +import { resolveThinkingLevelByPrecedence } from "../../sessions/thinking-level.js"; import { listChatCommands, shouldHandleTextCommands } from "../commands-registry.js"; import { listSkillCommandsForWorkspace } from "../skill-commands.js"; import type { MsgContext, TemplateContext } from "../templating.js"; @@ -338,9 +339,6 @@ export async function resolveReplyDirectives(params: { groupResolution, }); const defaultActivation = defaultGroupActivation(requireMention); - const resolvedThinkLevel = - directives.thinkLevel ?? (sessionEntry?.thinkingLevel as ThinkLevel | undefined); - const resolvedVerboseLevel = directives.verboseLevel ?? (sessionEntry?.verboseLevel as VerboseLevel | undefined) ?? @@ -388,10 +386,14 @@ export async function resolveReplyDirectives(params: { }); provider = modelState.provider; model = modelState.model; - const resolvedThinkLevelWithDefault = - resolvedThinkLevel ?? - (await modelState.resolveDefaultThinkingLevel()) ?? - (agentCfg?.thinkingDefault as ThinkLevel | undefined); + const resolvedThinkLevelWithDefault = ( + await resolveThinkingLevelByPrecedence({ + commandThinkLevel: directives.thinkLevel, + sessionThinkLevel: sessionEntry?.thinkingLevel as ThinkLevel | undefined, + resolveModelDefaultThinkingLevel: () => modelState.resolveModelDefaultThinkingLevel(), + globalDefaultThinkLevel: agentCfg?.thinkingDefault as ThinkLevel | undefined, + }) + ).level; // When neither directive nor session set reasoning, default to model capability // (e.g. OpenRouter with reasoning: true). Skip auto-enabling when thinking is diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index 493adec0515..8ac4b1e7268 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -1,14 +1,23 @@ import { describe, expect, it, vi } from "vitest"; +import { makeModelCatalogEntry } from "../../agents/model-catalog.test-helpers.js"; import type { OpenClawConfig } from "../../config/config.js"; import { createModelSelectionState } from "./model-selection.js"; vi.mock("../../agents/model-catalog.js", () => ({ loadModelCatalog: vi.fn(async () => [ - { provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus 4.5" }, - { provider: "inferencer", id: "deepseek-v3-4bit-mlx", name: "DeepSeek V3" }, - { provider: "kimi-coding", id: "k2p5", name: "Kimi K2.5" }, - { provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" }, - { provider: "openai", id: "gpt-4o", name: "GPT-4o" }, + makeModelCatalogEntry({ + provider: "anthropic", + id: "claude-opus-4-5", + name: "Claude Opus 4.5", + }), + makeModelCatalogEntry({ + provider: "inferencer", + id: "deepseek-v3-4bit-mlx", + name: "DeepSeek V3", + }), + makeModelCatalogEntry({ provider: "kimi-coding", id: "k2p5", name: "Kimi K2.5" }), + makeModelCatalogEntry({ provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" }), + makeModelCatalogEntry({ provider: "openai", id: "gpt-4o", name: "GPT-4o" }), ]), })); @@ -269,7 +278,10 @@ describe("createModelSelectionState resolveDefaultReasoningLevel", () => { it("returns on when catalog model has reasoning true", async () => { const { loadModelCatalog } = await import("../../agents/model-catalog.js"); vi.mocked(loadModelCatalog).mockResolvedValueOnce([ - { provider: "openrouter", id: "x-ai/grok-4.1-fast", name: "Grok", reasoning: true }, + makeModelCatalogEntry( + { provider: "openrouter", id: "x-ai/grok-4.1-fast", name: "Grok" }, + { reasoning: true }, + ), ]); const state = await createModelSelectionState({ cfg: {} as OpenClawConfig, diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index 1b666b6ded5..886807b7e88 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -7,9 +7,9 @@ import { type ModelAliasIndex, modelKey, normalizeProviderId, + resolveModelThinkingDefault, resolveModelRefFromString, resolveReasoningDefault, - resolveThinkingDefault, } from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; import { type SessionEntry, updateSessionStore } from "../../config/sessions.js"; @@ -32,6 +32,7 @@ type ModelSelectionState = { allowedModelKeys: Set; allowedModelCatalog: ModelCatalog; resetModelOverride: boolean; + resolveModelDefaultThinkingLevel: () => Promise; resolveDefaultThinkingLevel: () => Promise; /** Default reasoning level from model capability: "on" if model has reasoning, else "off". */ resolveDefaultReasoningLevel: () => Promise<"on" | "off">; @@ -379,25 +380,30 @@ export async function createModelSelectionState(params: { } } - let defaultThinkingLevel: ThinkLevel | undefined; - const resolveDefaultThinkingLevel = async () => { - if (defaultThinkingLevel) { - return defaultThinkingLevel; + let modelDefaultThinkingLevel: ThinkLevel | undefined; + let hasResolvedModelDefaultThinkingLevel = false; + const resolveModelDefaultThinkingLevel = async (): Promise => { + if (hasResolvedModelDefaultThinkingLevel) { + return modelDefaultThinkingLevel; } let catalogForThinking = modelCatalog ?? allowedModelCatalog; if (!catalogForThinking || catalogForThinking.length === 0) { modelCatalog = await loadModelCatalog({ config: cfg }); catalogForThinking = modelCatalog; } - const resolved = resolveThinkingDefault({ + modelDefaultThinkingLevel = resolveModelThinkingDefault({ cfg, provider, model, catalog: catalogForThinking, }); - defaultThinkingLevel = - resolved ?? (agentCfg?.thinkingDefault as ThinkLevel | undefined) ?? "off"; - return defaultThinkingLevel; + hasResolvedModelDefaultThinkingLevel = true; + return modelDefaultThinkingLevel; + }; + + const resolveDefaultThinkingLevel = async () => { + const modelDefault = await resolveModelDefaultThinkingLevel(); + return modelDefault ?? (agentCfg?.thinkingDefault as ThinkLevel | undefined) ?? "off"; }; const resolveDefaultReasoningLevel = async (): Promise<"on" | "off"> => { @@ -419,6 +425,7 @@ export async function createModelSelectionState(params: { allowedModelKeys, allowedModelCatalog, resetModelOverride, + resolveModelDefaultThinkingLevel, resolveDefaultThinkingLevel, resolveDefaultReasoningLevel, needsModelCatalog, diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 3d669bbac0f..ac03fd901a2 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -30,7 +30,7 @@ import { normalizeProviderId, resolveConfiguredModelRef, resolveDefaultModelForAgent, - resolveThinkingDefault, + resolveModelThinkingDefault, } from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; @@ -72,6 +72,7 @@ import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { applyVerboseOverride } from "../sessions/level-overrides.js"; import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js"; import { resolveSendPolicy } from "../sessions/send-policy.js"; +import { resolveThinkingLevelByPrecedence } from "../sessions/thinking-level.js"; import { resolveMessageChannel } from "../utils/message-channel.js"; import { deliverAgentCommandResult } from "./agent/delivery.js"; import { resolveAgentRunContext } from "./agent/run-context.js"; @@ -588,7 +589,7 @@ export async function agentCommand( }); } - let resolvedThinkLevel = thinkOnce ?? thinkOverride ?? persistedThinking; + const commandThinkLevel = thinkOnce ?? thinkOverride; const resolvedVerboseLevel = verboseOverride ?? persistedVerbose ?? (agentCfg?.verboseDefault as VerboseLevel | undefined); @@ -744,21 +745,28 @@ export async function agentCommand( } } - if (!resolvedThinkLevel) { - let catalogForThinking = modelCatalog ?? allowedModelCatalog; - if (!catalogForThinking || catalogForThinking.length === 0) { - modelCatalog = await loadModelCatalog({ config: cfg }); - catalogForThinking = modelCatalog; - } - resolvedThinkLevel = resolveThinkingDefault({ - cfg, - provider, - model, - catalog: catalogForThinking, - }); - } + let resolvedThinkLevel = ( + await resolveThinkingLevelByPrecedence({ + commandThinkLevel, + sessionThinkLevel: persistedThinking, + resolveModelDefaultThinkingLevel: async () => { + let catalogForThinking = modelCatalog ?? allowedModelCatalog; + if (!catalogForThinking || catalogForThinking.length === 0) { + modelCatalog = await loadModelCatalog({ config: cfg }); + catalogForThinking = modelCatalog; + } + return resolveModelThinkingDefault({ + cfg, + provider, + model, + catalog: catalogForThinking, + }); + }, + globalDefaultThinkLevel: cfg.agents?.defaults?.thinkingDefault as ThinkLevel | undefined, + }) + ).level; if (resolvedThinkLevel === "xhigh" && !supportsXHighThinking(provider, model)) { - const explicitThink = Boolean(thinkOnce || thinkOverride); + const explicitThink = Boolean(commandThinkLevel); if (explicitThink) { throw new Error(`Thinking level "xhigh" is only supported for ${formatXHighModelHint()}.`); } diff --git a/src/sessions/thinking-level.test.ts b/src/sessions/thinking-level.test.ts new file mode 100644 index 00000000000..1d8dd42f9f1 --- /dev/null +++ b/src/sessions/thinking-level.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ThinkLevel } from "../auto-reply/thinking.js"; +import { resolveThinkingLevelByPrecedence } from "./thinking-level.js"; + +function createModelDefaultResolver(value: ThinkLevel | undefined) { + const fn = vi.fn().mockResolvedValue(value); + return { + fn, + resolve: () => fn() as Promise, + }; +} + +describe("resolveThinkingLevelByPrecedence", () => { + it.each([ + { + name: "command override wins", + commandThinkLevel: "high" as ThinkLevel, + sessionThinkLevel: "medium" as ThinkLevel, + modelDefault: "low" as ThinkLevel, + globalDefaultThinkLevel: "minimal" as ThinkLevel, + expected: { level: "high", source: "command" }, + expectedModelCalls: 0, + }, + { + name: "session override wins when command unset", + commandThinkLevel: null, + sessionThinkLevel: "medium" as ThinkLevel, + modelDefault: "low" as ThinkLevel, + globalDefaultThinkLevel: "minimal" as ThinkLevel, + expected: { level: "medium", source: "session" }, + expectedModelCalls: 0, + }, + { + name: "model default wins when command and session unset", + commandThinkLevel: undefined, + sessionThinkLevel: null, + modelDefault: "low" as ThinkLevel, + globalDefaultThinkLevel: "minimal" as ThinkLevel, + expected: { level: "low", source: "model_default" }, + expectedModelCalls: 1, + }, + { + name: "global default wins when model default missing", + commandThinkLevel: undefined, + sessionThinkLevel: undefined, + modelDefault: undefined, + globalDefaultThinkLevel: "minimal" as ThinkLevel, + expected: { level: "minimal", source: "global_default" }, + expectedModelCalls: 1, + }, + { + name: "disabled fallback when everything unset", + commandThinkLevel: undefined, + sessionThinkLevel: undefined, + modelDefault: undefined, + globalDefaultThinkLevel: null, + expected: { level: "off", source: "disabled" }, + expectedModelCalls: 1, + }, + ])("$name", async (testCase) => { + const modelDefaultResolver = createModelDefaultResolver(testCase.modelDefault); + const result = await resolveThinkingLevelByPrecedence({ + commandThinkLevel: testCase.commandThinkLevel, + sessionThinkLevel: testCase.sessionThinkLevel, + resolveModelDefaultThinkingLevel: modelDefaultResolver.resolve, + globalDefaultThinkLevel: testCase.globalDefaultThinkLevel, + }); + + expect(result).toEqual(testCase.expected); + expect(modelDefaultResolver.fn).toHaveBeenCalledTimes(testCase.expectedModelCalls); + }); + + it("supports custom disabled fallback level", async () => { + const result = await resolveThinkingLevelByPrecedence({ + disabledThinkLevel: "minimal", + }); + expect(result).toEqual({ + level: "minimal", + source: "disabled", + }); + }); +}); diff --git a/src/sessions/thinking-level.ts b/src/sessions/thinking-level.ts new file mode 100644 index 00000000000..1e0066afaa4 --- /dev/null +++ b/src/sessions/thinking-level.ts @@ -0,0 +1,33 @@ +import type { ThinkLevel } from "../auto-reply/thinking.js"; + +export type ThinkingLevelSource = + | "command" + | "session" + | "model_default" + | "global_default" + | "disabled"; + +export async function resolveThinkingLevelByPrecedence(params: { + commandThinkLevel?: ThinkLevel | null; + sessionThinkLevel?: ThinkLevel | null; + resolveModelDefaultThinkingLevel?: () => Promise; + globalDefaultThinkLevel?: ThinkLevel | null; + disabledThinkLevel?: ThinkLevel; +}): Promise<{ level: ThinkLevel; source: ThinkingLevelSource }> { + if (params.commandThinkLevel) { + return { level: params.commandThinkLevel, source: "command" }; + } + if (params.sessionThinkLevel) { + return { level: params.sessionThinkLevel, source: "session" }; + } + if (params.resolveModelDefaultThinkingLevel) { + const modelDefault = await params.resolveModelDefaultThinkingLevel(); + if (modelDefault) { + return { level: modelDefault, source: "model_default" }; + } + } + if (params.globalDefaultThinkLevel) { + return { level: params.globalDefaultThinkLevel, source: "global_default" }; + } + return { level: params.disabledThinkLevel ?? "off", source: "disabled" }; +}