Skip to content

Commit 1962816

Browse files
authored
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
1 parent 8c92c7d commit 1962816

6 files changed

Lines changed: 296 additions & 10 deletions

File tree

packages/server/src/server/agent/agent-manager.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3103,6 +3103,53 @@ test("persists live mode, model, and thinking changes without an external snapsh
31033103
expect(persisted?.runtimeInfo?.model).toBe("gpt-5.4");
31043104
});
31053105

3106+
test("later explicit config mutations win over events emitted by earlier mutations", async () => {
3107+
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-config-mutation-order-"));
3108+
class ConfigMutationSession extends TestAgentSession {
3109+
async setModel(): Promise<void> {
3110+
this.pushEvent({
3111+
type: "timeline",
3112+
provider: "codex",
3113+
item: { type: "assistant_message", text: "model changed" },
3114+
});
3115+
this.pushEvent({
3116+
type: "thinking_option_changed",
3117+
provider: "codex",
3118+
thinkingOptionId: "low",
3119+
});
3120+
}
3121+
3122+
async setThinkingOption(): Promise<void> {}
3123+
}
3124+
class ConfigMutationClient extends TestAgentClient {
3125+
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
3126+
return new ConfigMutationSession(config);
3127+
}
3128+
}
3129+
3130+
const manager = new AgentManager({
3131+
clients: { codex: new ConfigMutationClient() },
3132+
logger,
3133+
idFactory: () => "00000000-0000-4000-8000-000000000134",
3134+
});
3135+
const snapshot = await manager.createAgent(
3136+
{
3137+
provider: "codex",
3138+
cwd: workdir,
3139+
model: "gpt-5.2-codex",
3140+
thinkingOptionId: "off",
3141+
},
3142+
undefined,
3143+
{ workspaceId: undefined },
3144+
);
3145+
3146+
await manager.setAgentModel(snapshot.id, "gpt-5.4");
3147+
await manager.setAgentThinkingOption(snapshot.id, "high");
3148+
await manager.flush();
3149+
3150+
expect(manager.getAgent(snapshot.id)?.config.thinkingOptionId).toBe("high");
3151+
});
3152+
31063153
test("session config drift events update state through the stream channel", async () => {
31073154
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-session-config-events-"));
31083155
let capturedSession: TestAgentSession | null = null;
@@ -3171,6 +3218,7 @@ test("session config drift events update state through the stream channel", asyn
31713218

31723219
const agent = manager.getAgent(snapshot.id);
31733220
expect(agent?.currentModeId).toBe("build");
3221+
expect(agent?.config.thinkingOptionId).toBe("high");
31743222
expect(agent?.availableModes).toEqual([
31753223
{ id: "plan", label: "Plan" },
31763224
{ id: "build", label: "Build" },

packages/server/src/server/agent/agent-manager.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1579,6 +1579,7 @@ export class AgentManager {
15791579
async setAgentMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null> {
15801580
const agent = this.requireSessionAgent(agentId);
15811581
const notice = (await agent.session.setMode(modeId)) ?? null;
1582+
await this.drainSessionEvents(agentId);
15821583
const currentMode = (await agent.session.getCurrentMode()) ?? modeId;
15831584
agent.config.modeId = currentMode ?? undefined;
15841585
agent.currentModeId = currentMode;
@@ -1599,6 +1600,7 @@ export class AgentManager {
15991600
if (agent.session.setModel) {
16001601
await agent.session.setModel(normalizedModelId);
16011602
}
1603+
await this.drainSessionEvents(agentId);
16021604

16031605
agent.config.model = normalizedModelId ?? undefined;
16041606
if (agent.runtimeInfo) {
@@ -1622,6 +1624,7 @@ export class AgentManager {
16221624
if (agent.session.setThinkingOption) {
16231625
notice = (await agent.session.setThinkingOption(normalizedThinkingOptionId)) ?? null;
16241626
}
1627+
await this.drainSessionEvents(agentId);
16251628

16261629
agent.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
16271630
if (agent.runtimeInfo) {
@@ -1643,6 +1646,7 @@ export class AgentManager {
16431646
}
16441647

16451648
await agent.session.setFeature(featureId, value);
1649+
await this.drainSessionEvents(agentId);
16461650
agent.config.featureValues = { ...agent.config.featureValues, [featureId]: value };
16471651
this.touchUpdatedAt(agent);
16481652
this.emitState(agent);
@@ -3009,6 +3013,24 @@ export class AgentManager {
30093013
});
30103014
}
30113015

3016+
/**
3017+
* Provider mutations may synchronously emit config events that are processed through the
3018+
* asynchronous session queue. Apply those events before committing the mutation's explicit
3019+
* manager state so call order remains authoritative.
3020+
*/
3021+
private async drainSessionEvents(agentId: string): Promise<void> {
3022+
while (true) {
3023+
const tail = this.sessionEventTails.get(agentId);
3024+
if (!tail) {
3025+
return;
3026+
}
3027+
await tail;
3028+
if (this.sessionEventTails.get(agentId) === tail) {
3029+
return;
3030+
}
3031+
}
3032+
}
3033+
30123034
private async dispatchSessionEvent(
30133035
agent: ActiveManagedAgent,
30143036
event: AgentStreamEvent,
@@ -3431,6 +3453,7 @@ export class AgentManager {
34313453
this.emitState(agent);
34323454
return undefined;
34333455
case "thinking_option_changed":
3456+
agent.config.thinkingOptionId = event.thinkingOptionId ?? undefined;
34343457
if (agent.runtimeInfo) {
34353458
agent.runtimeInfo = {
34363459
...agent.runtimeInfo,

packages/server/src/server/agent/providers/claude/agent.test.ts

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -583,8 +583,12 @@ describe("ClaudeAgentSession features", () => {
583583
};
584584
},
585585
};
586-
const queryFactory = vi.fn(() => queryMock);
587-
return { queryFactory, queryMock };
586+
const launches: Array<{ options: Record<string, unknown> }> = [];
587+
const queryFactory = vi.fn((input) => {
588+
launches.push(input);
589+
return queryMock;
590+
});
591+
return { queryFactory, queryMock, launches };
588592
}
589593

590594
test("lists fast mode only for supported Opus models", async () => {
@@ -686,6 +690,96 @@ describe("ClaudeAgentSession features", () => {
686690
await session.close();
687691
});
688692

693+
test("turns Claude thinking off without retaining an effort level", async () => {
694+
const { queryFactory, launches } = createQueryMock();
695+
const client = new ClaudeAgentClient({
696+
logger,
697+
queryFactory,
698+
resolveBinary: async () => "/test/claude/bin",
699+
});
700+
const session = await client.createSession({
701+
provider: "claude",
702+
cwd: process.cwd(),
703+
model: "claude-sonnet-5",
704+
thinkingOptionId: "off",
705+
});
706+
707+
await expect(session.startTurn("hello")).resolves.toEqual({
708+
turnId: expect.stringMatching(/^foreground-turn-/),
709+
});
710+
711+
expect(launches[0]?.options.thinking).toEqual({ type: "disabled" });
712+
expect(launches[0]?.options).not.toHaveProperty("effort");
713+
714+
await session.close();
715+
});
716+
717+
test.each([
718+
["supported model", "claude-opus-4-8", { type: "disabled" }, undefined],
719+
["unsupported model", "claude-fable-5", { type: "adaptive" }, "low"],
720+
["custom model", "openrouter/anthropic/claude-opus-4-8", undefined, undefined],
721+
["provider default", null, undefined, undefined],
722+
])("reconciles Off when switching to a %s", async (_label, modelId, thinking, effort) => {
723+
const { queryFactory, launches } = createQueryMock();
724+
const client = new ClaudeAgentClient({
725+
logger,
726+
queryFactory,
727+
resolveBinary: async () => "/test/claude/bin",
728+
});
729+
const session = await client.createSession({
730+
provider: "claude",
731+
cwd: process.cwd(),
732+
model: "claude-sonnet-5",
733+
thinkingOptionId: "off",
734+
});
735+
736+
await session.setModel?.(modelId);
737+
await session.startTurn("hello");
738+
739+
expect(launches.at(-1)?.options.thinking).toEqual(thinking);
740+
expect(launches.at(-1)?.options.effort).toBe(effort);
741+
742+
await session.close();
743+
});
744+
745+
test("rejects disabled thinking when the active model does not support it", async () => {
746+
const { queryFactory } = createQueryMock();
747+
const client = new ClaudeAgentClient({
748+
logger,
749+
queryFactory,
750+
resolveBinary: async () => "/test/claude/bin",
751+
});
752+
const session = await client.createSession({
753+
provider: "claude",
754+
cwd: process.cwd(),
755+
model: "claude-fable-5",
756+
});
757+
758+
await expect(session.setThinkingOption?.("off")).rejects.toThrow(
759+
"Thinking option 'off' is not available for model 'claude-fable-5'",
760+
);
761+
762+
await session.close();
763+
});
764+
765+
test("rejects an initial disabled-thinking config for an unsupported model", async () => {
766+
const { queryFactory } = createQueryMock();
767+
const client = new ClaudeAgentClient({
768+
logger,
769+
queryFactory,
770+
resolveBinary: async () => "/test/claude/bin",
771+
});
772+
773+
await expect(
774+
client.createSession({
775+
provider: "claude",
776+
cwd: process.cwd(),
777+
model: "claude-fable-5",
778+
thinkingOptionId: "off",
779+
}),
780+
).rejects.toThrow("Thinking option 'off' is not available for model 'claude-fable-5'");
781+
});
782+
689783
test("returns a next-turn notice when changing Claude thinking during an active turn", async () => {
690784
const { queryFactory } = createQueryMock();
691785
const client = new ClaudeAgentClient({

packages/server/src/server/agent/providers/claude/agent.ts

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@ import {
3434
getClaudeModelsWithSettings,
3535
normalizeClaudeRuntimeModelId,
3636
} from "./models.js";
37-
import { CLAUDE_ULTRACODE_THINKING_OPTION_ID } from "./model-manifest.js";
37+
import {
38+
CLAUDE_DISABLED_THINKING_OPTION_ID,
39+
CLAUDE_ULTRACODE_THINKING_OPTION_ID,
40+
resolveClaudeDisabledThinkingForModel,
41+
} from "./model-manifest.js";
3842
import { parsePartialJsonObject } from "./partial-json.js";
3943
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
4044
import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-definitions.js";
@@ -375,7 +379,10 @@ interface ClaudeAgentSessionOptions {
375379
}
376380

377381
type ClaudeThinkingEffort = "low" | "medium" | "high" | "xhigh" | "max";
378-
type ClaudeThinkingOption = ClaudeThinkingEffort | typeof CLAUDE_ULTRACODE_THINKING_OPTION_ID;
382+
type ClaudeThinkingOption =
383+
| ClaudeThinkingEffort
384+
| typeof CLAUDE_DISABLED_THINKING_OPTION_ID
385+
| typeof CLAUDE_ULTRACODE_THINKING_OPTION_ID;
379386

380387
function resolvePathEnvKey(): "Path" | "PATH" | null {
381388
if (process.env["Path"] !== undefined) return "Path";
@@ -423,7 +430,26 @@ function isClaudeThinkingEffort(value: string | null | undefined): value is Clau
423430
}
424431

425432
function isClaudeThinkingOption(value: string | null | undefined): value is ClaudeThinkingOption {
426-
return value === CLAUDE_ULTRACODE_THINKING_OPTION_ID || isClaudeThinkingEffort(value);
433+
return (
434+
value === CLAUDE_DISABLED_THINKING_OPTION_ID ||
435+
value === CLAUDE_ULTRACODE_THINKING_OPTION_ID ||
436+
isClaudeThinkingEffort(value)
437+
);
438+
}
439+
440+
function assertClaudeThinkingOptionSupported(
441+
modelId: string | null | undefined,
442+
thinkingOptionId: string | null | undefined,
443+
): void {
444+
if (
445+
thinkingOptionId !== CLAUDE_DISABLED_THINKING_OPTION_ID ||
446+
resolveClaudeDisabledThinkingForModel(modelId).supported
447+
) {
448+
return;
449+
}
450+
throw new Error(
451+
`Thinking option '${thinkingOptionId}' is not available for model '${modelId ?? "default"}'`,
452+
);
427453
}
428454

429455
interface ClaudeOptionsLogSummary {
@@ -1946,6 +1972,7 @@ class ClaudeAgentSession implements AgentSession {
19461972

19471973
constructor(config: ClaudeAgentConfig, options: ClaudeAgentSessionOptions) {
19481974
this.config = config;
1975+
assertClaudeThinkingOptionSupported(config.model, config.thinkingOptionId);
19491976
this.launchEnv = options.launchEnv;
19501977
this.agentId = options.agentId;
19511978
this.defaults = options.defaults;
@@ -2203,6 +2230,7 @@ class ClaudeAgentSession implements AgentSession {
22032230
const activeQuery = await this.ensureQuery();
22042231
await activeQuery.setModel(normalizedModelId ?? undefined);
22052232
this.config.model = normalizedModelId ?? undefined;
2233+
this.reconcileThinkingOptionForModel(normalizedModelId);
22062234
if (!claudeModelSupportsFastMode(this.config.model) && this.config.featureValues?.fast_mode) {
22072235
await this.applyFastModeFeature(false, activeQuery);
22082236
}
@@ -2216,6 +2244,26 @@ class ClaudeAgentSession implements AgentSession {
22162244
this.persistence = null;
22172245
}
22182246

2247+
private reconcileThinkingOptionForModel(modelId: string | null): void {
2248+
const thinkingOptionId = this.config.thinkingOptionId;
2249+
if (thinkingOptionId !== CLAUDE_DISABLED_THINKING_OPTION_ID) {
2250+
return;
2251+
}
2252+
2253+
const resolution = resolveClaudeDisabledThinkingForModel(modelId);
2254+
if (resolution.supported) {
2255+
return;
2256+
}
2257+
2258+
this.config.thinkingOptionId = resolution.fallbackThinkingOptionId;
2259+
this.queryRestartNeeded = true;
2260+
this.pushEvent({
2261+
type: "thinking_option_changed",
2262+
provider: "claude",
2263+
thinkingOptionId: this.config.thinkingOptionId ?? null,
2264+
});
2265+
}
2266+
22192267
async setThinkingOption(thinkingOptionId: string | null): Promise<void | AgentProviderNotice> {
22202268
const normalizedThinkingOptionId =
22212269
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
@@ -2225,6 +2273,7 @@ class ClaudeAgentSession implements AgentSession {
22252273
if (!normalizedThinkingOptionId || normalizedThinkingOptionId === "default") {
22262274
this.config.thinkingOptionId = undefined;
22272275
} else if (isClaudeThinkingOption(normalizedThinkingOptionId)) {
2276+
assertClaudeThinkingOptionSupported(this.config.model, normalizedThinkingOptionId);
22282277
this.config.thinkingOptionId = normalizedThinkingOptionId;
22292278
} else {
22302279
throw new Error(`Unknown thinking option: ${normalizedThinkingOptionId}`);
@@ -2899,6 +2948,10 @@ class ClaudeAgentSession implements AgentSession {
28992948
this.config.thinkingOptionId && this.config.thinkingOptionId !== "default"
29002949
? this.config.thinkingOptionId
29012950
: undefined;
2951+
assertClaudeThinkingOptionSupported(this.config.model, thinkingOptionId);
2952+
if (thinkingOptionId === CLAUDE_DISABLED_THINKING_OPTION_ID) {
2953+
return { thinking: { type: "disabled" }, effort: undefined, ultracode: false };
2954+
}
29022955
if (thinkingOptionId === CLAUDE_ULTRACODE_THINKING_OPTION_ID) {
29032956
return { thinking: { type: "adaptive" }, effort: "xhigh", ultracode: true };
29042957
}

0 commit comments

Comments
 (0)