diff --git a/src/agents/sessions/sdk.test.ts b/src/agents/sessions/sdk.test.ts index c12646a4912e..e2da2ebb40cb 100644 --- a/src/agents/sessions/sdk.test.ts +++ b/src/agents/sessions/sdk.test.ts @@ -1,7 +1,15 @@ // Agent session SDK tests cover default tool wiring, prompt preservation, and // session write-lock behavior. import { Type } from "typebox"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const thinkingMocks = vi.hoisted(() => ({ + resolveThinkingDefaultForModel: vi.fn(() => "medium"), +})); + +vi.mock("../../auto-reply/thinking.js", () => ({ + resolveThinkingDefaultForModel: thinkingMocks.resolveThinkingDefaultForModel, +})); import type { Model } from "../../llm/types.js"; import { AuthStorage } from "./auth-storage.js"; import { createExtensionRuntime } from "./extensions/loader.js"; @@ -316,3 +324,117 @@ describe("createAgentSession tool defaults", () => { expect(events).toEqual(["lock:start", "lock:end"]); }); }); + +describe("createAgentSession thinking level defaults", () => { + beforeEach(() => { + thinkingMocks.resolveThinkingDefaultForModel.mockReset(); + thinkingMocks.resolveThinkingDefaultForModel.mockReturnValue("medium"); + }); + + it("uses the provider-specific thinking default for new sessions", async () => { + thinkingMocks.resolveThinkingDefaultForModel.mockReturnValue("off"); + + const ollamaModel = { + ...testModel, + provider: "ollama", + reasoning: true, + params: { canonicalModelId: "qwen3:8b" }, + compat: { thinkingFormat: "ollama" }, + } satisfies Model; + const { session } = await createAgentSession({ + model: ollamaModel, + resourceLoader: createEmptyResourceLoader(), + sessionManager: SessionManager.inMemory(), + settingsManager: SettingsManager.inMemory(), + modelRegistry: ModelRegistry.inMemory(AuthStorage.inMemory()), + }); + + expect(session.thinkingLevel).toBe("off"); + expect(thinkingMocks.resolveThinkingDefaultForModel).toHaveBeenCalledWith({ + provider: "ollama", + model: testModel.id, + catalog: [ollamaModel], + }); + }); + + it("settings default overrides provider thinking default", async () => { + thinkingMocks.resolveThinkingDefaultForModel.mockReturnValue("off"); + + const { session } = await createAgentSession({ + model: { ...testModel, provider: "ollama", reasoning: true }, + resourceLoader: createEmptyResourceLoader(), + sessionManager: SessionManager.inMemory(), + settingsManager: SettingsManager.inMemory({ defaultThinkingLevel: "low" }), + modelRegistry: ModelRegistry.inMemory(AuthStorage.inMemory()), + }); + + // User-configured settings default beats provider default + expect(session.thinkingLevel).toBe("low"); + }); + + it("uses Ollama policy for custom providers backed by the Ollama API", async () => { + thinkingMocks.resolveThinkingDefaultForModel.mockReturnValue("off"); + const customOllamaModel = { + ...testModel, + provider: "ollama-spark", + api: "ollama", + reasoning: true, + } satisfies Model; + + const { session } = await createAgentSession({ + model: customOllamaModel, + resourceLoader: createEmptyResourceLoader(), + sessionManager: SessionManager.inMemory(), + settingsManager: SettingsManager.inMemory(), + modelRegistry: ModelRegistry.inMemory(AuthStorage.inMemory()), + }); + + expect(session.thinkingLevel).toBe("off"); + expect(thinkingMocks.resolveThinkingDefaultForModel).toHaveBeenCalledWith({ + provider: "ollama", + model: testModel.id, + catalog: [customOllamaModel], + }); + }); + + it("falls back to DEFAULT_THINKING_LEVEL for non-off provider defaults", async () => { + // Non-off provider defaults (adaptive, high, low) preserve prior SDK behaviour + // to avoid silent cost changes for DeepSeek, OpenRouter, xAI, and Anthropic users. + for (const nonOffDefault of ["adaptive", "high", "low"] as const) { + thinkingMocks.resolveThinkingDefaultForModel.mockReturnValue(nonOffDefault); + + const { session } = await createAgentSession({ + model: { ...testModel, reasoning: true }, + resourceLoader: createEmptyResourceLoader(), + sessionManager: SessionManager.inMemory(), + settingsManager: SettingsManager.inMemory(), + modelRegistry: ModelRegistry.inMemory(AuthStorage.inMemory()), + }); + + expect(session.thinkingLevel).toBe("medium"); + } + }); + + it("uses provider default for legacy sessions that have no thinking entry", async () => { + // Sessions created before thinking-level tracking (no thinking_level_change entry) + // should inherit the provider default, not the hard-coded global "medium". + thinkingMocks.resolveThinkingDefaultForModel.mockReturnValue("off"); + + const sessionManager = SessionManager.inMemory(); + sessionManager.appendMessage({ + role: "user", + content: "hello", + timestamp: Date.now(), + }); + + const { session } = await createAgentSession({ + model: { ...testModel, provider: "ollama", reasoning: true }, + resourceLoader: createEmptyResourceLoader(), + sessionManager, + settingsManager: SettingsManager.inMemory(), + modelRegistry: ModelRegistry.inMemory(AuthStorage.inMemory()), + }); + + expect(session.thinkingLevel).toBe("off"); + }); +}); diff --git a/src/agents/sessions/sdk.ts b/src/agents/sessions/sdk.ts index 5ab6170c7e58..3033f713de6c 100644 --- a/src/agents/sessions/sdk.ts +++ b/src/agents/sessions/sdk.ts @@ -4,6 +4,7 @@ * Selects models, wires built-in/custom tools, loads resources, and creates AgentSession instances. */ import { join } from "node:path"; +import { resolveThinkingDefaultForModel } from "../../auto-reply/thinking.js"; import { clampThinkingLevel } from "../../llm/model-utils.js"; import { streamSimple } from "../../llm/stream.js"; import type { Message, Model } from "../../llm/types.js"; @@ -268,16 +269,29 @@ export async function createAgentSession( let thinkingLevel = options.thinkingLevel; + // Use "off" when a provider explicitly opts out of thinking (e.g. Ollama). Non-off + // provider defaults (high, low, adaptive) fall back to DEFAULT_THINKING_LEVEL to avoid + // silent cost changes for DeepSeek, OpenRouter, xAI, and other providers. + const resolvedProviderDefault = model + ? resolveThinkingDefaultForModel({ + provider: model.api === "ollama" ? "ollama" : model.provider, + model: model.id, + catalog: [model], + }) + : undefined; + const modelThinkingDefault: ThinkingLevel = + resolvedProviderDefault === "off" ? "off" : DEFAULT_THINKING_LEVEL; + // If session has data, restore thinking level from it if (thinkingLevel === undefined && hasExistingSession) { thinkingLevel = hasThinkingEntry ? (existingSession.thinkingLevel as ThinkingLevel) - : (settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL); + : (settingsManager.getDefaultThinkingLevel() ?? modelThinkingDefault); } // Fall back to settings default if (thinkingLevel === undefined) { - thinkingLevel = settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL; + thinkingLevel = settingsManager.getDefaultThinkingLevel() ?? modelThinkingDefault; } // Clamp to model capabilities diff --git a/src/auto-reply/thinking.test.ts b/src/auto-reply/thinking.test.ts index 3761adab0fa1..1273ebb3f7ba 100644 --- a/src/auto-reply/thinking.test.ts +++ b/src/auto-reply/thinking.test.ts @@ -768,6 +768,27 @@ describe("resolveThinkingDefaultForModel", () => { }), ).toBe("off"); }); + + it("respects provider-declared 'off' default for reasoning-capable models", () => { + // Providers like Ollama declare defaultLevel:"off" even for reasoning=true models + // because thinking must be explicitly opted in, not activated by the global default. + providerRuntimeMocks.resolveProviderThinkingProfile.mockImplementation(({ provider }) => + provider === "ollama" + ? { + levels: [{ id: "off" }, { id: "low" }, { id: "medium" }, { id: "high" }, { id: "max" }], + defaultLevel: "off", + } + : undefined, + ); + + expect( + resolveThinkingDefaultForModel({ + provider: "ollama", + model: "gemma4", + catalog: [{ provider: "ollama", id: "gemma4", reasoning: true }], + }), + ).toBe("off"); + }); }); describe("normalizeReasoningLevel", () => {