Compare commits

...

2 Commits

Author SHA1 Message Date
acp-release-bot[bot]
9957b54882 chore(main): release 0.31.4 (#611)
🤖 I have created a release *beep* *boop*
---


##
[0.31.4](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.3...v0.31.4)
(2026-04-28)


### Bug Fixes

* gate auto mode on model support
([#604](https://github.com/agentclientprotocol/claude-agent-acp/issues/604))
([ec47d34](ec47d3446f))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-04-28 18:10:28 +02:00
davethedev3
ec47d3446f fix: gate auto mode on model support (#604)
## Summary

Closes #594

Only advertise and accept `auto` permission mode when the active SDK
model reports `supportsAutoMode: true`.

This fixes the Haiku path where clients could select `auto`, only for
the SDK to later reject it with `auto mode unavailable for this model`.

Changes included:

- build available permission modes from the active model's `ModelInfo`
- omit `auto` from `availableModes` when `supportsAutoMode` is absent or
false
- clamp `permissions.defaultMode: "auto"` to `default` when the resolved
model cannot use auto mode
- recompute and clamp the current mode on model switch
- reject direct `setSessionMode("auto")` when `auto` is not available
- filter `ExitPlanMode` choices so unavailable modes are not offered or
accepted
- add regression coverage for session creation, model switching, direct
mode setting, and `ExitPlanMode`

## Verification

- `npm run lint`
- `npx prettier --check src/acp-agent.ts
src/tests/acp-agent-settings.test.ts src/tests/acp-agent.test.ts
src/tests/session-config-options.test.ts`
- `npm run build`
- `npm run test:run`
- Manual ACP subprocess smoke test with `ANTHROPIC_MODEL=haiku`
  - `auto` was not advertised
  - `setSessionMode("auto")` was rejected before reaching the SDK

---------

Co-authored-by: dave <dave@mint.lan>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-04-28 16:08:46 +00:00
8 changed files with 743 additions and 58 deletions

View File

@@ -1,3 +1,3 @@
{
".": "0.31.3"
".": "0.31.4"
}

View File

@@ -1,5 +1,12 @@
# Changelog
## [0.31.4](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.3...v0.31.4) (2026-04-28)
### Bug Fixes
* gate auto mode on model support ([#604](https://github.com/agentclientprotocol/claude-agent-acp/issues/604)) ([ec47d34](https://github.com/agentclientprotocol/claude-agent-acp/commit/ec47d3446fa22e5895621f57c0a4497dbe044505))
## [0.31.3](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.2...v0.31.3) (2026-04-28)

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.31.3",
"version": "0.31.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.31.3",
"version": "0.31.4",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.21.0",

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.31.3",
"version": "0.31.4",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",

View File

@@ -1309,7 +1309,7 @@ export class ClaudeAcpAgent implements Agent {
// effort changes and effort changes induced by a model switch go through
// the same path.
await this.applyConfigOptionValue(session, params.configId, resolvedValue);
await this.applyConfigOptionValue(params.sessionId, session, params.configId, resolvedValue);
return { configOptions: session.configOptions };
}
@@ -1326,8 +1326,17 @@ export class ClaudeAcpAgent implements Agent {
default:
throw new Error("Invalid Mode");
}
const session = this.sessions[sessionId];
if (!session) {
throw new Error("Session not found");
}
if (!session.modes.availableModes.some((mode) => mode.id === modeId)) {
throw new Error(`Mode ${modeId} is not available in this session`);
}
try {
await this.sessions[sessionId].query.setPermissionMode(modeId);
await session.query.setPermissionMode(modeId);
} catch (error) {
if (error instanceof Error) {
if (!error.message) {
@@ -1397,7 +1406,7 @@ export class ClaudeAcpAgent implements Agent {
}
if (toolName === "ExitPlanMode") {
const options = [
const optionsAll = [
{ kind: "allow_always", name: 'Yes, and use "auto" mode', optionId: "auto" },
{
kind: "allow_always",
@@ -1408,12 +1417,20 @@ export class ClaudeAcpAgent implements Agent {
{ kind: "reject_once", name: "No, keep planning", optionId: "plan" },
];
if (ALLOW_BYPASS) {
options.unshift({
optionsAll.unshift({
kind: "allow_always",
name: "Yes, and bypass permissions",
optionId: "bypassPermissions",
});
}
// Filter against the session's currently-advertised modes so we never
// present options the active model can't honor (e.g. `auto` on Haiku).
// `bypassPermissions` is already covered by `availableModes` via
// `buildAvailableModes`/`ALLOW_BYPASS`. The `plan` option is a
// "keep planning" reject path; it's always present in `availableModes`.
const options = optionsAll.filter((o) =>
session.modes.availableModes.some((m) => m.id === o.optionId),
);
const response = await this.client.requestPermission({
options,
@@ -1432,27 +1449,30 @@ export class ClaudeAcpAgent implements Agent {
if (signal.aborted || response.outcome?.outcome === "cancelled") {
throw new Error("Tool use aborted");
}
const selectedMode =
response.outcome?.outcome === "selected" ? response.outcome.optionId : undefined;
const selectedModeWasOffered = options.some((option) => option.optionId === selectedMode);
if (
response.outcome?.outcome === "selected" &&
(response.outcome.optionId === "default" ||
response.outcome.optionId === "acceptEdits" ||
response.outcome.optionId === "auto" ||
response.outcome.optionId === "bypassPermissions")
selectedModeWasOffered &&
(selectedMode === "default" ||
selectedMode === "acceptEdits" ||
selectedMode === "auto" ||
selectedMode === "bypassPermissions")
) {
await this.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "current_mode_update",
currentModeId: response.outcome.optionId,
currentModeId: selectedMode,
},
});
await this.updateConfigOption(sessionId, "mode", response.outcome.optionId);
await this.updateConfigOption(sessionId, "mode", selectedMode);
return {
behavior: "allow",
updatedInput: toolInput,
updatedPermissions: suggestions ?? [
{ type: "setMode", mode: response.outcome.optionId, destination: "session" },
{ type: "setMode", mode: selectedMode, destination: "session" },
],
};
} else {
@@ -1550,7 +1570,7 @@ export class ClaudeAcpAgent implements Agent {
const session = this.sessions[sessionId];
if (!session) return;
await this.applyConfigOptionValue(session, configId, value);
await this.applyConfigOptionValue(sessionId, session, configId, value);
await this.client.sessionUpdate({
sessionId,
@@ -1562,13 +1582,16 @@ export class ClaudeAcpAgent implements Agent {
}
private async applyConfigOptionValue(
sessionId: string,
session: Session,
configId: string,
value: string,
): Promise<void> {
// Sync top-level session state
if (configId === "mode") {
session.modes = { ...session.modes, currentModeId: value };
session.configOptions = session.configOptions.map((o) =>
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
);
} else if (configId === "model") {
if (session.models.currentModelId !== value) {
// The cached context window was learned for the previous model; reset
@@ -1578,10 +1601,38 @@ export class ClaudeAcpAgent implements Agent {
session.contextWindowSize = inferContextWindowFromModel(value) ?? DEFAULT_CONTEXT_WINDOW;
}
session.models = { ...session.models, currentModelId: value };
}
// Update configOptions
if (configId === "model") {
// Recompute availableModes for the new model and clamp the current
// mode if the SDK no longer offers it (today: "auto" on Haiku).
// `ModelInfo.supportsAutoMode` is the canonical SDK signal.
const newModelInfo = session.modelInfos.find((m) => m.value === value);
const newAvailableModes = buildAvailableModes(newModelInfo);
// Capture BEFORE mutating session.modes so the log message reflects
// the invalidated mode rather than "default".
const previousModeId = session.modes.currentModeId;
let modeDowngraded = false;
if (!newAvailableModes.some((m) => m.id === previousModeId)) {
session.modes = {
availableModes: newAvailableModes,
currentModeId: "default",
};
try {
await session.query.setPermissionMode("default");
} catch (err) {
// Failing the entire model switch over a bookkeeping sync error is
// worse UX than logging and continuing; the user explicitly asked
// to change models. The next setPermissionMode from the user will
// either succeed or surface a fresh error.
this.logger.error(
`Failed to sync permissionMode to "default" after model switch invalidated "${previousModeId}":`,
err,
);
}
modeDowngraded = true;
} else {
session.modes = { ...session.modes, availableModes: newAvailableModes };
}
// Rebuild config options since effort levels depend on the selected model
const effortOpt = session.configOptions.find((o) => o.id === "effort");
const currentEffort =
@@ -1602,6 +1653,22 @@ export class ClaudeAcpAgent implements Agent {
effortLevel: newEffort as Settings["effortLevel"],
});
}
// Emit current_mode_update only after session.modes AND
// session.configOptions have been fully reconciled. This way, a failure
// in the configOptions/effort rebuild above can't leave the client with
// a clamped currentModeId but stale configOptions, and the notification
// still precedes the caller's config_option_update so order-sensitive
// clients update currentModeId before re-rendering the option list.
if (modeDowngraded) {
await this.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "current_mode_update",
currentModeId: "default",
},
});
}
} else {
session.configOptions = session.configOptions.map((o) =>
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
@@ -1873,44 +1940,45 @@ export class ClaudeAcpAgent implements Agent {
this.logger,
);
const availableModes = [
{
id: "auto",
name: "Auto",
description: "Use a model classifier to approve/deny permission prompts",
},
{
id: "default",
name: "Default",
description: "Standard behavior, prompts for dangerous operations",
},
{
id: "acceptEdits",
name: "Accept Edits",
description: "Auto-accept file edit operations",
},
{
id: "plan",
name: "Plan Mode",
description: "Planning mode, no actual tool execution",
},
{
id: "dontAsk",
name: "Don't Ask",
description: "Don't prompt for permissions, deny if not pre-approved",
},
];
// Only works in non-root mode
if (ALLOW_BYPASS) {
availableModes.push({
id: "bypassPermissions",
name: "Bypass Permissions",
description: "Bypass all permission checks",
});
// Gate `auto` (and future model-specific modes) on the resolved model's
// `ModelInfo`. See `buildAvailableModes` for the canonical SDK signal.
const currentModelInfo = initializationResult.models.find(
(m) => m.value === models.currentModelId,
);
const availableModes = buildAvailableModes(currentModelInfo);
// Clamp `permissionMode` if the resolved session does not offer it. The
// common case is `permissions.defaultMode: "auto"` resolving to a model
// that does not support auto mode (e.g. Haiku); without this clamp the
// SDK would later throw `"auto mode unavailable for this model"` from
// `setPermissionMode`. Keep `permissionMode` as the resolved user intent
// (matches what was passed into `options.permissionMode` above) and use
// `effectiveMode` for the post-clamp value the session actually runs in.
let effectiveMode: PermissionMode = permissionMode;
if (!availableModes.some((m) => m.id === effectiveMode)) {
if (effectiveMode === "auto") {
this.logger.error(
`permissions.defaultMode "auto" is not available for model ` +
`"${models.currentModelId}"; falling back to "default".`,
);
} else {
this.logger.error(
`permissions.defaultMode "${effectiveMode}" is not available in ` +
`this session; falling back to "default".`,
);
}
effectiveMode = "default";
// Sync the SDK so it doesn't keep "auto" cached internally. Wrapped in
// try/catch since failing here would abort session creation entirely.
try {
await q.setPermissionMode("default");
} catch (err) {
this.logger.error("Failed to sync clamped permissionMode to SDK:", err);
}
}
const modes = {
currentModeId: permissionMode,
currentModeId: effectiveMode,
availableModes,
};
@@ -2053,6 +2121,58 @@ function createEnvForGateway(gatewayMeta?: GatewayAuthMeta) {
};
}
/**
* Build the list of permission modes the agent will advertise for the given
* model. `auto` is gated by `ModelInfo.supportsAutoMode === true`, which is
* the SDK's model-level availability signal. `undefined`/`false` both exclude
* `auto`. `bypassPermissions` is still gated by `ALLOW_BYPASS`.
*/
function buildAvailableModes(modelInfo: ModelInfo | undefined): SessionModeState["availableModes"] {
const modes: SessionModeState["availableModes"] = [];
// Only advertise "auto" when the SDK reports the model supports it.
if (modelInfo?.supportsAutoMode === true) {
modes.push({
id: "auto",
name: "Auto",
description: "Use a model classifier to approve/deny permission prompts",
});
}
modes.push(
{
id: "default",
name: "Default",
description: "Standard behavior, prompts for dangerous operations",
},
{
id: "acceptEdits",
name: "Accept Edits",
description: "Auto-accept file edit operations",
},
{
id: "plan",
name: "Plan Mode",
description: "Planning mode, no actual tool execution",
},
{
id: "dontAsk",
name: "Don't Ask",
description: "Don't prompt for permissions, deny if not pre-approved",
},
);
if (ALLOW_BYPASS) {
modes.push({
id: "bypassPermissions",
name: "Bypass Permissions",
description: "Bypass all permission checks",
});
}
return modes;
}
function buildConfigOptions(
modes: SessionModeState,
models: SessionModelState,

View File

@@ -201,6 +201,167 @@ describe("ClaudeAcpAgent settings", () => {
expect(response.models.currentModelId).toBe("claude-sonnet-4-5");
});
describe("auto mode availability per model", () => {
function mockQueryWithModels(models: any[]): {
getCapturedOptions: () => any;
setModelSpy: ReturnType<typeof vi.fn>;
setPermissionModeSpy: ReturnType<typeof vi.fn>;
} {
let capturedOptions: any;
const setModelSpy = vi.fn();
const setPermissionModeSpy = vi.fn();
querySpy.mockImplementation(({ options }: any) => {
capturedOptions = options;
return {
initializationResult: async () => ({ models }),
setModel: setModelSpy,
setPermissionMode: setPermissionModeSpy,
supportedCommands: async () => [],
} as any;
});
return {
getCapturedOptions: () => capturedOptions,
setModelSpy,
setPermissionModeSpy,
};
}
it("omits `auto` from availableModes when the resolved model lacks supportsAutoMode", async () => {
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
mockQueryWithModels([
{
value: "claude-haiku-4-5",
displayName: "Claude Haiku",
description: "Fast",
// supportsAutoMode intentionally omitted
},
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
cwd: projectDir,
mcpServers: [],
_meta: { disableBuiltInTools: true },
});
const modeIds: string[] = response.modes.availableModes.map((m: any) => m.id);
expect(modeIds).not.toContain("auto");
expect(modeIds).toEqual(
expect.arrayContaining(["default", "acceptEdits", "plan", "dontAsk"]),
);
});
it("includes `auto` when the resolved model has supportsAutoMode: true", async () => {
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
mockQueryWithModels([
{
value: "claude-opus-4-5",
displayName: "Claude Opus",
description: "Most capable",
supportsAutoMode: true,
},
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
cwd: projectDir,
mcpServers: [],
_meta: { disableBuiltInTools: true },
});
const modeIds: string[] = response.modes.availableModes.map((m: any) => m.id);
expect(modeIds).toContain("auto");
});
it("clamps permissions.defaultMode='auto' to 'default' on a model that lacks supportsAutoMode", async () => {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({ permissions: { defaultMode: "auto" } }),
);
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
const { getCapturedOptions, setPermissionModeSpy } = mockQueryWithModels([
{
value: "claude-haiku-4-5",
displayName: "Claude Haiku",
description: "Fast",
// supportsAutoMode intentionally omitted
},
]);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
cwd: projectDir,
mcpServers: [],
_meta: { disableBuiltInTools: true },
});
// Options.permissionMode is built before init resolves, so it still
// carries the user-typed value; the SDK was synced via
// setPermissionMode after we discovered the model can't honor it.
expect(getCapturedOptions().permissionMode).toBe("auto");
expect(setPermissionModeSpy).toHaveBeenCalledWith("default");
expect(response.modes.currentModeId).toBe("default");
expect(response.modes.availableModes.map((m: any) => m.id)).not.toContain("auto");
// A descriptive warning was logged so operators see the clamp.
const messages = errorSpy.mock.calls.map((c) => c.join(" "));
expect(messages.some((m) => m.includes("auto") && m.includes("claude-haiku-4-5"))).toBe(
true,
);
} finally {
errorSpy.mockRestore();
}
});
it("does not clamp permissions.defaultMode='auto' on a model that supports auto", async () => {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({ permissions: { defaultMode: "auto" } }),
);
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
const { getCapturedOptions, setPermissionModeSpy } = mockQueryWithModels([
{
value: "claude-opus-4-5",
displayName: "Claude Opus",
description: "Most capable",
supportsAutoMode: true,
},
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
cwd: projectDir,
mcpServers: [],
_meta: { disableBuiltInTools: true },
});
expect(getCapturedOptions().permissionMode).toBe("auto");
expect(setPermissionModeSpy).not.toHaveBeenCalled();
expect(response.modes.currentModeId).toBe("auto");
});
});
it("resolves model aliases like opus[1m] to the correct model", async () => {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),

View File

@@ -2502,7 +2502,12 @@ describe("usage_update computation", () => {
const session = agent.sessions["test-session"];
expect(session.contextWindowSize).toBe(200000);
await (agent as any).applyConfigOptionValue(session, "model", "claude-opus-4-6-1m");
await (agent as any).applyConfigOptionValue(
"test-session",
session,
"model",
"claude-opus-4-6-1m",
);
expect(session.contextWindowSize).toBe(1000000);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
@@ -2587,7 +2592,12 @@ describe("usage_update computation", () => {
session.models = { ...session.models, currentModelId: "claude-opus-4-6-1m" };
// User flips the selector to a 200k model.
await (agent as any).applyConfigOptionValue(session, "model", "claude-sonnet-4-6");
await (agent as any).applyConfigOptionValue(
"test-session",
session,
"model",
"claude-sonnet-4-6",
);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });

View File

@@ -811,4 +811,391 @@ describe("session config options", () => {
);
});
});
describe("auto mode availability per model", () => {
/**
* Augment the session populated by `populateSession()` with a Haiku entry
* (no `supportsAutoMode`), Opus + Sonnet entries with `supportsAutoMode:
* true`, and seed `availableModes` so it currently includes `auto`. This
* exercises the per-model recomputation done by `applyConfigOptionValue`
* on a model switch.
*/
function setupHaikuOpusSession(currentModeId: string = "default") {
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
session.modelInfos = [
{
value: "claude-opus-4-5",
displayName: "Claude Opus",
description: "Most capable",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high"],
supportsAutoMode: true,
},
{
value: "claude-sonnet-4-5",
displayName: "Claude Sonnet",
description: "Balanced",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high"],
supportsAutoMode: true,
},
{
value: "claude-haiku-4-5",
displayName: "Claude Haiku",
description: "Fast",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high"],
// supportsAutoMode intentionally omitted
},
];
session.models = {
currentModelId: "claude-opus-4-5",
availableModels: [
{ modelId: "claude-opus-4-5", name: "Claude Opus", description: "Most capable" },
{ modelId: "claude-sonnet-4-5", name: "Claude Sonnet", description: "Balanced" },
{ modelId: "claude-haiku-4-5", name: "Claude Haiku", description: "Fast" },
],
};
session.modes = {
currentModeId,
availableModes: [
{
id: "auto",
name: "Auto",
description: "Use a model classifier to approve/deny permission prompts",
},
{
id: "default",
name: "Default",
description: "Standard behavior, prompts for dangerous operations",
},
{
id: "acceptEdits",
name: "Accept Edits",
description: "Auto-accept file edit operations",
},
{ id: "plan", name: "Plan Mode", description: "Planning mode" },
{
id: "dontAsk",
name: "Don't Ask",
description: "Don't prompt for permissions, deny if not pre-approved",
},
],
};
// Reflect the seeded availableModes/availableModels in configOptions so
// the pre-state matches what `createSession` would have produced for
// Opus, and `setSessionConfigOption` validation can accept the seeded
// model ids (notably the new Haiku entry).
session.configOptions = session.configOptions.map((o: any) => {
if (o.id === "mode") {
return {
...o,
currentValue: currentModeId,
options: session.modes.availableModes.map((m: any) => ({
value: m.id,
name: m.name,
description: m.description,
})),
};
}
if (o.id === "model") {
return {
...o,
currentValue: session.models.currentModelId,
options: session.models.availableModels.map((m: any) => ({
value: m.modelId,
name: m.name,
description: m.description,
})),
};
}
return o;
});
return session;
}
beforeEach(() => {
populateSession();
});
it("drops `auto` from available modes when switching to Haiku", async () => {
setupHaikuOpusSession("default");
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
modelId: "claude-haiku-4-5",
});
const configUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(configUpdate).toBeDefined();
const modeOption = (configUpdate?.update as any).configOptions.find(
(o: any) => o.id === "mode",
);
expect(modeOption).toBeDefined();
const modeValues = modeOption.options.map((o: any) => o.value);
expect(modeValues).not.toContain("auto");
expect(modeValues).toEqual(
expect.arrayContaining(["default", "acceptEdits", "plan", "dontAsk"]),
);
});
it("clamps to `default` and emits current_mode_update when Opus(auto) → Haiku", async () => {
setupHaikuOpusSession("auto");
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
modelId: "claude-haiku-4-5",
});
// SDK was synced to "default".
expect(setPermissionModeSpy).toHaveBeenCalledWith("default");
// current_mode_update was emitted before config_option_update so a
// client applying notifications in order observes the mode change
// before re-rendering the config-option list.
const modeUpdateIdx = sessionUpdates.findIndex(
(n) => n.update.sessionUpdate === "current_mode_update",
);
const configUpdateIdx = sessionUpdates.findIndex(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(modeUpdateIdx).toBeGreaterThanOrEqual(0);
expect(configUpdateIdx).toBeGreaterThanOrEqual(0);
expect(modeUpdateIdx).toBeLessThan(configUpdateIdx);
expect((sessionUpdates[modeUpdateIdx].update as any).currentModeId).toBe("default");
// configOptions reflect the clamped mode.
const modeOption = (sessionUpdates[configUpdateIdx].update as any).configOptions.find(
(o: any) => o.id === "mode",
);
expect(modeOption.currentValue).toBe("default");
});
it("re-adds `auto` when switching from Haiku back to Opus", async () => {
const session = setupHaikuOpusSession("default");
// Pretend Haiku is the current model with no `auto`.
session.models.currentModelId = "claude-haiku-4-5";
session.modes.availableModes = session.modes.availableModes.filter(
(m: any) => m.id !== "auto",
);
const modeOpt = session.configOptions.find((o: any) => o.id === "mode");
modeOpt.options = session.modes.availableModes.map((m: any) => ({
value: m.id,
name: m.name,
description: m.description,
}));
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
modelId: "claude-opus-4-5",
});
const configUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(configUpdate).toBeDefined();
const modeOption = (configUpdate?.update as any).configOptions.find(
(o: any) => o.id === "mode",
);
const modeValues = modeOption.options.map((o: any) => o.value);
expect(modeValues).toContain("auto");
// The current mode ("default") is still valid on Opus, so no
// current_mode_update should have been emitted by the model switch.
const modeUpdates = sessionUpdates.filter(
(n) => n.update.sessionUpdate === "current_mode_update",
);
expect(modeUpdates).toHaveLength(0);
});
it("preserves the current mode when it remains valid after a model switch", async () => {
setupHaikuOpusSession("plan");
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
modelId: "claude-haiku-4-5",
});
// `plan` is in availableModes for both Opus and Haiku, so no clamp.
expect(setPermissionModeSpy).not.toHaveBeenCalledWith("default");
const modeUpdates = sessionUpdates.filter(
(n) => n.update.sessionUpdate === "current_mode_update",
);
expect(modeUpdates).toHaveLength(0);
const configUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "config_option_update",
);
const modeOption = (configUpdate?.update as any).configOptions.find(
(o: any) => o.id === "mode",
);
expect(modeOption.currentValue).toBe("plan");
});
it("clamps mode and emits current_mode_update via setSessionConfigOption(model)", async () => {
// Mirrors the unstable_setSessionModel(auto → Haiku) test, but goes
// through the request/response API. The `current_mode_update` side
// effect must still fire so clients learn about the clamp regardless of
// which entry point triggered the model switch.
setupHaikuOpusSession("auto");
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-haiku-4-5",
});
expect(setPermissionModeSpy).toHaveBeenCalledWith("default");
const modeUpdates = sessionUpdates.filter(
(n) => n.update.sessionUpdate === "current_mode_update",
);
expect(modeUpdates).toHaveLength(1);
expect((modeUpdates[0].update as any).currentModeId).toBe("default");
// setSessionConfigOption is a request/response API: it returns the new
// configOptions in the response rather than emitting a
// config_option_update notification.
const configUpdates = sessionUpdates.filter(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(configUpdates).toHaveLength(0);
const modeOption = response.configOptions.find((o: any) => o.id === "mode");
expect(modeOption).toBeDefined();
expect((modeOption as any).currentValue).toBe("default");
expect((modeOption as any).options.map((o: any) => o.value)).not.toContain("auto");
});
it("rejects direct setSessionMode to `auto` when the active model does not offer it", async () => {
const session = setupHaikuOpusSession("default");
session.models.currentModelId = "claude-haiku-4-5";
session.modes.availableModes = session.modes.availableModes.filter(
(mode: any) => mode.id !== "auto",
);
await expect(agent.setSessionMode({ sessionId: SESSION_ID, modeId: "auto" })).rejects.toThrow(
"Mode auto is not available in this session",
);
expect(setPermissionModeSpy).not.toHaveBeenCalledWith("auto");
expect(sessionUpdates).toHaveLength(0);
});
});
describe("ExitPlanMode permission options filtered by availableModes", () => {
let capturedPermissionRequest: any;
let permissionResponse: any;
beforeEach(() => {
capturedPermissionRequest = null;
permissionResponse = { outcome: { outcome: "cancelled" } };
// Replace the default mock client with one that captures the
// requestPermission call so we can assert on the offered options.
(agent as any).client = {
sessionUpdate: async (notification: SessionNotification) => {
sessionUpdates.push(notification);
},
requestPermission: async (params: any) => {
capturedPermissionRequest = params;
return permissionResponse;
},
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
};
populateSession();
});
it("omits the `auto` option on a model without supportsAutoMode", async () => {
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
// Haiku-shaped session: availableModes does NOT include `auto`.
session.modes = {
currentModeId: "plan",
availableModes: [
{ id: "default", name: "Default", description: "Standard" },
{ id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" },
{ id: "plan", name: "Plan Mode", description: "Planning mode" },
{ id: "dontAsk", name: "Don't Ask", description: "Deny if not pre-approved" },
],
};
const canUseTool = (agent as any).canUseTool(SESSION_ID);
const signal = new AbortController().signal;
try {
await canUseTool(
"ExitPlanMode",
{ plan: "do stuff" },
{ signal, suggestions: undefined, toolUseID: "toolu_1" },
);
} catch {
// The mock client returns `cancelled`, which makes canUseTool throw.
// We only care about the captured requestPermission options.
}
expect(capturedPermissionRequest).not.toBeNull();
const optionIds = capturedPermissionRequest.options.map((o: any) => o.optionId);
expect(optionIds).not.toContain("auto");
expect(optionIds).toEqual(expect.arrayContaining(["default", "acceptEdits", "plan"]));
});
it("denies a selected `auto` option if the client did not receive that option", async () => {
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
session.modes = {
currentModeId: "plan",
availableModes: [
{ id: "default", name: "Default", description: "Standard" },
{ id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" },
{ id: "plan", name: "Plan Mode", description: "Planning mode" },
{ id: "dontAsk", name: "Don't Ask", description: "Deny if not pre-approved" },
],
};
permissionResponse = { outcome: { outcome: "selected", optionId: "auto" } };
const canUseTool = (agent as any).canUseTool(SESSION_ID);
const result = await canUseTool(
"ExitPlanMode",
{ plan: "do stuff" },
{ signal: new AbortController().signal, suggestions: undefined, toolUseID: "toolu_2" },
);
expect(capturedPermissionRequest).not.toBeNull();
const optionIds = capturedPermissionRequest.options.map((o: any) => o.optionId);
expect(optionIds).not.toContain("auto");
expect(result.behavior).toBe("deny");
expect(sessionUpdates).toHaveLength(0);
});
it("includes the `auto` option on a model with supportsAutoMode", async () => {
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
session.modes = {
currentModeId: "plan",
availableModes: [
{ id: "auto", name: "Auto", description: "Use a model classifier" },
{ id: "default", name: "Default", description: "Standard" },
{ id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" },
{ id: "plan", name: "Plan Mode", description: "Planning mode" },
{ id: "dontAsk", name: "Don't Ask", description: "Deny if not pre-approved" },
],
};
const canUseTool = (agent as any).canUseTool(SESSION_ID);
const signal = new AbortController().signal;
try {
await canUseTool(
"ExitPlanMode",
{ plan: "do stuff" },
{ signal, suggestions: undefined, toolUseID: "toolu_3" },
);
} catch {
// mock returns cancelled
}
expect(capturedPermissionRequest).not.toBeNull();
const optionIds = capturedPermissionRequest.options.map((o: any) => o.optionId);
expect(optionIds).toContain("auto");
});
});
});