From 97bd828475095da585754622d4f2561f5c6ecc88 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Wed, 12 Aug 2026 15:35:38 -0400 Subject: [PATCH] feat(models): prompt for reasoning level on DEFAULT assignments for all providers The model selector only opened the reasoning menu for the DEFAULT target when the model's provider was openai/openai-codex. Assigning an Anthropic reasoning model (e.g. claude-fable-5) to DEFAULT committed immediately with a bare selector, so there was no way to pin an explicit effort from the picker: the assignment silently inherited defaultThinkingLevel and re-picking stripped any hand-configured :level suffix from modelRoles.default. requiresExplicitThinkingChoice now returns true for the DEFAULT target whenever the model is reasoning-capable, matching the existing role-agent behavior. The selector-controller already composes the :level suffix and applies session.setThinkingLevel for default assignments, and session startup already honors an explicit suffix on the remembered default (explicitThinkingLevel), so the choice persists across restarts with no further changes. Non-reasoning models and temporary (role-less) model switches keep their current no-prompt behavior. --- packages/coding-agent/CHANGELOG.md | 3 + .../src/modes/components/model-selector.ts | 7 +- .../model-selector-batch-thinking.test.ts | 8 +- ...model-selector-role-badge-thinking.test.ts | 102 +++++++++++++++++- 4 files changed, 114 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f843cff533..8e1aba2038 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Added +- The model selector now prompts for a reasoning level when assigning any reasoning-capable model to the DEFAULT target, not just OpenAI/OpenAI-Codex models. Selecting e.g. an Anthropic reasoning model for DEFAULT opens the same "Reasoning for Default" menu as role-agent assignments and persists the choice as an explicit `:level` suffix in `modelRoles.default`, so the pinned effort survives restarts instead of silently inheriting `defaultThinkingLevel`. Non-reasoning models and temporary model switches are unaffected. + ## [0.13.1] - 2026-08-11 ### Added diff --git a/packages/coding-agent/src/modes/components/model-selector.ts b/packages/coding-agent/src/modes/components/model-selector.ts index 3f5d55c5af..83e9d3df24 100644 --- a/packages/coding-agent/src/modes/components/model-selector.ts +++ b/packages/coding-agent/src/modes/components/model-selector.ts @@ -2435,7 +2435,12 @@ export class ModelSelectorComponent extends Container { function requiresExplicitThinkingChoice(model: Model, role: GjcModelAssignmentTargetId | null): boolean { if (model.reasoning !== true) return false; if (model.provider === "openai" || model.provider === "openai-codex") return true; - return role !== null && GJC_MODEL_ASSIGNMENT_TARGETS[role].settingsPath === "task.agentModelOverrides"; + if (role === null) return false; + // DEFAULT assignments prompt for a reasoning level regardless of provider so + // non-OpenAI reasoning models (e.g. Anthropic) can pin an explicit effort in + // modelRoles.default instead of silently inheriting defaultThinkingLevel. + if (role === "default") return true; + return GJC_MODEL_ASSIGNMENT_TARGETS[role].settingsPath === "task.agentModelOverrides"; } function getSelectableThinkingLevels(model: Model): ThinkingLevel[] { diff --git a/packages/coding-agent/test/model-selector-batch-thinking.test.ts b/packages/coding-agent/test/model-selector-batch-thinking.test.ts index 87bce73972..8c2efa88b6 100644 --- a/packages/coding-agent/test/model-selector-batch-thinking.test.ts +++ b/packages/coding-agent/test/model-selector-batch-thinking.test.ts @@ -73,10 +73,10 @@ function createSelector( /** * Reasoning model whose provider alone does NOT force an explicit thinking - * choice for the DEFAULT target (unlike openai/openai-codex), mirroring - * Anthropic reasoning models such as claude-fable-5. Role-agent targets - * (task.agentModelOverrides) still require an explicit choice, so batch - * assignment must surface the reasoning menu. + * choice (unlike openai/openai-codex), mirroring Anthropic reasoning models + * such as claude-fable-5. DEFAULT and role-agent targets both require an + * explicit choice, so single and batch assignment must surface the + * reasoning menu. */ function createAnthropicReasoningModel(id: string): Model { return { diff --git a/packages/coding-agent/test/model-selector-role-badge-thinking.test.ts b/packages/coding-agent/test/model-selector-role-badge-thinking.test.ts index 80cb58961c..f66c5f6c4d 100644 --- a/packages/coding-agent/test/model-selector-role-badge-thinking.test.ts +++ b/packages/coding-agent/test/model-selector-role-badge-thinking.test.ts @@ -112,6 +112,26 @@ function createOpenAIModel(provider: "openai" | "openai-codex", id: string, reas }; } +function createAnthropicReasoningModel(id: string): Model { + return { + id, + name: id, + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + thinking: { + minLevel: Effort.Low, + maxLevel: Effort.XHigh, + mode: "anthropic-adaptive", + }, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_000_000, + maxTokens: 64000, + } as Model; +} + function createOllamaCloudModel(id: string): Model { return { id, @@ -191,12 +211,19 @@ describe("ModelSelector canonical model selection", () => { expect(actionRendered).not.toContain("Set as SMOL"); expect(actionRendered).not.toContain("Set as TASK"); + selector.handleInput("\n"); + // Reasoning-capable model on the DEFAULT target: the reasoning menu opens, + // seeded from the existing DEFAULT (low) binding. + expect(selected).toBeUndefined(); + const thinkingRendered = normalizeRenderedText(selector.render(220).join("\n")); + expect(thinkingRendered).toContain("Reasoning for Default: low"); + selector.handleInput("\n"); const selectedAfterEnter = selected; if (!selectedAfterEnter) throw new Error("Expected Enter to select a model"); expect(selectedAfterEnter.model).toBe(model); expect(selectedAfterEnter.role).toBe("default"); - expect(selectedAfterEnter.thinkingLevel).toBe(ThinkingLevel.Off); + expect(selectedAfterEnter.thinkingLevel).toBe(ThinkingLevel.Low); expect(selectedAfterEnter.selector).toBe(`${model.provider}/${model.id}`); }); @@ -525,6 +552,79 @@ describe("ModelSelector canonical model selection", () => { expect(selectedAfterThinking.selector).toBe(`${model.provider}/${model.id}`); }); + test("prompts for reasoning before assigning Anthropic reasoning default models", async () => { + installTestTheme(); + const model = createAnthropicReasoningModel("claude-fable-5"); + const settings = Settings.isolated({}); + + let selected: SelectionCapture | undefined; + const selector = createSelector( + model, + settings, + selection => { + if (selection.kind === "assignment") selected = selection; + }, + { thinkingLevel: null }, + ); + await Bun.sleep(0); + installTestTheme(); + + selector.handleInput("\n"); + selector.handleInput("\n"); + + // The reasoning menu must open instead of committing the assignment. + expect(selected).toBeUndefined(); + const thinkingRendered = normalizeRenderedText(selector.render(220).join("\n")); + expect(thinkingRendered).toContain("Reasoning for Default: off"); + expect(thinkingRendered).toContain("xhigh"); + + // Levels are [off, low, medium, high, xhigh]; pick xhigh. + for (let i = 0; i < 4; i++) selector.handleInput("\x1b[B"); + const afterNav = normalizeRenderedText(selector.render(220).join("\n")); + expect(afterNav).toContain("Reasoning for Default: xhigh"); + selector.handleInput("\n"); + + const selectedAfterThinking = selected; + if (!selectedAfterThinking) throw new Error("Expected Anthropic selection after reasoning choice"); + expect(selectedAfterThinking.model).toBe(model); + expect(selectedAfterThinking.role).toBe("default"); + expect(selectedAfterThinking.thinkingLevel).toBe(ThinkingLevel.XHigh); + expect(selectedAfterThinking.selector).toBe(`${model.provider}/${model.id}`); + }); + + test("does not prompt when assigning Anthropic non-reasoning models to default", async () => { + installTestTheme(); + const reasoningModel = createAnthropicReasoningModel("claude-plain-base"); + const model = { + ...reasoningModel, + id: "claude-plain", + name: "claude-plain", + reasoning: false, + thinking: undefined, + } as Model; + const settings = Settings.isolated({}); + + let selected: SelectionCapture | undefined; + const selector = createSelector( + model, + settings, + selection => { + if (selection.kind === "assignment") selected = selection; + }, + { thinkingLevel: null }, + ); + await Bun.sleep(0); + installTestTheme(); + + selector.handleInput("\n"); + selector.handleInput("\n"); + + const selectedDirect = selected; + if (!selectedDirect) throw new Error("Expected direct non-reasoning selection"); + expect(selectedDirect.role).toBe("default"); + expect(selectedDirect.selector).toBe(`${model.provider}/${model.id}`); + }); + test("can explicitly choose off for OpenAI reasoning default models", async () => { installTestTheme(); const model = createOpenAIModel("openai", "gpt-reasoning-off-test");