From f8953e4563f382c8bd169bcfd175c43ec185f486 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 | 2 +- .../coding-agent/src/config/model-registry.ts | 4 +- .../coding-agent/test/acp-builtins.test.ts | 42 ++++++ .../model-selector-batch-thinking.test.ts | 8 +- .../model-selector-controller-batch.test.ts | 36 ++++- ...model-selector-role-badge-thinking.test.ts | 133 +++++++++++++++++- 6 files changed, 217 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2550426edb..a8e72015e8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - Added built-in `grok-46-{eco,medium,pro}` role profiles using the existing xAI OAuth/subscription provider, and updated `/model` so direct xAI Grok 4.5/4.6 assignment prompts for a supported reasoning effort instead of retaining `(inherit)`. Grok 4.5 offers `low`/`medium`/`high`; Grok 4.6 adds `xhigh`. +- The model selector and argument-based `/model` assignment now require an explicit supported reasoning effort when assigning any reasoning-capable model to DEFAULT, regardless of provider. The selected `:effort` is persisted in `modelRoles.default`, while non-reasoning assignments and provider-general role-less temporary switches remain direct. - Managed master sessions now provide `gjc master create`, `gjc master list`, and `gjc master configure` for named headless orchestrators with durable state under `$GJC_HOME/master`, a default `maxConcurrentWorkers` of 3, and drain-aware capacity reductions. The separate private loopback/token master SDK v1 exposes strict snapshots, ordered event replay, worker ownership/claims, Coordinator-frozen orchestration, the injected `MemoryContract` boundary, and leased Telegram/Discord channel effects. At least one active reconciled provider keeps a master available while every configured provider retains durable ordered presentation replay until its delivery reconciles; master sessions have no repository-edit capability or bundled/default skill/agent. `DAEMON_GENERATION` is bumped to 60 and `CHAT_DAEMON_GENERATIONS.discord` to 30 so older daemons are replaced before master-channel delivery authority changes. - `가재씨` is now installed alongside `gjc` as a Korean launcher alias. Typing `가재씨` runs Gajae-Code identically to `gjc`; it is a package-owned bin entry created by npm/Bun during install with no shell alias or dotfile edit required. Supported on Linux and macOS (UTF-8 locales); on Windows the shim is created but invoking it from `cmd.exe` depends on the console's active code page (#4363). - `GJC_WORKTREE_DIR` now selects where `gjc --worktree` creates its launch worktrees. The bucket was derived entirely from the repository path (`/.gajae-code-worktrees`) with no override, so a machine that already keeps worktrees under its own convention accumulated a second bucket beside the first. The value is a path template: `{repo}` expands to the repository directory name so one exported value stays repo-scoped, a relative value resolves against the repository's parent directory (`{repo}.worktrees` adopts an existing sibling bucket), and an absolute or `~/`-prefixed value is used as given. Unset or blank keeps the previous default. See [`docs/environment-variables.md`](../../docs/environment-variables.md#6-storage-and-config-root-paths). @@ -71,7 +72,6 @@ - ACP `session/new` no longer fails with an uncertain-after-send internal error against a cold session host. Session-scoped commands were dispatched through `SessionRouter` on the SDK transport's one-shot default deadline (10s), which the first `models.list/current` (Q10) outruns whenever profile-provider credential collection has to refresh several OAuth providers — the reply landed after the deadline (measured 10002ms against an 11s answer), the outcome could then only be reported as uncertain after the frame was sent, and the ACP agent discarded the session it had just created while the immediate retry answered in ~260ms. Router-dispatched session requests now carry the long-lived session budget (two host heartbeat TTLs, matching the reconnect budget that already keeps these clients alive), and any caller-supplied timeout — coordinator prompt acknowledgement, lifecycle requests — still wins (#4258). ### Changed - macOS now emits the terminal BEL for completion, approval, and ask notifications by default when `notifications.terminalBell` has not been configured; an explicit setting still controls the behavior. - ## [0.13.1] - 2026-08-11 ### Added diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 8f3e273204..459a3dc987 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -180,7 +180,9 @@ export function requiresExplicitThinkingChoice(model: Model, role: GjcModelAssig (model.provider === "xai" && (model.id === "grok-4.5" || model.id === "grok-4.6")) ) return true; - return role !== null && GJC_MODEL_ASSIGNMENT_TARGETS[role].settingsPath === "task.agentModelOverrides"; + if (role === null) return false; + if (role === "default") return true; + return GJC_MODEL_ASSIGNMENT_TARGETS[role].settingsPath === "task.agentModelOverrides"; } /** Alias for ModelRoleInfo - used for both built-in and custom roles */ diff --git a/packages/coding-agent/test/acp-builtins.test.ts b/packages/coding-agent/test/acp-builtins.test.ts index 11cc994078..46fff87518 100644 --- a/packages/coding-agent/test/acp-builtins.test.ts +++ b/packages/coding-agent/test/acp-builtins.test.ts @@ -567,6 +567,48 @@ describe("ACP builtin slash commands", () => { expect(output[0]).toContain("/model "); }); + it("model: requires explicit effort for argument-based reasoning defaults from other providers", async () => { + const { output, runtime, session } = createRuntime(); + const reasoningModel = { + provider: "anthropic", + id: "claude-fable-5", + reasoning: true, + contextWindow: 500_000, + maxTokens: 64_000, + }; + session.getAvailableModels = () => [reasoningModel]; + const setModelSpy = spyOn(session, "setModel").mockResolvedValue(undefined); + + const result = await executeAcpBuiltinSlashCommand("/model anthropic/claude-fable-5", runtime); + + expect(result).toEqual({ consumed: true }); + expect(setModelSpy).not.toHaveBeenCalled(); + expect(output[0]).toContain("requires an explicit effort suffix"); + }); + + it("model: accepts explicit effort for argument-based reasoning defaults from other providers", async () => { + const { runtime, session } = createRuntime(); + const reasoningModel = { + provider: "anthropic", + id: "claude-fable-5", + reasoning: true, + contextWindow: 500_000, + maxTokens: 64_000, + }; + session.getAvailableModels = () => [reasoningModel]; + const setModelSpy = spyOn(session, "setModel").mockResolvedValue(undefined); + + const result = await executeAcpBuiltinSlashCommand("/model anthropic/claude-fable-5:xhigh", runtime); + + expect(result).toEqual({ consumed: true }); + expect(setModelSpy).toHaveBeenCalledWith(reasoningModel, "default", { + cause: "user-selection", + selector: "anthropic/claude-fable-5", + thinkingLevel: "xhigh", + }); + expect(runtime.settings.getModelRole("default")).toBe("anthropic/claude-fable-5:xhigh"); + }); + it("model: requires explicit Grok effort for argument-based role assignment", async () => { const { output, runtime, session } = createRuntime(); session.getAvailableModels = () => [ 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 d7dcb5108a..1c4e5e82df 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-controller-batch.test.ts b/packages/coding-agent/test/model-selector-controller-batch.test.ts index df2ffd46d7..7f808c10b4 100644 --- a/packages/coding-agent/test/model-selector-controller-batch.test.ts +++ b/packages/coding-agent/test/model-selector-controller-batch.test.ts @@ -1,7 +1,11 @@ import { beforeAll, describe, expect, test, vi } from "bun:test"; import { ThinkingLevel } from "@gajae-code/agent-core"; import type { Model } from "@gajae-code/ai"; -import { resolveAgentModelPatterns, resolveModelOverride } from "@gajae-code/coding-agent/config/model-resolver"; +import { + resolveAgentModelPatterns, + resolveModelOverride, + resolveModelRoleValue, +} from "@gajae-code/coding-agent/config/model-resolver"; import { Settings } from "@gajae-code/coding-agent/config/settings"; import type { ModelSelectorComponent } from "@gajae-code/coding-agent/modes/components/model-selector"; import { SelectorController } from "@gajae-code/coding-agent/modes/controllers/selector-controller"; @@ -471,6 +475,36 @@ describe("SelectorController model batch assignments", () => { expect(settings.getModelRole("default")).toBe("provider-a/original-default:medium"); }); + test("DEFAULT assignment persists its explicit effort for session restart resolution", async () => { + const { ctx, settings, setModelCalls } = createControllerContext(); + const selector = await openSelector(ctx); + + await selector.__testSelectAssignment({ + model: selectedModel, + role: "default", + thinkingLevel: ThinkingLevel.High, + selector: "provider-a/selected", + }); + + expect(setModelCalls).toEqual([ + { + model: selectedModel, + role: "default", + options: { + cause: "user-selection", + onMutationStarted: expect.any(Function), + selector: "provider-a/selected", + thinkingLevel: ThinkingLevel.High, + }, + }, + ]); + expect(settings.getModelRole("default")).toBe("provider-a/selected:high"); + const restarted = resolveModelRoleValue(settings.getModelRole("default"), [selectedModel]); + expect(restarted.model).toBe(selectedModel); + expect(restarted.thinkingLevel).toBe(ThinkingLevel.High); + expect(restarted.explicitThinkingLevel).toBe(true); + }); + test("relies on AgentSession to replace the prior temporary provider-session scope", async () => { const { ctx, setModelTemporary } = createControllerContext(); const selector = await openSelector(ctx); 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..a8ffb23ef1 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,110 @@ describe("ModelSelector canonical model selection", () => { expect(selectedAfterThinking.selector).toBe(`${model.provider}/${model.id}`); }); + test.each([ + ["anthropic", "claude-fable-5"], + ["google", "gemini-reasoning-test"], + ["ollama-cloud", "deepseek-reasoning-test"], + ] as const)("prompts for reasoning before assigning %s reasoning default models", async (provider, id) => { + installTestTheme(); + const model = { ...createAnthropicReasoningModel(id), provider } 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"); + + // 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("cancelling DEFAULT reasoning returns to the action menu without assignment", async () => { + installTestTheme(); + const model = createAnthropicReasoningModel("claude-fable-cancel"); + let selected: SelectionCapture | undefined; + const selector = createSelector( + model, + Settings.isolated({}), + selection => { + if (selection.kind === "assignment") selected = selection; + }, + { thinkingLevel: null }, + ); + await Bun.sleep(0); + installTestTheme(); + + selector.handleInput("\n"); + selector.handleInput("\n"); + expect(normalizeRenderedText(selector.render(220).join("\n"))).toContain("Reasoning for Default"); + + selector.handleInput("\x1b"); + + expect(selected).toBeUndefined(); + const actionRendered = normalizeRenderedText(selector.render(220).join("\n")); + expect(actionRendered).toContain("Action for:"); + expect(actionRendered).toContain("Set as DEFAULT (Default)"); + }); + + 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");