mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
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:
@@ -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/).
|
||||||
|
|
||||||
|
|||||||
208
docs/session-failure-extension.md
Normal file
208
docs/session-failure-extension.md
Normal 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
|
||||||
|
```
|
||||||
475
src/acp-agent.ts
475
src/acp-agent.ts
@@ -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 {
|
||||||
|
|||||||
463
src/session-failure-extension.ts
Normal file
463
src/session-failure-extension.ts
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
3
src/tests/fixtures/usage-limit-session.jsonl
vendored
Normal file
3
src/tests/fixtures/usage-limit-session.jsonl
vendored
Normal 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}}}
|
||||||
Reference in New Issue
Block a user