Compare commits

...

2 Commits

Author SHA1 Message Date
acp-release-bot[bot]
becd854dd8 chore(main): release 0.78.0 (#1130)
🤖 I have created a release *beep* *boop*
---


##
[0.78.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.77.0...v0.78.0)
(2026-09-15)


### Features

* Add experimental ACP compaction update support
([#1134](https://github.com/agentclientprotocol/claude-agent-acp/issues/1134))
([543a9a2](543a9a2f97))


### Bug Fixes

* keep the picked option when AskUserQuestion custom text is also
supplied
([#1131](https://github.com/agentclientprotocol/claude-agent-acp/issues/1131))
([8823ea6](8823ea6fcb))


### Performance Improvements

* report file changes from Claude checkpoints
([#1138](https://github.com/agentclientprotocol/claude-agent-acp/issues/1138))
([91f1699](91f1699366))
* supply AIR diff counts from Claude structured patches
([#1122](https://github.com/agentclientprotocol/claude-agent-acp/issues/1122))
([735950a](735950a83e))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-09-16 00:59:23 +04:00
nikita-ashihmin
91f1699366 perf: report file changes from Claude checkpoints (#1138)
## Summary

- replace the hidden model-authored file audit turn with Claude native
file checkpointing
- preview changed paths with rewindFiles at the terminal turn boundary
while preserving the existing AIR metadata contract
- enable checkpointing only for clients that negotiate
agentFileChangeReport and fail open after two seconds
- retain legacy audit filtering so persisted sessions do not replay old
hidden audit turns

This follows Claude Code checkpoint semantics. It tracks changes made
through supported Claude file tools; Bash and most subagent changes may
not be included.

Reference: https://code.claude.com/docs/en/checkpointing

## Validation

- npm run build
- npm run check
- env -u ANTHROPIC_BASE_URL npm run test:run (1368 passed, 28 skipped)
2026-09-16 00:54:39 +04:00
10 changed files with 562 additions and 1237 deletions

View File

@@ -1,3 +1,3 @@
{
".": "0.77.0"
".": "0.78.0"
}

View File

@@ -1,5 +1,23 @@
# Changelog
## [0.78.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.77.0...v0.78.0) (2026-09-15)
### Features
* Add experimental ACP compaction update support ([#1134](https://github.com/agentclientprotocol/claude-agent-acp/issues/1134)) ([543a9a2](https://github.com/agentclientprotocol/claude-agent-acp/commit/543a9a2f97429659bb28d09691a71e4c99e41ed5))
### Bug Fixes
* keep the picked option when AskUserQuestion custom text is also supplied ([#1131](https://github.com/agentclientprotocol/claude-agent-acp/issues/1131)) ([8823ea6](https://github.com/agentclientprotocol/claude-agent-acp/commit/8823ea6fcb6743d37c1aad91ccd44725164b8927))
### Performance Improvements
* report file changes from Claude checkpoints ([#1138](https://github.com/agentclientprotocol/claude-agent-acp/issues/1138)) ([91f1699](https://github.com/agentclientprotocol/claude-agent-acp/commit/91f1699366ee98f9b3e9a6b96f0662c40b6ee38e))
* supply AIR diff counts from Claude structured patches ([#1122](https://github.com/agentclientprotocol/claude-agent-acp/issues/1122)) ([735950a](https://github.com/agentclientprotocol/claude-agent-acp/commit/735950a83ee1fa25366515a3239482183a051dca))
## [0.77.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.76.0...v0.77.0) (2026-09-14)

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.77.0",
"version": "0.78.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.77.0",
"version": "0.78.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "1.4.0",

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.77.0",
"version": "0.78.0",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",

View File

@@ -197,16 +197,10 @@ import {
import {
AGENT_FILE_CHANGE_REPORT_CAPABILITY,
agentFileChangeReportMeta,
agentFileChangeReportRequestId,
containsFileChangeAuditMarker,
createFileChangeAuditSupport,
createFileChangeAuditTurnState,
FILE_CHANGE_AUDIT_SERVER_NAME,
type FileChangeAuditSupport,
type FileChangeAuditTurnState,
createNativeFileChangeReporter,
type FileChangeReportTurnState,
type FileChangeReportUnavailableReason,
isFileChangeAuditReportPhase,
isFileChangeAuditTool,
type NativeFileChangeReporter,
supportsAgentFileChangeReport,
} from "./file-change-audit.js";
import {
@@ -578,13 +572,20 @@ type Turn = {
* publish the structured replacement at most once. */
usageMarkdownDelivered?: boolean;
usageOriginalOutput?: string;
/** Optional hidden, model-authored file-change audit requested by the ACP
* client for this turn. The state is turn-owned so a late tool call can
* never be rebound to a newer prompt. */
fileChangeAudit?: FileChangeAuditTurnState;
/** Optional native checkpoint preview requested by the ACP client for this
* turn. The state is turn-owned so a late control response can never be
* rebound to a newer prompt. */
fileChangeReport?: FileChangeReportTurnState;
/** Set once the deferred has been resolved/rejected, so the consumer never
* settles a turn twice (idle + handoff + stream-end can all race). */
settled: boolean;
/** Set while the terminal checkpoint preview is in flight. The consumer can
* observe another terminal signal during that bounded await; only the first
* one may continue into settlement. */
settling?: boolean;
/** Outcome captured before the checkpoint preview await, so cancel() can
* preserve its usage and metadata while atomically winning that race. */
settlingOutcome?: PromptResponse;
/** Set when a `command_lifecycle` "started" frame arrives for this turn's
* uuid (msg_lifecycle_v1 CLIs): the SDK dispatched the command into a turn.
* Read by cancel() to seed the orphan's state — a started orphan's turn may
@@ -700,14 +701,8 @@ export type Session = {
/** The turn whose messages the consumer is currently attributing output to
* (the head of `turnQueue` once its user message has been echoed). */
activeTurn?: Turn | null;
/** Request ids already accepted for hidden agent file-change reports. Kept
* for the session lifetime so a redelivered prompt cannot publish the same
* audit twice or bind a late report to another turn. */
fileChangeReportRequestIds: Set<string>;
/** Session-owned publisher for negotiated file-change audits. Turn state
* stays on each Turn; this controller supplies the single idempotent
* unavailable terminal used by every non-report settlement path. */
fileChangeAuditSupport?: FileChangeAuditSupport;
/** Session-owned native checkpoint reporter. Turn state stays on each Turn. */
fileChangeReporter?: NativeFileChangeReporter;
/** Optimistic goal state published for a submitted `/goal` command whose
* matching runtime update has not arrived yet. Runtime updates for the old
* goal are suppressed until this command is echoed or completes, otherwise
@@ -1946,13 +1941,13 @@ export class ClaudeAcpAgent {
},
settleCancelledTurn: (original, session, turn) => {
disarmForceCancel(session);
this.finishFileChangeAudit(session, turn, "cancelled");
session.fileChangeReporter?.finish(turn.fileChangeReport, "cancelled");
turn.settled = true;
turn.resolve({ stopReason: "cancelled", usage: sessionUsage(original) });
},
settleFailedTurn: (session, turn, error) => {
disarmForceCancel(session);
this.finishFileChangeAudit(session, turn, "providerError");
session.fileChangeReporter?.finish(turn.fileChangeReport, "providerError");
turn.settled = true;
turn.reject(error);
},
@@ -2670,17 +2665,7 @@ export class ClaudeAcpAgent {
const isLocalOnlyCommand =
firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]);
const fileChangeReportRequestId = supportsAgentFileChangeReport(this.clientCapabilities)
? agentFileChangeReportRequestId(params._meta)
: undefined;
let fileChangeAudit: FileChangeAuditTurnState | undefined;
if (
fileChangeReportRequestId &&
!session.fileChangeReportRequestIds.has(fileChangeReportRequestId)
) {
session.fileChangeReportRequestIds.add(fileChangeReportRequestId);
fileChangeAudit = createFileChangeAuditTurnState(fileChangeReportRequestId);
}
const fileChangeReport = session.fileChangeReporter?.request(params._meta);
const isUsageCommand =
params.prompt.length === 1 &&
@@ -2699,7 +2684,7 @@ export class ClaudeAcpAgent {
isLocalOnlyCommand,
...(isUsageCommand ? { isUsageCommand: true } : {}),
...(usageMarkdownAbort ? { usageMarkdownAbort } : {}),
...(fileChangeAudit ? { fileChangeAudit } : {}),
...(fileChangeReport ? { fileChangeReport } : {}),
settled: false,
resolve: () => {},
reject: () => {},
@@ -3098,19 +3083,6 @@ export class ClaudeAcpAgent {
}
}
/** Publish the audit terminal for every turn path that did not reach the
* report tool. The support flips the turn state synchronously before its
* transport await, so callers can stay fail-open and settle the ACP prompt
* immediately without allowing a racing lifecycle path to publish twice. */
private finishFileChangeAudit(
session: Session,
turn: Turn,
reason: FileChangeReportUnavailableReason,
): void {
if (!turn.fileChangeAudit || !session.fileChangeAuditSupport) return;
void session.fileChangeAuditSupport.finishUnavailable(turn.fileChangeAudit, reason);
}
/** Lazily start the per-session consumer that drains the SDK query stream for
* the session's whole life. Idempotent: only the first `prompt()` starts it. */
private ensureConsumer(session: Session, sessionId: string): void {
@@ -3232,18 +3204,6 @@ export class ClaudeAcpAgent {
}
return;
}
if (
isFileChangeAuditReportPhase(session.activeTurn?.fileChangeAudit) &&
(update.sessionUpdate === "agent_message_chunk" ||
update.sessionUpdate === "agent_thought_chunk" ||
update.sessionUpdate === "user_message_chunk" ||
update.sessionUpdate === "tool_call" ||
update.sessionUpdate === "tool_call_update" ||
update.sessionUpdate === "compaction_update" ||
update.sessionUpdate === "compaction_summary_chunk")
) {
return;
}
if (update.sessionUpdate === "agent_message_chunk") {
if (
!claudeMeta?.parentToolUseId &&
@@ -3463,7 +3423,7 @@ export class ClaudeAcpAgent {
* a stamp naming an orphaned command consumes the result outright, and a
* stamp naming anything else positively refutes "this is a dead turn's
* result", so the dup-over-loss one-skip must not eat it. */
const ensureActiveTurn = (resultUserMessageUuid?: string) => {
const ensureActiveTurn = async (resultUserMessageUuid?: string) => {
if (session.activeTurn) {
if (!isHeldOpen(session.activeTurn)) {
return;
@@ -3485,7 +3445,7 @@ export class ClaudeAcpAgent {
// promoted command's own delivery decision is not judged against the
// held turn's followup text (issue #453) — the caller snapshots the
// flag AFTER this runs.
settleActive(session.activeTurn.deferredSettle);
await settleActive(session.activeTurn.deferredSettle);
}
// Orphan accounting runs BEFORE the head check: an orphan's echo-less
// result can arrive with an EMPTY queue (the common post-cancel
@@ -3651,10 +3611,10 @@ export class ClaudeAcpAgent {
/** Settle the active turn's stored deferred outcome once none of its
* spawned subagents is live. The single drain rule shared by the
* followup-result and idle settle sites, so the two lanes can't drift. */
const settleDeferredIfDrained = () => {
const settleDeferredIfDrained = async () => {
const turn = session.activeTurn;
if (isHeldOpen(turn) && !turnAwaitingSubagents(turn)) {
settleActive(turn.deferredSettle);
await settleActive(turn.deferredSettle);
}
};
@@ -3664,7 +3624,7 @@ export class ClaudeAcpAgent {
* turn that can have spawned subagents must route through here: a site
* calling settleActive directly bypasses the hold and re-opens the
* out-of-turn permission deadlock (issue #866) through its lane. */
const settleOrDefer = (outcome: PromptResponse) => {
const settleOrDefer = async (outcome: PromptResponse) => {
// No result ends a steered turn: the steer aborted the cycle this result
// may belong to, and the steered one is still to come. Record the outcome
// for the idle lane (see Turn.steeredEchoes); later cycles overwrite it,
@@ -3680,23 +3640,32 @@ export class ClaudeAcpAgent {
) {
session.activeTurn.deferredSettle = outcome;
} else {
settleActive(outcome);
await settleActive(outcome);
}
};
/** Settle the active turn's deferred exactly once, disarm the force-cancel
* backstop (the turn is over), and drop it from the queue. */
const settleActive = (
/** At the actual turn boundary, preview its checkpoint, settle the active
* turn exactly once, disarm the force-cancel backstop, and drop it from
* the queue. Cancellation and provider failures skip checkpoint I/O. */
const settleActive = async (
result: PromptResponse,
auditReason: FileChangeReportUnavailableReason = result.stopReason === "cancelled"
reportReason: FileChangeReportUnavailableReason = result.stopReason === "cancelled"
? "cancelled"
: "notReported",
) => {
const turn = session.activeTurn;
if (!turn || turn.settled) {
if (!turn || turn.settled || turn.settling) {
return;
}
this.finishFileChangeAudit(session, turn, auditReason);
turn.settling = true;
turn.settlingOutcome = result;
if (reportReason === "notReported") {
await session.fileChangeReporter?.report(turn, session.query);
// cancel() can settle a held turn while the bounded checkpoint preview
// is in flight. Its cancellation outcome wins; never settle twice.
if (turn.settled || session.activeTurn !== turn) return;
}
session.fileChangeReporter?.finish(turn.fileChangeReport, reportReason);
// Captured before the settled flip below (isHeldOpen tests !settled).
const wasHeld = isHeldOpen(turn);
turn.settled = true;
@@ -3734,7 +3703,7 @@ export class ClaudeAcpAgent {
);
return;
}
this.finishFileChangeAudit(session, turn, "providerError");
session.fileChangeReporter?.finish(turn.fileChangeReport, "providerError");
turn.settled = true;
session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
session.activeTurn = null;
@@ -3803,7 +3772,10 @@ export class ClaudeAcpAgent {
return;
}
sessionFailures.recordActive(failure);
settleActive(turnOutcome(session, "end_turn", sessionFailureMeta(failure)), "providerError");
await settleActive(
turnOutcome(session, "end_turn", sessionFailureMeta(failure)),
"providerError",
);
};
/** Reject every in-flight turn — used when the stream dies. */
@@ -3816,7 +3788,7 @@ export class ClaudeAcpAgent {
session.turnQueue = [];
for (const turn of turns) {
if (!turn.settled) {
this.finishFileChangeAudit(session, turn, "providerError");
session.fileChangeReporter?.finish(turn.fileChangeReport, "providerError");
const wasHeld = isHeldOpen(turn);
turn.settled = true;
if (wasHeld) {
@@ -3906,7 +3878,7 @@ export class ClaudeAcpAgent {
this.trackOrphanCommand(session, active.promptUuid, "started");
}
}
settleActive(turnOutcome(session, "cancelled"));
await settleActive(turnOutcome(session, "cancelled"));
// The cancelled turn's result may never come (that's why the
// backstop fired) — close its delivery stretch here so partial
// streamed text can't suppress the next turn's issue-#453 fallback.
@@ -3969,7 +3941,7 @@ export class ClaudeAcpAgent {
session.cancelled ? "stopped" : "failed",
"at end of stream",
);
settleActive(
await settleActive(
session.cancelled
? turnOutcome(session, "cancelled")
: (inFlight?.deferredSettle ?? turnOutcome(session, stopReason)),
@@ -3981,7 +3953,7 @@ export class ClaudeAcpAgent {
// still here was enqueued afterward and was not part of the cancel.)
for (const queued of [...(session.turnQueue ?? [])]) {
if (!queued.settled) {
this.finishFileChangeAudit(session, queued, "providerError");
session.fileChangeReporter?.finish(queued.fileChangeReport, "providerError");
queued.settled = true;
queued.reject(RequestError.internalError(undefined, SESSION_ENDED_MESSAGE));
}
@@ -4272,7 +4244,7 @@ export class ClaudeAcpAgent {
// when the cancel pre-empted the result (wedge/force-cancel).
if (session.cancelled && session.activeTurn && !session.activeTurn.settled) {
await compaction.reset();
settleActive(turnOutcome(session, "cancelled"));
await settleActive(turnOutcome(session, "cancelled"));
// An interrupt can pre-empt the turn's result entirely
// (nothing ran the result-case `finally`), so close the
// delivery stretch here: idle is the SDK's authoritative
@@ -4297,7 +4269,7 @@ export class ClaudeAcpAgent {
if (session.owedTrailingIdles > 0) {
session.owedTrailingIdles--;
}
settleDeferredIfDrained();
await settleDeferredIfDrained();
} else if (session.owedTrailingIdles > 0) {
// Absorb a settled turn's trailing idle. Also covers a
// cancel that landed between a turn's counted result and
@@ -4327,7 +4299,7 @@ export class ClaudeAcpAgent {
steered.deferredSettle = steered.steeredSettle;
steered.steeredEchoes = undefined;
steered.steeredSettle = undefined;
settleDeferredIfDrained();
await settleDeferredIfDrained();
}
} else if (
!session.cancelled &&
@@ -4851,7 +4823,7 @@ export class ClaudeAcpAgent {
// the map in that case).
if (!isAutonomousResult) {
recordResultForOrphanCommands();
ensureActiveTurn(message.user_message_uuid);
await ensureActiveTurn(message.user_message_uuid);
// Once the submitted goal command has produced its own result,
// no older runtime update can still precede it in the ordered
// SDK stream. Stop suppressing updates even when this runtime
@@ -5046,7 +5018,7 @@ export class ClaudeAcpAgent {
// failActive a live turn (the held one, or the user's next
// prompt) whose own result recorded a different outcome.
if (isAutonomousResult) {
settleDeferredIfDrained();
await settleDeferredIfDrained();
// With no turn in flight OR QUEUED (also after the settle
// above), the stretch holds only autonomous prose — close
// it, so a replayed next prompt isn't silently suppressed by
@@ -5135,7 +5107,7 @@ export class ClaudeAcpAgent {
return;
}
stopReason = "cancelled";
settleOrDefer(turnOutcome(session, "cancelled"));
await settleOrDefer(turnOutcome(session, "cancelled"));
break;
}
if (pendingExitPlanModeInterruption) {
@@ -5168,7 +5140,7 @@ export class ClaudeAcpAgent {
// settling it out from under them would strand their output
// and permission requests out-of-turn (issue #866's deadlock,
// through the refusal lane).
settleOrDefer(turnOutcome(session, "refusal"));
await settleOrDefer(turnOutcome(session, "refusal"));
break;
}
@@ -5182,7 +5154,7 @@ export class ClaudeAcpAgent {
isSteering(session.activeTurn) &&
session.activeTurn.steeredEchoes.size > 0
) {
settleOrDefer(turnOutcome(session, "end_turn"));
await settleOrDefer(turnOutcome(session, "end_turn"));
break;
}
@@ -5368,7 +5340,7 @@ export class ClaudeAcpAgent {
await compaction.reset();
}
if (!session.cancelled) {
settleOrDefer(turnOutcome(session, stopReason));
await settleOrDefer(turnOutcome(session, stopReason));
}
} finally {
if (!isAutonomousResult) {
@@ -5578,7 +5550,7 @@ export class ClaudeAcpAgent {
session.owedTrailingIdles++;
// Before activateTurn resets the accumulator, so the
// usage still belongs to the cancelled turn.
settleActive(turnOutcome(session, "cancelled"));
await settleActive(turnOutcome(session, "cancelled"));
} else if (isHeldOpen(session.activeTurn)) {
// A turn held open for its background subagents (see
// Turn.deferredSettle) hands off with the real outcome
@@ -5587,7 +5559,7 @@ export class ClaudeAcpAgent {
// subagent, but it must not rewrite the stop reason
// either. Its trailing-idle debt stands and is absorbed
// when the drain idle eventually arrives.
settleActive(session.activeTurn.deferredSettle);
await settleActive(session.activeTurn.deferredSettle);
} else if (
isSteering(session.activeTurn) &&
session.activeTurn.steeredSettle !== undefined
@@ -5604,9 +5576,9 @@ export class ClaudeAcpAgent {
// turn and false-fails it (issue #825). Harmless if it
// never comes: the debt absorbs one future idle.
session.owedTrailingIdles++;
settleActive(session.activeTurn.steeredSettle);
await settleActive(session.activeTurn.steeredSettle);
} else {
settleActive(turnOutcome(session, "end_turn"));
await settleActive(turnOutcome(session, "end_turn"));
}
}
// Unlike the no-result teardown lanes, this hand-off must
@@ -6166,7 +6138,7 @@ export class ClaudeAcpAgent {
if (session.turnQueue) {
for (const turn of session.turnQueue) {
if (turn !== session.activeTurn && !turn.settled) {
this.finishFileChangeAudit(session, turn, "cancelled");
session.fileChangeReporter?.finish(turn.fileChangeReport, "cancelled");
turn.settled = true;
// Deliberately no `usage`: a queued turn never ran, so the session
// accumulator (the active turn's tally) is not its spend.
@@ -6246,20 +6218,18 @@ export class ClaudeAcpAgent {
}
}
// A deferred active turn (see Turn.deferredSettle) already has its
// result — it is only held open for its background subagents, which the
// interrupt below tears down. Settle it "cancelled" NOW: during the hold
// the session is typically already in state idle (the CLI's trailer
// fired at the result), so the interrupt may produce no fresh idle for
// the consumer's cancelled-settle path to run on, and the cancel would
// otherwise stall until the force-cancel backstop. Any outstanding
// trailer debt is absorbed by the idle handler when its idle does come.
// The turn's own usage snapshot is reported per the cancelled-usage
// contract (issue #844).
// A deferred active turn (see Turn.deferredSettle) already has its result
// and is only held open for subagents. A settling turn likewise has its
// result but is awaiting the bounded checkpoint preview. Settle either one
// "cancelled" NOW: the consumer cannot process the interrupt's trailing
// idle while blocked in that preview, and a held turn may already be idle.
// The captured outcome preserves usage and metadata (issue #844), while
// reporter state makes a late checkpoint response harmless.
{
const active = session.activeTurn;
if (isHeldOpen(active)) {
this.finishFileChangeAudit(session, active, "cancelled");
const pendingOutcome = active?.deferredSettle ?? active?.settlingOutcome;
if (active && pendingOutcome && (isHeldOpen(active) || active.settling)) {
session.fileChangeReporter?.finish(active.fileChangeReport, "cancelled");
active.settled = true;
// Mirror settleActive's invariants (it is consumer-scoped and
// unreachable from here): disarm the backstop — none should be
@@ -6297,8 +6267,8 @@ export class ClaudeAcpAgent {
// from it here.
active.resolve({
stopReason: "cancelled",
usage: active.deferredSettle.usage,
...(active.deferredSettle._meta ? { _meta: active.deferredSettle._meta } : {}),
usage: pendingOutcome.usage,
...(pendingOutcome._meta ? { _meta: pendingOutcome._meta } : {}),
});
}
}
@@ -6623,13 +6593,6 @@ export class ClaudeAcpAgent {
})
: undefined;
let replayTurnId: string | undefined;
// Stop-hook additionalContext is persisted as an internal user message.
// Once that marker (or the internal tool itself) appears, suppress the
// whole audit exchange until its tool result. This also hides a disobedient
// model's separate prose message, while an ordinary next user prompt safely
// ends an incomplete audit lane.
let replayingFileChangeAudit = false;
const replayFileChangeAuditToolUseIds = new Set<string>();
const nativeReplayEnabled = clientSupportsSubagents(this.clientCapabilities);
const replayCompactionUpdates = clientSupportsCompactionUpdates(this.clientCapabilities);
const replayTerminalStates = new Map<string, "completed" | "failed" | "cancelled">();
@@ -6824,66 +6787,6 @@ export class ClaudeAcpAgent {
if (content === null) continue;
}
const auditBlocks = Array.isArray(content)
? content.filter(
(block): block is Record<string, unknown> =>
typeof block === "object" && block !== null,
)
: [];
const hasFileChangeAuditMarker =
(typeof content === "string" && containsFileChangeAuditMarker(content)) ||
auditBlocks.some(
(block) => typeof block.text === "string" && containsFileChangeAuditMarker(block.text),
);
const fileChangeAuditToolUseIds = auditBlocks.flatMap((block) =>
(block.type === "tool_use" ||
block.type === "server_tool_use" ||
block.type === "mcp_tool_use") &&
typeof block.name === "string" &&
isFileChangeAuditTool(block.name) &&
typeof block.id === "string"
? [block.id]
: [],
);
const replayMessageRole = (message as unknown as { message?: { role?: unknown } }).message
?.role;
if (hasFileChangeAuditMarker || fileChangeAuditToolUseIds.length > 0) {
replayingFileChangeAudit = true;
for (const toolUseId of fileChangeAuditToolUseIds) {
replayFileChangeAuditToolUseIds.add(toolUseId);
}
continue;
}
if (replayingFileChangeAudit) {
const toolResultIds = auditBlocks.flatMap((block) =>
(block.type === "tool_result" || block.type === "mcp_tool_result") &&
typeof block.tool_use_id === "string"
? [block.tool_use_id]
: [],
);
let completedReport = false;
for (const toolUseId of toolResultIds) {
if (replayFileChangeAuditToolUseIds.delete(toolUseId)) completedReport = true;
}
if (completedReport && replayFileChangeAuditToolUseIds.size === 0) {
replayingFileChangeAudit = false;
continue;
}
// A denied attempt to call another tool is still part of the hidden
// lane. Its result must not end replay suppression before the report.
if (toolResultIds.length > 0) {
continue;
}
// The next real user prompt is already represented by the client and
// starts a new turn; do not let a missing audit result hide it or the
// rest of the replay.
if (replayMessageRole === "user") {
replayingFileChangeAudit = false;
} else {
continue;
}
}
// Claude persists the retained summary as a user message framed with
// continuation instructions for the model. Clients on the compaction
// contract get it materialized as the completed compaction entity at
@@ -7134,27 +7037,6 @@ export class ClaudeAcpAgent {
};
}
const fileChangeAudit = session.activeTurn?.fileChangeAudit;
if (isFileChangeAuditReportPhase(fileChangeAudit)) {
// The hidden continuation is an audit-only lane: it may submit the
// wrapper-owned report, but it must not run another command after the
// user-visible answer has already completed.
if (isFileChangeAuditTool(toolName) && fileChangeAudit?.phase === "collecting") {
return { behavior: "allow", updatedInput: toolInput };
}
return {
behavior: "deny",
message: "Only the internal file-change report is allowed during the audit.",
};
}
// The tool is intentionally unusable outside a negotiated audit turn.
if (isFileChangeAuditTool(toolName)) {
return {
behavior: "deny",
message: "No file-change report was requested for this turn.",
};
}
// When the tool call originates inside a subagent, attribute the eagerly
// emitted tool_call (and the permission request itself) to the Agent/Task
// tool call that spawned the subagent, mirroring the streamed subagent
@@ -8049,8 +7931,8 @@ export class ClaudeAcpAgent {
// the same Map that the streaming message handler will read from.
const taskState: TaskState = new Map();
// Resolve every workspace root once. The hidden report tool uses this same
// set for lexical path validation, and the SDK receives it below.
// Resolve every workspace root once. The native checkpoint report uses
// this same set for lexical path validation, and the SDK receives it below.
const acpAdditionalDirectories =
params.additionalDirectories ?? sessionMeta?.additionalRoots ?? [];
const additionalDirectories = [
@@ -8058,11 +7940,10 @@ export class ClaudeAcpAgent {
...acpAdditionalDirectories,
];
const fileChangeAuditSupport = supportsAgentFileChangeReport(this.clientCapabilities)
? createFileChangeAuditSupport({
const fileChangeReporter = supportsAgentFileChangeReport(this.clientCapabilities)
? createNativeFileChangeReporter({
cwd: params.cwd,
additionalDirectories,
getActiveState: () => this.sessions[sessionId]?.activeTurn?.fileChangeAudit,
publish: async (result) => {
await this.client.sessionUpdate({
sessionId,
@@ -8135,6 +8016,10 @@ export class ClaudeAcpAgent {
settingSources: ["user", "project", "local"],
...(thinking !== undefined && { thinking }),
...userProvidedOptions,
// Claude Code uses the same checkpoint store for /rewind. Enable it only
// for clients that negotiated per-turn file-change reports; this avoids
// snapshot I/O for every other session.
...(fileChangeReporter ? { enableFileCheckpointing: true } : {}),
...(settings && { settings }),
env,
// Override certain fields that must be controlled by ACP
@@ -8144,9 +8029,6 @@ export class ClaudeAcpAgent {
mcpServers: {
...(userProvidedOptions?.mcpServers || {}),
...mcpServers,
...(fileChangeAuditSupport
? { [FILE_CHANGE_AUDIT_SERVER_NAME]: fileChangeAuditSupport.mcpServer }
: {}),
},
allowDangerouslySkipPermissions: allowBypass,
permissionMode: initialPermissionMode,
@@ -8179,14 +8061,6 @@ export class ClaudeAcpAgent {
tools,
hooks: {
...userProvidedOptions?.hooks,
...(fileChangeAuditSupport
? {
PreToolUse: [
...(userProvidedOptions?.hooks?.PreToolUse || []),
{ hooks: [fileChangeAuditSupport.preToolUseHook] },
],
}
: {}),
PostToolUse: [
...(userProvidedOptions?.hooks?.PostToolUse || []),
{
@@ -8258,14 +8132,6 @@ export class ClaudeAcpAgent {
],
},
],
...(fileChangeAuditSupport
? {
Stop: [
...(userProvidedOptions?.hooks?.Stop || []),
{ hooks: [fileChangeAuditSupport.stopHook] },
],
}
: {}),
TaskCreated: [
...(userProvidedOptions?.hooks?.TaskCreated || []),
{
@@ -8563,8 +8429,7 @@ export class ClaudeAcpAgent {
sessionFailureState: createSessionFailureState(),
claudeSubscriptionGuard,
accountKind: fromAccountInfo(initializationResult.account)?.kind,
fileChangeReportRequestIds: new Set(),
fileChangeAuditSupport,
fileChangeReporter,
};
timing.phase("register");
@@ -9380,7 +9245,7 @@ function isTaskTool(toolName: string): boolean {
* permission-surfaced tool_call for them (see `ensureToolCallEmitted`) must be
* resolved explicitly at tool_result time. */
function shouldEmitToolCall(toolName: string): boolean {
return toolName !== "TodoWrite" && !isTaskTool(toolName) && !isFileChangeAuditTool(toolName);
return toolName !== "TodoWrite" && !isTaskTool(toolName);
}
/** Build the Claude Code-specific metadata for a tool call. Bash descriptions
@@ -9622,7 +9487,7 @@ export function toAcpNotifications(
const registerHooks = options?.registerHooks !== false;
const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
if (typeof content === "string") {
if (content.length === 0 || containsFileChangeAuditMarker(content)) {
if (content.length === 0) {
return [];
}
const update: SessionNotification["update"] = {
@@ -9661,17 +9526,6 @@ export function toAcpNotifications(
// Unlike `tool_use_result`, entries carry their own tool_use_id, so batched
// messages need no single-block guard.
const toolResultMeta = parseToolResultMeta(options?.toolResultMeta);
// A report-phase assistant message may contain a short text preface and the
// internal tool call in the same content array. Hide the whole message, not
// only the tool block, so it stays absent on session replay as well as live.
const containsFileChangeAuditToolUse = content.some(
(chunk) =>
(chunk.type === "tool_use" ||
chunk.type === "server_tool_use" ||
chunk.type === "mcp_tool_use") &&
isFileChangeAuditTool(chunk.name),
);
const output = [];
// Only handle the first chunk for streaming; extend as needed for batching
for (const chunk of content) {
@@ -9679,11 +9533,7 @@ export function toAcpNotifications(
switch (chunk.type) {
case "text":
case "text_delta": {
if (
chunk.text &&
!containsFileChangeAuditToolUse &&
!containsFileChangeAuditMarker(chunk.text)
) {
if (chunk.text) {
update = {
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
content: {
@@ -9695,22 +9545,21 @@ export function toAcpNotifications(
break;
}
case "image":
if (!containsFileChangeAuditToolUse)
update = {
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
content: {
type: "image",
data: chunk.source.type === "base64" ? chunk.source.data : "",
mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
uri: chunk.source.type === "url" ? chunk.source.url : undefined,
},
};
update = {
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
content: {
type: "image",
data: chunk.source.type === "base64" ? chunk.source.data : "",
mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
uri: chunk.source.type === "url" ? chunk.source.url : undefined,
},
};
break;
case "thinking":
case "thinking_delta": {
// Recent models default `thinking.display` to "omitted", which streams
// signature-only thinking blocks whose text is empty.
if (chunk.thinking && !containsFileChangeAuditToolUse) {
if (chunk.thinking) {
update = {
sessionUpdate: "agent_thought_chunk",
content: {
@@ -9726,10 +9575,7 @@ export function toAcpNotifications(
case "mcp_tool_use": {
const alreadyCached = chunk.id in toolUseCache;
toolUseCache[chunk.id] = chunk;
if (isFileChangeAuditTool(chunk.name)) {
// Wrapper-owned audit protocol: never surface or register generic
// PostToolUse callbacks for the internal tool.
} else if (chunk.name === "TodoWrite") {
if (chunk.name === "TodoWrite") {
// @ts-expect-error - sometimes input is empty object or undefined
if (Array.isArray(chunk.input?.todos)) {
update = {
@@ -9872,11 +9718,6 @@ export function toAcpNotifications(
break;
}
if (isFileChangeAuditTool(toolUse.name)) {
delete toolUseCache[chunk.tool_use_id];
break;
}
// A permission request may have surfaced a plan-rendered (TodoWrite) or
// suppressed (Task*) tool as a real tool_call so the request referenced
// a tool call the client knows about (see `ensureToolCallEmitted`,

View File

@@ -1,39 +1,29 @@
import {
createSdkMcpServer,
type HookCallback,
type McpSdkServerConfigWithInstance,
type SdkMcpToolDefinition,
} from "@anthropic-ai/claude-agent-sdk";
import type { Query, RewindFilesResult } from "@anthropic-ai/claude-agent-sdk";
import * as fs from "node:fs";
import * as path from "node:path";
import { z } from "zod";
import { airExtensionMeta, clientSupportsAirCapability, withAirMeta } from "./air-extension.js";
export const AGENT_FILE_CHANGE_REPORT_CAPABILITY = "agentFileChangeReport";
export const FILE_CHANGE_AUDIT_SERVER_NAME = "claude_agent_acp";
export const FILE_CHANGE_AUDIT_TOOL_NAME = "report_changed_files";
export const FILE_CHANGE_AUDIT_WIRE_TOOL_NAME = `mcp__${FILE_CHANGE_AUDIT_SERVER_NAME}__${FILE_CHANGE_AUDIT_TOOL_NAME}`;
const FILE_CHANGE_AUDIT_MARKER = "claude-agent-acp-file-change-audit";
const MAX_REPORTED_PATHS = 1024;
const MAX_REPORTED_PATH_LENGTH = 4096;
export const AGENT_FILE_CHANGE_REPORT_MAX_BYTES = 256 * 1024;
export const AGENT_FILE_CHANGE_REPORT_TIMEOUT_MS = 2_000;
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
export type FileChangeAuditTurnState = {
export type FileChangeReportTurnState = {
requestId: string;
phase: "requested" | "collecting" | "finished";
};
export type FileChangeAuditWorkspace = {
cwd: string;
additionalDirectories: string[];
export type NativeFileChangeReportTurn = {
promptUuid: string;
fileChangeReport?: FileChangeReportTurnState;
};
export type AgentFileChangeReport = {
paths: string[];
complete: boolean;
uncertainty?: string;
export type FileChangeReportWorkspace = {
cwd: string;
additionalDirectories: string[];
};
export type AgentFileChangeReportResult = {
@@ -45,7 +35,6 @@ export type AgentFileChangeReportResult = {
paths: string[];
declaredComplete: boolean;
truncated: boolean;
uncertainty?: string;
}
| {
status: "unavailable";
@@ -56,40 +45,24 @@ export type AgentFileChangeReportResult = {
export type FileChangeReportUnavailableReason =
"cancelled" | "timeout" | "invalidOutput" | "notReported" | "providerError";
type FileChangeAuditSupportOptions = {
type NativeFileChangeReporterOptions = {
cwd: string;
additionalDirectories: string[];
getActiveState: () => FileChangeAuditTurnState | undefined;
publish: (result: AgentFileChangeReportResult) => Promise<void>;
logError: (message: string) => void;
timeoutMs?: number;
};
export type FileChangeAuditSupport = {
mcpServer: McpSdkServerConfigWithInstance;
preToolUseHook: HookCallback;
stopHook: HookCallback;
finishUnavailable: (
state: FileChangeAuditTurnState,
export type NativeFileChangeReporter = {
request(meta: unknown): FileChangeReportTurnState | undefined;
report(
turn: NativeFileChangeReportTurn | null | undefined,
query: Pick<Query, "rewindFiles">,
): Promise<void>;
finish(
state: FileChangeReportTurnState | undefined,
reason: FileChangeReportUnavailableReason,
) => Promise<void>;
};
const reportInputSchema = {
paths: z
.array(z.string())
.describe(
"Workspace file paths changed during this turn. Use absolute paths or paths relative to cwd.",
),
complete: z
.boolean()
.describe("True only when paths contains every workspace file changed during this turn."),
uncertainty: z
.string()
.trim()
.min(1)
.max(2000)
.optional()
.describe("Why the report may be incomplete or uncertain."),
): void;
};
export function agentFileChangeReportRequestId(meta: unknown): string | undefined {
@@ -121,180 +94,106 @@ export function agentFileChangeReportMeta(
return withAirMeta(undefined, AGENT_FILE_CHANGE_REPORT_CAPABILITY, result);
}
export function createFileChangeAuditTurnState(requestId: string): FileChangeAuditTurnState {
return {
requestId,
phase: "requested",
};
}
export function isFileChangeAuditReportPhase(state: FileChangeAuditTurnState | undefined): boolean {
return state?.phase === "collecting" || state?.phase === "finished";
}
export function isFileChangeAuditTool(toolName: string): boolean {
return toolName === FILE_CHANGE_AUDIT_WIRE_TOOL_NAME;
}
export function containsFileChangeAuditMarker(text: string): boolean {
return text.includes(`<${FILE_CHANGE_AUDIT_MARKER}>`);
}
export function createFileChangeAuditSupport(
options: FileChangeAuditSupportOptions,
): FileChangeAuditSupport {
export function createNativeFileChangeReporter(
options: NativeFileChangeReporterOptions,
): NativeFileChangeReporter {
const workspace = normalizeWorkspace(options.cwd, options.additionalDirectories);
const timeoutMs = options.timeoutMs ?? AGENT_FILE_CHANGE_REPORT_TIMEOUT_MS;
const requestIds = new Set<string>();
const finishUnavailable = async (
state: FileChangeAuditTurnState,
reason: FileChangeReportUnavailableReason,
) => {
// Claim the terminal outcome before the first await. The report tool, a
// Stop hook, cancellation, and stream teardown can race, but only the
// winner may publish for this request id. Publication remains fail-open:
// an audit transport failure must never hold the user prompt open.
const publish = async (state: FileChangeReportTurnState, result: AgentFileChangeReportResult) => {
if (state.phase === "finished") return;
state.phase = "finished";
try {
await options.publish({
version: 1,
requestId: state.requestId,
status: "unavailable",
reason,
});
await options.publish(result);
} catch (error) {
options.logError(`Failed to publish unavailable report ${state.requestId}: ${error}`);
options.logError(`Failed to publish file-change report ${state.requestId}: ${error}`);
}
};
const tool: SdkMcpToolDefinition<typeof reportInputSchema> = {
name: FILE_CHANGE_AUDIT_TOOL_NAME,
description:
"Internal audit tool. Call it only when a stop-hook message explicitly requests the file-change audit.",
inputSchema: reportInputSchema,
_meta: {
"anthropic/alwaysLoad": true,
"claude/endTurn": true,
},
handler: async (report) => {
const state = options.getActiveState();
if (!state || state.phase !== "collecting") {
return {
isError: true,
content: [
{
type: "text",
text: `<${FILE_CHANGE_AUDIT_MARKER}>No file-change audit is active.</${FILE_CHANGE_AUDIT_MARKER}>`,
},
],
};
}
// Flip before publishing: a client transport failure must not make the
// model retry the report and produce duplicates. Delivery is audit-only
// and fail-open; the normal turn must still finish.
state.phase = "finished";
const normalized = normalizeReportedPaths(report.paths, workspace);
const result = fitReportedAgentFileChangeReport({
version: 1,
requestId: state.requestId,
status: "reported",
paths: normalized.paths,
declaredComplete: report.complete && !normalized.truncated,
truncated: normalized.truncated,
...(report.uncertainty ? { uncertainty: report.uncertainty } : {}),
});
try {
await options.publish(result);
} catch (error) {
options.logError(`Failed to publish file-change report ${state.requestId}: ${error}`);
}
return {
content: [
{
type: "text",
text: `<${FILE_CHANGE_AUDIT_MARKER}>File-change audit recorded.</${FILE_CHANGE_AUDIT_MARKER}>`,
},
],
structuredContent: result,
};
},
const finishUnavailable = async (
state: FileChangeReportTurnState,
reason: FileChangeReportUnavailableReason,
) => {
await publish(state, {
version: 1,
requestId: state.requestId,
status: "unavailable",
reason,
});
};
// canUseTool is not guaranteed to run for tools that the current permission
// mode auto-allows (notably bypassPermissions). A PreToolUse hook is the
// enforcement boundary that keeps the hidden continuation read-only in every
// mode: it can submit the report, but cannot inspect or mutate more files.
const preToolUseHook: HookCallback = async (input) => {
if (input.hook_event_name !== "PreToolUse") return {};
const state = options.getActiveState();
const isReportTool = isFileChangeAuditTool(input.tool_name);
if (!isReportTool && !isFileChangeAuditReportPhase(state)) return {};
const allowReport = state?.phase === "collecting" && isReportTool;
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: allowReport ? "allow" : "deny",
...(!allowReport
? {
permissionDecisionReason:
"Only the internal file-change report is allowed during the audit.",
}
: {}),
},
};
};
const stopHook: HookCallback = async (input) => {
if (input.hook_event_name !== "Stop") return {};
const state = options.getActiveState();
if (!state || state.phase === "finished") return {};
// A Stop can be only a pause while a background generator is still
// running. Keep the request pending so a later final Stop can include its
// writes instead of declaring a prematurely complete path set.
if (state.phase === "requested" && (input.background_tasks?.length ?? 0) > 0) return {};
if (state.phase === "collecting" || input.stop_hook_active) {
await finishUnavailable(state, "notReported");
return {};
}
// Set the phase before returning the continuation so every following SDK
// message is hidden even if the next stream starts immediately.
const report = async (
turn: NativeFileChangeReportTurn | null | undefined,
query: Pick<Query, "rewindFiles">,
) => {
const state = turn?.fileChangeReport;
if (!state || state.phase !== "requested") return;
state.phase = "collecting";
return {
hookSpecificOutput: {
hookEventName: "Stop",
additionalContext:
`<${FILE_CHANGE_AUDIT_MARKER}>` +
`Before stopping, call ${FILE_CHANGE_AUDIT_WIRE_TOOL_NAME} exactly once. ` +
"Report every workspace file changed during this user turn, including files changed " +
"by commands, generators, version-control operations, and child processes. " +
"Do not call any other tool and do not emit user-facing prose. " +
"Set complete to false and explain uncertainty when you are not sure the list is complete." +
`</${FILE_CHANGE_AUDIT_MARKER}>`,
},
};
let timer: ReturnType<typeof setTimeout> | undefined;
const previewPromise = query.rewindFiles(turn.promptUuid, { dryRun: true });
// A timed-out control request may still settle later. Observe it so a late
// rejection cannot become unhandled, but never let it publish a second terminal.
void previewPromise.catch(() => {});
try {
const preview = await Promise.race<RewindFilesResult | null>([
previewPromise,
new Promise<null>((resolve) => {
timer = setTimeout(() => resolve(null), timeoutMs);
timer.unref?.();
}),
]);
if (preview === null) {
await finishUnavailable(state, "timeout");
return;
}
if (!preview.canRewind || !Array.isArray(preview.filesChanged)) {
await finishUnavailable(state, "invalidOutput");
return;
}
const normalized = normalizeReportedPaths(preview.filesChanged, workspace);
await publish(
state,
fitReportedAgentFileChangeReport({
version: 1,
requestId: state.requestId,
status: "reported",
paths: normalized.paths,
// Checkpoints cover Claude file tools, but not every mutation source
// (notably Bash and most subagents), so this list is never exhaustive.
declaredComplete: false,
truncated: normalized.truncated,
}),
);
} catch (error) {
options.logError(`Failed to inspect Claude file checkpoint ${turn.promptUuid}: ${error}`);
await finishUnavailable(state, "providerError");
} finally {
if (timer) clearTimeout(timer);
}
};
return {
mcpServer: createSdkMcpServer({
name: FILE_CHANGE_AUDIT_SERVER_NAME,
version: "1.0.0",
tools: [tool],
alwaysLoad: true,
}),
preToolUseHook,
stopHook,
finishUnavailable,
request(meta) {
const requestId = agentFileChangeReportRequestId(meta);
if (!requestId || requestIds.has(requestId)) return undefined;
requestIds.add(requestId);
return { requestId, phase: "requested" };
},
report,
finish(state, reason) {
if (!state) return;
void finishUnavailable(state, reason);
},
};
}
function normalizeWorkspace(
cwd: string,
additionalDirectories: string[],
): FileChangeAuditWorkspace {
): FileChangeReportWorkspace {
const normalizedCwd = canonicalizeWorkspaceRoot(path.resolve(cwd));
const seen = new Set([pathKey(normalizedCwd)]);
const normalizedAdditionalDirectories: string[] = [];
@@ -313,7 +212,7 @@ function normalizeWorkspace(
function normalizeReportedPaths(
reportedPaths: string[],
workspace: FileChangeAuditWorkspace,
workspace: FileChangeReportWorkspace,
): { paths: string[]; truncated: boolean } {
const roots = [workspace.cwd, ...workspace.additionalDirectories];
const seen = new Set<string>();
@@ -322,6 +221,7 @@ function normalizeReportedPaths(
let truncated = false;
for (const reportedPath of reportedPaths) {
if (
typeof reportedPath !== "string" ||
reportedPath.trim().length === 0 ||
reportedPath.length > MAX_REPORTED_PATH_LENGTH ||
hasControlCharacter(reportedPath)
@@ -349,9 +249,7 @@ function normalizeReportedPaths(
truncated = true;
continue;
}
if (seen.has(key)) {
continue;
}
if (seen.has(key)) continue;
const pathBytes = Buffer.byteLength(normalized, "utf8");
if (
result.length >= MAX_REPORTED_PATHS ||
@@ -367,7 +265,6 @@ function normalizeReportedPaths(
return { paths: result, truncated };
}
/** Keep the complete serialized report within AIR's wire limit. */
function fitReportedAgentFileChangeReport(
report: Extract<AgentFileChangeReportResult, { status: "reported" }>,
): Extract<AgentFileChangeReportResult, { status: "reported" }> {
@@ -375,8 +272,6 @@ function fitReportedAgentFileChangeReport(
let fitted = report;
while (Buffer.byteLength(JSON.stringify(fitted), "utf8") > AGENT_FILE_CHANGE_REPORT_MAX_BYTES) {
if (paths.length === 0) {
// The fixed fields and bounded uncertainty are far below the cap. Keep
// this loud if the wire contract changes without updating this helper.
throw new Error("Agent file-change report exceeds the wire limit without paths");
}
paths.pop();
@@ -395,19 +290,10 @@ function isWithinRoot(candidate: string, root: string): boolean {
return !path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`) && relative !== "..";
}
/**
* Resolve filesystem aliases in a workspace root, including macOS' /tmp ->
* /private/tmp alias. For a root that does not exist yet, resolve its nearest
* existing ancestor and retain the missing suffix.
*/
function canonicalizeWorkspaceRoot(value: string): string {
return canonicalizeFromExistingAncestor(value);
}
/**
* Canonicalize the parent but not the leaf itself. A changed path may be
* deleted, or it may be a symlink whose node (rather than target) changed.
*/
function canonicalizeReportedPath(value: string): string {
const parent = canonicalizeFromExistingAncestor(path.dirname(value));
return path.resolve(parent, path.basename(value));
@@ -417,7 +303,6 @@ function canonicalizeFromExistingAncestor(value: string): string {
const original = path.resolve(value);
let current = original;
const missingSegments: string[] = [];
while (true) {
try {
const canonical = fs.realpathSync.native(current);
@@ -425,7 +310,6 @@ function canonicalizeFromExistingAncestor(value: string): string {
} catch (error) {
if (!isMissingPathError(error)) return original;
}
const parent = path.dirname(current);
if (parent === current) return original;
missingSegments.push(path.basename(current));

View File

@@ -3376,7 +3376,6 @@ describe("permission request cancellation", () => {
emittedAssistantText: false,
owedTrailingIdles: 0,
messageIdToUuid: new Map(),
fileChangeReportRequestIds: new Set(),
} as any;
return agent.sessions[sessionId]!;
}
@@ -8920,7 +8919,6 @@ describe("session/close", () => {
owedTrailingIdles: 0,
messageIdToUuid: new Map(),
sessionFailureState: { epoch: randomUUID(), revisions: new Map(), active: new Map() },
fileChangeReportRequestIds: new Set(),
};
return agent.sessions[sessionId]!;
}
@@ -9014,7 +9012,6 @@ describe("session/delete", () => {
owedTrailingIdles: 0,
messageIdToUuid: new Map(),
sessionFailureState: { epoch: randomUUID(), revisions: new Map(), active: new Map() },
fileChangeReportRequestIds: new Set(),
};
return agent.sessions[sessionId]!;
}
@@ -9125,7 +9122,6 @@ describe("getOrCreateSession param change detection", () => {
owedTrailingIdles: 0,
messageIdToUuid: new Map(),
sessionFailureState: { epoch: randomUUID(), revisions: new Map(), active: new Map() },
fileChangeReportRequestIds: new Set(),
};
return agent.sessions[sessionId]!;
}
@@ -11686,62 +11682,6 @@ describe("assembled assistant text fallback", () => {
);
});
it("ends a dangling replayed audit lane at a compaction summary", async () => {
// A Stop-hook audit whose result was never persisted must not hide the
// post-compaction history from a client on the compaction contract.
const { agent, updates } = compactionCapableAgent();
agent.sessions["test-session"] = mockSessionState();
vi.mocked(getSessionMessages).mockResolvedValueOnce([
{
type: "user",
uuid: "audit-marker",
session_id: "test-session",
parent_tool_use_id: null,
parent_agent_id: null,
message: {
role: "user",
content: [
{
type: "text",
text: "<claude-agent-acp-file-change-audit>report now</claude-agent-acp-file-change-audit>",
},
],
},
},
{
type: "user",
uuid: "compact-summary",
session_id: "test-session",
parent_tool_use_id: null,
parent_agent_id: null,
isCompactSummary: true,
message: {
role: "user",
content:
"This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\nSummary:\nCounted.",
},
},
{
type: "assistant",
uuid: "after",
session_id: "test-session",
parent_tool_use_id: null,
parent_agent_id: null,
message: {
role: "assistant",
model: "claude-sonnet-4-6",
content: [{ type: "text", text: "Back after compaction." }],
},
},
] as any);
await (agent as any).replaySessionHistory("test-session");
const kinds = updates.map((notification) => notification.update.sessionUpdate);
expect(kinds).toContain("compaction_update");
expect(messageChunkTexts(updates)).toEqual(["Back after compaction."]);
});
it("preserves the model response after multiple compactions in one turn", async () => {
const { agent, updates } = createMockAgentWithCapture();
injectSession(agent, [
@@ -12701,7 +12641,6 @@ describe("post-error recovery", () => {
owedTrailingIdles: 0,
messageIdToUuid: new Map(),
sessionFailureState: { epoch: randomUUID(), revisions: new Map(), active: new Map() },
fileChangeReportRequestIds: new Set(),
};
return { interrupt };
}
@@ -14991,25 +14930,36 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
// deferral gate, or the subagent's remaining work is stranded
// out-of-turn through the refusal lane.
const agent = createMockAgent();
const report = vi.fn(async () => {});
let releaseDrain!: () => void;
const drainGate = new Promise<void>((resolve) => (releaseDrain = resolve));
injectGeneratorSession(agent, (input) => {
async function* messageGenerator() {
const iter = input[Symbol.asyncIterator]();
const { value: userMessage } = await iter.next();
yield userEcho(userMessage);
yield running();
yield subagentStarted("agent-1");
yield resultMessage({ stop_reason: "refusal" }); // held, not settled
yield idle();
await drainGate;
yield taskNotification("agent-1");
yield resultMessage({ origin: { kind: "task-notification" } }); // settles
yield idle();
}
return messageGenerator();
});
injectGeneratorSession(
agent,
(input) => {
async function* messageGenerator() {
const iter = input[Symbol.asyncIterator]();
const { value: userMessage } = await iter.next();
yield userEcho(userMessage);
yield running();
yield subagentStarted("agent-1");
yield resultMessage({ stop_reason: "refusal" }); // held, not settled
yield idle();
await drainGate;
yield taskNotification("agent-1");
yield resultMessage({ origin: { kind: "task-notification" } }); // settles
yield idle();
}
return messageGenerator();
},
{
fileChangeReporter: {
request: vi.fn(),
report,
finish: vi.fn(),
},
},
);
const response = agent.prompt({
sessionId: "test-session",
@@ -15020,8 +14970,10 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
await waitFor(
() => agent.sessions["test-session"]?.activeTurn?.deferredSettle?.stopReason === "refusal",
);
expect(report).not.toHaveBeenCalled();
releaseDrain();
await expect(response).resolves.toEqual(expect.objectContaining({ stopReason: "refusal" }));
expect(report).toHaveBeenCalledTimes(1);
await agent.sessions["test-session"]?.consumer;
});
@@ -16417,29 +16369,40 @@ describe("turn steering (_session/steering)", () => {
error: () => {},
});
injectGeneratorSession(agent, (input) => {
async function* messageGenerator() {
const iter = input[Symbol.asyncIterator]();
const u1 = await iter.next();
yield userEcho(u1.value); // turn becomes active
yield createAssistantText("working on it");
// The steered message is pushed at priority 'now'...
const steered = await iter.next();
// ...so the CLI aborts the query, ending the interrupted cycle with a
// result of its own. No idle follows it: one comes at the very end, for
// the whole interrupted + steered sequence.
yield interruptedCycleResult();
// Only now does the steered message run, as a second cycle. Its echo
// matches no queued turn (dropped as an unrelated replay), its output is
// the answer the user is waiting for, and its result has the last word
// on the turn's stop reason.
yield userEcho(steered.value);
yield createAssistantText("STEERED-OK");
yield createResultMessage();
yield idleMessage();
}
return messageGenerator();
});
const report = vi.fn(async () => void timeline.push("checkpoint"));
injectGeneratorSession(
agent,
(input) => {
async function* messageGenerator() {
const iter = input[Symbol.asyncIterator]();
const u1 = await iter.next();
yield userEcho(u1.value); // turn becomes active
yield createAssistantText("working on it");
// The steered message is pushed at priority 'now'...
const steered = await iter.next();
// ...so the CLI aborts the query, ending the interrupted cycle with a
// result of its own. No idle follows it: one comes at the very end, for
// the whole interrupted + steered sequence.
yield interruptedCycleResult();
// Only now does the steered message run, as a second cycle. Its echo
// matches no queued turn (dropped as an unrelated replay), its output is
// the answer the user is waiting for, and its result has the last word
// on the turn's stop reason.
yield userEcho(steered.value);
yield createAssistantText("STEERED-OK");
yield createResultMessage();
yield idleMessage();
}
return messageGenerator();
},
{
fileChangeReporter: {
request: vi.fn(),
report,
finish: vi.fn(),
},
},
);
const turn = agent
.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "start" }] })
@@ -16461,7 +16424,8 @@ describe("turn steering (_session/steering)", () => {
// The steered continuation belongs to the turn the client is waiting on, so
// all of it precedes that turn's response. A `prompt:` entry anywhere but
// last is the bug: updates outlived the stopReason.
expect(timeline).toEqual(["working on it", "STEERED-OK", "prompt:end_turn"]);
expect(timeline).toEqual(["working on it", "STEERED-OK", "checkpoint", "prompt:end_turn"]);
expect(report).toHaveBeenCalledTimes(1);
// Both cycles ran for this one prompt, so its usage covers both (2 × the
// mock result's 10 in / 5 out) rather than stopping at the interrupt.
expect(response.usage).toEqual({
@@ -17326,7 +17290,6 @@ describe("session/cancel wedge recovery (issue #680)", () => {
owedTrailingIdles: 0,
messageIdToUuid: new Map(),
sessionFailureState: { epoch: randomUUID(), revisions: new Map(), active: new Map() },
fileChangeReportRequestIds: new Set(),
};
return { interrupt };
}

View File

@@ -1,33 +1,24 @@
import type {
HookCallback,
Options,
PermissionResult,
SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";
import type { Options, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import type { SessionNotification } from "@agentclientprotocol/sdk";
import * as path from "node:path";
import { randomUUID } from "node:crypto";
import * as path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import {
AGENT_FILE_CHANGE_REPORT_CAPABILITY,
FILE_CHANGE_AUDIT_SERVER_NAME,
FILE_CHANGE_AUDIT_TOOL_NAME,
FILE_CHANGE_AUDIT_WIRE_TOOL_NAME,
type AgentFileChangeReport,
type AgentFileChangeReportResult,
} from "../file-change-audit.js";
import type { Pushable } from "../utils.js";
let observedStopOutputs: unknown[] = [];
let observedPreToolDecision: unknown;
let observedCanUseToolDecision: PermissionResult | null | undefined;
type Scenario =
"reported" | "providerError" | "localOnly" | "waitForCancel" | "waitForCheckpointCancel";
let scenario: Scenario = "reported";
let observedOptions: Options | undefined;
let replayMessages: unknown[] = [];
type AuditScenario =
"reported" | "successWithoutReport" | "providerError" | "localOnly" | "waitForCancel";
let auditScenario: AuditScenario = "reported";
let auditTurnActivated = Promise.resolve();
let resolveAuditTurnActivated = () => {};
let turnActivated = Promise.resolve();
let resolveTurnActivated = () => {};
let resolveCheckpointPreview = () => {};
const rewindFiles = vi.fn();
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
@@ -37,11 +28,31 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
...actual,
getSessionMessages: vi.fn(async () => replayMessages),
query: ({ prompt, options }: { prompt: Pushable<SDKUserMessage>; options: Options }) => {
observedOptions = options;
let resolveInterrupt = () => {};
const interrupted = new Promise<void>((resolve) => {
resolveInterrupt = resolve;
});
return Object.assign(runAuditedTurn(prompt, options, interrupted), {
rewindFiles.mockImplementation(() => {
if (scenario === "waitForCheckpointCancel") {
return new Promise((resolve) => {
resolveCheckpointPreview = () =>
resolve({
canRewind: true,
filesChanged: ["src/late.ts"],
insertions: 1,
deletions: 0,
});
});
}
return Promise.resolve({
canRewind: true,
filesChanged: ["src/changed.ts"],
insertions: 4,
deletions: 1,
});
});
return Object.assign(runTurn(prompt, interrupted), {
initializationResult: async () => ({
models: [
{
@@ -57,6 +68,7 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
supportedAgents: vi.fn(async () => []),
supportedCommands: vi.fn(async () => []),
getContextUsage: vi.fn(async () => ({ totalTokens: 0, rawMaxTokens: 200000 })),
rewindFiles,
interrupt: vi.fn(async () => {
resolveInterrupt();
return undefined;
@@ -72,29 +84,7 @@ vi.mock("../tools.js", async () => ({
registerHookCallback: vi.fn(),
}));
type RegisteredAuditTool = {
handler: (
report: AgentFileChangeReport,
) => Promise<{ content: unknown; structuredContent?: AgentFileChangeReportResult }>;
};
function hookFrom(options: Options, event: "PreToolUse" | "Stop"): HookCallback {
const matcher = options.hooks?.[event]?.at(-1);
const hook = matcher?.hooks.at(-1);
if (!hook) throw new Error(`Missing ${event} audit hook`);
return hook;
}
function auditToolFrom(options: Options): RegisteredAuditTool {
const server = options.mcpServers?.[FILE_CHANGE_AUDIT_SERVER_NAME] as unknown as {
instance: { _registeredTools: Record<string, RegisteredAuditTool> };
};
const tool = server.instance._registeredTools[FILE_CHANGE_AUDIT_TOOL_NAME];
if (!tool) throw new Error("Missing internal audit tool");
return tool;
}
function assistantMessage(content: unknown[]) {
function assistantMessage(text: string) {
return {
type: "assistant" as const,
parent_tool_use_id: null,
@@ -110,7 +100,7 @@ function assistantMessage(content: unknown[]) {
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
content,
content: [{ type: "text", text }],
},
};
}
@@ -125,7 +115,7 @@ function successResult() {
errors: [],
duration_ms: 0,
duration_api_ms: 0,
num_turns: 2,
num_turns: 1,
total_cost_usd: 0,
usage: {
input_tokens: 2,
@@ -140,16 +130,12 @@ function successResult() {
};
}
async function* runAuditedTurn(
input: Pushable<SDKUserMessage>,
options: Options,
interrupted: Promise<void>,
) {
async function* runTurn(input: Pushable<SDKUserMessage>, interrupted: Promise<void>) {
const iterator = input[Symbol.asyncIterator]();
const { value: userMessage } = await iterator.next();
if (!userMessage) return;
if (auditScenario === "localOnly") {
if (scenario === "localOnly") {
yield { ...successResult(), result: "Local command output" };
return;
}
@@ -162,23 +148,9 @@ async function* runAuditedTurn(
session_id: "sdk-session",
isReplay: true,
};
resolveAuditTurnActivated();
resolveTurnActivated();
if (auditScenario === "successWithoutReport") {
yield assistantMessage([{ type: "text", text: "Visible answer" }]);
yield successResult();
return;
}
if (auditScenario === "providerError") {
yield {
...successResult(),
is_error: true,
result: "Provider failed",
errors: ["Provider failed"],
};
return;
}
if (auditScenario === "waitForCancel") {
if (scenario === "waitForCancel") {
await interrupted;
yield {
type: "system" as const,
@@ -190,90 +162,29 @@ async function* runAuditedTurn(
return;
}
yield assistantMessage([{ type: "text", text: "Visible answer" }]);
if (scenario === "providerError") {
yield {
...successResult(),
is_error: true,
result: "Provider failed",
errors: ["Provider failed"],
};
return;
}
const hookOptions = { signal: new AbortController().signal };
const stopHook = hookFrom(options, "Stop");
observedStopOutputs.push(
await stopHook(
{ hook_event_name: "Stop", stop_hook_active: false } as Parameters<HookCallback>[0],
undefined,
hookOptions,
),
);
const preToolUseHook = hookFrom(options, "PreToolUse");
observedPreToolDecision = await preToolUseHook(
{
hook_event_name: "PreToolUse",
tool_name: "Bash",
tool_input: { command: "git status" },
tool_use_id: "blocked-tool",
} as Parameters<HookCallback>[0],
"blocked-tool",
hookOptions,
);
observedCanUseToolDecision = await options.canUseTool?.(
"Bash",
{ command: "git status" },
{
signal: hookOptions.signal,
toolUseID: "blocked-tool",
requestId: "permission-request",
},
);
const toolUseId = "audit-tool-use";
yield assistantMessage([
{ type: "text", text: "Audit prose must stay hidden" },
{
type: "tool_use",
id: toolUseId,
name: FILE_CHANGE_AUDIT_WIRE_TOOL_NAME,
input: { paths: ["src/changed.ts"], complete: true },
},
]);
const toolResult = await auditToolFrom(options).handler({
paths: ["src/changed.ts"],
complete: true,
});
yield {
type: "user" as const,
message: {
role: "user" as const,
content: [
{
type: "tool_result",
tool_use_id: toolUseId,
content: toolResult.content,
},
],
},
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: "sdk-session",
};
observedStopOutputs.push(
await stopHook(
{ hook_event_name: "Stop", stop_hook_active: true } as Parameters<HookCallback>[0],
undefined,
hookOptions,
),
);
yield assistantMessage("Visible answer");
yield successResult();
}
describe("agent file-change audit integration", () => {
describe("native file-change report integration", () => {
beforeEach(() => {
observedStopOutputs = [];
observedPreToolDecision = undefined;
observedCanUseToolDecision = undefined;
scenario = "reported";
observedOptions = undefined;
replayMessages = [];
auditScenario = "reported";
auditTurnActivated = new Promise<void>((resolve) => {
resolveAuditTurnActivated = resolve;
rewindFiles.mockReset();
resolveCheckpointPreview = () => {};
turnActivated = new Promise<void>((resolve) => {
resolveTurnActivated = resolve;
});
});
@@ -288,7 +199,7 @@ describe("agent file-change audit integration", () => {
},
};
async function createAuditAgent(updates: SessionNotification[]) {
async function createAgent(updates: SessionNotification[]) {
const client = {
sessionUpdate: async (notification: SessionNotification) => void updates.push(notification),
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
@@ -308,7 +219,7 @@ describe("agent file-change audit integration", () => {
return { agent, sessionId };
}
function fileChangeTerminals(updates: SessionNotification[]): AgentFileChangeReportResult[] {
function reports(updates: SessionNotification[]): AgentFileChangeReportResult[] {
return updates.flatMap((notification) => {
if (notification.update.sessionUpdate !== "session_info_update") return [];
const meta = notification.update._meta as
@@ -323,7 +234,7 @@ describe("agent file-change audit integration", () => {
});
}
function auditedPrompt(sessionId: string, requestId: string, text: string) {
function prompt(sessionId: string, requestId: string, text: string) {
return {
sessionId,
prompt: [{ type: "text" as const, text }],
@@ -337,268 +248,106 @@ describe("agent file-change audit integration", () => {
};
}
it.each([
{
name: "normal success without a Stop report",
scenario: "successWithoutReport" as const,
prompt: "Finish normally",
reason: "notReported" as const,
rejects: false,
},
{
name: "a local-only command",
scenario: "localOnly" as const,
prompt: "/context",
reason: "notReported" as const,
rejects: false,
},
{
name: "a provider error",
scenario: "providerError" as const,
prompt: "Call the provider",
reason: "providerError" as const,
rejects: false,
},
])("publishes one terminal for $name", async ({ scenario, prompt, reason, rejects }) => {
auditScenario = scenario;
it("enables native checkpointing and reports its dry-run paths", async () => {
const updates: SessionNotification[] = [];
const { agent, sessionId } = await createAuditAgent(updates);
const requestId = `request-${scenario}`;
const result = agent.prompt(auditedPrompt(sessionId, requestId, prompt));
const { agent, sessionId } = await createAgent(updates);
if (rejects) {
await expect(result).rejects.toBeDefined();
} else {
await expect(result).resolves.toBeDefined();
}
expect(fileChangeTerminals(updates)).toEqual([
await expect(
agent.prompt(prompt(sessionId, "request-native", "Change a file")),
).resolves.toMatchObject({ stopReason: "end_turn" });
expect(observedOptions?.enableFileCheckpointing).toBe(true);
expect(observedOptions?.mcpServers).not.toHaveProperty("claude_agent_acp");
expect(observedOptions?.hooks?.Stop).toBeUndefined();
expect(rewindFiles).toHaveBeenCalledTimes(1);
expect(rewindFiles).toHaveBeenCalledWith(expect.any(String), { dryRun: true });
expect(reports(updates)).toEqual([
{
version: 1,
requestId,
status: "unavailable",
reason,
requestId: "request-native",
status: "reported",
paths: [path.join(process.cwd(), "src/changed.ts")],
declaredComplete: false,
truncated: false,
},
]);
await agent.dispose();
expect(fileChangeTerminals(updates)).toHaveLength(1);
});
it.each([
{ name: "cancel", close: false },
{ name: "session close", close: true },
])("publishes one cancelled terminal and settles the prompt on $name", async ({ close }) => {
auditScenario = "waitForCancel";
it("does not inspect a checkpoint when the prompt did not request a report", async () => {
const updates: SessionNotification[] = [];
const { agent, sessionId } = await createAuditAgent(updates);
const requestId = close ? "request-close" : "request-cancel";
const result = agent.prompt(auditedPrompt(sessionId, requestId, "Wait"));
await auditTurnActivated;
const { agent, sessionId } = await createAgent(updates);
if (close) {
await agent.closeSession({ sessionId });
} else {
await agent.cancel({ sessionId });
}
await expect(result).resolves.toMatchObject({ stopReason: "cancelled" });
expect(fileChangeTerminals(updates)).toEqual([
await agent.prompt({ sessionId, prompt: [{ type: "text", text: "No report" }] });
expect(rewindFiles).not.toHaveBeenCalled();
expect(reports(updates)).toEqual([]);
await agent.dispose();
});
it("publishes providerError without attempting checkpoint inspection", async () => {
scenario = "providerError";
const updates: SessionNotification[] = [];
const { agent, sessionId } = await createAgent(updates);
await expect(agent.prompt(prompt(sessionId, "request-error", "Fail"))).resolves.toBeDefined();
expect(rewindFiles).not.toHaveBeenCalled();
expect(reports(updates)).toEqual([
{
version: 1,
requestId,
requestId: "request-error",
status: "unavailable",
reason: "providerError",
},
]);
await agent.dispose();
});
it("publishes cancelled without attempting checkpoint inspection", async () => {
scenario = "waitForCancel";
const updates: SessionNotification[] = [];
const { agent, sessionId } = await createAgent(updates);
const result = agent.prompt(prompt(sessionId, "request-cancel", "Wait"));
await turnActivated;
await agent.cancel({ sessionId });
await expect(result).resolves.toMatchObject({ stopReason: "cancelled" });
expect(rewindFiles).not.toHaveBeenCalled();
expect(reports(updates)).toEqual([
{
version: 1,
requestId: "request-cancel",
status: "unavailable",
reason: "cancelled",
},
]);
await agent.dispose();
expect(fileChangeTerminals(updates)).toHaveLength(1);
});
it("negotiates an opt-in turn, publishes one report, and hides the audit continuation", async () => {
it("lets cancellation settle a turn while checkpoint inspection is pending", async () => {
scenario = "waitForCheckpointCancel";
const updates: SessionNotification[] = [];
const client = {
sessionUpdate: async (notification: SessionNotification) => void updates.push(notification),
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AcpClient;
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(client, {
log: () => {},
error: () => {},
});
const { agent, sessionId } = await createAgent(updates);
const result = agent.prompt(prompt(sessionId, "request-checkpoint-cancel", "Change a file"));
await vi.waitFor(() => expect(rewindFiles).toHaveBeenCalledTimes(1));
const capabilities = {
_meta: {
jetbrains: {
air: {
version: 1,
capabilities: [AGENT_FILE_CHANGE_REPORT_CAPABILITY],
},
},
},
};
const initialize = await agent.initialize({
protocolVersion: 1,
clientCapabilities: capabilities,
});
expect(initialize._meta).toMatchObject({
jetbrains: {
air: {
await agent.cancel({ sessionId });
await expect(result).resolves.toMatchObject({ stopReason: "cancelled" });
await vi.waitFor(() =>
expect(reports(updates)).toEqual([
{
version: 1,
capabilities: expect.arrayContaining([AGENT_FILE_CHANGE_REPORT_CAPABILITY]),
},
},
});
const cwd = process.cwd();
const { sessionId } = await agent.newSession({ cwd, mcpServers: [] });
const response = await agent.prompt({
sessionId,
prompt: [{ type: "text", text: "Change the file" }],
_meta: {
jetbrains: {
air: {
agentFileChangeReportRequest: { version: 1, requestId: "request-integration" },
},
},
},
});
expect(response.stopReason).toBe("end_turn");
expect(observedStopOutputs).toHaveLength(2);
expect(observedStopOutputs[0]).toMatchObject({
hookSpecificOutput: {
hookEventName: "Stop",
additionalContext: expect.stringContaining(FILE_CHANGE_AUDIT_WIRE_TOOL_NAME),
},
});
expect(observedStopOutputs[1]).toEqual({});
expect(observedPreToolDecision).toMatchObject({
hookSpecificOutput: { permissionDecision: "deny" },
});
expect(observedCanUseToolDecision).toMatchObject({ behavior: "deny" });
const reportUpdates = updates.filter((notification) => {
if (notification.update.sessionUpdate !== "session_info_update") return false;
const meta = notification.update._meta as
{ jetbrains?: { air?: { agentFileChangeReport?: unknown } } } | undefined;
return meta?.jetbrains?.air?.agentFileChangeReport !== undefined;
});
expect(reportUpdates).toEqual([
{
sessionId,
update: {
sessionUpdate: "session_info_update",
_meta: {
jetbrains: {
air: {
version: 1,
agentFileChangeReport: {
version: 1,
requestId: "request-integration",
status: "reported",
paths: [path.join(cwd, "src/changed.ts")],
declaredComplete: true,
truncated: false,
},
},
},
},
},
},
]);
const serializedUpdates = JSON.stringify(updates);
expect(serializedUpdates).toContain("Visible answer");
expect(serializedUpdates).not.toContain("Audit prose must stay hidden");
expect(serializedUpdates).not.toContain(FILE_CHANGE_AUDIT_WIRE_TOOL_NAME);
await agent.dispose();
const firstStop = observedStopOutputs[0] as {
hookSpecificOutput?: { additionalContext?: string };
};
const auditContext = firstStop.hookSpecificOutput?.additionalContext;
if (!auditContext) throw new Error("Missing audit continuation context");
const replayToolUseId = "replay-audit-tool";
const deniedReplayToolUseId = "replay-denied-tool";
const replayUserMessage = (content: unknown) => ({
type: "user" as const,
message: { role: "user" as const, content },
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: "replay-session",
});
replayMessages = [
replayUserMessage("Original prompt"),
assistantMessage([{ type: "text", text: "Visible persisted answer" }]),
replayUserMessage(auditContext),
assistantMessage([{ type: "text", text: "Separate audit prose must stay hidden" }]),
assistantMessage([
{
type: "tool_use",
id: deniedReplayToolUseId,
name: "Bash",
input: { command: "git status" },
requestId: "request-checkpoint-cancel",
status: "unavailable",
reason: "cancelled",
},
]),
replayUserMessage([
{
type: "tool_result",
tool_use_id: deniedReplayToolUseId,
content: "denied",
is_error: true,
},
]),
assistantMessage([{ type: "text", text: "Prose after denial must stay hidden" }]),
assistantMessage([
{
type: "tool_use",
id: replayToolUseId,
name: FILE_CHANGE_AUDIT_WIRE_TOOL_NAME,
input: { paths: ["src/changed.ts"], complete: true },
},
]),
replayUserMessage([
{
type: "tool_result",
tool_use_id: replayToolUseId,
content: "recorded",
},
]),
replayUserMessage("Follow-up prompt"),
assistantMessage([{ type: "text", text: "Follow-up answer" }]),
];
const replayUpdates: SessionNotification[] = [];
const replayAgent = new ClaudeAcpAgent(
{
...client,
sessionUpdate: async (notification: SessionNotification) =>
void replayUpdates.push(notification),
} as AcpClient,
{ log: () => {}, error: () => {} },
);
await replayAgent.initialize({
protocolVersion: 1,
clientCapabilities: capabilities,
});
await replayAgent.loadSession({
sessionId: "replay-session",
cwd,
mcpServers: [],
});
const serializedReplay = JSON.stringify(replayUpdates);
expect(serializedReplay).toContain("Original prompt");
expect(serializedReplay).toContain("Visible persisted answer");
expect(serializedReplay).toContain("Follow-up prompt");
expect(serializedReplay).toContain("Follow-up answer");
expect(serializedReplay).not.toContain("Separate audit prose must stay hidden");
expect(serializedReplay).not.toContain("Prose after denial must stay hidden");
expect(serializedReplay).not.toContain(FILE_CHANGE_AUDIT_WIRE_TOOL_NAME);
expect(serializedReplay).not.toContain("claude-agent-acp-file-change-audit");
await replayAgent.dispose();
resolveCheckpointPreview();
await agent.dispose();
});
});

View File

@@ -1,4 +1,3 @@
import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
@@ -6,56 +5,40 @@ import { describe, expect, it, vi } from "vitest";
import {
AGENT_FILE_CHANGE_REPORT_MAX_BYTES,
agentFileChangeReportRequestId,
createFileChangeAuditSupport,
createFileChangeAuditTurnState,
FILE_CHANGE_AUDIT_TOOL_NAME,
FILE_CHANGE_AUDIT_WIRE_TOOL_NAME,
type AgentFileChangeReport,
createNativeFileChangeReporter,
type AgentFileChangeReportResult,
type FileChangeAuditTurnState,
type NativeFileChangeReporter,
} from "../file-change-audit.js";
type RegisteredAuditTool = {
_meta?: Record<string, unknown>;
handler: (report: AgentFileChangeReport) => Promise<{
isError?: boolean;
structuredContent?: AgentFileChangeReportResult;
}>;
};
function auditToolHandler(
support: ReturnType<typeof createFileChangeAuditSupport>,
): RegisteredAuditTool {
const server = support.mcpServer as unknown as {
instance: { _registeredTools: Record<string, RegisteredAuditTool> };
};
return server.instance._registeredTools[FILE_CHANGE_AUDIT_TOOL_NAME];
}
function stopInput(stopHookActive = false): Parameters<HookCallback>[0] {
return {
hook_event_name: "Stop",
stop_hook_active: stopHookActive,
} as Parameters<HookCallback>[0];
}
function createSupport(options: {
state: FileChangeAuditTurnState;
cwd?: string;
additionalDirectories?: string[];
publish?: (result: AgentFileChangeReportResult) => Promise<void>;
logError?: (message: string) => void;
timeoutMs?: number;
}) {
return createFileChangeAuditSupport({
return createNativeFileChangeReporter({
cwd: options.cwd ?? process.cwd(),
additionalDirectories: options.additionalDirectories ?? [],
getActiveState: () => options.state,
publish: options.publish ?? (async () => {}),
logError: options.logError ?? (() => {}),
timeoutMs: options.timeoutMs,
});
}
describe("agent file-change audit", () => {
function requestedTurn(reporter: NativeFileChangeReporter, requestId: string) {
const fileChangeReport = reporter.request({
jetbrains: {
air: {
agentFileChangeReportRequest: { version: 1, requestId },
},
},
});
expect(fileChangeReport).toBeDefined();
return { promptUuid: "prompt-uuid", fileChangeReport };
}
describe("native agent file-change report", () => {
it("accepts only the versioned prompt request shape", () => {
expect(
agentFileChangeReportRequestId({
@@ -83,285 +66,173 @@ describe("agent file-change audit", () => {
}
});
it("continues Stop at most once and reports a missing tool call once", async () => {
const state = createFileChangeAuditTurnState("request-1");
const publish = vi.fn(async (_result: AgentFileChangeReportResult) => {});
const support = createSupport({ state, publish });
const hookOptions = { signal: new AbortController().signal };
const preToolInput = (toolName: string) =>
({
hook_event_name: "PreToolUse",
tool_name: toolName,
tool_input: {},
tool_use_id: "tool-1",
}) as Parameters<HookCallback>[0];
it("owns request-id deduplication for the session", () => {
const reporter = createSupport({});
const turn = requestedTurn(reporter, "request-deduplicated");
await expect(
support.preToolUseHook(preToolInput(FILE_CHANGE_AUDIT_WIRE_TOOL_NAME), "tool-1", hookOptions),
).resolves.toMatchObject({
hookSpecificOutput: { permissionDecision: "deny" },
});
const pausedStop = {
...stopInput(),
background_tasks: [{ id: "generator", type: "shell", status: "running", description: "" }],
} as Parameters<HookCallback>[0];
expect(await support.stopHook(pausedStop, undefined, hookOptions)).toEqual({});
expect(state.phase).toBe("requested");
const first = await support.stopHook(stopInput(), undefined, hookOptions);
expect(state.phase).toBe("collecting");
expect(first).toMatchObject({
hookSpecificOutput: {
hookEventName: "Stop",
additionalContext: expect.stringContaining(FILE_CHANGE_AUDIT_WIRE_TOOL_NAME),
},
});
await expect(
support.preToolUseHook(preToolInput("Bash"), "tool-1", hookOptions),
).resolves.toMatchObject({
hookSpecificOutput: { permissionDecision: "deny" },
});
await expect(
support.preToolUseHook(preToolInput(FILE_CHANGE_AUDIT_WIRE_TOOL_NAME), "tool-1", hookOptions),
).resolves.toMatchObject({
hookSpecificOutput: { permissionDecision: "allow" },
});
expect(await support.stopHook(stopInput(true), undefined, hookOptions)).toEqual({});
expect(state.phase).toBe("finished");
expect(publish).toHaveBeenCalledWith({
version: 1,
requestId: "request-1",
status: "unavailable",
reason: "notReported",
});
await support.stopHook(stopInput(true), undefined, hookOptions);
expect(publish).toHaveBeenCalledTimes(1);
expect(turn.fileChangeReport?.requestId).toBe("request-deduplicated");
expect(
reporter.request({
jetbrains: {
air: {
agentFileChangeReportRequest: {
version: 1,
requestId: "request-deduplicated",
},
},
},
}),
).toBeUndefined();
});
it("publishes only the first unavailable terminal and rejects a late report", async () => {
const state = createFileChangeAuditTurnState("request-terminal-race");
state.phase = "collecting";
const publish = vi.fn(async (_result: AgentFileChangeReportResult) => {});
const support = createSupport({ state, publish });
await Promise.all([
support.finishUnavailable(state, "cancelled"),
support.finishUnavailable(state, "providerError"),
]);
expect(publish).toHaveBeenCalledTimes(1);
expect(publish).toHaveBeenCalledWith({
version: 1,
requestId: "request-terminal-race",
status: "unavailable",
reason: "cancelled",
});
await expect(
auditToolHandler(support).handler({ paths: ["src/late.ts"], complete: true }),
).resolves.toMatchObject({ isError: true });
expect(publish).toHaveBeenCalledTimes(1);
});
it("normalizes roots, drops invalid paths, and deduplicates without truncation", async () => {
const cwd = path.join(os.tmpdir(), "file-audit-project");
const additionalRoot = path.join(os.tmpdir(), "file-audit-shared");
it("uses Claude checkpoint dry-run and normalizes its paths", async () => {
const cwd = path.join(os.tmpdir(), "native-file-report-project");
const additionalRoot = path.join(os.tmpdir(), "native-file-report-shared");
const canonicalTempRoot = fs.realpathSync.native(os.tmpdir());
const canonicalCwd = path.join(canonicalTempRoot, "file-audit-project");
const canonicalAdditionalRoot = path.join(canonicalTempRoot, "file-audit-shared");
const outside = path.join(os.tmpdir(), "file-audit-outside", "outside.txt");
const state = createFileChangeAuditTurnState("request-2");
state.phase = "collecting";
const published: AgentFileChangeReportResult[] = [];
const support = createSupport({
state,
cwd,
additionalDirectories: [additionalRoot],
publish: async (result) => void published.push(result),
});
const tool = auditToolHandler(support);
const turn = requestedTurn(support, "request-1");
const rewindFiles = vi.fn(async () => ({
canRewind: true,
filesChanged: ["src/a.ts", "src/a.ts", path.join(additionalRoot, "generated.ts")],
insertions: 12,
deletions: 3,
}));
expect(tool._meta).toMatchObject({
"anthropic/alwaysLoad": true,
"claude/endTurn": true,
});
const result = await tool.handler({
paths: [
"src/a.ts",
"src/a.ts",
cwd,
additionalRoot,
path.join(additionalRoot, "generated.ts"),
outside,
"bad\u0000path",
"bad\u0085path",
"x".repeat(4090),
],
complete: true,
uncertainty: "Generated files may be missing",
});
await support.report(turn, { rewindFiles });
expect(result.isError).not.toBe(true);
expect(rewindFiles).toHaveBeenCalledWith("prompt-uuid", { dryRun: true });
expect(published).toEqual([
{
version: 1,
requestId: "request-2",
requestId: "request-1",
status: "reported",
paths: [
path.join(canonicalCwd, "src/a.ts"),
path.join(canonicalAdditionalRoot, "generated.ts"),
path.join(canonicalTempRoot, "native-file-report-project", "src/a.ts"),
path.join(canonicalTempRoot, "native-file-report-shared", "generated.ts"),
],
declaredComplete: false,
truncated: true,
uncertainty: "Generated files may be missing",
truncated: false,
},
]);
expect(await tool.handler({ paths: ["src/late.ts"], complete: true })).toMatchObject({
isError: true,
});
expect(published).toHaveLength(1);
const duplicateOnlyState = createFileChangeAuditTurnState("request-3");
duplicateOnlyState.phase = "collecting";
const duplicateOnlyReports: AgentFileChangeReportResult[] = [];
const duplicateOnlyTool = auditToolHandler(
createSupport({
state: duplicateOnlyState,
cwd,
publish: async (report) => void duplicateOnlyReports.push(report),
}),
);
await duplicateOnlyTool.handler({ paths: ["src/a.ts", "src/a.ts"], complete: true });
expect(duplicateOnlyReports[0]).toMatchObject({
declaredComplete: true,
truncated: false,
paths: [path.join(canonicalCwd, "src/a.ts")],
});
expect(turn.fileChangeReport?.phase).toBe("finished");
});
it("accepts canonical paths under a symlinked workspace root", async () => {
if (process.platform === "win32") return;
it("publishes unavailable when Claude cannot preview the checkpoint", async () => {
const published: AgentFileChangeReportResult[] = [];
const support = createSupport({ publish: async (result) => void published.push(result) });
const turn = requestedTurn(support, "request-invalid");
const realRoot = fs.mkdtempSync(path.join(os.tmpdir(), "file-audit-real-"));
const linkedRoot = `${realRoot}-link`;
fs.symlinkSync(realRoot, linkedRoot, "dir");
try {
const state = createFileChangeAuditTurnState("request-symlink-root");
state.phase = "collecting";
const reports: AgentFileChangeReportResult[] = [];
const tool = auditToolHandler(
createSupport({
state,
cwd: linkedRoot,
publish: async (report) => void reports.push(report),
}),
);
const canonicalPath = path.join(fs.realpathSync.native(realRoot), "generated.ts");
await support.report(turn, {
rewindFiles: vi.fn(async () => ({ canRewind: false, error: "checkpoint missing" })),
});
await tool.handler({
paths: [canonicalPath, path.join(linkedRoot, "generated.ts")],
complete: true,
});
expect(reports[0]).toMatchObject({
paths: [canonicalPath],
declaredComplete: true,
truncated: false,
});
} finally {
fs.unlinkSync(linkedRoot);
fs.rmSync(realRoot, { recursive: true, force: true });
}
expect(published).toEqual([
{
version: 1,
requestId: "request-invalid",
status: "unavailable",
reason: "invalidOutput",
},
]);
});
it("caps path count and total UTF-8 bytes", async () => {
const cwd = path.join(os.tmpdir(), "file-audit-caps");
const countState = createFileChangeAuditTurnState("request-count");
countState.phase = "collecting";
const countReports: AgentFileChangeReportResult[] = [];
await auditToolHandler(
createSupport({
state: countState,
cwd,
publish: async (report) => void countReports.push(report),
}),
).handler({
paths: Array.from({ length: 1030 }, (_, index) => `src/file-${index}.ts`),
complete: true,
it("times out fail-open and publishes only one terminal", async () => {
const published: AgentFileChangeReportResult[] = [];
const support = createSupport({
publish: async (result) => void published.push(result),
timeoutMs: 1,
});
expect(countReports[0]).toMatchObject({
const turn = requestedTurn(support, "request-timeout");
const never = new Promise<never>(() => {});
await support.report(turn, { rewindFiles: vi.fn(() => never) });
support.finish(turn.fileChangeReport, "cancelled");
expect(published).toEqual([
{
version: 1,
requestId: "request-timeout",
status: "unavailable",
reason: "timeout",
},
]);
});
it("lets cancellation win a race with a late checkpoint response", async () => {
const published: AgentFileChangeReportResult[] = [];
const support = createSupport({ publish: async (result) => void published.push(result) });
const turn = requestedTurn(support, "request-race");
let resolvePreview!: (value: { canRewind: true; filesChanged: string[] }) => void;
const preview = new Promise<{ canRewind: true; filesChanged: string[] }>((resolve) => {
resolvePreview = resolve;
});
const reporting = support.report(turn, { rewindFiles: vi.fn(() => preview) });
support.finish(turn.fileChangeReport, "cancelled");
resolvePreview({ canRewind: true, filesChanged: ["src/late.ts"] });
await reporting;
expect(published).toEqual([
{
version: 1,
requestId: "request-race",
status: "unavailable",
reason: "cancelled",
},
]);
});
it("caps path count and serialized report bytes", async () => {
const cwd = path.join(os.tmpdir(), "native-file-report-caps");
const reports: AgentFileChangeReportResult[] = [];
const support = createSupport({
cwd,
publish: async (result) => void reports.push(result),
});
const turn = requestedTurn(support, "request-caps");
await support.report(turn, {
rewindFiles: vi.fn(async () => ({
canRewind: true,
filesChanged: Array.from(
{ length: 1030 },
(_, index) => `generated/${index}-${"x".repeat(280)}.txt`,
),
})),
});
expect(reports[0]).toMatchObject({
status: "reported",
declaredComplete: false,
truncated: true,
});
expect(countReports[0].status).toBe("reported");
if (countReports[0].status !== "reported") throw new Error("Expected a reported result");
expect(countReports[0].paths).toHaveLength(1024);
const byteState = createFileChangeAuditTurnState("request-bytes");
byteState.phase = "collecting";
const byteReports: AgentFileChangeReportResult[] = [];
await auditToolHandler(
createSupport({
state: byteState,
cwd,
publish: async (report) => void byteReports.push(report),
}),
).handler({
paths: Array.from(
{ length: 1000 },
(_, index) => `generated/${index}-${"x".repeat(280)}.txt`,
),
complete: true,
});
expect(byteReports[0]).toMatchObject({
declaredComplete: false,
truncated: true,
});
expect(byteReports[0].status).toBe("reported");
if (byteReports[0].status !== "reported") throw new Error("Expected a reported result");
expect(byteReports[0].paths.length).toBeLessThan(1000);
expect(Buffer.byteLength(JSON.stringify(byteReports[0]), "utf8")).toBeLessThanOrEqual(
expect(Buffer.byteLength(JSON.stringify(reports[0]), "utf8")).toBeLessThanOrEqual(
AGENT_FILE_CHANGE_REPORT_MAX_BYTES,
);
});
it("fails open when report publication fails", async () => {
const state = createFileChangeAuditTurnState("request-4");
state.phase = "collecting";
const logError = vi.fn();
const tool = auditToolHandler(
createSupport({
state,
publish: async () => {
throw new Error("transport closed");
},
logError,
}),
);
await expect(tool.handler({ paths: ["src/a.ts"], complete: true })).resolves.toMatchObject({
structuredContent: { requestId: "request-4", status: "reported" },
});
expect(state.phase).toBe("finished");
expect(logError).toHaveBeenCalledWith(expect.stringContaining("transport closed"));
const unavailableState = createFileChangeAuditTurnState("request-unavailable-failure");
const unavailableSupport = createSupport({
state: unavailableState,
const support = createSupport({
publish: async () => {
throw new Error("transport closed");
},
logError,
});
const turn = requestedTurn(support, "request-publish-failure");
await expect(
unavailableSupport.finishUnavailable(unavailableState, "providerError"),
support.report(turn, {
rewindFiles: vi.fn(async () => ({ canRewind: true, filesChanged: [] })),
}),
).resolves.toBeUndefined();
expect(unavailableState.phase).toBe("finished");
expect(logError).toHaveBeenCalledWith(expect.stringContaining("request-unavailable-failure"));
expect(turn.fileChangeReport?.phase).toBe("finished");
expect(logError).toHaveBeenCalledWith(expect.stringContaining("transport closed"));
});
});

View File

@@ -88,7 +88,6 @@ export function mockSessionState(
owedTrailingIdles: 0,
messageIdToUuid: new Map(),
sessionFailureState: { epoch: randomUUID(), revisions: new Map(), active: new Map() },
fileChangeReportRequestIds: new Set(),
...overrides,
} as any;
}