mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-10 11:21:06 +00:00
Compare commits
2 Commits
v0.77.1-pr
...
v0.78.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
becd854dd8 | ||
|
|
91f1699366 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.77.0"
|
||||
".": "0.78.0"
|
||||
}
|
||||
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
357
src/acp-agent.ts
357
src/acp-agent.ts
@@ -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`,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user