Allow turning thinking off (#2257)

* feat(providers): allow turning thinking off

Expose Off only for models that support disabled thinking while preserving Low as the default.

* fix(providers): reset unsupported thinking on model change

Keep the selected thinking option only when the new model advertises it; otherwise use that model's default and persist the runtime change.

* fix(providers): isolate custom model capabilities

Use strict manifest identity when reconciling thinking options so provider-prefixed custom models cannot inherit capabilities they do not advertise.

* fix(providers): clear thinking with default model

* fix(providers): validate disabled thinking support

* fix(providers): validate initial thinking config

* refactor(providers): centralize thinking capability

* fix(server): order config mutation events
This commit is contained in:
Mohamed Boudra
2026-07-20 22:52:06 +02:00
committed by GitHub
parent 8c92c7d423
commit 1962816c1e
6 changed files with 296 additions and 10 deletions

View File

@@ -3103,6 +3103,53 @@ test("persists live mode, model, and thinking changes without an external snapsh
expect(persisted?.runtimeInfo?.model).toBe("gpt-5.4");
});
test("later explicit config mutations win over events emitted by earlier mutations", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-config-mutation-order-"));
class ConfigMutationSession extends TestAgentSession {
async setModel(): Promise<void> {
this.pushEvent({
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "model changed" },
});
this.pushEvent({
type: "thinking_option_changed",
provider: "codex",
thinkingOptionId: "low",
});
}
async setThinkingOption(): Promise<void> {}
}
class ConfigMutationClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new ConfigMutationSession(config);
}
}
const manager = new AgentManager({
clients: { codex: new ConfigMutationClient() },
logger,
idFactory: () => "00000000-0000-4000-8000-000000000134",
});
const snapshot = await manager.createAgent(
{
provider: "codex",
cwd: workdir,
model: "gpt-5.2-codex",
thinkingOptionId: "off",
},
undefined,
{ workspaceId: undefined },
);
await manager.setAgentModel(snapshot.id, "gpt-5.4");
await manager.setAgentThinkingOption(snapshot.id, "high");
await manager.flush();
expect(manager.getAgent(snapshot.id)?.config.thinkingOptionId).toBe("high");
});
test("session config drift events update state through the stream channel", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-session-config-events-"));
let capturedSession: TestAgentSession | null = null;
@@ -3171,6 +3218,7 @@ test("session config drift events update state through the stream channel", asyn
const agent = manager.getAgent(snapshot.id);
expect(agent?.currentModeId).toBe("build");
expect(agent?.config.thinkingOptionId).toBe("high");
expect(agent?.availableModes).toEqual([
{ id: "plan", label: "Plan" },
{ id: "build", label: "Build" },

View File

@@ -1579,6 +1579,7 @@ export class AgentManager {
async setAgentMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null> {
const agent = this.requireSessionAgent(agentId);
const notice = (await agent.session.setMode(modeId)) ?? null;
await this.drainSessionEvents(agentId);
const currentMode = (await agent.session.getCurrentMode()) ?? modeId;
agent.config.modeId = currentMode ?? undefined;
agent.currentModeId = currentMode;
@@ -1599,6 +1600,7 @@ export class AgentManager {
if (agent.session.setModel) {
await agent.session.setModel(normalizedModelId);
}
await this.drainSessionEvents(agentId);
agent.config.model = normalizedModelId ?? undefined;
if (agent.runtimeInfo) {
@@ -1622,6 +1624,7 @@ export class AgentManager {
if (agent.session.setThinkingOption) {
notice = (await agent.session.setThinkingOption(normalizedThinkingOptionId)) ?? null;
}
await this.drainSessionEvents(agentId);
agent.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
if (agent.runtimeInfo) {
@@ -1643,6 +1646,7 @@ export class AgentManager {
}
await agent.session.setFeature(featureId, value);
await this.drainSessionEvents(agentId);
agent.config.featureValues = { ...agent.config.featureValues, [featureId]: value };
this.touchUpdatedAt(agent);
this.emitState(agent);
@@ -3009,6 +3013,24 @@ export class AgentManager {
});
}
/**
* Provider mutations may synchronously emit config events that are processed through the
* asynchronous session queue. Apply those events before committing the mutation's explicit
* manager state so call order remains authoritative.
*/
private async drainSessionEvents(agentId: string): Promise<void> {
while (true) {
const tail = this.sessionEventTails.get(agentId);
if (!tail) {
return;
}
await tail;
if (this.sessionEventTails.get(agentId) === tail) {
return;
}
}
}
private async dispatchSessionEvent(
agent: ActiveManagedAgent,
event: AgentStreamEvent,
@@ -3431,6 +3453,7 @@ export class AgentManager {
this.emitState(agent);
return undefined;
case "thinking_option_changed":
agent.config.thinkingOptionId = event.thinkingOptionId ?? undefined;
if (agent.runtimeInfo) {
agent.runtimeInfo = {
...agent.runtimeInfo,

View File

@@ -583,8 +583,12 @@ describe("ClaudeAgentSession features", () => {
};
},
};
const queryFactory = vi.fn(() => queryMock);
return { queryFactory, queryMock };
const launches: Array<{ options: Record<string, unknown> }> = [];
const queryFactory = vi.fn((input) => {
launches.push(input);
return queryMock;
});
return { queryFactory, queryMock, launches };
}
test("lists fast mode only for supported Opus models", async () => {
@@ -686,6 +690,96 @@ describe("ClaudeAgentSession features", () => {
await session.close();
});
test("turns Claude thinking off without retaining an effort level", async () => {
const { queryFactory, launches } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-sonnet-5",
thinkingOptionId: "off",
});
await expect(session.startTurn("hello")).resolves.toEqual({
turnId: expect.stringMatching(/^foreground-turn-/),
});
expect(launches[0]?.options.thinking).toEqual({ type: "disabled" });
expect(launches[0]?.options).not.toHaveProperty("effort");
await session.close();
});
test.each([
["supported model", "claude-opus-4-8", { type: "disabled" }, undefined],
["unsupported model", "claude-fable-5", { type: "adaptive" }, "low"],
["custom model", "openrouter/anthropic/claude-opus-4-8", undefined, undefined],
["provider default", null, undefined, undefined],
])("reconciles Off when switching to a %s", async (_label, modelId, thinking, effort) => {
const { queryFactory, launches } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-sonnet-5",
thinkingOptionId: "off",
});
await session.setModel?.(modelId);
await session.startTurn("hello");
expect(launches.at(-1)?.options.thinking).toEqual(thinking);
expect(launches.at(-1)?.options.effort).toBe(effort);
await session.close();
});
test("rejects disabled thinking when the active model does not support it", async () => {
const { queryFactory } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-fable-5",
});
await expect(session.setThinkingOption?.("off")).rejects.toThrow(
"Thinking option 'off' is not available for model 'claude-fable-5'",
);
await session.close();
});
test("rejects an initial disabled-thinking config for an unsupported model", async () => {
const { queryFactory } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
await expect(
client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-fable-5",
thinkingOptionId: "off",
}),
).rejects.toThrow("Thinking option 'off' is not available for model 'claude-fable-5'");
});
test("returns a next-turn notice when changing Claude thinking during an active turn", async () => {
const { queryFactory } = createQueryMock();
const client = new ClaudeAgentClient({

View File

@@ -34,7 +34,11 @@ import {
getClaudeModelsWithSettings,
normalizeClaudeRuntimeModelId,
} from "./models.js";
import { CLAUDE_ULTRACODE_THINKING_OPTION_ID } from "./model-manifest.js";
import {
CLAUDE_DISABLED_THINKING_OPTION_ID,
CLAUDE_ULTRACODE_THINKING_OPTION_ID,
resolveClaudeDisabledThinkingForModel,
} from "./model-manifest.js";
import { parsePartialJsonObject } from "./partial-json.js";
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-definitions.js";
@@ -375,7 +379,10 @@ interface ClaudeAgentSessionOptions {
}
type ClaudeThinkingEffort = "low" | "medium" | "high" | "xhigh" | "max";
type ClaudeThinkingOption = ClaudeThinkingEffort | typeof CLAUDE_ULTRACODE_THINKING_OPTION_ID;
type ClaudeThinkingOption =
| ClaudeThinkingEffort
| typeof CLAUDE_DISABLED_THINKING_OPTION_ID
| typeof CLAUDE_ULTRACODE_THINKING_OPTION_ID;
function resolvePathEnvKey(): "Path" | "PATH" | null {
if (process.env["Path"] !== undefined) return "Path";
@@ -423,7 +430,26 @@ function isClaudeThinkingEffort(value: string | null | undefined): value is Clau
}
function isClaudeThinkingOption(value: string | null | undefined): value is ClaudeThinkingOption {
return value === CLAUDE_ULTRACODE_THINKING_OPTION_ID || isClaudeThinkingEffort(value);
return (
value === CLAUDE_DISABLED_THINKING_OPTION_ID ||
value === CLAUDE_ULTRACODE_THINKING_OPTION_ID ||
isClaudeThinkingEffort(value)
);
}
function assertClaudeThinkingOptionSupported(
modelId: string | null | undefined,
thinkingOptionId: string | null | undefined,
): void {
if (
thinkingOptionId !== CLAUDE_DISABLED_THINKING_OPTION_ID ||
resolveClaudeDisabledThinkingForModel(modelId).supported
) {
return;
}
throw new Error(
`Thinking option '${thinkingOptionId}' is not available for model '${modelId ?? "default"}'`,
);
}
interface ClaudeOptionsLogSummary {
@@ -1946,6 +1972,7 @@ class ClaudeAgentSession implements AgentSession {
constructor(config: ClaudeAgentConfig, options: ClaudeAgentSessionOptions) {
this.config = config;
assertClaudeThinkingOptionSupported(config.model, config.thinkingOptionId);
this.launchEnv = options.launchEnv;
this.agentId = options.agentId;
this.defaults = options.defaults;
@@ -2203,6 +2230,7 @@ class ClaudeAgentSession implements AgentSession {
const activeQuery = await this.ensureQuery();
await activeQuery.setModel(normalizedModelId ?? undefined);
this.config.model = normalizedModelId ?? undefined;
this.reconcileThinkingOptionForModel(normalizedModelId);
if (!claudeModelSupportsFastMode(this.config.model) && this.config.featureValues?.fast_mode) {
await this.applyFastModeFeature(false, activeQuery);
}
@@ -2216,6 +2244,26 @@ class ClaudeAgentSession implements AgentSession {
this.persistence = null;
}
private reconcileThinkingOptionForModel(modelId: string | null): void {
const thinkingOptionId = this.config.thinkingOptionId;
if (thinkingOptionId !== CLAUDE_DISABLED_THINKING_OPTION_ID) {
return;
}
const resolution = resolveClaudeDisabledThinkingForModel(modelId);
if (resolution.supported) {
return;
}
this.config.thinkingOptionId = resolution.fallbackThinkingOptionId;
this.queryRestartNeeded = true;
this.pushEvent({
type: "thinking_option_changed",
provider: "claude",
thinkingOptionId: this.config.thinkingOptionId ?? null,
});
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void | AgentProviderNotice> {
const normalizedThinkingOptionId =
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
@@ -2225,6 +2273,7 @@ class ClaudeAgentSession implements AgentSession {
if (!normalizedThinkingOptionId || normalizedThinkingOptionId === "default") {
this.config.thinkingOptionId = undefined;
} else if (isClaudeThinkingOption(normalizedThinkingOptionId)) {
assertClaudeThinkingOptionSupported(this.config.model, normalizedThinkingOptionId);
this.config.thinkingOptionId = normalizedThinkingOptionId;
} else {
throw new Error(`Unknown thinking option: ${normalizedThinkingOptionId}`);
@@ -2899,6 +2948,10 @@ class ClaudeAgentSession implements AgentSession {
this.config.thinkingOptionId && this.config.thinkingOptionId !== "default"
? this.config.thinkingOptionId
: undefined;
assertClaudeThinkingOptionSupported(this.config.model, thinkingOptionId);
if (thinkingOptionId === CLAUDE_DISABLED_THINKING_OPTION_ID) {
return { thinking: { type: "disabled" }, effort: undefined, ultracode: false };
}
if (thinkingOptionId === CLAUDE_ULTRACODE_THINKING_OPTION_ID) {
return { thinking: { type: "adaptive" }, effort: "xhigh", ultracode: true };
}

View File

@@ -9,6 +9,7 @@ interface ClaudeModelManifestEntry {
isDefault?: boolean;
contextWindowMaxTokens?: number;
effortLevels?: readonly ClaudeEffortLevel[];
supportsThinkingDisabled?: boolean;
supportsFastMode?: boolean;
}
@@ -25,6 +26,7 @@ const CLAUDE_EFFORT_LABELS = {
max: "Max",
} as const satisfies Record<ClaudeEffortLevel, string>;
export const CLAUDE_DISABLED_THINKING_OPTION_ID = "off";
export const CLAUDE_ULTRACODE_THINKING_OPTION_ID = "ultracode";
export const CLAUDE_MODEL_MANIFEST = [
@@ -41,6 +43,7 @@ export const CLAUDE_MODEL_MANIFEST = [
description: "Opus 4.8 with 1M context window",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
supportsThinkingDisabled: true,
supportsFastMode: true,
},
{
@@ -50,6 +53,7 @@ export const CLAUDE_MODEL_MANIFEST = [
isDefault: true,
contextWindowMaxTokens: 200_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
supportsThinkingDisabled: true,
supportsFastMode: true,
},
{
@@ -58,6 +62,7 @@ export const CLAUDE_MODEL_MANIFEST = [
description: "Sonnet 5 · Best for everyday tasks",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
supportsThinkingDisabled: true,
},
{
id: "claude-opus-4-7[1m]",
@@ -65,6 +70,7 @@ export const CLAUDE_MODEL_MANIFEST = [
description: "Opus 4.7 with 1M context window",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
supportsThinkingDisabled: true,
supportsFastMode: true,
},
{
@@ -73,6 +79,7 @@ export const CLAUDE_MODEL_MANIFEST = [
description: "Opus 4.7 · Previous release",
contextWindowMaxTokens: 200_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
supportsThinkingDisabled: true,
supportsFastMode: true,
},
{
@@ -81,6 +88,7 @@ export const CLAUDE_MODEL_MANIFEST = [
description: "Opus 4.6 with 1M context window",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.standard,
supportsThinkingDisabled: true,
supportsFastMode: true,
},
{
@@ -89,6 +97,7 @@ export const CLAUDE_MODEL_MANIFEST = [
description: "Opus 4.6 · Most capable for complex work",
contextWindowMaxTokens: 200_000,
effortLevels: CLAUDE_EFFORT_LEVELS.standard,
supportsThinkingDisabled: true,
supportsFastMode: true,
},
{
@@ -97,6 +106,7 @@ export const CLAUDE_MODEL_MANIFEST = [
description: "Sonnet 4.6 with 1M context window",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.standard,
supportsThinkingDisabled: true,
},
{
id: "claude-sonnet-4-6",
@@ -104,6 +114,7 @@ export const CLAUDE_MODEL_MANIFEST = [
description: "Sonnet 4.6 · Best for everyday tasks",
contextWindowMaxTokens: 200_000,
effortLevels: CLAUDE_EFFORT_LEVELS.standard,
supportsThinkingDisabled: true,
},
{
id: "claude-haiku-4-5",
@@ -115,15 +126,19 @@ export const CLAUDE_MODEL_MANIFEST = [
function buildThinkingOptions(
effortLevels: readonly ClaudeEffortLevel[] | undefined,
supportsThinkingDisabled: boolean,
): AgentSelectOption[] | undefined {
if (!effortLevels) {
return undefined;
}
const options: AgentSelectOption[] = effortLevels.map((id) => ({
id,
label: CLAUDE_EFFORT_LABELS[id],
}));
const options: AgentSelectOption[] = [
...(supportsThinkingDisabled ? [{ id: CLAUDE_DISABLED_THINKING_OPTION_ID, label: "Off" }] : []),
...effortLevels.map((id) => ({
id,
label: CLAUDE_EFFORT_LABELS[id],
})),
];
if (effortLevels.includes("xhigh")) {
options.push({ id: CLAUDE_ULTRACODE_THINKING_OPTION_ID, label: "Ultra Code" });
@@ -136,6 +151,7 @@ export function getClaudeManifestModels(): AgentModelDefinition[] {
return CLAUDE_MODEL_MANIFEST.map((model) => {
const thinkingOptions = buildThinkingOptions(
"effortLevels" in model ? model.effortLevels : undefined,
"supportsThinkingDisabled" in model && model.supportsThinkingDisabled,
);
return {
provider: "claude",
@@ -146,11 +162,40 @@ export function getClaudeManifestModels(): AgentModelDefinition[] {
...(model.contextWindowMaxTokens !== undefined
? { contextWindowMaxTokens: model.contextWindowMaxTokens }
: {}),
...(thinkingOptions ? { thinkingOptions } : {}),
...(thinkingOptions
? {
thinkingOptions,
defaultThinkingOptionId: "effortLevels" in model ? model.effortLevels?.[0] : undefined,
}
: {}),
};
});
}
export interface ClaudeDisabledThinkingResolution {
supported: boolean;
fallbackThinkingOptionId: string | undefined;
}
/**
* Resolve the disabled-thinking capability from the curated manifest only. Runtime/provider
* model aliases intentionally do not inherit this capability.
*/
export function resolveClaudeDisabledThinkingForModel(
modelId: string | null | undefined,
): ClaudeDisabledThinkingResolution {
const normalizedModelId = normalizeClaudeManifestModelId(modelId);
const model = normalizedModelId
? CLAUDE_MODEL_MANIFEST.find((candidate) => candidate.id === normalizedModelId)
: undefined;
return {
supported:
!!model && "supportsThinkingDisabled" in model && model.supportsThinkingDisabled === true,
fallbackThinkingOptionId:
model && "effortLevels" in model ? model.effortLevels?.[0] : undefined,
};
}
export function isClaudeManifestModelId(modelId: string): boolean {
return CLAUDE_MODEL_MANIFEST.some((model) => model.id === modelId);
}

View File

@@ -6,9 +6,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./agent.js";
import {
CLAUDE_DISABLED_THINKING_OPTION_ID,
CLAUDE_ULTRACODE_THINKING_OPTION_ID,
claudeManifestModelSupportsFastMode,
normalizeClaudeManifestModelId,
resolveClaudeDisabledThinkingForModel,
} from "./model-manifest.js";
import { findClaudeModel, getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js";
@@ -87,6 +89,7 @@ describe("getClaudeModels", () => {
const models = new Map(getClaudeModels().map((model) => [model.id, model]));
expect(models.get("claude-sonnet-5")?.thinkingOptions?.map((option) => option.id)).toEqual([
CLAUDE_DISABLED_THINKING_OPTION_ID,
"low",
"medium",
"high",
@@ -100,8 +103,10 @@ describe("getClaudeModels", () => {
?.thinkingOptions?.find((option) => option.id === CLAUDE_ULTRACODE_THINKING_OPTION_ID)
?.label,
).toBe("Ultra Code");
expect(models.get("claude-sonnet-5")?.defaultThinkingOptionId).toBe("low");
expect(models.get("claude-opus-4-7")?.thinkingOptions?.map((option) => option.id)).toEqual([
CLAUDE_DISABLED_THINKING_OPTION_ID,
"low",
"medium",
"high",
@@ -110,14 +115,32 @@ describe("getClaudeModels", () => {
CLAUDE_ULTRACODE_THINKING_OPTION_ID,
]);
expect(models.get("claude-sonnet-4-6")?.thinkingOptions?.map((option) => option.id)).toEqual([
CLAUDE_DISABLED_THINKING_OPTION_ID,
"low",
"medium",
"high",
"max",
]);
expect(models.get("claude-fable-5")?.thinkingOptions?.map((option) => option.id)).not.toContain(
CLAUDE_DISABLED_THINKING_OPTION_ID,
);
expect(models.get("claude-haiku-4-5")?.thinkingOptions).toBeUndefined();
});
it.each([
["claude-sonnet-5", true, "low"],
["claude-sonnet-5-20260101", true, "low"],
["claude-fable-5", false, "low"],
["claude-haiku-4-5", false, undefined],
["openrouter/anthropic/claude-opus-4-8", false, undefined],
[null, false, undefined],
])("resolves disabled thinking for model %s", (modelId, supported, fallbackThinkingOptionId) => {
expect(resolveClaudeDisabledThinkingForModel(modelId)).toEqual({
supported,
fallbackThinkingOptionId,
});
});
it("returns fresh copies each call", () => {
const a = getClaudeModels();
const b = getClaudeModels();