feat: align typed session failures with AIR protocol (#992)

## Summary

- align `_meta.jetbrains.air.sessionFailure` with AIR's
transcript-record protocol: `id`, `revision`, broad `category`, explicit
`severity`, `title`, optional `details`, and `actions`
- forward Claude's actual user-facing assistant/result text as `title`;
use adapter-authored fallback text only when Claude provides no message
(transport failure, worker EOF, or an internal invariant)
- reduce categories to `connection`, `access`, `limit`, `request`,
`service`, and `unknown`
- limit suggested actions to `retry`, `login`, and `new_session`
- update one transcript record through `id + revision`, keep resolved
entries in history, and remove clear/tombstone updates
- suppress duplicate assistant failure prose only for clients that
negotiated typed failures; preserve legacy ACP behavior otherwise
- restore synthetic usage-limit failures from session history with their
exact stored Claude text
- document negotiation, delivery carriers, identity/revision behavior,
mappings, recovery, and compatibility in the extension spec

## Validation

- `npm run check`
- `npm run build`
- `npm run test:run` — 777 passed, 26 skipped
- `git diff --check`

## Tracking

[IJAI-993](https://youtrack.jetbrains.com/issue/IJAI-993)
This commit is contained in:
nikita-ashihmin
2026-08-14 17:29:39 +04:00
committed by GitHub
parent 11dd73b871
commit 0581b9cf39
6 changed files with 1503 additions and 377 deletions

View File

@@ -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) - Custom [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands)
- Client MCP servers - Client MCP servers
- Session-scoped long-running goals through the provider-neutral [goal extension](docs/goal-extension.md) - 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/). Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/).

View File

@@ -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 `<turnId>: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
`<sessionId>:history-error:<messageUuid>`.
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 `<synthetic>` 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
```

View File

