diff --git a/README.md b/README.md index 09ea678e..3ccc9b51 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ This tool implements an ACP agent by using the official [Claude Agent SDK](https - Custom [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands) - Client MCP servers - Session-scoped long-running goals through the provider-neutral [goal extension](docs/goal-extension.md) +- Structured errors, recovery, and warnings through the opt-in [session failure extension](docs/session-failure-extension.md) Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/). diff --git a/docs/session-failure-extension.md b/docs/session-failure-extension.md new file mode 100644 index 00000000..cc1446dd --- /dev/null +++ b/docs/session-failure-extension.md @@ -0,0 +1,208 @@ +# Session failure extension + +This document defines the provider-neutral experimental `sessionFailure` extension implemented by +`claude-agent-acp`. Warnings and errors are durable transcript entries shown in order beside user, +agent, and tool messages. They are not assistant-authored text and not ephemeral chat banners. + +## Capability negotiation + +The extension is opt-in. A client advertises it in `initialize`: + +```json +{ + "clientCapabilities": { + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "capabilities": ["sessionFailure"] + } + } + } + } +} +``` + +The adapter enables the extension only when `version` is a finite integer greater than or equal to +`1` and `capabilities` contains `sessionFailure`. Otherwise it preserves the legacy ACP behavior. + +## Wire record + +The record is placed under `_meta.jetbrains.air.sessionFailure`: + +```json +{ + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "sessionFailure": { + "id": "prompt-uuid:error", + "revision": 1, + "category": "limit", + "severity": "error", + "title": "You've hit your individual spend limit · run /usage-credits to ask your admin for a higher limit", + "actions": [] + } + } + } + } +} +``` + +| Field | Required | Type | Meaning | +| ---------- | -------: | -------------------- | ----------------------------------------------------------------------- | +| `id` | yes | non-empty string | Stable identity of one logical incident. | +| `revision` | yes | positive integer | Monotonically increasing version of that incident. | +| `category` | yes | category enum | Broad machine-readable visual group. | +| `severity` | yes | `warning` or `error` | Inline warning or error presentation. | +| `title` | yes | string | Complete normal user-facing presentation. | +| `details` | no | string | Long explanation used only when required text is too large for `title`. | +| `actions` | yes | ordered string array | Recovery actions recommended by the adapter. | + +There are no `phase`, `source`, `safeMessage`, `retryable`, `retryAfterMs`, `turnId`, retry-counter, +or provider-code fields. Retry progress belongs in `title` when Claude supplies it. Transcript order +and persistence time come from the receiving ACP event. + +## Identity, revisions, and history + +`id` identifies one occurrence, not an error type. + +- The first record for an incident creates one transcript entry at the current stream position. +- The same `id` with a higher `revision` updates that entry in place without moving it. +- The same or a lower revision is idempotently ignored by the client. +- A later independent occurrence uses a new `id`, even when its category and text are identical. +- Consecutive updates for the same notice may reuse one id and increment its revision. +- Different ids are never deduplicated by comparing title or category. + +Live turn failures use `:error`. Session-scoped incidents use an adapter-session epoch plus an +incident sequence. Notices have their own `:notice:` namespace. Replayed usage-limit failures recover +the persisted user-message UUID used as the live turn id, so the same occurrence keeps the same id. +Malformed history without a preceding user message falls back to +`:history-error:`. + +Resolved records remain in transcript history. Recovery removes only the adapter's internal active +state; it does not publish a clear tombstone and does not delete or rewrite the historical entry. A +producer may publish a higher revision only when it has a real new user-facing status to show. + +## Delivery surfaces + +A turn-terminal failure is attached to the successful ACP `PromptResponse` in `_meta`; the response +uses `stopReason: end_turn`. Session-scoped, replay-restored, warning, and background incidents use a +`session_info_update`. The schema is identical on both carriers. + +A warning does not end a turn. An error record does not independently invent ACP lifecycle state; +the prompt response or transport lifecycle remains authoritative. If no capable client negotiated +the extension, existing JSON-RPC errors and transcript text remain unchanged. + +## Categories + +Categories are deliberately broad and drive only iconography and generic accessibility labels. + +| Category | Covers | +| ------------ | --------------------------------------------------------------------- | +| `connection` | Lost transport, stopped or shutting-down worker, unavailable runtime. | +| `access` | Authentication required, rejected credentials, denied access. | +| `limit` | Rate, quota, token, context, turn, or configured budget limit. | +| `request` | Invalid input, unsupported model or operation, rejected request. | +| `service` | Provider overload, provider failure, adapter/internal failure. | +| `unknown` | A warning or failure that does not fit another group. | + +Claude SDK conditions map to these groups: + +| Claude condition | Category | +| ---------------------------------------------------------------------------------------- | ---------------------------------- | +| `authentication_failed`, `oauth_org_not_allowed`, synthetic login message | `access` | +| `billing_error`, `rate_limit`, `max_output_tokens`, usage/spend limit, budget/turn limit | `limit` | +| `invalid_request`, `model_not_found` | `request` | +| `overloaded`, `server_error`, unknown provider error, adapter internal error | `service` | +| API retry without an HTTP response, query transport loss, worker shutdown | `connection` | +| model fallback notice | `unknown` with `severity: warning` | + +Unknown SDK error kinds degrade to `service`; they never become success. Category does not determine +message text or client behavior beyond presentation. + +## Severity + +- `warning` means the operation may still succeed and normally has no actions while recovery is in + progress. It does not terminate the turn. SDK `api_retry` progress reuses the active turn failure id; + a later terminal failure updates that record with a higher revision. +- `error` means the operation cannot continue without user action or another request. + +Severity is always explicit. + +## Title and details + +`title` is the complete normal presentation: what happened, retry progress when present, and a short +next step. The adapter must not replace a real Claude message with category-specific canned wording. + +For Claude SDK failures the title is copied from the user-facing text Claude already emitted: + +- top-level assistant error text, including synthetic usage-limit and login messages; +- otherwise the terminal SDK result or error text; +- the exact model-fallback notice for warning records. + +Adapter-authored fallback text is allowed only when no Claude user-facing message exists, such as a +query iterator throwing, worker-shutdown EOF, or an internal missing-result invariant. Raw thrown +exceptions, stack traces, transport URLs, tokens, headers, environment values, and private paths are +never used as titles. + +`details` is reserved exclusively for required explanations too large to fit reasonably in `title`. +If the complete message fits in `title`, `details` must be omitted. It is not a place for status text, +retry counters, provider payloads, or diagnostics. + +## Actions + +Version 1 defines only: + +| Action | Meaning | +| ------------- | -------------------------------------------------- | +| `retry` | Retry the operation associated with this incident. | +| `login` | Start authentication. | +| `new_session` | Start a fresh agent session/runtime. | + +The adapter supplies action ordering. The client filters actions it cannot safely execute and ignores +unknown or duplicate values. The client must not infer actions from category. + +Current Claude policies are: + +- authentication: `login`; +- provider overload/error and recoverable internal error: `retry`; +- transport loss or worker shutdown: `new_session`; +- context or configured session-budget exhaustion: `new_session`; +- account quota and invalid request: no action. + +## Internal recovery + +Recovery policy is internal adapter state and is not serialized: + +- restored quota remains active until a real model answer; +- authentication remains active until successful `auth_status`; +- transport loss and worker shutdown remain active until runtime replacement; +- other turn-scoped failures stop being active at a later confirmed attempt boundary; +- notices are not cleared merely by generic success. + +Recovery never removes the transcript record. Publishing another incident is not evidence that an +older one recovered. + +## History replay + +`session/load` scans top-level assistant history for SDK synthetic usage-limit messages. It recognizes +only `` messages beginning with SDK-owned stable prefixes, not arbitrary model prose. Every +recognized occurrence is replayed as a typed record at its original transcript position. The latest +matching message stays active until a later real-model answer proves recovery; older and recovered +records remain historical typed entries. + +For a capable client, replay suppresses duplicate assistant prose and restores typed errors using the +exact stored Claude text as `title`. Legacy clients receive the original transcript and no typed record. + +## Verification + +The executable contract lives primarily in `src/tests/acp-agent.test.ts`, under `usage-limit failure +replay`, `stop reason propagation`, and `model refusal fallback handling`. + +```sh +npm run build +npm run check +npm run test:run +``` diff --git a/src/acp-agent.ts b/src/acp-agent.ts index c16625f2..fc2bd8d2 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -121,6 +121,20 @@ import { refusalFallbackToCreateRequest, } from "./elicitation.js"; import { SettingsManager } from "./settings.js"; +import { + activeUsageLimitMessage, + airSessionFailureCapabilityMeta, + assistantMessageText, + type ClaudeFailureKind, + createSessionFailureState, + isSyntheticUsageLimitMessage, + type PublishedSessionFailure, + providerFailureCategory, + SessionFailureController, + type SessionFailureState, + sessionFailureMeta, + supportsAirSessionFailures, +} from "./session-failure-extension.js"; import { applyTaskCreate, applyTaskList, @@ -147,6 +161,7 @@ export const CLAUDE_CONFIG_DIR = const execFileAsync = promisify(execFile); const MAX_TITLE_LENGTH = 256; +const MAX_INLINE_FAILURE_TITLE_LENGTH = 256; function sanitizeTitle(text: string): string { // Replace newlines and collapse whitespace @@ -709,6 +724,10 @@ type Session = { * NOT READ YET — recorded now so the mapping exists if/when we wire up * fork/rewind. */ messageIdToUuid: Map; + /** Durable-for-this-consumer failure state shared with session/load replay. + * Keeping it on the Session lets replay seed a failure that the persistent + * consumer can later clear with the same id and a higher revision. */ + sessionFailureState: SessionFailureState; }; /** Result-message origin kinds that mark an AUTONOMOUS cycle — work the @@ -921,149 +940,11 @@ export type ToolUpdateMeta = { }; const SUBAGENT_TRANSCRIPT_CAPABILITY = "subagent-transcript"; -// This is a protocol namespace, not user-facing branding: `jetbrains` owns the -// non-standard contract and `air` identifies the client defining its rendering -// semantics, without affecting unrelated JetBrains ACP clients. -const JETBRAINS_META_KEY = "jetbrains"; -const AIR_META_KEY = "air"; -const AIR_EXTENSION_VERSION_KEY = "version"; -const AIR_EXTENSION_CAPABILITIES_KEY = "capabilities"; -const AIR_SESSION_FAILURE_KEY = "sessionFailure"; -const AIR_EXTENSION_VERSION = 1; function supportsSubagentTranscript(capabilities?: ClientCapabilities | null): boolean { return capabilities?._meta?.[SUBAGENT_TRANSCRIPT_CAPABILITY] === true; } -type AirSessionFailureCategory = - | "advisory" - | "auth_required" - | "bad_request" - | "budget_exhausted" - | "context_exhausted" - | "internal_error" - | "overloaded" - | "provider_error" - | "quota_exhausted" - | "rate_limited" - | "transport_lost" - | "worker_shutdown"; - -/** - * How loudly the client renders a record. Absent on the wire means `error`, so a client that - * predates warning support keeps treating everything it receives as a failure. - */ -type AirSessionFailureSeverity = "error" | "warning"; - -const AIR_FAILURE_PRESENTATION: Record< - AirSessionFailureCategory, - { message: string; retryable: boolean; actions: string[] } -> = { - // A non-fatal notice whose wording comes from the adapter or the SDK, so this message is only the - // fallback for an advisory published without one. - advisory: { - message: "Claude reported a notice.", - retryable: false, - actions: [], - }, - auth_required: { - message: "Sign in to continue using Claude.", - retryable: false, - actions: ["login"], - }, - bad_request: { - message: "Claude could not process this request.", - retryable: false, - actions: ["new_turn"], - }, - budget_exhausted: { - message: "This Claude session reached its configured budget.", - retryable: false, - actions: ["new_session"], - }, - context_exhausted: { - message: "This Claude turn reached its configured limit.", - retryable: false, - actions: ["new_turn"], - }, - internal_error: { - message: "Claude Agent encountered an internal error.", - retryable: true, - actions: ["retry"], - }, - overloaded: { - message: "Claude is temporarily overloaded.", - retryable: true, - actions: ["retry"], - }, - provider_error: { - message: "The model provider reported an error.", - retryable: true, - actions: ["retry"], - }, - quota_exhausted: { - message: "The Claude account has no available quota.", - retryable: false, - actions: ["new_session"], - }, - rate_limited: { - message: "Claude is temporarily rate limited.", - retryable: true, - actions: ["retry"], - }, - transport_lost: { - message: "The connection to Claude was lost.", - retryable: false, - actions: ["new_session"], - }, - worker_shutdown: { - message: "The Claude worker is shutting down.", - retryable: false, - actions: ["new_session"], - }, -}; - -function supportsAirSessionFailures(capabilities?: ClientCapabilities): boolean { - const jetbrains = capabilities?._meta?.[JETBRAINS_META_KEY] as - Record | undefined; - const air = jetbrains?.[AIR_META_KEY] as Record | undefined; - const version = air?.[AIR_EXTENSION_VERSION_KEY]; - const advertised = air?.[AIR_EXTENSION_CAPABILITIES_KEY]; - return ( - typeof version === "number" && - Number.isFinite(version) && - Number.isInteger(version) && - version >= AIR_EXTENSION_VERSION && - Array.isArray(advertised) && - advertised.some((capability) => capability === AIR_SESSION_FAILURE_KEY) - ); -} - -function providerFailureCategory(errorKind?: SDKAssistantMessageError): AirSessionFailureCategory { - switch (errorKind) { - case "authentication_failed": - case "oauth_org_not_allowed": - return "auth_required"; - case "billing_error": - return "quota_exhausted"; - case "rate_limit": - return "rate_limited"; - case "overloaded": - return "overloaded"; - case "invalid_request": - case "model_not_found": - return "bad_request"; - case "max_output_tokens": - return "context_exhausted"; - case "server_error": - case "unknown": - case undefined: - return "provider_error"; - default: - return "provider_error"; - } -} - function parentToolUseIdOf(message: { parent_tool_use_id?: unknown }): string | null { if (!("parent_tool_use_id" in message)) return null; return typeof message.parent_tool_use_id === "string" ? message.parent_tool_use_id : null; @@ -1764,12 +1645,7 @@ export class ClaudeAcpAgent { // steering extension contract: advertises the `_session/steering` request // so clients know they may inject a follow-up into a running turn. _meta: { - [JETBRAINS_META_KEY]: { - [AIR_META_KEY]: { - [AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION, - [AIR_EXTENSION_CAPABILITIES_KEY]: [AIR_SESSION_FAILURE_KEY], - }, - }, + ...airSessionFailureCapabilityMeta(), steering: { supported: true, }, @@ -2284,6 +2160,8 @@ export class ClaudeAcpAgent { // it here so the subsequent `RequestError.internalError` can forward it to // clients as structured `data`, sparing them from pattern-matching on text. let lastAssistantError: SDKAssistantMessageError | undefined; + let lastAssistantWasUsageLimit = false; + let lastAssistantFailureTitle: string | undefined; // When a streaming classifier refuses a turn, the assistant message carries // stop_reason "refusal" and structured stop_details. We capture the // human-readable explanation so the terminal `result` can surface it. @@ -2339,140 +2217,60 @@ export class ClaudeAcpAgent { await this.client.sessionUpdate(notification); }; - type PublishedSessionFailure = { - id: string; - revision: number; - category: AirSessionFailureCategory; - turnId?: string; - severity?: AirSessionFailureSeverity; - /** Set when the notice carries its own wording instead of the canned per-category message. */ - safeMessage?: string; - }; - const isAdvisory = (failure: PublishedSessionFailure) => failure.severity === "warning"; - const failureRevisions = new Map(); - const sessionFailureEpoch = randomUUID(); - const activeSessionFailures = new Map(); let pendingWorkerShutdown = false; const isCurrentConsumer = () => this.sessions[params.sessionId] === session; - - const sessionFailureMeta = (failure: PublishedSessionFailure, phase: "active" | "cleared") => { - const presentation = AIR_FAILURE_PRESENTATION[failure.category]; - return { - [JETBRAINS_META_KEY]: { - [AIR_META_KEY]: { - [AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION, - [AIR_SESSION_FAILURE_KEY]: { - id: failure.id, - revision: failure.revision, - phase, - category: failure.category, - source: "claude", - safeMessage: failure.safeMessage ?? presentation.message, - retryable: presentation.retryable, - actions: presentation.actions, - ...(failure.turnId ? { turnId: failure.turnId } : {}), - ...(failure.severity ? { severity: failure.severity } : {}), - }, - }, - }, - }; - }; - - const emitSessionFailure = async ( - failure: PublishedSessionFailure, - phase: "active" | "cleared", - ): Promise => { - if (!isCurrentConsumer()) { - this.logger.error( - `Session ${params.sessionId}: ignored AIR session failure from a stale query consumer`, - ); - return false; - } - try { - await sendUpdate({ - sessionId: params.sessionId, - update: { - sessionUpdate: "session_info_update", - _meta: sessionFailureMeta(failure, phase), - }, - }); - return true; - } catch (error) { - this.logger.error( - `Session ${params.sessionId}: failed to publish AIR session failure: ${error}`, - ); - return false; - } - }; - - const clearSessionFailure = async ( - shouldClear: (failure: PublishedSessionFailure) => boolean = () => true, - ) => { - if (!supportsAirSessionFailures(this.clientCapabilities)) return; - for (const failure of [...activeSessionFailures.values()]) { - if (!shouldClear(failure)) continue; - const cleared = { - ...failure, - revision: failure.revision + 1, - }; - if (await emitSessionFailure(cleared, "cleared")) { - failureRevisions.set(cleared.id, cleared.revision); - activeSessionFailures.delete(cleared.id); - } - } - }; - + const sessionFailures = new SessionFailureController({ + sessionId: params.sessionId, + state: session.sessionFailureState, + capabilities: this.clientCapabilities, + isCurrent: isCurrentConsumer, + sendUpdate, + logger: this.logger, + }); const createSessionFailure = async ( - category: AirSessionFailureCategory, - options: { turnScoped?: boolean; safeMessage?: string } = {}, + kind: ClaudeFailureKind, + options: { + turnScoped?: boolean; + title?: string; + details?: string; + severity?: "warning" | "error"; + } = {}, ): Promise => { - if (!supportsAirSessionFailures(this.clientCapabilities) || !isCurrentConsumer()) { - return undefined; - } - // An advisory is not a failure of the turn: it keeps its own id namespace and its own slot, so - // it neither clears an actionable error nor is cleared by one. Only the newest advisory - // matters, so they all share one id and bump its revision. - if (category === "advisory") { - const id = `${params.sessionId}:notice:${sessionFailureEpoch}`; - return { - id, - revision: (failureRevisions.get(id) ?? 0) + 1, - category, - severity: "warning", - ...(options.safeMessage ? { safeMessage: options.safeMessage } : {}), - }; - } const turnId = options.turnScoped === false ? undefined : session.activeTurn?.promptUuid; - const id = turnId - ? `${turnId}:error` - : `${params.sessionId}:session-error:${sessionFailureEpoch}`; - await clearSessionFailure((failure) => failure.id !== id && !isAdvisory(failure)); - return { - id, - revision: (failureRevisions.get(id) ?? 0) + 1, - category, - ...(turnId ? { turnId } : {}), - }; + return sessionFailures.prepare(kind, { + turnId, + sessionScoped: options.turnScoped === false, + title: options.title, + details: options.details, + severity: options.severity, + }); }; const publishSessionFailure = async ( - category: AirSessionFailureCategory, - options: { turnScoped?: boolean; safeMessage?: string } = {}, + kind: ClaudeFailureKind, + options: { + turnScoped?: boolean; + title?: string; + details?: string; + severity?: "warning" | "error"; + } = {}, ) => { - const failure = await createSessionFailure(category, options); - if (!failure) return; - if (await emitSessionFailure(failure, "active")) { - failureRevisions.set(failure.id, failure.revision); - activeSessionFailures.set(failure.id, failure); - } + const turnId = options.turnScoped === false ? undefined : session.activeTurn?.promptUuid; + await sessionFailures.publish(kind, { + turnId, + sessionScoped: options.turnScoped === false, + title: options.title, + details: options.details, + severity: options.severity, + }); }; const clearFailuresFromEarlierTurns = async () => { const activeTurnId = session.activeTurn?.promptUuid; // Advisories carry no turnId, so without the guard every turn boundary would sweep them away. // They are session-scoped and stay until superseded or dismissed by the user. - await clearSessionFailure( - (failure) => failure.turnId !== activeTurnId && !isAdvisory(failure), + await sessionFailures.clear( + (failure) => failure.recoveryPolicy === "next_attempt" && failure.turnId !== activeTurnId, ); }; @@ -2487,6 +2285,8 @@ export class ClaudeAcpAgent { lastAssistantUsage = null; lastAssistantModel = null; lastAssistantError = undefined; + lastAssistantWasUsageLimit = false; + lastAssistantFailureTitle = undefined; lastRefusalExplanation = null; compactionInProgress = false; // Do NOT reset currentStreamMessageId or streamedBlocks here. Turn @@ -2796,8 +2596,9 @@ export class ClaudeAcpAgent { * which is the canonical AIR carrier. Legacy clients keep the historical * JSON-RPC rejection path. */ const failActiveWithSessionFailure = async ( - category: AirSessionFailureCategory, + kind: ClaudeFailureKind, error: unknown, + title?: string, ) => { if (!supportsAirSessionFailures(this.clientCapabilities)) { failActive(error); @@ -2805,22 +2606,21 @@ export class ClaudeAcpAgent { } if (!session.activeTurn || session.activeTurn.settled) { this.logger.error( - `Session ${params.sessionId}: cannot attach ${category} to a prompt response because no active turn exists; publishing a session-scoped failure`, + `Session ${params.sessionId}: cannot attach ${kind} to a prompt response because no active turn exists; publishing a session-scoped failure`, ); - await publishSessionFailure(category, { turnScoped: false }); + await publishSessionFailure(kind, { turnScoped: false, title }); return; } - const failure = await createSessionFailure(category); + const failure = await createSessionFailure(kind, { title }); if (!failure) { failActive(error); return; } - failureRevisions.set(failure.id, failure.revision); - activeSessionFailures.set(failure.id, failure); + sessionFailures.recordActive(failure); settleActive({ stopReason: "end_turn", usage: sessionUsage(session), - _meta: sessionFailureMeta(failure, "active"), + _meta: sessionFailureMeta(failure), }); }; @@ -3344,6 +3144,7 @@ export class ClaudeAcpAgent { errorKindData("no_result"), TURN_NO_RESULT_MESSAGE, ), + TURN_NO_RESULT_MESSAGE, ); } // The SDK generates the session title in a background task and @@ -3581,10 +3382,25 @@ export class ClaudeAcpAgent { } case "plugin_install": case "notification": - case "api_retry": case "thinking_tokens": // Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output break; + case "api_retry": { + const title = + message.error_status === null + ? `Reconnecting to Claude, attempt ${message.attempt} of ${message.max_retries}.` + : `Retrying Claude, attempt ${message.attempt} of ${message.max_retries}.`; + await publishSessionFailure( + message.error_status === null + ? "transport_lost" + : providerFailureCategory(message.error), + { + title, + severity: "warning", + }, + ); + break; + } case "model_refusal_fallback": { // The SDK retried a refused turn on the fallback model and made // the swap persistent for the session. Without a notice the @@ -3610,22 +3426,30 @@ export class ClaudeAcpAgent { const category = message.api_refusal_category ? ` (${message.api_refusal_category})` : ""; - const explanation = message.api_refusal_explanation - ? `\n\n${message.api_refusal_explanation}` - : ""; const outcome = persistent ? `The session will continue on ${message.fallback_model}.` : local ? `Only that response came from ${message.fallback_model}; the session stays on ${message.original_model}.` : `The session stays on ${message.original_model}.`; - const fallbackNotice = + const fallbackSummary = `${message.original_model} declined this request${category}; ` + - `retried with ${message.fallback_model}. ${outcome}${explanation}`; + `retried with ${message.fallback_model}. ${outcome}`; + const explanation = message.api_refusal_explanation || undefined; + const fallbackNotice = explanation + ? `${fallbackSummary}\n\n${explanation}` + : fallbackSummary; // A silent model swap is a session-level advisory, not something the model said. // Clients that negotiated typed records get it as one; the rest keep the bold-label // transcript line, which was the only way to flag it before. if (supportsAirSessionFailures(this.clientCapabilities)) { - await publishSessionFailure("advisory", { safeMessage: fallbackNotice }); + const useDetails = + explanation !== undefined && + fallbackSummary.length + 2 + explanation.length > + MAX_INLINE_FAILURE_TITLE_LENGTH; + await publishSessionFailure("advisory", { + title: useDetails ? fallbackSummary : fallbackNotice, + ...(useDetails ? { details: explanation } : {}), + }); } else { await sendUpdate({ sessionId: message.session_id, @@ -3983,8 +3807,13 @@ export class ClaudeAcpAgent { break; } - if (!message.is_error) { - await clearSessionFailure(); + if (!message.is_error && lastAssistantModel !== null) { + const activeTurnId = session.activeTurn?.promptUuid; + await sessionFailures.clear( + (failure) => + failure.recoveryPolicy === "real_model_success" || + (failure.severity === "warning" && failure.turnId === activeTurnId), + ); } switch (message.subtype) { @@ -3993,6 +3822,7 @@ export class ClaudeAcpAgent { await failActiveWithSessionFailure( "auth_required", RequestError.authRequired(), + message.result, ); break; } @@ -4002,8 +3832,9 @@ export class ClaudeAcpAgent { } if (message.is_error) { await failActiveWithSessionFailure( - providerFailureCategory(lastAssistantError), + providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit), internalErrorForClient(errorKindData(lastAssistantError), message.result), + lastAssistantFailureTitle ?? message.result, ); break; } @@ -4050,11 +3881,12 @@ export class ClaudeAcpAgent { } if (message.is_error) { await failActiveWithSessionFailure( - providerFailureCategory(lastAssistantError), + providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit), internalErrorForClient( errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype, ), + lastAssistantFailureTitle ?? (message.errors.join(", ") || message.subtype), ); break; } @@ -4069,6 +3901,7 @@ export class ClaudeAcpAgent { errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype, ), + message.errors.join(", ") || message.subtype, ); break; } @@ -4077,11 +3910,12 @@ export class ClaudeAcpAgent { case "error_max_turns": if (message.is_error) { await failActiveWithSessionFailure( - "provider_error", + "context_exhausted", internalErrorForClient( errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype, ), + message.errors.join(", ") || message.subtype, ); break; } @@ -4095,6 +3929,7 @@ export class ClaudeAcpAgent { errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype, ), + message.errors.join(", ") || message.subtype, ); break; } @@ -4385,6 +4220,10 @@ export class ClaudeAcpAgent { if (message.type === "assistant" && message.parent_tool_use_id === null) { lastAssistantUsage = snapshotFromUsage(message.message.usage); lastAssistantTotalUsage = totalTokens(lastAssistantUsage); + lastAssistantWasUsageLimit = isSyntheticUsageLimitMessage(message.message); + if (message.error || lastAssistantWasUsageLimit) { + lastAssistantFailureTitle = assistantMessageText(message.message); + } if (message.message.model && message.message.model !== "") { lastAssistantModel = message.message.model; } @@ -4459,7 +4298,25 @@ export class ClaudeAcpAgent { } if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) { - await failActiveWithSessionFailure("auth_required", RequestError.authRequired()); + await failActiveWithSessionFailure( + "auth_required", + RequestError.authRequired(), + assistantMessageText(message.message), + ); + break; + } + + // AIR receives this provider condition on the terminal prompt + // response as a typed failure whose title is the exact assistant + // error text captured above. Do not duplicate that text as an + // ordinary assistant message; legacy clients retain the historical + // transcript behavior. + if ( + message.type === "assistant" && + message.parent_tool_use_id === null && + (message.error || isSyntheticUsageLimitMessage(message.message)) && + supportsAirSessionFailures(this.clientCapabilities) + ) { break; } @@ -4649,7 +4506,7 @@ export class ClaudeAcpAgent { break; case "auth_status": if (!message.isAuthenticating && message.error === undefined) { - await clearSessionFailure((failure) => failure.category === "auth_required"); + await sessionFailures.clear((failure) => failure.kind === "auth_required"); } break; default: @@ -5199,11 +5056,33 @@ export class ClaudeAcpAgent { private async replaySessionHistory(sessionId: string): Promise { const toolUseCache: ToolUseCache = {}; const messages = await getSessionMessages(sessionId); + const session = this.sessions[sessionId]; const forwardSubagentText = - this.sessions[sessionId]?.forwardSubagentText ?? - supportsSubagentTranscript(this.clientCapabilities); + session?.forwardSubagentText ?? supportsSubagentTranscript(this.clientCapabilities); + const supportsTypedFailures = supportsAirSessionFailures(this.clientCapabilities); + const activeUsageLimit = supportsTypedFailures ? activeUsageLimitMessage(messages) : undefined; + const sessionFailures = + session && supportsTypedFailures + ? new SessionFailureController({ + sessionId, + state: session.sessionFailureState, + capabilities: this.clientCapabilities, + isCurrent: () => this.sessions[sessionId] === session, + sendUpdate: (notification) => this.client.sessionUpdate(notification), + logger: this.logger, + }) + : undefined; + let replayTurnId: string | undefined; for (const message of messages) { + if ( + message.type === "user" && + message.parent_tool_use_id === null && + typeof message.uuid === "string" && + message.uuid.length > 0 + ) { + replayTurnId = message.uuid; + } // Backfill the ACP messageId -> SDK uuid mapping for messages we didn't // observe live (resumed/loaded sessions), so rewind/resume can translate // a client-supplied id without an extra getSessionMessages read. Not read @@ -5221,6 +5100,29 @@ export class ClaudeAcpAgent { continue; } + // Capable clients saw every synthetic usage-limit message as a typed + // failure live, so replay it at the same transcript position. The + // preceding persisted user uuid is the live prompt uuid and therefore + // recreates the same incident identity. Only the latest limit not + // followed by a real model answer remains active internally. + if ( + sessionFailures && + message.type === "assistant" && + message.parent_tool_use_id === null && + isSyntheticUsageLimitMessage(message.message) + ) { + const title = assistantMessageText(message.message); + if (title) { + await sessionFailures.restore( + replayTurnId ? `${replayTurnId}:error` : `${sessionId}:history-error:${message.uuid}`, + "quota_exhausted", + title, + message.uuid === activeUsageLimit?.uuid, + ); + } + continue; + } + // @ts-expect-error - untyped in SDK but we handle all of these let content: unknown = message.message.content; const parentToolUseId = parentToolUseIdOf(message); @@ -6599,6 +6501,7 @@ export class ClaudeAcpAgent { emittedAssistantText: false, owedTrailingIdles: 0, messageIdToUuid: new Map(), + sessionFailureState: createSessionFailureState(), }; return { diff --git a/src/session-failure-extension.ts b/src/session-failure-extension.ts new file mode 100644 index 00000000..b2517c26 --- /dev/null +++ b/src/session-failure-extension.ts @@ -0,0 +1,463 @@ +import type { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk"; +import { + getSessionMessages, + type SDKAssistantMessageError, + USAGE_LIMIT_ERROR_PREFIXES, +} from "@anthropic-ai/claude-agent-sdk"; +import { randomUUID } from "node:crypto"; + +const JETBRAINS_META_KEY = "jetbrains"; +const AIR_META_KEY = "air"; +const AIR_EXTENSION_VERSION_KEY = "version"; +const AIR_EXTENSION_CAPABILITIES_KEY = "capabilities"; +const AIR_SESSION_FAILURE_KEY = "sessionFailure"; +const AIR_EXTENSION_VERSION = 1; + +export function airSessionFailureCapabilityMeta() { + return { + [JETBRAINS_META_KEY]: { + [AIR_META_KEY]: { + [AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION, + [AIR_EXTENSION_CAPABILITIES_KEY]: [AIR_SESSION_FAILURE_KEY], + }, + }, + }; +} + +export type ClaudeFailureKind = + | "advisory" + | "auth_required" + | "bad_request" + | "budget_exhausted" + | "context_exhausted" + | "internal_error" + | "overloaded" + | "provider_error" + | "quota_exhausted" + | "rate_limited" + | "transport_lost" + | "worker_shutdown"; + +type AirSessionFailureCategory = + "connection" | "access" | "limit" | "request" | "service" | "unknown"; + +type AirSessionFailureSeverity = "warning" | "error"; +type AirSessionFailureAction = "retry" | "login" | "new_session"; + +type SessionFailureRecoveryPolicy = + "never" | "next_attempt" | "real_model_success" | "auth_status" | "runtime_rebind"; + +export type PublishedSessionFailure = { + id: string; + revision: number; + kind: ClaudeFailureKind; + category: AirSessionFailureCategory; + turnId?: string; + severity: AirSessionFailureSeverity; + title: string; + details?: string; + actions: AirSessionFailureAction[]; + recoveryPolicy: SessionFailureRecoveryPolicy; +}; + +type SessionFailureOptions = { + turnId?: string; + sessionScoped?: boolean; + title?: string; + details?: string; + severity?: AirSessionFailureSeverity; +}; + +export type SessionFailureState = { + epoch: string; + revisions: Map; + active: Map; + nextIncident?: number; + lastNotice?: { title: string; id: string }; +}; + +type Logger = { + error: (...args: unknown[]) => void; +}; + +export function createSessionFailureState(): SessionFailureState { + return { + epoch: randomUUID(), + revisions: new Map(), + active: new Map(), + }; +} + +const AIR_FAILURE_POLICY: Record< + ClaudeFailureKind, + { + category: AirSessionFailureCategory; + actions: AirSessionFailureAction[]; + fallbackTitle: string; + } +> = { + advisory: { + category: "unknown", + actions: [], + fallbackTitle: "Claude reported a notice.", + }, + auth_required: { + category: "access", + actions: ["login"], + fallbackTitle: "Sign in to continue using Claude.", + }, + bad_request: { + category: "request", + actions: [], + fallbackTitle: "Claude could not process this request.", + }, + budget_exhausted: { + category: "limit", + actions: ["new_session"], + fallbackTitle: "This Claude session reached its configured budget.", + }, + context_exhausted: { + category: "limit", + actions: ["new_session"], + fallbackTitle: "This Claude turn reached its configured limit.", + }, + internal_error: { + category: "service", + actions: ["retry", "new_session"], + fallbackTitle: "Claude Agent encountered an internal error.", + }, + overloaded: { + category: "service", + actions: ["retry"], + fallbackTitle: "Claude is temporarily overloaded.", + }, + provider_error: { + category: "service", + actions: ["retry"], + fallbackTitle: "The model provider reported an error.", + }, + quota_exhausted: { + category: "limit", + actions: [], + fallbackTitle: "The Claude account has no available quota.", + }, + rate_limited: { + category: "limit", + actions: ["retry"], + fallbackTitle: "Claude is temporarily rate limited.", + }, + transport_lost: { + category: "connection", + actions: ["new_session"], + fallbackTitle: "The connection to Claude was lost.", + }, + worker_shutdown: { + category: "connection", + actions: ["new_session"], + fallbackTitle: "The Claude worker is shutting down.", + }, +}; + +export function sessionFailureMeta(failure: PublishedSessionFailure) { + return { + [JETBRAINS_META_KEY]: { + [AIR_META_KEY]: { + [AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION, + [AIR_SESSION_FAILURE_KEY]: { + id: failure.id, + revision: failure.revision, + category: failure.category, + severity: failure.severity, + title: failure.title, + ...(failure.details ? { details: failure.details } : {}), + actions: failure.actions, + }, + }, + }, + }; +} + +/** `getSessionMessages` deliberately exposes only the API message and strips + * transcript-level `error` / `isApiErrorMessage` fields. The SDK exports the + * exact stable prefixes used by its synthetic usage-limit errors, so replay + * can recover this one typed failure without matching arbitrary model prose. */ +export function assistantMessageText(apiMessage: unknown): string | undefined { + if (!apiMessage || typeof apiMessage !== "object") return undefined; + const { content } = apiMessage as { content?: unknown }; + if (typeof content === "string") return content || undefined; + if (!Array.isArray(content)) return undefined; + const text = content + .map((block) => + block && + typeof block === "object" && + "type" in block && + block.type === "text" && + "text" in block && + typeof block.text === "string" + ? block.text + : "", + ) + .join(""); + return text || undefined; +} + +export function isSyntheticUsageLimitMessage(apiMessage: unknown): boolean { + if (!apiMessage || typeof apiMessage !== "object") return false; + const { model } = apiMessage as { model?: unknown }; + if (model !== "") return false; + const text = assistantMessageText(apiMessage); + if (!text) return false; + return USAGE_LIMIT_ERROR_PREFIXES.some((prefix) => text.startsWith(prefix)); +} + +export function activeUsageLimitMessage( + messages: Awaited>, +): { uuid: string; title: string } | undefined { + let active: { uuid: string; title: string } | undefined; + for (const message of messages) { + if (message.type !== "assistant" || message.parent_tool_use_id !== null) continue; + if (isSyntheticUsageLimitMessage(message.message)) { + const title = assistantMessageText(message.message); + if (title) active = { uuid: message.uuid, title }; + continue; + } + const model = + message.message && typeof message.message === "object" && "model" in message.message + ? message.message.model + : undefined; + // A later real model answer proves the account recovered. Synthetic + // local-command/interruption messages do not: they can be appended while + // the provider remains unavailable. + if (model !== undefined && model !== "") active = undefined; + } + return active; +} + +export function supportsAirSessionFailures(capabilities?: ClientCapabilities): boolean { + const jetbrains = capabilities?._meta?.[JETBRAINS_META_KEY] as + Record | undefined; + const air = jetbrains?.[AIR_META_KEY] as Record | undefined; + const version = air?.[AIR_EXTENSION_VERSION_KEY]; + const advertised = air?.[AIR_EXTENSION_CAPABILITIES_KEY]; + return ( + typeof version === "number" && + Number.isFinite(version) && + Number.isInteger(version) && + version >= AIR_EXTENSION_VERSION && + Array.isArray(advertised) && + advertised.some((capability) => capability === AIR_SESSION_FAILURE_KEY) + ); +} + +function sessionFailureRecoveryPolicy( + kind: ClaudeFailureKind, + turnId?: string, +): SessionFailureRecoveryPolicy { + switch (kind) { + case "advisory": + return "never"; + case "auth_required": + return "auth_status"; + case "transport_lost": + case "worker_shutdown": + return "runtime_rebind"; + case "quota_exhausted": + return "real_model_success"; + default: + return turnId ? "next_attempt" : "real_model_success"; + } +} + +/** Owns the AIR failure lifecycle for both the live consumer and history replay. + * Published revisions become active only after delivery succeeds; recovery only + * removes internal active state because transcript records are historical. */ +export class SessionFailureController { + private readonly sessionId: string; + private readonly state: SessionFailureState; + private readonly capabilities?: ClientCapabilities; + private readonly isCurrent: () => boolean; + private readonly sendUpdate: (notification: SessionNotification) => Promise; + private readonly logger: Logger; + + constructor(options: { + sessionId: string; + state: SessionFailureState; + capabilities?: ClientCapabilities; + isCurrent: () => boolean; + sendUpdate: (notification: SessionNotification) => Promise; + logger: Logger; + }) { + this.sessionId = options.sessionId; + this.state = options.state; + this.capabilities = options.capabilities; + this.isCurrent = options.isCurrent; + this.sendUpdate = options.sendUpdate; + this.logger = options.logger; + } + + private isSupported(): boolean { + return supportsAirSessionFailures(this.capabilities); + } + + private async emit(failure: PublishedSessionFailure): Promise { + if (!this.isCurrent()) { + this.logger.error( + `Session ${this.sessionId}: ignored AIR session failure from a stale consumer`, + ); + return false; + } + try { + await this.sendUpdate({ + sessionId: this.sessionId, + update: { + sessionUpdate: "session_info_update", + _meta: sessionFailureMeta(failure), + }, + }); + return true; + } catch (error) { + this.logger.error( + `Session ${this.sessionId}: failed to publish AIR session failure: ${error}`, + ); + return false; + } + } + + recordActive(failure: PublishedSessionFailure): void { + this.state.revisions.set(failure.id, failure.revision); + this.state.active.set(failure.id, failure); + this.state.lastNotice = + failure.kind === "advisory" ? { title: failure.title, id: failure.id } : undefined; + } + + async clear( + shouldClear: (failure: PublishedSessionFailure) => boolean = () => true, + ): Promise { + if (!this.isSupported()) return true; + this.state.lastNotice = undefined; + for (const failure of [...this.state.active.values()]) { + if (!shouldClear(failure)) continue; + this.state.active.delete(failure.id); + } + return true; + } + + async prepare( + kind: ClaudeFailureKind, + failureOptions: SessionFailureOptions = {}, + ): Promise { + if (!this.isSupported() || !this.isCurrent()) return undefined; + const policy = AIR_FAILURE_POLICY[kind]; + const title = failureOptions.title || policy.fallbackTitle; + if (kind === "advisory") { + const id = + this.state.lastNotice?.title === title + ? this.state.lastNotice.id + : `${this.sessionId}:notice:${this.state.epoch}:${this.state.nextIncident ?? 1}`; + if (this.state.lastNotice?.title !== title) { + this.state.nextIncident = (this.state.nextIncident ?? 1) + 1; + } + return { + id, + revision: (this.state.revisions.get(id) ?? 0) + 1, + kind, + category: policy.category, + severity: "warning", + title, + ...(failureOptions.details ? { details: failureOptions.details } : {}), + actions: policy.actions, + recoveryPolicy: sessionFailureRecoveryPolicy(kind), + }; + } + const id = + failureOptions.turnId && !failureOptions.sessionScoped + ? `${failureOptions.turnId}:error` + : `${this.sessionId}:session-error:${this.state.epoch}:${this.state.nextIncident ?? 1}`; + if (!failureOptions.turnId || failureOptions.sessionScoped) { + this.state.nextIncident = (this.state.nextIncident ?? 1) + 1; + } + return { + id, + revision: (this.state.revisions.get(id) ?? 0) + 1, + kind, + category: policy.category, + severity: failureOptions.severity ?? "error", + title, + ...(failureOptions.details ? { details: failureOptions.details } : {}), + actions: failureOptions.severity === "warning" ? [] : policy.actions, + recoveryPolicy: sessionFailureRecoveryPolicy( + kind, + failureOptions.turnId && !failureOptions.sessionScoped ? failureOptions.turnId : undefined, + ), + ...(failureOptions.turnId && !failureOptions.sessionScoped + ? { turnId: failureOptions.turnId } + : {}), + }; + } + + async publish( + kind: ClaudeFailureKind, + failureOptions: SessionFailureOptions = {}, + ): Promise { + const failure = await this.prepare(kind, failureOptions); + if (!failure) return; + if (await this.emit(failure)) this.recordActive(failure); + } + + async restore( + id: string, + kind: ClaudeFailureKind, + title: string, + active = true, + ): Promise { + if (!this.isSupported() || !this.isCurrent()) return false; + const policy = AIR_FAILURE_POLICY[kind]; + const failure: PublishedSessionFailure = { + id, + revision: (this.state.revisions.get(id) ?? 0) + 1, + kind, + category: policy.category, + severity: "error", + title, + actions: policy.actions, + recoveryPolicy: sessionFailureRecoveryPolicy(kind), + }; + if (!(await this.emit(failure))) return false; + this.state.revisions.set(failure.id, failure.revision); + if (active) { + this.state.active.set(failure.id, failure); + } else { + this.state.active.delete(failure.id); + } + return true; + } +} + +export function providerFailureCategory( + errorKind?: SDKAssistantMessageError, + isUsageLimit = false, +): ClaudeFailureKind { + if (isUsageLimit) return "quota_exhausted"; + switch (errorKind) { + case "authentication_failed": + case "oauth_org_not_allowed": + return "auth_required"; + case "billing_error": + return "quota_exhausted"; + case "rate_limit": + return "rate_limited"; + case "overloaded": + return "overloaded"; + case "invalid_request": + case "model_not_found": + return "bad_request"; + case "max_output_tokens": + return "context_exhausted"; + case "server_error": + case "unknown": + case undefined: + return "provider_error"; + default: + return "provider_error"; + } +} diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index d327928c..3ba8ca87 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -54,6 +54,7 @@ import { SDKAssistantMessage, } from "@anthropic-ai/claude-agent-sdk"; import { randomUUID } from "crypto"; +import { readFile } from "node:fs/promises"; import { GOAL_CONTROL_METHOD, goalUpdateFromPrompt, @@ -173,6 +174,7 @@ function mockSessionState(overrides: Record = {}) { emittedAssistantText: false, owedTrailingIdles: 0, messageIdToUuid: new Map(), + sessionFailureState: { epoch: randomUUID(), revisions: new Map(), active: new Map() }, ...overrides, } as any; } @@ -1804,6 +1806,325 @@ describe("synthetic login message (issue #863)", () => { }); }); +describe("usage-limit failure replay", () => { + const usageLimitMessage = { + type: "assistant" as const, + uuid: "usage-limit-message", + session_id: "s1", + parent_tool_use_id: null, + parent_agent_id: null, + message: { + id: "api-usage-limit-message", + model: "", + role: "assistant", + type: "message", + stop_reason: "stop_sequence", + content: [ + { + type: "text", + text: "You've hit your individual spend limit · run /usage-credits to ask your admin for a higher limit", + }, + ], + }, + }; + const airCapabilities = { + _meta: { jetbrains: { air: { version: 1, capabilities: ["sessionFailure"] } } }, + }; + const successfulResult = () => ({ + type: "result" as const, + subtype: "success" as const, + stop_reason: "end_turn", + is_error: false, + result: "", + errors: [], + duration_ms: 0, + duration_api_ms: 0, + num_turns: 1, + total_cost_usd: 0, + usage: { + input_tokens: 0, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + modelUsage: {}, + permission_denials: [], + uuid: randomUUID(), + session_id: "s1", + }); + + async function replay(messages: Awaited>, capable = true) { + const updates: SessionNotification[] = []; + const agent = new ClaudeAcpAgent( + { + sessionUpdate: async (update: SessionNotification) => updates.push(update), + } as unknown as AcpClient, + { log: () => {}, error: () => {} }, + ); + (agent as any).clientCapabilities = capable ? airCapabilities : {}; + agent.sessions.s1 = mockSessionState(); + vi.mocked(getSessionMessages).mockResolvedValueOnce(messages); + await ( + agent as unknown as { replaySessionHistory(sessionId: string): Promise } + ).replaySessionHistory("s1"); + return { agent, updates }; + } + + it("restores the latest synthetic usage limit as a typed failure", async () => { + const { agent, updates } = await replay([ + usageLimitMessage, + { + ...usageLimitMessage, + uuid: "local-command-result", + message: { + ...usageLimitMessage.message, + id: "api-local-command-result", + content: [{ type: "text", text: "No response requested." }], + }, + }, + ]); + + const failure = updates + .map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure) + .find(Boolean); + expect(failure).toEqual( + expect.objectContaining({ + category: "limit", + severity: "error", + title: + "You've hit your individual spend limit · run /usage-credits to ask your admin for a higher limit", + actions: [], + revision: 1, + }), + ); + expect( + updates.some( + (update) => + update.update.sessionUpdate === "agent_message_chunk" && + update.update.content.type === "text" && + update.update.content.text.includes("individual spend limit"), + ), + ).toBe(false); + expect(agent.sessions.s1.sessionFailureState.active.get(failure.id)).toEqual( + expect.objectContaining({ kind: "quota_exhausted", category: "limit", revision: 1 }), + ); + }); + + it("keeps a recovered historical failure as a typed transcript record", async () => { + const { updates } = await replay([ + usageLimitMessage, + { + ...usageLimitMessage, + uuid: "recovered-answer", + message: { + ...usageLimitMessage.message, + id: "api-recovered-answer", + model: "claude-sonnet-4-6", + content: [{ type: "text", text: "Recovered" }], + }, + }, + ]); + + const failures = updates + .map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure) + .filter(Boolean); + expect(failures).toEqual([ + expect.objectContaining({ + id: "s1:history-error:usage-limit-message", + category: "limit", + severity: "error", + }), + ]); + expect( + updates.some( + (update) => + update.update.sessionUpdate === "agent_message_chunk" && + update.update.content.type === "text" && + update.update.content.text.includes("individual spend limit"), + ), + ).toBe(false); + }); + + it("preserves the standard transcript for clients without the extension", async () => { + const { updates } = await replay([usageLimitMessage], false); + + expect(JSON.stringify(updates)).toContain("individual spend limit"); + expect(JSON.stringify(updates)).not.toContain("sessionFailure"); + }); + + it("clears the replayed failure after a successful follow-up", async () => { + const { agent, updates } = await replay([usageLimitMessage]); + const session = agent.sessions.s1; + expect([...session.sessionFailureState.active.values()]).toEqual([ + expect.objectContaining({ recoveryPolicy: "real_model_success" }), + ]); + const input = new Pushable(); + async function* messages() { + const iterator = input[Symbol.asyncIterator](); + const user = await iterator.next(); + yield userEcho(user.value); + yield { + ...usageLimitMessage, + uuid: "recovered-model-answer", + message: { + ...usageLimitMessage.message, + id: "api-recovered-model-answer", + model: "claude-sonnet-4-6", + usage: { + input_tokens: 1, + output_tokens: 1, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + content: [{ type: "text", text: "Recovered" }], + }, + }; + yield successfulResult(); + await iterator.next(); + } + session.query = wrapQuery(messages()); + session.input = input; + + await agent.prompt({ sessionId: "s1", prompt: [{ type: "text", text: "try again" }] }); + await vi.waitFor(() => { + expect(session.sessionFailureState.active.size).toBe(0); + }); + + const failures = updates + .map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure) + .filter(Boolean); + expect(failures).toHaveLength(1); + expect(session.sessionFailureState.active.has(failures[0].id)).toBe(false); + }); + + it("does not clear restored quota after a successful local command", async () => { + const { agent, updates } = await replay([usageLimitMessage]); + const session = agent.sessions.s1; + expect([...session.sessionFailureState.active.values()]).toEqual([ + expect.objectContaining({ recoveryPolicy: "real_model_success" }), + ]); + const input = new Pushable(); + async function* messages() { + const iterator = input[Symbol.asyncIterator](); + const user = await iterator.next(); + yield userEcho(user.value); + yield { + ...usageLimitMessage, + uuid: "local-command-result", + message: { + ...usageLimitMessage.message, + id: "api-local-command-result", + usage: { + input_tokens: 0, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + content: [{ type: "text", text: "No response requested." }], + }, + }; + yield successfulResult(); + await iterator.next(); + } + session.query = wrapQuery(messages()); + session.input = input; + + await agent.prompt({ sessionId: "s1", prompt: [{ type: "text", text: "/usage" }] }); + await vi.waitFor(() => { + expect(updates.some((update) => update.update.sessionUpdate === "usage_update")).toBe(true); + }); + + const failures = updates + .map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure) + .filter(Boolean); + const quotaFailure = failures.find((failure) => failure.category === "limit"); + expect(quotaFailure).toEqual(expect.objectContaining({ severity: "error", revision: 1 })); + expect(session.sessionFailureState.active.has(quotaFailure.id)).toBe(true); + }); + + it("replays every quota failure with its persisted turn id and keeps only the latest active", async () => { + const newerUsageLimitMessage = { + ...usageLimitMessage, + uuid: "newer-usage-limit-message", + message: { + ...usageLimitMessage.message, + id: "api-newer-usage-limit-message", + content: [{ type: "text", text: "You've reached your account usage limit" }], + }, + }; + const userMessage = (uuid: string) => ({ + type: "user" as const, + uuid, + session_id: "s1", + parent_tool_use_id: null, + parent_agent_id: null, + message: { role: "user", content: [{ type: "text", text: "try" }] }, + }); + const { agent, updates } = await replay([ + userMessage("turn-one"), + usageLimitMessage, + userMessage("turn-two"), + newerUsageLimitMessage, + ]); + + const failures = updates + .map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure) + .filter(Boolean); + expect(failures).toEqual([ + expect.objectContaining({ id: "turn-one:error", severity: "error", revision: 1 }), + expect.objectContaining({ id: "turn-two:error", severity: "error", revision: 1 }), + ]); + expect([...agent.sessions.s1.sessionFailureState.active.keys()]).toEqual(["turn-two:error"]); + }); + + it("does not record a restored failure when publishing it fails", async () => { + const errors: unknown[][] = []; + const agent = new ClaudeAcpAgent( + { + sessionUpdate: async () => { + throw new Error("client disconnected"); + }, + } as unknown as AcpClient, + { log: () => {}, error: (...args) => errors.push(args) }, + ); + (agent as any).clientCapabilities = airCapabilities; + agent.sessions.s1 = mockSessionState(); + vi.mocked(getSessionMessages).mockResolvedValueOnce([usageLimitMessage]); + + await ( + agent as unknown as { replaySessionHistory(sessionId: string): Promise } + ).replaySessionHistory("s1"); + + expect(agent.sessions.s1.sessionFailureState.active.size).toBe(0); + expect(agent.sessions.s1.sessionFailureState.revisions.size).toBe(0); + expect(errors.flat().join(" ")).toContain("failed to publish AIR session failure"); + }); + + it("recognizes a sanitized raw transcript through the real SDK parser", async () => { + const raw = await readFile( + new URL("./fixtures/usage-limit-session.jsonl", import.meta.url), + "utf8", + ); + const entries = raw + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const parsed = await getSessionMessages("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", { + sessionStore: { load: async () => entries } as any, + }); + + expect(entries[1]).toMatchObject({ error: "rate_limit", isApiErrorMessage: true }); + expect(parsed.find((message) => message.uuid === entries[1].uuid)).not.toHaveProperty("error"); + + const { updates } = await replay(parsed); + const failure = updates + .map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure) + .find(Boolean); + expect(failure).toEqual(expect.objectContaining({ category: "limit", severity: "error" })); + expect(failure.title).toContain("individual spend limit"); + }); +}); + describe("subagent transcript replay", () => { const replayHistory = [ { @@ -3158,7 +3479,10 @@ describe("stop reason propagation", () => { }; } - function createAssistantError(error: SDKAssistantMessage["error"]): SDKAssistantMessage { + function createAssistantError( + error: SDKAssistantMessage["error"], + text?: string, + ): SDKAssistantMessage { return { type: "assistant", parent_tool_use_id: null, @@ -3171,7 +3495,7 @@ describe("stop reason propagation", () => { role: "assistant", container: null, model: "claude-sonnet-4-20250514", - content: [], + content: text === undefined ? [] : [{ type: "text", text, citations: null }], stop_reason: "stop_sequence", stop_sequence: null, usage: { @@ -3190,6 +3514,19 @@ describe("stop reason propagation", () => { }; } + function createUsageLimitAssistantError(): SDKAssistantMessage { + const message = createAssistantError("rate_limit"); + message.message.model = ""; + message.message.content = [ + { + type: "text", + text: "You've hit your individual spend limit · run /usage-credits to ask your admin for a higher limit", + citations: null, + }, + ]; + return message; + } + const airSessionFailureCapabilities = { _meta: { jetbrains: { @@ -3983,7 +4320,7 @@ describe("stop reason propagation", () => { }); it.each([1, 2])( - "publishes a safe typed failure for compatible AIR version %i", + "publishes the Claude failure title for compatible AIR version %i", async (version) => { const updates: SessionNotification[] = []; const agent = new ClaudeAcpAgent( @@ -4005,7 +4342,7 @@ describe("stop reason propagation", () => { subtype: "success", stop_reason: "end_turn", is_error: true, - result: "raw provider secret must not be shown", + result: "Claude could not complete the request.", }), ]); @@ -4013,22 +4350,139 @@ describe("stop reason propagation", () => { sessionId: "test-session", prompt: [{ type: "text", text: "test" }], }); - expect(sessionFailureFromResponse(response)).toEqual( + const failure = sessionFailureFromResponse(response); + expect(failure).toEqual( expect.objectContaining({ revision: 1, - phase: "active", - category: "provider_error", - source: "claude", - safeMessage: "The model provider reported an error.", - retryable: true, + category: "service", + severity: "error", + title: "Claude could not complete the request.", actions: ["retry"], }), ); - expect(JSON.stringify(response)).not.toContain("raw provider secret"); + for (const removedField of [ + "phase", + "source", + "safeMessage", + "retryable", + "retryAfterMs", + "turnId", + ]) { + expect(failure).not.toHaveProperty(removedField); + } expect(JSON.stringify(updates)).not.toContain("sessionFailure"); }, ); + it("updates an api retry warning into the terminal failure", async () => { + const updates: SessionNotification[] = []; + const agent = new ClaudeAcpAgent( + { + sessionUpdate: async (update: SessionNotification) => updates.push(update), + } as unknown as AcpClient, + { log: () => {}, error: () => {} }, + ); + (agent as any).clientCapabilities = airSessionFailureCapabilities; + injectSession(agent, [ + { + type: "system", + subtype: "api_retry", + attempt: 1, + max_retries: 5, + retry_delay_ms: 100, + error_status: 429, + error: "rate_limit", + uuid: randomUUID(), + session_id: "test-session", + }, + createAssistantError("rate_limit", "Rate limit reached."), + createResultMessage({ + subtype: "success", + stop_reason: "end_turn", + is_error: true, + result: "Rate limit reached.", + }), + ]); + + const response = await agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "test" }], + }); + const warning = updates + .map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure) + .find(Boolean); + const terminal = sessionFailureFromResponse(response); + + expect(warning).toEqual( + expect.objectContaining({ + category: "limit", + severity: "warning", + revision: 1, + title: "Retrying Claude, attempt 1 of 5.", + actions: [], + }), + ); + expect(terminal).toEqual( + expect.objectContaining({ + id: warning.id, + category: "limit", + severity: "error", + revision: 2, + title: "Rate limit reached.", + actions: ["retry"], + }), + ); + }); + + it("clears a connection retry warning internally after the turn succeeds", async () => { + const updates: SessionNotification[] = []; + const agent = new ClaudeAcpAgent( + { + sessionUpdate: async (update: SessionNotification) => updates.push(update), + } as unknown as AcpClient, + { log: () => {}, error: () => {} }, + ); + (agent as any).clientCapabilities = airSessionFailureCapabilities; + injectSession(agent, [ + { + type: "system", + subtype: "api_retry", + attempt: 2, + max_retries: 5, + retry_delay_ms: 100, + error_status: null, + error: "unknown", + uuid: randomUUID(), + session_id: "test-session", + }, + createAssistantError(undefined, "Recovered."), + createResultMessage({ + subtype: "success", + stop_reason: "end_turn", + is_error: false, + result: "Recovered.", + }), + ]); + + await agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "test" }], + }); + const warning = updates + .map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure) + .find(Boolean); + + expect(warning).toEqual( + expect.objectContaining({ + category: "connection", + severity: "warning", + title: "Reconnecting to Claude, attempt 2 of 5.", + actions: [], + }), + ); + expect(agent.sessions["test-session"].sessionFailureState.active.has(warning.id)).toBe(false); + }); + it.each([ ["missing envelope", {}], ["missing capability", { _meta: { jetbrains: { air: { version: 1, capabilities: [] } } } }], @@ -4087,12 +4541,13 @@ describe("stop reason propagation", () => { }); it.each([ - ["authentication_failed", "auth_required", false], - ["billing_error", "quota_exhausted", false], - ["overloaded", "overloaded", true], - ["invalid_request", "bad_request", false], - ["max_output_tokens", "context_exhausted", false], - ] as const)("maps SDK error %s to %s", async (sdkError, expectedCategory, retryable) => { + ["authentication_failed", "access"], + ["billing_error", "limit"], + ["rate_limit", "limit"], + ["overloaded", "service"], + ["invalid_request", "request"], + ["max_output_tokens", "limit"], + ] as const)("maps SDK error %s to broad category %s", async (sdkError, expectedCategory) => { const updates: SessionNotification[] = []; const agent = new ClaudeAcpAgent( { @@ -4104,7 +4559,41 @@ describe("stop reason propagation", () => { ); (agent as any).clientCapabilities = airSessionFailureCapabilities; injectSession(agent, [ - createAssistantError(sdkError), + createAssistantError(sdkError, "Claude request failed."), + createResultMessage({ + subtype: "success", + stop_reason: "end_turn", + is_error: true, + result: "Claude request failed.", + }), + ]); + + const response = await agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "test" }], + }); + expect(sessionFailureFromResponse(response)).toEqual( + expect.objectContaining({ + category: expectedCategory, + severity: "error", + title: "Claude request failed.", + }), + ); + expect(JSON.stringify(updates)).not.toContain("sessionFailure"); + expect(JSON.stringify(updates)).not.toContain("Claude request failed."); + }); + + it("classifies a live synthetic spend limit as exhausted quota", async () => { + const updates: SessionNotification[] = []; + const agent = new ClaudeAcpAgent( + { + sessionUpdate: async (update: SessionNotification) => updates.push(update), + } as unknown as AcpClient, + { log: () => {}, error: () => {} }, + ); + (agent as any).clientCapabilities = airSessionFailureCapabilities; + injectSession(agent, [ + createUsageLimitAssistantError(), createResultMessage({ subtype: "success", stop_reason: "end_turn", @@ -4117,16 +4606,47 @@ describe("stop reason propagation", () => { sessionId: "test-session", prompt: [{ type: "text", text: "test" }], }); + expect(sessionFailureFromResponse(response)).toEqual( - expect.objectContaining({ category: expectedCategory, retryable }), + expect.objectContaining({ + category: "limit", + severity: "error", + title: + "You've hit your individual spend limit · run /usage-credits to ask your admin for a higher limit", + actions: [], + }), ); - expect(JSON.stringify(updates)).not.toContain("sessionFailure"); + expect(JSON.stringify(updates)).not.toContain("agent_message_chunk"); + }); + + it("preserves live usage-limit prose for clients without typed failures", async () => { + const updates: SessionNotification[] = []; + const agent = new ClaudeAcpAgent( + { + sessionUpdate: async (update: SessionNotification) => updates.push(update), + } as unknown as AcpClient, + { log: () => {}, error: () => {} }, + ); + injectSession(agent, [ + createUsageLimitAssistantError(), + createResultMessage({ + subtype: "success", + stop_reason: "end_turn", + is_error: true, + result: "provider detail", + }), + ]); + + await expect( + agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }), + ).rejects.toThrow("provider detail"); + expect(JSON.stringify(updates)).toContain("individual spend limit"); }); it.each([ - ["error_max_budget_usd", "budget_exhausted"], - ["error_max_turns", "provider_error"], - ["error_max_structured_output_retries", "provider_error"], + ["error_max_budget_usd", "limit"], + ["error_max_turns", "limit"], + ["error_max_structured_output_retries", "service"], ] as const)("maps terminal subtype %s to %s", async (subtype, expectedCategory) => { const updates: SessionNotification[] = []; const agent = new ClaudeAcpAgent( @@ -4143,7 +4663,7 @@ describe("stop reason propagation", () => { subtype, stop_reason: null, is_error: true, - errors: ["private provider detail"], + errors: ["Claude request failed."], }), ]); @@ -4152,12 +4672,12 @@ describe("stop reason propagation", () => { prompt: [{ type: "text", text: "test" }], }); expect(sessionFailureFromResponse(response)).toEqual( - expect.objectContaining({ category: expectedCategory }), + expect.objectContaining({ category: expectedCategory, title: "Claude request failed." }), ); expect(JSON.stringify(updates)).not.toContain("sessionFailure"); }); - it("clears auth_required after a successful auth_status without leaking auth output", async () => { + it("recovers auth_required internally after a successful auth_status", async () => { const updates: SessionNotification[] = []; const agent = new ClaudeAcpAgent( { @@ -4169,12 +4689,12 @@ describe("stop reason propagation", () => { ); (agent as any).clientCapabilities = airSessionFailureCapabilities; injectSession(agent, [ - createAssistantError("authentication_failed"), + createAssistantError("authentication_failed", "Authentication required."), createResultMessage({ subtype: "success", stop_reason: "end_turn", is_error: true, - result: "private auth detail", + result: "Authentication required.", }), { type: "auth_status", @@ -4191,14 +4711,16 @@ describe("stop reason propagation", () => { }); await agent.sessions["test-session"]?.consumer; const active = sessionFailureFromResponse(response); - const cleared = updates - .map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure) - .find(Boolean); - expect(active).toEqual(expect.objectContaining({ category: "auth_required", revision: 1 })); - expect(cleared).toEqual( - expect.objectContaining({ id: active.id, revision: 2, phase: "cleared" }), + expect(active).toEqual( + expect.objectContaining({ + category: "access", + revision: 1, + title: "Authentication required.", + }), ); - expect(JSON.stringify({ response, updates })).not.toContain("private"); + expect(JSON.stringify(updates)).not.toContain("sessionFailure"); + expect(agent.sessions["test-session"].sessionFailureState.active.has(active.id)).toBe(false); + expect(JSON.stringify({ response, updates })).not.toContain("private login token"); }); it("keeps auth_required active when auth_status reports an error", async () => { @@ -4213,12 +4735,12 @@ describe("stop reason propagation", () => { ); (agent as any).clientCapabilities = airSessionFailureCapabilities; injectSession(agent, [ - createAssistantError("authentication_failed"), + createAssistantError("authentication_failed", "Authentication required."), createResultMessage({ subtype: "success", stop_reason: "end_turn", is_error: true, - result: "private auth detail", + result: "Authentication required.", }), { type: "auth_status", @@ -4236,13 +4758,14 @@ describe("stop reason propagation", () => { }); await agent.sessions["test-session"]?.consumer; expect(sessionFailureFromResponse(response)).toEqual( - expect.objectContaining({ category: "auth_required", phase: "active" }), + expect.objectContaining({ category: "access", severity: "error" }), ); expect(JSON.stringify(updates)).not.toContain("sessionFailure"); - expect(JSON.stringify({ response, updates })).not.toContain("private"); + expect(JSON.stringify({ response, updates })).not.toContain("private login token"); + expect(JSON.stringify({ response, updates })).not.toContain("private login error"); }); - it("clears the active failure with the same id and a higher revision after recovery", async () => { + it("keeps the historical failure record unchanged after recovery", async () => { const updates: SessionNotification[] = []; const agent = new ClaudeAcpAgent( { @@ -4304,18 +4827,12 @@ describe("stop reason propagation", () => { .map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure) .filter(Boolean); const active = sessionFailureFromResponse(failed); - expect(active).toEqual(expect.objectContaining({ revision: 1, phase: "active" })); - expect(failures).toHaveLength(1); - expect(failures[0]).toEqual( - expect.objectContaining({ - id: active.id, - revision: 2, - phase: "cleared", - }), - ); + expect(active).toEqual(expect.objectContaining({ revision: 1, severity: "error" })); + expect(failures).toEqual([]); + expect(agent.sessions["test-session"].sessionFailureState.active.has(active.id)).toBe(false); }); - it("clears a previous turn failure when the next turn is refused", async () => { + it("does not publish a clear record when the next turn is refused", async () => { const updates: SessionNotification[] = []; const agent = new ClaudeAcpAgent( { @@ -4371,23 +4888,15 @@ describe("stop reason propagation", () => { .map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure) .filter(Boolean); const active = sessionFailureFromResponse(failed); - expect(failures).toHaveLength(1); - expect(failures[0]).toEqual( - expect.objectContaining({ id: active.id, revision: 2, phase: "cleared" }), - ); + expect(failures).toEqual([]); + expect(agent.sessions["test-session"].sessionFailureState.active.has(active.id)).toBe(false); }); - it("retries a clear whose first delivery failed", async () => { + it("does not publish clear records on repeated successful turns", async () => { const updates: SessionNotification[] = []; - let rejectFirstClear = true; const agent = new ClaudeAcpAgent( { sessionUpdate: async (update: SessionNotification) => { - const failure = (update.update._meta as any)?.jetbrains?.air?.sessionFailure; - if (failure?.phase === "cleared" && rejectFirstClear) { - rejectFirstClear = false; - throw new Error("temporary client write failure"); - } updates.push(update); }, } as unknown as AcpClient, @@ -4444,10 +4953,8 @@ describe("stop reason propagation", () => { .map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure) .filter(Boolean); const active = sessionFailureFromResponse(failed); - expect(failures).toHaveLength(1); - expect(failures[0]).toEqual( - expect.objectContaining({ id: active.id, revision: 2, phase: "cleared" }), - ); + expect(failures).toEqual([]); + expect(agent.sessions["test-session"].sessionFailureState.active.has(active.id)).toBe(false); }); it("logs loudly when a failure has no active turn to reject", async () => { @@ -4520,8 +5027,10 @@ describe("stop reason propagation", () => { expect(sessionFailureFromResponse(response)).toBeUndefined(); const failure = updates .map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure) - .find((candidate) => candidate?.category === "worker_shutdown"); - expect(failure).toEqual(expect.objectContaining({ phase: "active", revision: 1 })); + .find((candidate) => candidate?.title === "The Claude worker is shutting down."); + expect(failure).toEqual( + expect.objectContaining({ category: "connection", severity: "error", revision: 1 }), + ); }); it("returns an active-turn worker shutdown on PromptResponse only at live tail", async () => { @@ -4576,9 +5085,9 @@ describe("stop reason propagation", () => { const response = await prompt; const failure = sessionFailureFromResponse(response); expect(failure).toEqual( - expect.objectContaining({ category: "worker_shutdown", phase: "active", revision: 1 }), + expect.objectContaining({ category: "connection", severity: "error", revision: 1 }), ); - expect(failure).toHaveProperty("turnId"); + expect(failure).not.toHaveProperty("turnId"); expect(JSON.stringify(updates)).not.toContain("sessionFailure"); }); @@ -4613,8 +5122,10 @@ describe("stop reason propagation", () => { expect(sessionFailureFromResponse(response)).toBeUndefined(); const failure = updates .map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure) - .find((candidate) => candidate?.category === "worker_shutdown"); - expect(failure).toEqual(expect.objectContaining({ phase: "active", revision: 1 })); + .find((candidate) => candidate?.title === "The Claude worker is shutting down."); + expect(failure).toEqual( + expect.objectContaining({ category: "connection", severity: "error", revision: 1 }), + ); expect(failure).not.toHaveProperty("turnId"); }); @@ -4704,13 +5215,13 @@ describe("stop reason propagation", () => { await consumer; return updates .map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure) - .find((candidate) => candidate?.category === "worker_shutdown"); + .find((candidate) => candidate?.title === "The Claude worker is shutting down."); }; const first = await runWorkerTail(); const restarted = await runWorkerTail(); - expect(first).toEqual(expect.objectContaining({ revision: 1, phase: "active" })); - expect(restarted).toEqual(expect.objectContaining({ revision: 1, phase: "active" })); + expect(first).toEqual(expect.objectContaining({ revision: 1, severity: "error" })); + expect(restarted).toEqual(expect.objectContaining({ revision: 1, severity: "error" })); expect(restarted.id).not.toBe(first.id); }); @@ -4750,8 +5261,9 @@ describe("stop reason propagation", () => { }); expect(sessionFailureFromResponse(response)).toEqual( expect.objectContaining({ - category: "transport_lost", - retryable: false, + category: "connection", + severity: "error", + title: "The connection to Claude was lost.", actions: ["new_session"], }), ); @@ -5023,25 +5535,55 @@ describe("model refusal fallback handling", () => { const advisory = updates .map((u: any) => u._meta?.jetbrains?.air?.sessionFailure) - .find((record: any) => record?.category === "advisory"); + .find((record: any) => record?.severity === "warning"); expect(advisory).toEqual( expect.objectContaining({ - category: "advisory", + category: "unknown", severity: "warning", - phase: "active", - source: "claude", - retryable: false, actions: [], }), ); // Its own wording, carried through instead of the canned per-category message. - expect(advisory.safeMessage).toContain("claude-fable-5"); - expect(advisory.safeMessage).toContain("claude-opus-4-8"); - expect(advisory.safeMessage).toContain("(cyber)"); + expect(advisory.title).toContain("claude-fable-5"); + expect(advisory.title).toContain("claude-opus-4-8"); + expect(advisory.title).toContain("(cyber)"); // No markdown lead-in: the banner supplies the severity, so the text must not fake one. - expect(advisory.safeMessage).not.toContain("**"); + expect(advisory.title).not.toContain("**"); // Its own id namespace, so it never collides with a turn's error record. expect(advisory.id).toContain(":notice:"); + expect( + updates + .map((u: any) => u._meta?.jetbrains?.air?.sessionFailure) + .filter((record: any) => record?.id === advisory.id), + ).toEqual([advisory]); + }); + + it("puts only a genuinely long model fallback explanation in details", async () => { + const { agent, sessionUpdate } = createCapturingAgent(); + await agent.initialize({ + protocolVersion: 1, + clientCapabilities: { + _meta: { jetbrains: { air: { version: 1, capabilities: ["sessionFailure"] } } }, + }, + }); + const explanation = "Long provider explanation. ".repeat(20); + injectGeneratorSession( + agent, + makeGenerator([ + refusalFallbackMessage({ api_refusal_explanation: explanation }), + successResult(), + ]), + modelStateOverrides, + ); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const advisory = sessionUpdate.mock.calls + .map((call: any[]) => (call[0] as { update: any }).update) + .map((update: any) => update._meta?.jetbrains?.air?.sessionFailure) + .find((record: any) => record?.severity === "warning"); + expect(advisory.title).not.toContain(explanation); + expect(advisory.details).toBe(explanation); }); it("tracks the raw model id when the fallback model is not among the options", async () => { @@ -5521,6 +6063,7 @@ describe("session/close", () => { emittedAssistantText: false, owedTrailingIdles: 0, messageIdToUuid: new Map(), + sessionFailureState: { epoch: randomUUID(), revisions: new Map(), active: new Map() }, }; return agent.sessions[sessionId]!; } @@ -5614,6 +6157,7 @@ describe("session/delete", () => { emittedAssistantText: false, owedTrailingIdles: 0, messageIdToUuid: new Map(), + sessionFailureState: { epoch: randomUUID(), revisions: new Map(), active: new Map() }, }; return agent.sessions[sessionId]!; } @@ -5724,6 +6268,7 @@ describe("getOrCreateSession param change detection", () => { emittedAssistantText: false, owedTrailingIdles: 0, messageIdToUuid: new Map(), + sessionFailureState: { epoch: randomUUID(), revisions: new Map(), active: new Map() }, }; return agent.sessions[sessionId]!; } @@ -8582,6 +9127,7 @@ describe("post-error recovery", () => { emittedAssistantText: false, owedTrailingIdles: 0, messageIdToUuid: new Map(), + sessionFailureState: { epoch: randomUUID(), revisions: new Map(), active: new Map() }, }; return { interrupt }; } @@ -10742,11 +11288,11 @@ describe("deferred settlement for live background subagents (issues #864/#866)", await agent.sessions["test-session"]?.consumer; const failure = updates .map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure) - .find((candidate) => candidate?.category === "transport_lost"); + .find((candidate) => candidate?.title === "The connection to Claude was lost."); expect(failure).toEqual( expect.objectContaining({ - phase: "active", - retryable: false, + category: "connection", + severity: "error", actions: ["new_session"], }), ); @@ -10802,11 +11348,11 @@ describe("deferred settlement for live background subagents (issues #864/#866)", await agent.sessions["test-session"]?.consumer; const failure = updates .map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure) - .find((candidate) => candidate?.category === "worker_shutdown"); + .find((candidate) => candidate?.title === "The Claude worker is shutting down."); expect(failure).toEqual( expect.objectContaining({ - phase: "active", - retryable: false, + category: "connection", + severity: "error", actions: ["new_session"], }), ); @@ -12360,6 +12906,7 @@ describe("session/cancel wedge recovery (issue #680)", () => { emittedAssistantText: false, owedTrailingIdles: 0, messageIdToUuid: new Map(), + sessionFailureState: { epoch: randomUUID(), revisions: new Map(), active: new Map() }, }; return { interrupt }; } @@ -13796,6 +14343,7 @@ describe("agent selection config option", () => { emittedAssistantText: false, owedTrailingIdles: 0, messageIdToUuid: new Map(), + sessionFailureState: { epoch: randomUUID(), revisions: new Map(), active: new Map() }, }; return { session: agent.sessions[sessionId]!, applyFlagSettings }; } diff --git a/src/tests/fixtures/usage-limit-session.jsonl b/src/tests/fixtures/usage-limit-session.jsonl new file mode 100644 index 00000000..6e67ef53 --- /dev/null +++ b/src/tests/fixtures/usage-limit-session.jsonl @@ -0,0 +1,3 @@ +{"type":"user","uuid":"11111111-1111-4111-8111-111111111111","parentUuid":null,"sessionId":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-01-01T00:00:00.000Z","message":{"role":"user","content":[{"type":"text","text":"Create a short HTML page"}]}} +{"type":"assistant","uuid":"22222222-2222-4222-8222-222222222222","parentUuid":"11111111-1111-4111-8111-111111111111","sessionId":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-01-01T00:00:01.000Z","error":"rate_limit","isApiErrorMessage":true,"message":{"id":"msg_usage_limit","type":"message","role":"assistant","model":"","content":[{"type":"text","text":"You've hit your individual spend limit · run /usage-credits to ask your admin for a higher limit"}],"stop_reason":"stop_sequence","stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}} +{"type":"assistant","uuid":"33333333-3333-4333-8333-333333333333","parentUuid":"22222222-2222-4222-8222-222222222222","sessionId":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-01-01T00:00:02.000Z","message":{"id":"msg_local_command","type":"message","role":"assistant","model":"","content":[{"type":"text","text":"No response requested."}],"stop_reason":"stop_sequence","stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}