feat: Update to claude agent sdk v0.3.220 (#921)

- **feat: Update to claude-agent-sdk 0.3.220**
- **Explain unavailable Fast mode reasons**
This commit is contained in:
Ben Brandt
2026-07-27 11:38:15 +02:00
committed by GitHub
parent 8663170f18
commit 4c7b897183
5 changed files with 294 additions and 77 deletions

130
package-lock.json generated
View File

@@ -10,22 +10,22 @@
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "1.3.0",
"@anthropic-ai/claude-agent-sdk": "0.3.219",
"@anthropic-ai/claude-agent-sdk": "0.3.220",
"zod": "^3.25.0 || ^4.0.0"
},
"bin": {
"claude-agent-acp": "dist/index.js"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.114.0",
"@anthropic-ai/sdk": "0.115.0",
"@eslint/js": "10.0.1",
"@tsconfig/node22": "22.0.5",
"@types/node": "26.1.1",
"@typescript-eslint/eslint-plugin": "8.65.0",
"@typescript-eslint/parser": "8.65.0",
"eslint": "10.7.0",
"eslint": "10.8.0",
"eslint-config-prettier": "10.1.8",
"globals": "17.7.0",
"globals": "17.8.0",
"prettier": "3.9.6",
"ts-node": "10.9.2",
"typescript": "6.0.3",
@@ -45,22 +45,22 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.3.219",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.219.tgz",
"integrity": "sha512-dJjzKxoyCQSEEEo9a0g3wBQprRMCpsqjmsNOZaGOWw+W+JHZsolDBhoYfoZ9BA8EdwgaJmbQUvhkAZjROUsBnQ==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.220.tgz",
"integrity": "sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.219",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.219",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.219",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.219",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.219",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.219",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.219",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.219"
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.220",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.220",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.220",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.220",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.220",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.220",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.220",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.220"
},
"peerDependencies": {
"@anthropic-ai/sdk": ">=0.93.0",
@@ -69,9 +69,9 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
"version": "0.3.219",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.219.tgz",
"integrity": "sha512-TQhGAlbMsOGXi03dwf4nD274Lc5BOJg4QFJTuXcQyhLhx0hUqzxCmtvpXt6S70K6yl1Zgc+2n5W/r1Dgt8G8iw==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.220.tgz",
"integrity": "sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q==",
"cpu": [
"arm64"
],
@@ -82,9 +82,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
"version": "0.3.219",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.219.tgz",
"integrity": "sha512-zA/wHN+os4yqXASXJxvGGNSD1p7LZ6BMN71CCphoy5u+d6IpNOCtATnZwINEc2fGFHpv/QDci6jhywJ2O9Flbw==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.220.tgz",
"integrity": "sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA==",
"cpu": [
"x64"
],
@@ -95,9 +95,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
"version": "0.3.219",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.219.tgz",
"integrity": "sha512-lizasky9Xj0ouYrz/Akst573Sm8NO3k/0iGCeCDBoQKLnONbVFwc7sHq9x426Pfcllgb4C2Xyc79/1iOALh/9g==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.220.tgz",
"integrity": "sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA==",
"cpu": [
"arm64"
],
@@ -111,9 +111,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
"version": "0.3.219",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.219.tgz",
"integrity": "sha512-yiZo+UBCp42FAYqSakgVj/g6LfTH0AklWqIZNEHfVDPaofledXdSu0QJM5yScWNg0hVMaNRXPt0DmuS1oIWW6g==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.220.tgz",
"integrity": "sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ==",
"cpu": [
"arm64"
],
@@ -127,9 +127,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
"version": "0.3.219",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.219.tgz",
"integrity": "sha512-yzXhEsT5XcKa8Dc3sM4D1CQ92/6YGmsBbAhRAdw0PYNkG8x6wbHqfzYZS491Bdjry4tSWemNFtGPNkHiWVo3Hg==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.220.tgz",
"integrity": "sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w==",
"cpu": [
"x64"
],
@@ -143,9 +143,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
"version": "0.3.219",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.219.tgz",
"integrity": "sha512-IhfG57/XorWOQTB6BVdjEFPGH0LwOWO6HnAi+FNuCeiC+XRFl9QI9AKY43opS0gbvJWRobZO865tHX5j7Ou+ow==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.220.tgz",
"integrity": "sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA==",
"cpu": [
"x64"
],
@@ -159,9 +159,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
"version": "0.3.219",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.219.tgz",
"integrity": "sha512-L/cAT27t3FGP4GlJ16WpXMtaeqDu9olpOlIaqqmjElQp5Lknd8alJ3NIY/SlRAtHDvm4WXug74wnfBOSlwppkg==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.220.tgz",
"integrity": "sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA==",
"cpu": [
"arm64"
],
@@ -172,9 +172,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
"version": "0.3.219",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.219.tgz",
"integrity": "sha512-Fl8Rb9K64jbpC/omIF9y828LIvwRmm1zhZgO8zjalTpjZR/0C31Kl+5FwAS9KkXXUXwmdHSY+82e9dyvBrbIRw==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.220.tgz",
"integrity": "sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw==",
"cpu": [
"x64"
],
@@ -185,9 +185,9 @@
]
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.114.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.114.0.tgz",
"integrity": "sha512-zRFGTVMFEm77gt70Q0B+CDRKa9AcguydCcp6bD/dXWv8UkfsVFqCbaqU8+4B/pob3+Vy434LgtrBmwSvxfKr3g==",
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.115.0.tgz",
"integrity": "sha512-BJrFIVyjNuU8lfDyIJTvlRYzgQg+zEl78BxE7fq8esULsGz9IRQvGtW5spq3tydmtjQb/GFdooKGdGsetpx+lQ==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1",
@@ -306,9 +306,9 @@
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz",
"integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==",
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz",
"integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -377,9 +377,9 @@
}
},
"node_modules/@hono/node-server": {
"version": "1.19.15",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.15.tgz",
"integrity": "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg==",
"version": "1.19.17",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz",
"integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==",
"license": "MIT",
"peer": true,
"engines": {
@@ -1741,9 +1741,9 @@
}
},
"node_modules/eslint": {
"version": "10.7.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz",
"integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==",
"version": "10.8.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz",
"integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==",
"dev": true,
"license": "MIT",
"workspaces": [
@@ -1753,7 +1753,7 @@
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
"@eslint/config-array": "^0.23.5",
"@eslint/config-helpers": "^0.6.0",
"@eslint/config-helpers": "^0.7.0",
"@eslint/core": "^1.2.1",
"@eslint/plugin-kit": "^0.7.2",
"@humanfs/node": "^0.16.6",
@@ -1777,7 +1777,7 @@
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"minimatch": "^10.2.4",
"minimatch": "^10.2.5",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3"
},
@@ -2069,9 +2069,9 @@
}
},
"node_modules/express-rate-limit": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz",
"integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==",
"version": "8.6.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz",
"integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -2320,9 +2320,9 @@
}
},
"node_modules/globals": {
"version": "17.7.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz",
"integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==",
"version": "17.8.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz",
"integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2447,9 +2447,9 @@
"peer": true
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz",
"integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==",
"license": "MIT",
"peer": true,
"engines": {
@@ -3193,9 +3193,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.22",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz",
"integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==",
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"dev": true,
"funding": [
{

View File

@@ -64,19 +64,19 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "1.3.0",
"@anthropic-ai/claude-agent-sdk": "0.3.219",
"@anthropic-ai/claude-agent-sdk": "0.3.220",
"zod": "^3.25.0 || ^4.0.0"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.114.0",
"@anthropic-ai/sdk": "0.115.0",
"@eslint/js": "10.0.1",
"@tsconfig/node22": "22.0.5",
"@types/node": "26.1.1",
"@typescript-eslint/eslint-plugin": "8.65.0",
"@typescript-eslint/parser": "8.65.0",
"eslint": "10.7.0",
"eslint": "10.8.0",
"eslint-config-prettier": "10.1.8",
"globals": "17.7.0",
"globals": "17.8.0",
"prettier": "3.9.6",
"ts-node": "10.9.2",
"typescript": "6.0.3",

View File

@@ -60,6 +60,7 @@ import {
CanUseTool,
deleteSession,
EffortLevel,
FastModeDisabledReason,
FastModeState,
getSessionInfo,
getSessionMessages,
@@ -438,6 +439,13 @@ type Session = {
* user's intent so it persists across model switches; the Fast mode config
* option is only surfaced while the selected model supports it. */
fastModeEnabled: boolean;
/** Why the SDK currently can't serve Fast mode, when the reason is one worth
* telling the user about (see {@link FAST_MODE_UNAVAILABLE_EXPLANATIONS} —
* routine states like the SDK's own opt-in requirement normalize to
* `undefined`). Refreshed from every `fast_mode_disabled_reason` the SDK
* reports on `system`/init and user-turn `result`s; surfaced in the Fast mode
* option's description so a toggle that snaps back off explains itself. */
fastModeDisabledReason?: FastModeDisabledReason;
abortController: AbortController;
/** Signal the consumer races `query.next()` against. Aborted by cancel()
* (after a grace period) to force the active turn to settle "cancelled" when
@@ -2501,7 +2509,12 @@ export class ClaudeAcpAgent {
// A fresh `system`/init (e.g. after reinitialize) can carry an
// updated Fast mode state; reconcile it with what we seeded at
// session creation.
await this.syncFastModeState(message.session_id, session, message.fast_mode_state);
await this.syncFastModeState(
message.session_id,
session,
message.fast_mode_state,
message.fast_mode_disabled_reason,
);
break;
case "status": {
// These banners count as delivered text (via sendUpdate), so
@@ -3011,7 +3024,12 @@ export class ClaudeAcpAgent {
// an autonomous cycle's state lands on the next user turn's
// result. Runs even when the turn errors or was cancelled.
if (!isAutonomousResult) {
await this.syncFastModeState(params.sessionId, session, message.fast_mode_state);
await this.syncFastModeState(
params.sessionId,
session,
message.fast_mode_state,
message.fast_mode_disabled_reason,
);
}
// A user-turn result needs an active turn so its stop reason is
@@ -4902,6 +4920,14 @@ export class ClaudeAcpAgent {
session.modes = { ...session.modes, availableModes: newAvailableModes };
}
// `model_not_allowed` described the model we just left, so it must not
// follow us onto the new one; the remaining reasons are account- or
// environment-scoped and stay true across a switch. Either way the next
// init/result report refreshes this.
if (session.fastModeDisabledReason === "model_not_allowed") {
session.fastModeDisabledReason = undefined;
}
// Rebuild config options since effort levels depend on the selected model
const effortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
const currentEffort =
@@ -4920,6 +4946,7 @@ export class ClaudeAcpAgent {
supported: newModelInfo?.supportsFastMode ?? false,
enabled: session.fastModeEnabled,
useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
disabledReason: session.fastModeDisabledReason,
},
);
@@ -5018,6 +5045,7 @@ export class ClaudeAcpAgent {
const refreshed = createFastModeConfigOption(
enabled,
clientSupportsBooleanConfigOptions(this.clientCapabilities),
session.fastModeDisabledReason,
);
session.configOptions = session.configOptions.map((o) =>
o.id === FAST_MODE_CONFIG_ID ? refreshed : o,
@@ -5051,11 +5079,19 @@ export class ClaudeAcpAgent {
* here).
* - `cooldown`: a transient suspension of an already-enabled fast mode.
* Leave the toggle as-is rather than flapping it — and never let a stray
* cooldown spuriously enable a toggle the user has off. */
* cooldown spuriously enable a toggle the user has off.
*
* `reason` is the SDK's `fast_mode_disabled_reason`, reported alongside the
* state. Only explainable reasons are retained (see
* {@link normalizeFastModeDisabledReason}), so the comparison below tracks
* exactly what the user can see: a routine `sdk_opt_in_required` report on
* every turn's result can't churn the option, while a real blocker updates
* the description even when the toggle's own value is unchanged. */
private async syncFastModeState(
sessionId: string,
session: Session,
state: FastModeState | undefined,
reason?: FastModeDisabledReason,
): Promise<void> {
if (state === undefined) {
return;
@@ -5067,11 +5103,31 @@ export class ClaudeAcpAgent {
return;
}
const enabled = state === "on";
if (enabled === session.fastModeEnabled) {
// A reason only describes an off state; drop any that rides an `on` report
// so it can't decorate the option the next time fast mode goes off.
const nextReason = enabled ? undefined : normalizeFastModeDisabledReason(reason);
if (enabled === session.fastModeEnabled && nextReason === session.fastModeDisabledReason) {
return;
}
// The user asked for Fast mode and the SDK is telling us it can't serve it.
// The description carries the same explanation, but a toggle silently
// snapping back is the case worth saying out loud once, at the flip.
const explain = session.fastModeEnabled && !enabled && nextReason !== undefined;
session.fastModeEnabled = enabled;
session.fastModeDisabledReason = nextReason;
this.refreshFastModeOption(session, enabled);
if (explain) {
await this.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: `**Fast mode turned off:** ${FAST_MODE_UNAVAILABLE_EXPLANATIONS[nextReason]}.`,
},
},
});
}
await this.client.sessionUpdate({
sessionId,
update: {
@@ -5581,10 +5637,18 @@ export class ClaudeAcpAgent {
const fastModeEnabled =
initializationResult.fast_mode_state !== undefined &&
fastModeStateEnabled(initializationResult.fast_mode_state);
// `fast_mode_disabled_reason` reflects the post-switch model since SDK
// 0.3.219 (the initialize response used to answer from the spawn-time
// model). A fresh SDK session reports `sdk_opt_in_required` — the toggle IS
// the opt-in — which normalizes away, so only real blockers are retained.
const fastModeDisabledReason = fastModeEnabled
? undefined
: normalizeFastModeDisabledReason(initializationResult.fast_mode_disabled_reason);
const fastMode: FastModeOptionState = {
supported: currentModelInfo?.supportsFastMode ?? false,
enabled: fastModeEnabled,
useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
disabledReason: fastModeDisabledReason,
};
const configOptions = buildConfigOptions(
@@ -5660,6 +5724,7 @@ export class ClaudeAcpAgent {
agents,
currentAgent,
fastModeEnabled,
fastModeDisabledReason,
abortController,
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
forwardSubagentText,
@@ -5967,6 +6032,36 @@ export function fastModeStateEnabled(state: FastModeState): boolean {
return state !== "off";
}
/** User-facing explanations for the SDK's `fast_mode_disabled_reason` values
* that a user can act on (or at least wants to know about). Deliberately
* partial — the omitted reasons are not worth surfacing:
* - `sdk_opt_in_required`: every SDK session starts here (the toggle IS the
* opt-in), so it describes the default, not a problem.
* - `preference`: the user turned Fast mode off themselves.
* - `pending`: eligibility is still resolving; the next report supersedes it.
* - `unknown`: nothing meaningful to say.
* Unknown future reasons fall through the same way (open set — the SDK's docs
* say to ignore values you don't handle). */
const FAST_MODE_UNAVAILABLE_EXPLANATIONS: Partial<Record<FastModeDisabledReason, string>> = {
free: "not available on the free plan",
extra_usage_disabled: "requires extra usage to be enabled for this account",
model_not_allowed: "not available for the selected model",
not_first_party: "not available on this API provider",
disabled_by_env: "disabled by environment configuration",
network_error: "eligibility could not be verified (network error)",
};
/** Normalize an SDK-reported `fast_mode_disabled_reason` to the one we retain:
* a reason we have an explanation for, else `undefined`. Keeping only
* explainable reasons means state comparisons (see `syncFastModeState`) track
* exactly what the user can see, so routine reports like
* `sdk_opt_in_required` never churn the config option. */
export function normalizeFastModeDisabledReason(
reason: FastModeDisabledReason | undefined,
): FastModeDisabledReason | undefined {
return reason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[reason] ? reason : undefined;
}
/** Whether the Client advertised support for boolean session config options
* (`session.configOptions.boolean`). Agents MUST only send `type: "boolean"`
* config options to Clients that opt in; otherwise we fall back to a `select`.
@@ -5980,15 +6075,25 @@ export function clientSupportsBooleanConfigOptions(
/** Build the Fast mode config option. When the Client supports boolean config
* options we expose a native `type: "boolean"` toggle; otherwise we degrade to
* a two-value `select` ("on"/"off") so older Clients still get a usable
* control. */
* control.
*
* `disabledReason` (the SDK's `fast_mode_disabled_reason`) is folded into the
* description while the toggle reads off, so a user whose account or provider
* can't serve Fast mode sees why instead of a switch that silently refuses to
* stay on. Ignored while enabled: a reason reported alongside an `on`/`cooldown`
* state isn't blocking anything right now. */
export function createFastModeConfigOption(
enabled: boolean,
useBooleanOption: boolean,
disabledReason?: FastModeDisabledReason,
): SessionConfigOption {
const explanation = enabled
? undefined
: disabledReason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[disabledReason];
const base = {
id: FAST_MODE_CONFIG_ID,
name: "Fast mode",
description: FAST_MODE_DESCRIPTION,
description: explanation ? `${FAST_MODE_DESCRIPTION} — ${explanation}` : FAST_MODE_DESCRIPTION,
category: "model_config",
} as const;
@@ -6031,6 +6136,9 @@ export type FastModeOptionState = {
enabled: boolean;
/** Whether the Client opted into boolean config options. */
useBooleanOption: boolean;
/** Latest explainable `fast_mode_disabled_reason`, folded into the option's
* description while the toggle reads off. */
disabledReason?: FastModeDisabledReason;
};
export function buildConfigOptions(
@@ -6108,7 +6216,13 @@ export function buildConfigOptions(
// option renders as a native boolean toggle for Clients that opted in, and a
// two-value select otherwise.
if (fastMode?.supported) {
options.push(createFastModeConfigOption(fastMode.enabled, fastMode.useBooleanOption));
options.push(
createFastModeConfigOption(
fastMode.enabled,
fastMode.useBooleanOption,
fastMode.disabledReason,
),
);
}
// Only surface the Agent picker when there's a real choice — i.e. the user

View File

@@ -975,6 +975,7 @@ describe("tool conversions", () => {
inference_geo: null,
iterations: null,
output_tokens_details: null,
fallback_credit: null,
speed: null,
},
context_management: null,

View File

@@ -1,11 +1,12 @@
import { describe, it, expect, vi } from "vitest";
import type { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk";
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import type { FastModeDisabledReason, ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import {
buildConfigOptions,
clientSupportsBooleanConfigOptions,
createFastModeConfigOption,
fastModeStateEnabled,
normalizeFastModeDisabledReason,
resolveFastModeEnabled,
FAST_MODE_CONFIG_ID,
FAST_MODE_ON,
@@ -42,6 +43,28 @@ describe("createFastModeConfigOption", () => {
});
});
it("explains a blocking disabled reason while the toggle reads off", () => {
const option = createFastModeConfigOption(false, true, "free");
expect(option.description).toBe(
"Faster responses on supported models — not available on the free plan",
);
});
it("ignores routine and on-state reasons", () => {
// Every SDK session starts at `sdk_opt_in_required` — the toggle IS the
// opt-in, so it must not read as a blocker.
expect(createFastModeConfigOption(false, true, "sdk_opt_in_required").description).toBe(
"Faster responses on supported models",
);
expect(createFastModeConfigOption(false, true, "preference").description).toBe(
"Faster responses on supported models",
);
// A reason riding an `on` state isn't blocking anything right now.
expect(createFastModeConfigOption(true, true, "free").description).toBe(
"Faster responses on supported models",
);
});
it("falls back to an on/off select when the client did not opt in", () => {
const option = createFastModeConfigOption(false, false);
expect(option).toMatchObject({
@@ -104,6 +127,28 @@ describe("fastModeStateEnabled", () => {
});
});
describe("normalizeFastModeDisabledReason", () => {
it("retains only reasons we have an explanation for", () => {
expect(normalizeFastModeDisabledReason("free")).toBe("free");
expect(normalizeFastModeDisabledReason("extra_usage_disabled")).toBe("extra_usage_disabled");
expect(normalizeFastModeDisabledReason("model_not_allowed")).toBe("model_not_allowed");
expect(normalizeFastModeDisabledReason("not_first_party")).toBe("not_first_party");
expect(normalizeFastModeDisabledReason("disabled_by_env")).toBe("disabled_by_env");
expect(normalizeFastModeDisabledReason("network_error")).toBe("network_error");
});
it("drops routine states and unknown values", () => {
expect(normalizeFastModeDisabledReason("sdk_opt_in_required")).toBeUndefined();
expect(normalizeFastModeDisabledReason("preference")).toBeUndefined();
expect(normalizeFastModeDisabledReason("pending")).toBeUndefined();
expect(normalizeFastModeDisabledReason("unknown")).toBeUndefined();
expect(normalizeFastModeDisabledReason(undefined)).toBeUndefined();
expect(
normalizeFastModeDisabledReason("some_future_reason" as FastModeDisabledReason),
).toBeUndefined();
});
});
describe("buildConfigOptions Fast mode", () => {
it("omits the Fast mode option when the model does not support it", () => {
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, [], "default", {
@@ -249,6 +294,7 @@ describe("syncFastModeState (SDK-driven state changes)", () => {
const session = {
query: {},
fastModeEnabled: opts.fastModeEnabled,
fastModeDisabledReason: undefined as FastModeDisabledReason | undefined,
configOptions: opts.withOption
? [createFastModeConfigOption(opts.fastModeEnabled, true)]
: [],
@@ -261,6 +307,7 @@ describe("syncFastModeState (SDK-driven state changes)", () => {
sessionId: string,
session: unknown,
state: string | undefined,
reason?: FastModeDisabledReason,
) => Promise<void>;
}
).syncFastModeState.bind(agent);
@@ -319,10 +366,65 @@ describe("syncFastModeState (SDK-driven state changes)", () => {
await sync(SESSION_ID, session, undefined);
await sync(SESSION_ID, session, "off");
// A routine reason normalizes away, so it's still "unchanged" — an
// `sdk_opt_in_required` on every turn's result must not churn the option.
await sync(SESSION_ID, session, "off", "sdk_opt_in_required");
expect(sessionUpdates).toHaveLength(0);
});
it("explains a blocking reason once when the SDK refuses a toggle the user turned on", async () => {
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: true, withOption: true });
await sync(SESSION_ID, session, "off", "extra_usage_disabled");
expect(session.fastModeEnabled).toBe(false);
expect(session.fastModeDisabledReason).toBe("extra_usage_disabled");
expect(sessionUpdates).toHaveLength(2);
expect(sessionUpdates[0].update).toEqual({
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: "**Fast mode turned off:** requires extra usage to be enabled for this account.",
},
});
expect(sessionUpdates[1].update).toMatchObject({ sessionUpdate: "config_option_update" });
expect(session.configOptions).toContainEqual(
createFastModeConfigOption(false, true, "extra_usage_disabled"),
);
// The same report again changes nothing the user can see: no repeat notice.
await sync(SESSION_ID, session, "off", "extra_usage_disabled");
expect(sessionUpdates).toHaveLength(2);
});
it("updates the description when only the reason changes", async () => {
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: false, withOption: true });
await sync(SESSION_ID, session, "off", "not_first_party");
expect(session.fastModeEnabled).toBe(false);
// The toggle was already off, so the flip-time notice doesn't fire — only
// the description carries the explanation.
expect(sessionUpdates).toHaveLength(1);
expect(sessionUpdates[0].update).toMatchObject({ sessionUpdate: "config_option_update" });
expect(session.configOptions).toContainEqual(
createFastModeConfigOption(false, true, "not_first_party"),
);
});
it("drops a retained reason once fast mode comes back on", async () => {
const { sync, session } = setup({ fastModeEnabled: false, withOption: true });
await sync(SESSION_ID, session, "off", "network_error");
expect(session.fastModeDisabledReason).toBe("network_error");
await sync(SESSION_ID, session, "on");
expect(session.fastModeEnabled).toBe(true);
expect(session.fastModeDisabledReason).toBeUndefined();
expect(session.configOptions).toContainEqual(createFastModeConfigOption(true, true));
});
it("preserves the retained setting (no clobber) when the model has no Fast mode option", async () => {
// Model without fast support: the SDK reports a capability-driven state, not
// the user's intent. We must leave session.fastModeEnabled untouched so it's