Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<parent>/<repo>.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).
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion packages/coding-agent/src/config/model-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
42 changes: 42 additions & 0 deletions packages/coding-agent/test/acp-builtins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,48 @@ describe("ACP builtin slash commands", () => {
expect(output[0]).toContain("/model <target> <model[:effort]>");
});

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 = () => [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down
133 changes: 132 additions & 1 deletion packages/coding-agent/test/model-selector-role-badge-thinking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}`);
});

Expand Down Expand Up @@ -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");
Expand Down
Loading