From 4c7b89718306229254879e5045a183233b5ed073 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 27 Jul 2026 11:38:15 +0200 Subject: [PATCH] feat: Update to claude agent sdk v0.3.220 (#921) - **feat: Update to claude-agent-sdk 0.3.220** - **Explain unavailable Fast mode reasons** --- package-lock.json | 130 ++++++++++++++--------------- package.json | 8 +- src/acp-agent.ts | 128 ++++++++++++++++++++++++++-- src/tests/acp-agent.test.ts | 1 + src/tests/fast-mode-config.test.ts | 104 ++++++++++++++++++++++- 5 files changed, 294 insertions(+), 77 deletions(-) diff --git a/package-lock.json b/package-lock.json index 03001b3d..77ebf662 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": [ { diff --git a/package.json b/package.json index 762fc930..7bd4e8a4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/acp-agent.ts b/src/acp-agent.ts index f23bb465..9d8e76eb 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -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 { 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> = { + 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 diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index ab27876d..313cd8db 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -975,6 +975,7 @@ describe("tool conversions", () => { inference_geo: null, iterations: null, output_tokens_details: null, + fallback_credit: null, speed: null, }, context_management: null, diff --git a/src/tests/fast-mode-config.test.ts b/src/tests/fast-mode-config.test.ts index 2843076f..9d190347 100644 --- a/src/tests/fast-mode-config.test.ts +++ b/src/tests/fast-mode-config.test.ts @@ -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; } ).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