feat: AIR tool call contract, exact git patches, plan file, and fixes for every client (#1153)

## Summary

One PR with separate commits (the earlier stack #1168 and #1169 is
folded back in).

### Exact git patches
- `src/diff.ts` builds exact git patches for Edit and Write: real line
numbers, file modes, CRLF, the final-newline marker, a size limit
(including a bound for `replace_all`), binary detection, and an
asynchronous diff with a time budget. The approval preview mirrors the
input normalisation of the bundled CLI.
- No creation patch over an existing file or for an Edit of an existing
empty file, and no patch for a Write that changed nothing.

### For every client
Clients that do not declare `_meta.jetbrains.air` (Zed, plain ACP
clients) get the same information as on `main`.
- A `tool_call_update` omits top-level fields that did not change since
the last report of the same tool call (ACP merge semantics). The `_meta`
of each report stays complete.
- Bug fixes: a failed approval preview no longer blanks the approval
diff; a permission denial replaces a pinned approval patch; the
compaction summary is not repeated when the streamed text is identical;
streamed subagent text is not sent twice; async task fields are recorded
only after a successful send; late updates of a finished native subagent
are dropped; the updates of a fresh Claude context after a clear-context
restart reach the ACP session.

### For AIR only
- One fact in one field: input in `rawInput`, the file text of an edit
only in the diff (`diffPatch`), output once, no display copies of the
input (`rawInputRendering`), `_meta.claudeCode` reduced to `toolName`,
`parentToolUseId` and the `toolResponse` markers, per-key `_meta`
filtering.
- Plan file mode (`planFile`): ExitPlanMode carries only
`rawInput.planFilePath`; AIR follows the file that Claude writes.
- AIR-only keys move under `_meta.jetbrains.air.*` and are no longer
sent to other clients.

### Structure and docs
- `src/tool-calls/`: one reporter per tool, one `ClientCapabilities`,
one `AcpToolCallRenderer`, the field tracker and the changed-meta
filter.
- `docs/air-extensions.md` replaces the per-extension AIR docs.

## Tests
- `npm run build`, `npm run check`, `npm run test:run`: 1995 passed.
- Scenario tests for plain, Zed, and AIR clients: the SDK mock honours
the adapter's session id, every update must reach its ACP session, every
message is validated against the ACP JSON schema with all formats
checked, plain and Zed are compared with a recorded `main` baseline, AIR
keeps compact golden recordings.
This commit is contained in:
nikita-ashihmin
2026-09-28 17:58:00 +04:00
committed by GitHub
parent e6681d2a57
commit 875d75f2a4
154 changed files with 15028 additions and 3351 deletions

1267
docs/air-extensions.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +0,0 @@
# AIR diff statistics
The adapter supplies counts in each ACP diff block through `_meta.jetbrains.air.diffStats`.
This uses the same contract as the Codex adapter. No capability negotiation is required.
```json
{
"_meta": {
"jetbrains": {
"air": {
"version": 1,
"diffStats": { "version": 1, "added": 20, "removed": 30 }
}
}
}
}
```
Both counts are nonnegative integers. They describe the operations in that block's patch.
Context lines and EOF markers do not increase the counts.
The extension carries no navigation position.
For `Edit` and `Write`, the adapter counts operations while it builds diff texts from the SDK's `structuredPatch`.
Each hunk produces one block with its own counts. The adapter does not compare or scan the full file texts for statistics.
When a patch has unsafe, unordered, or mutually inconsistent coordinates, inconsistent line counts,
misplaced EOF markers, or unsupported prefixes, the adapter omits the statistics.
Empty patches, initial tool inputs, and history without structured patches retain their existing behavior.
They provide no statistics because the adapter has no patch operations to count.
AIR uses its existing comparison when statistics are absent.

View File

@@ -1,57 +0,0 @@
# Goal extension
This document defines a provider-neutral experimental ACP extension implemented by `claude-agent-acp`. It is intentionally shaped like a possible future first-class ACP API: implementations publish `_meta.goal`, not provider-specific metadata such as `_meta.claudeCode.goal`.
## Capability negotiation
An agent advertises support in its `initialize` response:
```json
{
"_meta": {
"goal": {
"version": 1,
"controlMethod": "_session/goal",
"actions": ["clear"]
}
}
}
```
`actions` is the implementation-supported subset of `set`, `pause`, `resume`, and `clear`. Clients must not infer support for an action that is not advertised. The control request contains `sessionId` and `action`; a future version may add action-specific fields such as an objective for `set`.
## Session state
The current snapshot is published in `session_info_update._meta.goal`. Clearing a goal publishes `goal: null`.
```json
{
"objective": "Ship the change",
"status": "active",
"iterations": 3,
"lastReason": "Tests still need work",
"createdAt": 1710000000123,
"controlMethod": "_session/goal"
}
```
Common statuses are `active`, `paused`, `blocked`, `limited`, and `complete`. Optional fields allow implementations to report budgets, usage, iteration count, and the last continuation reason. Timestamps are Unix milliseconds.
## Lifecycle architecture
A goal belongs to the ACP session, not to an individual `session/prompt` request. Goal activity and prompt activity are independent:
- `status: active` means the persistent objective can drive more work; it does not mean an ACP prompt is currently executing.
- A prompt completes when its current backend turn reaches a quiescent boundary, even when the goal remains active.
- A later autonomous cycle may publish more session updates outside that completed prompt.
- While a turn is running, clients use steering or prompt queueing when advertised. While the session is quiescent, clients may send an ordinary `session/prompt`.
This separation prevents a persistent goal from monopolizing the session's prompt slot and lets clients model “working now” independently from “objective remains active.”
## Claude mapping
Claude's session-scoped `/goal` command installs a Stop hook. The runtime emits internal `active_goal` messages when that hook updates or clears its state; the adapter maps them into the neutral snapshot before the exhaustive SDK message switch. `condition`, `iterations`, `set_at`, and `last_reason` become `objective`, `iterations`, `createdAt`, and `lastReason`. Provider-only bookkeeping such as `tokens_at_start` is not exposed.
Some Claude runtime versions persist the goal and begin the Stop-hook workflow without emitting the initial `active_goal` message on the SDK stream. To avoid leaving clients without visible state for the whole long-running prompt, the adapter publishes a minimal active snapshot as soon as it submits `/goal <condition>`, and publishes `goal: null` for `/goal clear`. While that command is pending, late updates for the previous goal are ignored. A matching runtime `active_goal` remains authoritative and enriches the optimistic snapshot with iteration, reason, and timestamp fields; a failed command restores the previously published state.
`set` and `clear` are advertised for out-of-band control; pause and resume are not. When a goal turn is running, the adapter injects the corresponding `/goal` command at steering priority `now` so it cannot remain queued behind the Stop-hook workflow; on an idle session it uses the ordinary prompt lifecycle. Cancelling a turn does not clear the session-scoped goal: clients use the goal control method when they intend to replace or remove it.

View File

@@ -1,253 +0,0 @@
# Permission extension
This document defines the experimental permission presentation extension implemented by
`claude-agent-acp`. The adapter uses standard ACP permission requests and responses, while `_meta`
adds compact request presentation text.
The extension does not replace ACP permissions. `RequestPermissionRequest.toolCall`, `options`,
`RequestPermissionResponse.outcome`, and each option's standard `kind` remain authoritative. A client
that does not understand the extension can ignore `_meta` and render the ordinary ACP request.
## Request lifecycle
When the Claude Agent SDK invokes `canUseTool`, the adapter:
1. stops immediately if the tool-call signal is already aborted;
2. ensures the referenced ACP `tool_call` has been emitted;
3. validates and snapshots any SDK `suggestions: PermissionUpdate[]`;
4. builds a tool-specific presentation and ordered option list;
5. sends `session/request_permission` with the tool-call cancellation signal;
6. validates that the selected option was offered in the exact request;
7. maps the selection to a Claude SDK `PermissionResult`.
The eager `tool_call` announcement always precedes the permission request. If the announcement fails,
its de-duplication marker is removed so the normal streamed tool-use path may retry it.
Request-level presentation metadata requires no capability negotiation. It is an additive `_meta`
record and may be ignored by clients that do not render it.
## Request presentation
The request-level record is placed under `RequestPermissionRequest._meta.permission`:
```json
{
"sessionId": "session-1",
"toolCall": {
"toolCallId": "toolu_1",
"name": "Bash",
"status": "pending",
"rawInput": { "command": "npm test" }
},
"options": [
{ "optionId": "allow-once", "name": "Yes", "kind": "allow_once" },
{ "optionId": "reject", "name": "No", "kind": "reject_once" }
],
"_meta": {
"permission": {
"version": 1,
"title": "npm test",
"description": "Reason: Needed to verify the change."
}
}
}
```
| Field | Required | Type | Meaning |
| ------------- | -------: | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `version` | yes | integer `1` | Permission presentation schema version. |
| `title` | yes | non-empty string | The standard tool-call operation title. |
| `description` | no | string | Temporary diagnostic SDK decision reason. |
| `defaultToNo` | no | `true` | The ask must not be approvable by a stray keystroke; the reject options are listed first and a client that pre-selects an option should focus the decline. |
The permission title normally duplicates `toolCall.title`: one operation has one heading across the
tool card and approval UI. `ExitPlanMode` is the deliberate exception and uses the action-oriented
permission heading `Ready to code?`. Commands, paths, URLs, and other structured details remain in
`rawInput`, `content`, and `locations`.
`description` temporarily carries the SDK's non-blank `decisionReason` for diagnostics, prefixed
with `Reason: `. The SDK `description` operation subtitle is not copied there.
## Tool-call presentation
The permission request carries the same standard ACP tool information used for normal tool updates:
- `name` is the SDK tool name (for example `Read` or `mcp__server__tool`), matching the standard
`name` field on the initial `tool_call` update;
- `kind`, `title`, `content`, and `locations` come from `toolInfoFromToolUse`;
- `status` is `pending`;
- `rawInput` is the original SDK input object;
- `blockedPath` is appended to `locations` when it is valid and not already present;
- subagent calls include `_meta.claudeCode.parentToolUseId` on the tool call;
- Sandbox Network and Computer Use requests synthesize JSON content when the normal renderer has no
content.
Compact text removes control characters, collapses whitespace where appropriate, and enforces length
limits. Invalid optional presentation text is omitted instead of being truncated into misleading UI.
Shell permission titles (`Bash` and `PowerShell`) preserve the full command verbatim, including
whitespace and line breaks, without applying compact-text normalization or length limits.
Permission options are fixed. When a durable suggestion contains a command prefix, path, host, or
other rule, the adapter includes that value directly in `PermissionOption.name`, for example
`Yes, and don't ask again for npm test commands`. Selecting the option applies the exact snapshotted
Claude SDK `PermissionUpdate`; the client cannot edit it. A selected reject is distinct from
`{ "outcome": "cancelled" }`: cancellation aborts the tool use instead of returning a user rejection.
## Standard option ids
| Option id | Meaning |
| ------------------------------ | ----------------------------------------------------------- |
| `allow-once` | Allow this call without changing permission state. |
| `allow-with-updates` | Allow and apply the displayed durable effect. |
| `allow-skill-exact` | Allow the exact Skill invocation in local settings. |
| `allow-skill-prefix` | Allow the parameterized Skill prefix in local settings. |
| `exit-plan-auto` | Exit plan mode and set session mode to `auto`. |
| `exit-plan-bypass` | Exit plan mode and set session mode to `bypassPermissions`. |
| `exit-plan-accept-edits` | Exit plan mode and set session mode to `acceptEdits`. |
| `exit-plan-default` | Exit plan mode and set session mode to `default`. |
| `exit-plan-clear-auto` | Clear context, continue the plan, and use `auto`. |
| `exit-plan-clear-bypass` | Clear context, continue the plan, and bypass permissions. |
| `exit-plan-clear-accept-edits` | Clear context, continue the plan, and accept edits. |
| `reject` | Deny the call, optionally returning feedback to Claude. |
The adapter stably groups options as `allow_once`, `allow_always`, then reject kinds. Options of the
same kind retain their builder order. Clients must return the selected option id unchanged.
## Durable SDK suggestions
Claude may provide `suggestions: PermissionUpdate[]`. Before a suggestion can appear as a durable
choice, the adapter validates the complete array and takes a structured clone. Validation recognizes:
- `addRules`, `replaceRules`, and `removeRules`;
- `setMode` for known Claude permission modes;
- `addDirectories` and `removeDirectories`;
- known destinations and rule behaviors;
- bounded array sizes and bounded non-empty strings.
Unknown update types, invalid values, oversized bundles, non-cloneable data, and empty suggestion
arrays are omitted. The snapshot prevents the label shown to the user from diverging from the effect
returned after a delayed response.
An SDK suggestion is offered only when the tool-specific builder can accurately describe the entire
effect. The adapter never labels one part of a mixed bundle while silently applying the rest.
When `matchedAskRule` is present, all persistent choices are suppressed. A configured `ask` rule must
continue asking; accepting a generated allow rule that cannot override it would be misleading.
## Tool-specific behavior
| Tool family | Permission choices and effects |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Bash / PowerShell | One-time allow; exact representable SDK bundle with its complete effect in the option label; reject. |
| Read / Glob / Grep | One-time allow; matching session-scoped read grant; reject. |
| Edit / Write / NotebookEdit | One-time allow; matching session edit grant, including `.claude` handling; reject. |
| WebFetch | One-time allow; generated `domain:<hostname>` local rule; reject. |
| Skill | One-time allow; exact skill and optional `prefix:*` local rules; reject. |
| EnterPlanMode | Enter plan mode once, or reject and continue implementing. |
| ExitPlanMode | One keep-context and one clear-context elevated mode (`auto` > bypass > accept edits), manual approval, or reject. |
| SandboxNetworkAccess | One-time allow; exact SDK host rule when representable; reject. |
| Computer Use MCP | One-time allow; exact representable SDK allow suggestion; reject. |
| MCP | One-time allow; exact representable SDK allow suggestion for that tool; reject. |
| WebSearch / Agent / Task | One-time allow; generated whole-tool local rule; reject. |
| ReviewArtifact / Workflow | Generic fallback behavior until SDK renderer state is available. |
| Monitor / unknown tools | Generic fallback behavior. |
Generated WebFetch, Skill, and non-MCP fallback effects use `localSettings`. Filesystem grants are
offered only when the SDK bundle is session-scoped and covers the current path. Shell labels may
describe command rules, read paths, additional directories, or a representable combination.
`AskUserQuestion` is not a permission dialog. With ACP form elicitation support it is routed through
ACP elicitation; without that support it is disabled when the Claude session is created.
## Permission modes and bypass
The session exposes the Claude modes available for the current model using Claude Code's labels:
`Manual`, `Accept edits`, `Plan`, conditional `Auto`, and conditional `Bypass permissions`. The
internal `dontAsk` SDK mode is accepted from settings for compatibility but is not advertised as a
user-selectable mode.
Hosts can remove `Bypass permissions` from a session by sending
`_meta.claudeCode.options.allowDangerouslySkipPermissions: false` on `session/new` (and again on
`session/load`); the mode leaves the catalog and a `bypassPermissions` settings default clamps to
`default`.
The ACP mode ids are the Claude SDK wire ids: `default`, `acceptEdits`, `plan`, `auto`, and
`bypassPermissions`. `Manual` deliberately retains the SDK id `default`; `manual` is only a settings
input alias. The adapter does not invent parallel ids for presentation labels.
Like Claude Code, `ExitPlanMode` offers one elevated keep-context choice by priority: `auto`, then
`bypassPermissions`, then `acceptEdits`. A non-empty plan also gets exactly one clear-context choice
with the same priority. This requires no ACP capability or setting: after selection the adapter
interrupts the old private Claude query, hides that internal rejection from the client, creates a
new private query under the same public ACP session, and continues the pending turn with
`Implement the following plan:` followed by the complete plan. Mode, model, agent, effort, Fast
mode, and accumulated turn usage are preserved.
The SDK cannot reapply `allowedPrompts` through a public control request, so those renderer hints do
not survive this internal handoff. Ultraplan remains unavailable because its required renderer state
is not exposed by the SDK.
The adapter does not auto-allow a callback merely because the session advertises
`bypassPermissions`. Claude Code applies bypass before invoking `canUseTool`. A request that still
reaches the callback is bypass-immune, safety-sensitive, interactive, or forced by an explicit ask
rule and must remain visible to the user.
## Cancellation and failures
The SDK tool-call `AbortSignal` is forwarded to the ACP client. The adapter also races the client
request locally against the same signal, so a client that ignores cancellation cannot leave Claude's
tool call waiting forever.
These cases all abort the tool use with `Tool use aborted`:
- the signal was already aborted;
- the signal aborts while the permission request is open;
- the client returns a cancelled outcome;
- the client rejects after cancellation.
A normal selected reject returns `behavior: "deny"`, `decisionClassification: "user_reject"`, and
the default refusal message.
## Client requirements
A client implementing version 1 should:
- render standard ACP tool-call content and locations as the action subject;
- treat request `_meta.permission.title` and `description` as optional presentation hints;
- preserve option ids;
- ignore unknown `_meta` fields and future feature names;
- settle the request when its cancellation signal fires.
The client must not infer storage scope, lifetime, or permission effects from `kind` or button text.
The adapter owns the Claude `PermissionUpdate` and applies it only after validating the response.
## Current limits
The SDK does not expose enough renderer state to reproduce every Claude Code dialog. Version 1 does
not implement:
- Bash output-redirection rewriting;
- classifier-specific prompt state;
- ExitPlanMode Ultraplan actions;
- Computer Use macOS/TCC or application-allowlist dialogs;
- structured permission lifetime or storage metadata for clients.
These omissions do not weaken the one-time choice. When a durable effect cannot be represented
honestly, the adapter sends only one-time allow and reject.
## Verification
The executable contract lives in:
- `src/tests/permission-presentation.test.ts` for capability and request presentation;
- `src/tests/permission-options.test.ts` for tool-specific option construction;
- `src/tests/permission-effects.test.ts` for response validation and effects;
- `src/tests/acp-agent.test.ts` for request ordering, cancellation, bypass, and subagent attribution;
- `src/tests/session-config-options.test.ts` for plan-mode integration;
- `src/tests/live-capability-*-audit.test.ts` for opt-in live SDK audits.
```sh
npm run build
npm run check
npm run test:run
```

View File

@@ -1,118 +0,0 @@
# Recommended config values extension
This document defines the experimental AIR `recommendedValue` session configuration extension
implemented by `claude-agent-acp`. It lets a client show concrete model and effort choices without
an ambiguous `default` row while preserving legacy config options for every client that does not
opt in.
Permission modes and all other session config options are outside this extension and remain
unchanged.
## Capability negotiation
A client opts in through the common AIR capability list in `initialize`:
```json
{
"clientCapabilities": {
"_meta": {
"jetbrains": {
"air": {
"version": 1,
"capabilities": ["recommendedValue"]
}
}
}
}
}
```
The adapter enables the extension only when the AIR version is a finite integer greater than or
equal to `1` and `capabilities` contains the singular `recommendedValue` capability. Otherwise it
preserves the existing `default` rows, current values, and metadata exactly. The adapter advertises
the same capability in its top-level initialize-response `_meta.jetbrains.air.capabilities` list.
## Config option metadata
For each selector transformed by the extension, the adapter writes the concrete recommendation
through the common AIR metadata envelope at `_meta.jetbrains.air.recommendedValue`:
```json
{
"id": "model",
"type": "select",
"currentValue": "sonnet",
"options": [
{ "value": "opus", "name": "Claude Opus" },
{ "value": "sonnet", "name": "Claude Sonnet" },
{ "value": "haiku", "name": "Claude Haiku" }
],
"_meta": {
"jetbrains": {
"air": {
"version": 1,
"recommendedValue": "sonnet"
}
}
}
}
```
`recommendedValue` always names one of that selector's advertised option values. It is independent
from `currentValue`: a user's explicit selection remains current even when the SDK recommends a
different value.
## Model behavior
The SDK's `default` model entry may carry a `resolvedModel` identifying the model currently
recommended by Claude. The adapter matches that identifier exactly to a selectable named model
(accepting equivalent `-1m` and `[1m]` suffix spellings),
removes the `default` row, and advertises the named value in
`_meta.jetbrains.air.recommendedValue`. When the session itself is still using the SDK default, the
same concrete value is presented as `currentValue`.
If the SDK recommendation cannot be mapped to an advertised named model, the adapter retains the
legacy `default` row for that selector and omits `recommendedValue`. This prevents a client from
receiving a recommendation or current value it cannot select.
Terse SDK labels that match their underlying model family include the concrete version derived
from model metadata: for example, `Sonnet` becomes `Sonnet 5`, `Fable` becomes `Fable 5.1`, and
`Claude Haiku` becomes `Claude Haiku 4.5`.
Context suffixes such as `Opus (1M context)` are omitted from the label because the context remains
in the description. Custom labels are preserved, and normalization falls back to the original
labels if two selectable entries would otherwise collide. This presentation also applies to
legacy clients.
## Effort behavior
When the current model supports effort selection, the adapter removes the `default` effort row and
advertises `medium` as the recommendation. An existing explicit or settings-derived effort remains
the `currentValue`; an absent or legacy `default` current effort is presented as `medium`.
The adapter applies the displayed effort to the SDK on session creation and model switches, so
the concrete selection reflects the effort actually used. Explicit SDK `options.effort` and ACP
picker choices remain pinned across model switches while supported. Otherwise each switch
re-reads the new model's settings before falling back to the recommendation. Switching to a
model without effort support clears the flag override. Legacy clients continue to let the SDK
resolve automatic effort.
If effort synchronization fails after the model has already switched, the adapter still reports
the new model but never presents the unapplied effort as current. It retains the last successfully
applied value when that value is selectable for the new model; otherwise it temporarily omits the
effort selector until a later successful switch can rebuild truthful state.
If a future model exposes effort choices without `medium`, the first SDK-advertised effort level is
used so both `recommendedValue` and `currentValue` remain valid option values.
## SDK upgrade check
The ordinary test suite pins the SDK version whose Opus label was verified. When updating the
SDK, first run the live contract test in an authenticated environment:
```sh
RUN_INTEGRATION_TESTS=true npx vitest run src/tests/model-presentation.test.ts
```
Review any changes to the available Opus entries and their labels before updating the version
guard and, if necessary, the normalization. The live test only initializes the SDK; it sends no
model prompt.

View File

@@ -1,214 +0,0 @@
# 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` |
| `verification_required`, `cloud_credential_error` | `access` (no `login` action) |
| `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.
The model fallback notice is the one advisory that also has a standard ACP home. When the client
advertises `clientCapabilities.session.notices` (the Session Notices RFD), the adapter sends it as an
ACP `notice` update instead and publishes no AIR advisory record, even if `sessionFailure` is also
advertised.
## 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
```