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");