@@ -121,6 +121,20 @@ import {
refusalFallbackToCreateRequest, refusalFallbackToCreateRequest,
} from "./elicitation.js"; } from "./elicitation.js";
import { SettingsManager } from "./settings.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 { import {
applyTaskCreate, applyTaskCreate,
applyTaskList, applyTaskList,
@@ -147,6 +161,7 @@ export const CLAUDE_CONFIG_DIR =
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const MAX_TITLE_LENGTH = 256; const MAX_TITLE_LENGTH = 256;
const MAX_INLINE_FAILURE_TITLE_LENGTH = 256;
function sanitizeTitle(text: string): string { function sanitizeTitle(text: string): string {
// Replace newlines and collapse whitespace // 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 * NOT READ YET — recorded now so the mapping exists if/when we wire up
* fork/rewind. */ * fork/rewind. */
messageIdToUuid: Map<string, string>; messageIdToUuid: Map<string, string>;
/** 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 /** Result-message origin kinds that mark an AUTONOMOUS cycle — work the
@@ -921,149 +940,11 @@ export type ToolUpdateMeta = {
}; };
const SUBAGENT_TRANSCRIPT_CAPABILITY = "subagent-transcript"; 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 { function supportsSubagentTranscript(capabilities?: ClientCapabilities | null): boolean {
return capabilities?._meta?.[SUBAGENT_TRANSCRIPT_CAPABILITY] === true; 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<string, unknown> | undefined;
const air = jetbrains?.[AIR_META_KEY] as Record<string, unknown> | 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 { function parentToolUseIdOf(message: { parent_tool_use_id?: unknown }): string | null {
if (!("parent_tool_use_id" in message)) return null; if (!("parent_tool_use_id" in message)) return null;
return typeof message.parent_tool_use_id === "string" ? message.parent_tool_use_id : 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 // steering extension contract: advertises the `_session/steering` request
// so clients know they may inject a follow-up into a running turn. // so clients know they may inject a follow-up into a running turn.
_meta: { _meta: {
[JETBRAINS_META_KEY]: { ...airSessionFailureCapabilityMeta(),
[AIR_META_KEY]: {
[AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION,
[AIR_EXTENSION_CAPABILITIES_KEY]: [AIR_SESSION_FAILURE_KEY],
},
},
steering: { steering: {
supported: true, supported: true,
}, },
@@ -2284,6 +2160,8 @@ export class ClaudeAcpAgent {
// it here so the subsequent `RequestError.internalError` can forward it to // it here so the subsequent `RequestError.internalError` can forward it to
// clients as structured `data`, sparing them from pattern-matching on text. // clients as structured `data`, sparing them from pattern-matching on text.
let lastAssistantError: SDKAssistantMessageError | undefined; let lastAssistantError: SDKAssistantMessageError | undefined;
let lastAssistantWasUsageLimit = false;
let lastAssistantFailureTitle: string | undefined;
// When a streaming classifier refuses a turn, the assistant message carries // When a streaming classifier refuses a turn, the assistant message carries
// stop_reason "refusal" and structured stop_details. We capture the // stop_reason "refusal" and structured stop_details. We capture the
// human-readable explanation so the terminal `result` can surface it. // human-readable explanation so the terminal `result` can surface it.
@@ -2339,140 +2217,60 @@ export class ClaudeAcpAgent {
await this.client.sessionUpdate(notification); 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<string, number>();
const sessionFailureEpoch = randomUUID();
const activeSessionFailures = new Map<string, PublishedSessionFailure>();
let pendingWorkerShutdown = false; let pendingWorkerShutdown = false;
const isCurrentConsumer = () => this.sessions[params.sessionId] === session; const isCurrentConsumer = () => this.sessions[params.sessionId] === session;
const sessionFailures = new SessionFailureController({
const sessionFailureMeta = (failure: PublishedSessionFailure, phase: "active" | "cleared") => { sessionId: params.sessionId,
const presentation = AIR_FAILURE_PRESENTATION[failure.category]; state: session.sessionFailureState,
return { capabilities: this.clientCapabilities,
[JETBRAINS_META_KEY]: { isCurrent: isCurrentConsumer,
[AIR_META_KEY]: { sendUpdate,
[AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION, logger: this.logger,
[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<boolean> => {
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 createSessionFailure = async ( const createSessionFailure = async (
category: AirSessionFailureCategory, kind: ClaudeFailureKind,
options: { turnScoped?: boolean; safeMessage?: string } = {}, options: {
turnScoped?: boolean;
title?: string;
details?: string;
severity?: "warning" | "error";
} = {},
): Promise<PublishedSessionFailure | undefined> => { ): Promise<PublishedSessionFailure | undefined> => {
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 turnId = options.turnScoped === false ? undefined : session.activeTurn?.promptUuid;
const id = turnId return sessionFailures.prepare(kind, {
? `${turnId}:error` turnId,
: `${params.sessionId}:session-error:${sessionFailureEpoch}`; sessionScoped: options.turnScoped === false,
await clearSessionFailure((failure) => failure.id !== id && !isAdvisory(failure)); title: options.title,
return { details: options.details,
id, severity: options.severity,
revision: (failureRevisions.get(id) ?? 0) + 1, });
category,
...(turnId ? { turnId } : {}),
};
}; };
const publishSessionFailure = async ( const publishSessionFailure = async (
category: AirSessionFailureCategory, kind: ClaudeFailureKind,
options: { turnScoped?: boolean; safeMessage?: string } = {}, options: {
turnScoped?: boolean;
title?: string;
details?: string;
severity?: "warning" | "error";
} = {},
) => { ) => {
const failure = await createSessionFailure(category, options); const turnId = options.turnScoped === false ? undefined : session.activeTurn?.promptUuid;
if (!failure) return; await sessionFailures.publish(kind, {
if (await emitSessionFailure(failure, "active")) { turnId,
failureRevisions.set(failure.id, failure.revision); sessionScoped: options.turnScoped === false,
activeSessionFailures.set(failure.id, failure); title: options.title,
} details: options.details,
severity: options.severity,
});
}; };
const clearFailuresFromEarlierTurns = async () => { const clearFailuresFromEarlierTurns = async () => {
const activeTurnId = session.activeTurn?.promptUuid; const activeTurnId = session.activeTurn?.promptUuid;
// Advisories carry no turnId, so without the guard every turn boundary would sweep them away. // 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. // They are session-scoped and stay until superseded or dismissed by the user.
await clearSessionFailure( await sessionFailures.clear(
(failure) => failure.turnId !== activeTurnId && !isAdvisory(failure), (failure) => failure.recoveryPolicy === "next_attempt" && failure.turnId !== activeTurnId,
); );
}; };
@@ -2487,6 +2285,8 @@ export class ClaudeAcpAgent {
lastAssistantUsage = null; lastAssistantUsage = null;
lastAssistantModel = null; lastAssistantModel = null;
lastAssistantError = undefined; lastAssistantError = undefined;
lastAssistantWasUsageLimit = false;
lastAssistantFailureTitle = undefined;
lastRefusalExplanation = null; lastRefusalExplanation = null;
compactionInProgress = false; compactionInProgress = false;
// Do NOT reset currentStreamMessageId or streamedBlocks here. Turn // 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 * which is the canonical AIR carrier. Legacy clients keep the historical
* JSON-RPC rejection path. */ * JSON-RPC rejection path. */
const failActiveWithSessionFailure = async ( const failActiveWithSessionFailure = async (
category: AirSessionFailureCategory, kind: ClaudeFailureKind,
error: unknown, error: unknown,
title?: string,
) => { ) => {
if (!supportsAirSessionFailures(this.clientCapabilities)) { if (!supportsAirSessionFailures(this.clientCapabilities)) {
failActive(error); failActive(error);
@@ -2805,22 +2606,21 @@ export class ClaudeAcpAgent {
} }
if (!session.activeTurn || session.activeTurn.settled) { if (!session.activeTurn || session.activeTurn.settled) {
this.logger.error( 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; return;
} }
const failure = await createSessionFailure(category); const failure = await createSessionFailure(kind, { title });
if (!failure) { if (!failure) {
failActive(error); failActive(error);
return; return;
} }
failureRevisions.set(failure.id, failure.revision); sessionFailures.recordActive(failure);
activeSessionFailures.set(failure.id, failure);
settleActive({ settleActive({
stopReason: "end_turn", stopReason: "end_turn",
usage: sessionUsage(session), usage: sessionUsage(session),
_meta: sessionFailureMeta(failure, "active"), _meta: sessionFailureMeta(failure),
}); });
}; };
@@ -3344,6 +3144,7 @@ export class ClaudeAcpAgent {
errorKindData("no_result"), errorKindData("no_result"),
TURN_NO_RESULT_MESSAGE, TURN_NO_RESULT_MESSAGE,
), ),
TURN_NO_RESULT_MESSAGE,
); );
} }
// The SDK generates the session title in a background task and // The SDK generates the session title in a background task and
@@ -3581,10 +3382,25 @@ export class ClaudeAcpAgent {
} }
case "plugin_install": case "plugin_install":
case "notification": case "notification":
case "api_retry":
case "thinking_tokens": case "thinking_tokens":
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output // Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
break; 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": { case "model_refusal_fallback": {
// The SDK retried a refused turn on the fallback model and made // The SDK retried a refused turn on the fallback model and made
// the swap persistent for the session. Without a notice the // the swap persistent for the session. Without a notice the
@@ -3610,22 +3426,30 @@ export class ClaudeAcpAgent {
const category = message.api_refusal_category const category = message.api_refusal_category
? ` (${message.api_refusal_category})` ? ` (${message.api_refusal_category})`
: ""; : "";
const explanation = message.api_refusal_explanation
? `\n\n${message.api_refusal_explanation}`
: "";
const outcome = persistent const outcome = persistent
? `The session will continue on ${message.fallback_model}.` ? `The session will continue on ${message.fallback_model}.`
: local : local
? `Only that response came from ${message.fallback_model}; the session stays on ${message.original_model}.` ? `Only that response came from ${message.fallback_model}; the session stays on ${message.original_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}; ` + `${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. // 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 // 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. // transcript line, which was the only way to flag it before.
if (supportsAirSessionFailures(this.clientCapabilities)) { 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 { } else {
await sendUpdate({ await sendUpdate({
sessionId: message.session_id, sessionId: message.session_id,
@@ -3983,8 +3807,13 @@ export class ClaudeAcpAgent {
break; break;
} }
if (!message.is_error) { if (!message.is_error && lastAssistantModel !== null) {
await clearSessionFailure(); const activeTurnId = session.activeTurn?.promptUuid;
await sessionFailures.clear(
(failure) =>
failure.recoveryPolicy === "real_model_success" ||
(failure.severity === "warning" && failure.turnId === activeTurnId),
);
} }
switch (message.subtype) { switch (message.subtype) {
@@ -3993,6 +3822,7 @@ export class ClaudeAcpAgent {
await failActiveWithSessionFailure( await failActiveWithSessionFailure(
"auth_required", "auth_required",
RequestError.authRequired(), RequestError.authRequired(),
message.result,
); );
break; break;
} }
@@ -4002,8 +3832,9 @@ export class ClaudeAcpAgent {
} }
if (message.is_error) { if (message.is_error) {
await failActiveWithSessionFailure( await failActiveWithSessionFailure(
providerFailureCategory(lastAssistantError), providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit),
internalErrorForClient(errorKindData(lastAssistantError), message.result), internalErrorForClient(errorKindData(lastAssistantError), message.result),
lastAssistantFailureTitle ?? message.result,
); );
break; break;
} }
@@ -4050,11 +3881,12 @@ export class ClaudeAcpAgent {
} }
if (message.is_error) { if (message.is_error) {
await failActiveWithSessionFailure( await failActiveWithSessionFailure(
providerFailureCategory(lastAssistantError), providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit),
internalErrorForClient( internalErrorForClient(
errorKindData(lastAssistantError), errorKindData(lastAssistantError),
message.errors.join(", ") || message.subtype, message.errors.join(", ") || message.subtype,
), ),
lastAssistantFailureTitle ?? (message.errors.join(", ") || message.subtype),
); );
break; break;
} }
@@ -4069,6 +3901,7 @@ export class ClaudeAcpAgent {
errorKindData(lastAssistantError), errorKindData(lastAssistantError),
message.errors.join(", ") || message.subtype, message.errors.join(", ") || message.subtype,
), ),
message.errors.join(", ") || message.subtype,
); );
break; break;
} }
@@ -4077,11 +3910,12 @@ export class ClaudeAcpAgent {
case "error_max_turns": case "error_max_turns":
if (message.is_error) { if (message.is_error) {
await failActiveWithSessionFailure( await failActiveWithSessionFailure(
"provider_error", "context_exhausted",
internalErrorForClient( internalErrorForClient(
errorKindData(lastAssistantError), errorKindData(lastAssistantError),
message.errors.join(", ") || message.subtype, message.errors.join(", ") || message.subtype,
), ),
message.errors.join(", ") || message.subtype,
); );
break; break;
} }
@@ -4095,6 +3929,7 @@ export class ClaudeAcpAgent {
errorKindData(lastAssistantError), errorKindData(lastAssistantError),
message.errors.join(", ") || message.subtype, message.errors.join(", ") || message.subtype,
), ),
message.errors.join(", ") || message.subtype,
); );
break; break;
} }
@@ -4385,6 +4220,10 @@ export class ClaudeAcpAgent {
if (message.type === "assistant" && message.parent_tool_use_id === null) { if (message.type === "assistant" && message.parent_tool_use_id === null) {
lastAssistantUsage = snapshotFromUsage(message.message.usage); lastAssistantUsage = snapshotFromUsage(message.message.usage);
lastAssistantTotalUsage = totalTokens(lastAssistantUsage); lastAssistantTotalUsage = totalTokens(lastAssistantUsage);
lastAssistantWasUsageLimit = isSyntheticUsageLimitMessage(message.message);
if (message.error || lastAssistantWasUsageLimit) {
lastAssistantFailureTitle = assistantMessageText(message.message);
}
if (message.message.model && message.message.model !== "<synthetic>") { if (message.message.model && message.message.model !== "<synthetic>") {
lastAssistantModel = message.message.model; lastAssistantModel = message.message.model;
} }
@@ -4459,7 +4298,25 @@ export class ClaudeAcpAgent {
} }
if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) { 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; break;
} }
@@ -4649,7 +4506,7 @@ export class ClaudeAcpAgent {
break; break;
case "auth_status": case "auth_status":
if (!message.isAuthenticating && message.error === undefined) { if (!message.isAuthenticating && message.error === undefined) {
await clearSessionFailure((failure) => failure.category === "auth_required"); await sessionFailures.clear((failure) => failure.kind === "auth_required");
} }
break; break;
default: default:
@@ -5199,11 +5056,33 @@ export class ClaudeAcpAgent {
private async replaySessionHistory(sessionId: string): Promise<void> { private async replaySessionHistory(sessionId: string): Promise<void> {
const toolUseCache: ToolUseCache = {}; const toolUseCache: ToolUseCache = {};
const messages = await getSessionMessages(sessionId); const messages = await getSessionMessages(sessionId);
const session = this.sessions[sessionId];
const forwardSubagentText = const forwardSubagentText =
this.sessions[sessionId]?.forwardSubagentText ?? session?.forwardSubagentText ?? supportsSubagentTranscript(this.clientCapabilities);
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) { 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 // Backfill the ACP messageId -> SDK uuid mapping for messages we didn't
// observe live (resumed/loaded sessions), so rewind/resume can translate // observe live (resumed/loaded sessions), so rewind/resume can translate
// a client-supplied id without an extra getSessionMessages read. Not read // a client-supplied id without an extra getSessionMessages read. Not read
@@ -5221,6 +5100,29 @@ export class ClaudeAcpAgent {
continue; 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 // @ts-expect-error - untyped in SDK but we handle all of these
let content: unknown = message.message.content; let content: unknown = message.message.content;
const parentToolUseId = parentToolUseIdOf(message); const parentToolUseId = parentToolUseIdOf(message);
@@ -6599,6 +6501,7 @@ export class ClaudeAcpAgent {
emittedAssistantText: false, emittedAssistantText: false,
owedTrailingIdles: 0, owedTrailingIdles: 0,
messageIdToUuid: new Map(), messageIdToUuid: new Map(),
sessionFailureState: createSessionFailureState(),
}; };
return { return {

View File

@@ -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<string, number>;
active: Map<string, PublishedSessionFailure>;
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 !== "<synthetic>") 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<ReturnType<typeof getSessionMessages>>,
): { 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 !== "<synthetic>") active = undefined;
}
return active;
}
export function supportsAirSessionFailures(capabilities?: ClientCapabilities): boolean {
const jetbrains = capabilities?._meta?.[JETBRAINS_META_KEY] as
Record<string, unknown> | undefined;
const air = jetbrains?.[AIR_META_KEY] as Record<string, unknown> | 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<void>;
private readonly logger: Logger;
constructor(options: {
sessionId: string;
state: SessionFailureState;
capabilities?: ClientCapabilities;
isCurrent: () => boolean;
sendUpdate: (notification: SessionNotification) => Promise<void>;
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<boolean> {
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<boolean> {
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<PublishedSessionFailure | undefined> {
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<void> {
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<boolean> {
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";
}
}

View File

@@ -54,6 +54,7 @@ import {
SDKAssistantMessage, SDKAssistantMessage,
} from "@anthropic-ai/claude-agent-sdk"; } from "@anthropic-ai/claude-agent-sdk";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { readFile } from "node:fs/promises";
import { import {
GOAL_CONTROL_METHOD, GOAL_CONTROL_METHOD,
goalUpdateFromPrompt, goalUpdateFromPrompt,
@@ -173,6 +174,7 @@ function mockSessionState(overrides: Record<string, any> = {}) {
emittedAssistantText: false, emittedAssistantText: false,
owedTrailingIdles: 0, owedTrailingIdles: 0,
messageIdToUuid: new Map(), messageIdToUuid: new Map(),
sessionFailureState: { epoch: randomUUID(), revisions: new Map(), active: new Map() },
...overrides, ...overrides,
} as any; } 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: "<synthetic>",
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<ReturnType<typeof getSessionMessages>>, 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<void> }
).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<any>();
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<any>();
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<void> }
).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", () => { describe("subagent transcript replay", () => {
const replayHistory = [ 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 { return {
type: "assistant", type: "assistant",
parent_tool_use_id: null, parent_tool_use_id: null,
@@ -3171,7 +3495,7 @@ describe("stop reason propagation", () => {
role: "assistant", role: "assistant",
container: null, container: null,
model: "claude-sonnet-4-20250514", model: "claude-sonnet-4-20250514",
content: [], content: text === undefined ? [] : [{ type: "text", text, citations: null }],
stop_reason: "stop_sequence", stop_reason: "stop_sequence",
stop_sequence: null, stop_sequence: null,
usage: { usage: {
@@ -3190,6 +3514,19 @@ describe("stop reason propagation", () => {
}; };
} }
function createUsageLimitAssistantError(): SDKAssistantMessage {
const message = createAssistantError("rate_limit");
message.message.model = "<synthetic>";
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 = { const airSessionFailureCapabilities = {
_meta: { _meta: {
jetbrains: { jetbrains: {
@@ -3983,7 +4320,7 @@ describe("stop reason propagation", () => {
}); });
it.each([1, 2])( 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) => { async (version) => {
const updates: SessionNotification[] = []; const updates: SessionNotification[] = [];
const agent = new ClaudeAcpAgent( const agent = new ClaudeAcpAgent(
@@ -4005,7 +4342,7 @@ describe("stop reason propagation", () => {
subtype: "success", subtype: "success",
stop_reason: "end_turn", stop_reason: "end_turn",
is_error: true, 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", sessionId: "test-session",
prompt: [{ type: "text", text: "test" }], prompt: [{ type: "text", text: "test" }],
}); });
expect(sessionFailureFromResponse(response)).toEqual( const failure = sessionFailureFromResponse(response);
expect(failure).toEqual(
expect.objectContaining({ expect.objectContaining({
revision: 1, revision: 1,
phase: "active", category: "service",
category: "provider_error", severity: "error",
source: "claude", title: "Claude could not complete the request.",
safeMessage: "The model provider reported an error.",
retryable: true,
actions: ["retry"], 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"); 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([ it.each([
["missing envelope", {}], ["missing envelope", {}],
["missing capability", { _meta: { jetbrains: { air: { version: 1, capabilities: [] } } } }], ["missing capability", { _meta: { jetbrains: { air: { version: 1, capabilities: [] } } } }],
@@ -4087,12 +4541,13 @@ describe("stop reason propagation", () => {
}); });
it.each([ it.each([
["authentication_failed", "auth_required", false], ["authentication_failed", "access"],
["billing_error", "quota_exhausted", false], ["billing_error", "limit"],
["overloaded", "overloaded", true], ["rate_limit", "limit"],
["invalid_request", "bad_request", false], ["overloaded", "service"],
["max_output_tokens", "context_exhausted", false], ["invalid_request", "request"],
] as const)("maps SDK error %s to %s", async (sdkError, expectedCategory, retryable) => { ["max_output_tokens", "limit"],
] as const)("maps SDK error %s to broad category %s", async (sdkError, expectedCategory) => {
const updates: SessionNotification[] = []; const updates: SessionNotification[] = [];
const agent = new ClaudeAcpAgent( const agent = new ClaudeAcpAgent(
{ {
@@ -4104,7 +4559,41 @@ describe("stop reason propagation", () => {
); );
(agent as any).clientCapabilities = airSessionFailureCapabilities; (agent as any).clientCapabilities = airSessionFailureCapabilities;
injectSession(agent, [ 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({ createResultMessage({
subtype: "success", subtype: "success",
stop_reason: "end_turn", stop_reason: "end_turn",
@@ -4117,16 +4606,47 @@ describe("stop reason propagation", () => {
sessionId: "test-session", sessionId: "test-session",
prompt: [{ type: "text", text: "test" }], prompt: [{ type: "text", text: "test" }],
}); });
expect(sessionFailureFromResponse(response)).toEqual( 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([ it.each([
["error_max_budget_usd", "budget_exhausted"], ["error_max_budget_usd", "limit"],
["error_max_turns", "provider_error"], ["error_max_turns", "limit"],
["error_max_structured_output_retries", "provider_error"], ["error_max_structured_output_retries", "service"],
] as const)("maps terminal subtype %s to %s", async (subtype, expectedCategory) => { ] as const)("maps terminal subtype %s to %s", async (subtype, expectedCategory) => {
const updates: SessionNotification[] = []; const updates: SessionNotification[] = [];
const agent = new ClaudeAcpAgent( const agent = new ClaudeAcpAgent(
@@ -4143,7 +4663,7 @@ describe("stop reason propagation", () => {
subtype, subtype,
stop_reason: null, stop_reason: null,
is_error: true, is_error: true,
errors: ["private provider detail"], errors: ["Claude request failed."],
}), }),
]); ]);
@@ -4152,12 +4672,12 @@ describe("stop reason propagation", () => {
prompt: [{ type: "text", text: "test" }], prompt: [{ type: "text", text: "test" }],
}); });
expect(sessionFailureFromResponse(response)).toEqual( expect(sessionFailureFromResponse(response)).toEqual(
expect.objectContaining({ category: expectedCategory }), expect.objectContaining({ category: expectedCategory, title: "Claude request failed." }),
); );
expect(JSON.stringify(updates)).not.toContain("sessionFailure"); 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 updates: SessionNotification[] = [];
const agent = new ClaudeAcpAgent( const agent = new ClaudeAcpAgent(
{ {
@@ -4169,12 +4689,12 @@ describe("stop reason propagation", () => {
); );
(agent as any).clientCapabilities = airSessionFailureCapabilities; (agent as any).clientCapabilities = airSessionFailureCapabilities;
injectSession(agent, [ injectSession(agent, [
createAssistantError("authentication_failed"), createAssistantError("authentication_failed", "Authentication required."),
createResultMessage({ createResultMessage({
subtype: "success", subtype: "success",
stop_reason: "end_turn", stop_reason: "end_turn",
is_error: true, is_error: true,
result: "private auth detail", result: "Authentication required.",
}), }),
{ {
type: "auth_status", type: "auth_status",
@@ -4191,14 +4711,16 @@ describe("stop reason propagation", () => {
}); });
await agent.sessions["test-session"]?.consumer; await agent.sessions["test-session"]?.consumer;
const active = sessionFailureFromResponse(response); const active = sessionFailureFromResponse(response);
const cleared = updates expect(active).toEqual(
.map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure) expect.objectContaining({
.find(Boolean); category: "access",
expect(active).toEqual(expect.objectContaining({ category: "auth_required", revision: 1 })); revision: 1,
expect(cleared).toEqual( title: "Authentication required.",
expect.objectContaining({ id: active.id, revision: 2, phase: "cleared" }), }),
); );
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 () => { 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; (agent as any).clientCapabilities = airSessionFailureCapabilities;
injectSession(agent, [ injectSession(agent, [
createAssistantError("authentication_failed"), createAssistantError("authentication_failed", "Authentication required."),
createResultMessage({ createResultMessage({
subtype: "success", subtype: "success",
stop_reason: "end_turn", stop_reason: "end_turn",
is_error: true, is_error: true,
result: "private auth detail", result: "Authentication required.",
}), }),
{ {
type: "auth_status", type: "auth_status",
@@ -4236,13 +4758,14 @@ describe("stop reason propagation", () => {
}); });
await agent.sessions["test-session"]?.consumer; await agent.sessions["test-session"]?.consumer;
expect(sessionFailureFromResponse(response)).toEqual( 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(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 updates: SessionNotification[] = [];
const agent = new ClaudeAcpAgent( const agent = new ClaudeAcpAgent(
{ {
@@ -4304,18 +4827,12 @@ describe("stop reason propagation", () => {
.map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure) .map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure)
.filter(Boolean); .filter(Boolean);
const active = sessionFailureFromResponse(failed); const active = sessionFailureFromResponse(failed);
expect(active).toEqual(expect.objectContaining({ revision: 1, phase: "active" })); expect(active).toEqual(expect.objectContaining({ revision: 1, severity: "error" }));
expect(failures).toHaveLength(1); expect(failures).toEqual([]);
expect(failures[0]).toEqual( expect(agent.sessions["test-session"].sessionFailureState.active.has(active.id)).toBe(false);
expect.objectContaining({
id: active.id,
revision: 2,
phase: "cleared",
}),
);
}); });
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 updates: SessionNotification[] = [];
const agent = new ClaudeAcpAgent( const agent = new ClaudeAcpAgent(
{ {
@@ -4371,23 +4888,15 @@ describe("stop reason propagation", () => {
.map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure) .map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure)
.filter(Boolean); .filter(Boolean);
const active = sessionFailureFromResponse(failed); const active = sessionFailureFromResponse(failed);
expect(failures).toHaveLength(1); expect(failures).toEqual([]);
expect(failures[0]).toEqual( expect(agent.sessions["test-session"].sessionFailureState.active.has(active.id)).toBe(false);
expect.objectContaining({ id: active.id, revision: 2, phase: "cleared" }),
);
}); });
it("retries a clear whose first delivery failed", async () => { it("does not publish clear records on repeated successful turns", async () => {
const updates: SessionNotification[] = []; const updates: SessionNotification[] = [];
let rejectFirstClear = true;
const agent = new ClaudeAcpAgent( const agent = new ClaudeAcpAgent(
{ {
sessionUpdate: async (update: SessionNotification) => { 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); updates.push(update);
}, },
} as unknown as AcpClient, } as unknown as AcpClient,
@@ -4444,10 +4953,8 @@ describe("stop reason propagation", () => {
.map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure) .map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure)
.filter(Boolean); .filter(Boolean);
const active = sessionFailureFromResponse(failed); const active = sessionFailureFromResponse(failed);
expect(failures).toHaveLength(1); expect(failures).toEqual([]);
expect(failures[0]).toEqual( expect(agent.sessions["test-session"].sessionFailureState.active.has(active.id)).toBe(false);
expect.objectContaining({ id: active.id, revision: 2, phase: "cleared" }),
);
}); });
it("logs loudly when a failure has no active turn to reject", async () => { 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(); expect(sessionFailureFromResponse(response)).toBeUndefined();
const failure = updates const failure = updates
.map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure) .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", revision: 1 })); 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 () => { 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 response = await prompt;
const failure = sessionFailureFromResponse(response); const failure = sessionFailureFromResponse(response);
expect(failure).toEqual( 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"); expect(JSON.stringify(updates)).not.toContain("sessionFailure");
}); });
@@ -4613,8 +5122,10 @@ describe("stop reason propagation", () => {
expect(sessionFailureFromResponse(response)).toBeUndefined(); expect(sessionFailureFromResponse(response)).toBeUndefined();
const failure = updates const failure = updates
.map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure) .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", revision: 1 })); expect(failure).toEqual(
expect.objectContaining({ category: "connection", severity: "error", revision: 1 }),
);
expect(failure).not.toHaveProperty("turnId"); expect(failure).not.toHaveProperty("turnId");
}); });
@@ -4704,13 +5215,13 @@ describe("stop reason propagation", () => {
await consumer; await consumer;
return updates return updates
.map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure) .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 first = await runWorkerTail();
const restarted = await runWorkerTail(); const restarted = await runWorkerTail();
expect(first).toEqual(expect.objectContaining({ revision: 1, phase: "active" })); expect(first).toEqual(expect.objectContaining({ revision: 1, severity: "error" }));
expect(restarted).toEqual(expect.objectContaining({ revision: 1, phase: "active" })); expect(restarted).toEqual(expect.objectContaining({ revision: 1, severity: "error" }));
expect(restarted.id).not.toBe(first.id); expect(restarted.id).not.toBe(first.id);
}); });
@@ -4750,8 +5261,9 @@ describe("stop reason propagation", () => {
}); });
expect(sessionFailureFromResponse(response)).toEqual( expect(sessionFailureFromResponse(response)).toEqual(
expect.objectContaining({ expect.objectContaining({
category: "transport_lost", category: "connection",
retryable: false, severity: "error",
title: "The connection to Claude was lost.",
actions: ["new_session"], actions: ["new_session"],
}), }),
); );
@@ -5023,25 +5535,55 @@ describe("model refusal fallback handling", () => {
const advisory = updates const advisory = updates
.map((u: any) => u._meta?.jetbrains?.air?.sessionFailure) .map((u: any) => u._meta?.jetbrains?.air?.sessionFailure)
.find((record: any) => record?.category === "advisory"); .find((record: any) => record?.severity === "warning");
expect(advisory).toEqual( expect(advisory).toEqual(
expect.objectContaining({ expect.objectContaining({
category: "advisory", category: "unknown",
severity: "warning", severity: "warning",
phase: "active",
source: "claude",
retryable: false,
actions: [], actions: [],
}), }),
); );
// Its own wording, carried through instead of the canned per-category message. // Its own wording, carried through instead of the canned per-category message.
expect(advisory.safeMessage).toContain("claude-fable-5"); expect(advisory.title).toContain("claude-fable-5");
expect(advisory.safeMessage).toContain("claude-opus-4-8"); expect(advisory.title).toContain("claude-opus-4-8");
expect(advisory.safeMessage).toContain("(cyber)"); expect(advisory.title).toContain("(cyber)");
// No markdown lead-in: the banner supplies the severity, so the text must not fake one. // 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. // Its own id namespace, so it never collides with a turn's error record.
expect(advisory.id).toContain(":notice:"); 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 () => { 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, emittedAssistantText: false,
owedTrailingIdles: 0, owedTrailingIdles: 0,
messageIdToUuid: new Map(), messageIdToUuid: new Map(),
sessionFailureState: { epoch: randomUUID(), revisions: new Map(), active: new Map() },
}; };
return agent.sessions[sessionId]!; return agent.sessions[sessionId]!;
} }
@@ -5614,6 +6157,7 @@ describe("session/delete", () => {
emittedAssistantText: false, emittedAssistantText: false,
owedTrailingIdles: 0, owedTrailingIdles: 0,
messageIdToUuid: new Map(), messageIdToUuid: new Map(),
sessionFailureState: { epoch: randomUUID(), revisions: new Map(), active: new Map() },
}; };
return agent.sessions[sessionId]!; return agent.sessions[sessionId]!;
} }
@@ -5724,6 +6268,7 @@ describe("getOrCreateSession param change detection", () => {
emittedAssistantText: false, emittedAssistantText: false,
owedTrailingIdles: 0, owedTrailingIdles: 0,
messageIdToUuid: new Map(), messageIdToUuid: new Map(),
sessionFailureState: { epoch: randomUUID(), revisions: new Map(), active: new Map() },
}; };
return agent.sessions[sessionId]!; return agent.sessions[sessionId]!;
} }
@@ -8582,6 +9127,7 @@ describe("post-error recovery", () => {
emittedAssistantText: false, emittedAssistantText: false,
owedTrailingIdles: 0, owedTrailingIdles: 0,
messageIdToUuid: new Map(), messageIdToUuid: new Map(),
sessionFailureState: { epoch: randomUUID(), revisions: new Map(), active: new Map() },
}; };
return { interrupt }; return { interrupt };
} }
@@ -10742,11 +11288,11 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
await agent.sessions["test-session"]?.consumer; await agent.sessions["test-session"]?.consumer;
const failure = updates const failure = updates
.map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure) .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(failure).toEqual(
expect.objectContaining({ expect.objectContaining({
phase: "active", category: "connection",
retryable: false, severity: "error",
actions: ["new_session"], actions: ["new_session"],
}), }),
); );
@@ -10802,11 +11348,11 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
await agent.sessions["test-session"]?.consumer; await agent.sessions["test-session"]?.consumer;
const failure = updates const failure = updates
.map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure) .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(failure).toEqual(
expect.objectContaining({ expect.objectContaining({
phase: "active", category: "connection",
retryable: false, severity: "error",
actions: ["new_session"], actions: ["new_session"],
}), }),
); );
@@ -12360,6 +12906,7 @@ describe("session/cancel wedge recovery (issue #680)", () => {
emittedAssistantText: false, emittedAssistantText: false,
owedTrailingIdles: 0, owedTrailingIdles: 0,
messageIdToUuid: new Map(), messageIdToUuid: new Map(),
sessionFailureState: { epoch: randomUUID(), revisions: new Map(), active: new Map() },
}; };
return { interrupt }; return { interrupt };
} }
@@ -13796,6 +14343,7 @@ describe("agent selection config option", () => {
emittedAssistantText: false, emittedAssistantText: false,
owedTrailingIdles: 0, owedTrailingIdles: 0,
messageIdToUuid: new Map(), messageIdToUuid: new Map(),
sessionFailureState: { epoch: randomUUID(), revisions: new Map(), active: new Map() },
}; };
return { session: agent.sessions[sessionId]!, applyFlagSettings }; return { session: agent.sessions[sessionId]!, applyFlagSettings };
} }

View File

@@ -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":"<synthetic>","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":"<synthetic>","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}}}