mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
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>
This commit is contained in:
224
src/acp-agent.ts
224
src/acp-agent.ts
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user