mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
fix: include subagent file edits in the file-change report (#1357)
## Summary `agentFileChangeReport` took its path list only from Claude's file checkpoints (`rewindFiles` dry run). A checkpoint tracks only the files that the root agent edits with its own file tool. So a file that only a subagent edited never appeared in the report. Air then showed no "Since last turn" row for it, and Last Turn review could not include it. - Collect the `file_path` / `notebook_path` of successful `Edit`/`Write`/`MultiEdit`/`NotebookEdit` results from subagent messages in the stream. Store them on the active turn. This also covers background subagents, because the turn stays open until they finish. - Merge these paths with the checkpoint list. When the checkpoint preview times out or is unavailable, report the subagent paths instead of `unavailable`.
This commit is contained in:
@@ -240,6 +240,7 @@ import {
|
||||
type FileChangeReportTurnState,
|
||||
type FileChangeReportUnavailableReason,
|
||||
type NativeFileChangeReporter,
|
||||
recordSubagentFileEdits,
|
||||
supportsAgentFileChangeReport,
|
||||
} from "./file-change-audit.js";
|
||||
import {
|
||||
@@ -733,6 +734,9 @@ type Turn = {
|
||||
* turn. The state is turn-owned so a late control response can never be
|
||||
* rebound to a newer prompt. */
|
||||
fileChangeReport?: FileChangeReportTurnState;
|
||||
/** Files that subagents of this turn edited with a file tool, collected only
|
||||
* when a report was requested (see {@link recordSubagentFileEdits}). */
|
||||
editedPaths?: Set<string>;
|
||||
/** 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;
|
||||
@@ -7770,6 +7774,12 @@ export class ClaudeAcpAgent {
|
||||
let runningTerminal: string | undefined;
|
||||
if (message.type === "user") {
|
||||
rememberResolvedToolNames(session, content);
|
||||
recordSubagentFileEdits(
|
||||
session.activeTurn,
|
||||
message.parent_tool_use_id,
|
||||
content,
|
||||
session.toolUseCache,
|
||||
);
|
||||
const backgroundBashTask = backgroundBashTaskFromToolResult(
|
||||
content,
|
||||
message.tool_use_result,
|
||||
|
||||
@@ -19,6 +19,10 @@ export type FileChangeReportTurnState = {
|
||||
export type NativeFileChangeReportTurn = {
|
||||
promptUuid: string;
|
||||
fileChangeReport?: FileChangeReportTurnState;
|
||||
/** Raw `file_path`/`notebook_path` values of the file-tool edits that
|
||||
* subagents of this turn completed. Checkpoints do not track files that
|
||||
* only a subagent edited. */
|
||||
editedPaths?: Set<string>;
|
||||
};
|
||||
|
||||
export type FileChangeReportWorkspace = {
|
||||
@@ -94,6 +98,41 @@ export function agentFileChangeReportMeta(
|
||||
return withAirMeta(undefined, AGENT_FILE_CHANGE_REPORT_CAPABILITY, result);
|
||||
}
|
||||
|
||||
const FILE_EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
||||
|
||||
/**
|
||||
* Adds to `turn.editedPaths` the `file_path` (`notebook_path` for NotebookEdit)
|
||||
* of each successful file-tool edit among the tool results of a subagent
|
||||
* message. Root-agent messages (`parentToolUseId === null`) are skipped: the
|
||||
* checkpoint already tracks their files. Must run before `toAcpNotifications`
|
||||
* drops the tool use cache entries.
|
||||
*/
|
||||
export function recordSubagentFileEdits(
|
||||
turn: (NativeFileChangeReportTurn & { settled: boolean }) | null | undefined,
|
||||
parentToolUseId: string | null,
|
||||
content: unknown,
|
||||
toolUses: Record<string, { name: string; input: unknown } | undefined>,
|
||||
): void {
|
||||
if (parentToolUseId === null || !turn || turn.settled || !turn.fileChangeReport) return;
|
||||
if (!Array.isArray(content)) return;
|
||||
for (const block of content) {
|
||||
if (
|
||||
block?.type !== "tool_result" ||
|
||||
block.is_error === true ||
|
||||
typeof block.tool_use_id !== "string"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const toolUse = toolUses[block.tool_use_id];
|
||||
if (!toolUse || !FILE_EDIT_TOOLS.has(toolUse.name)) continue;
|
||||
const input = toolUse.input as Record<string, unknown> | undefined;
|
||||
const editedPath = toolUse.name === "NotebookEdit" ? input?.notebook_path : input?.file_path;
|
||||
if (typeof editedPath === "string" && editedPath.length > 0) {
|
||||
(turn.editedPaths ??= new Set()).add(editedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createNativeFileChangeReporter(
|
||||
options: NativeFileChangeReporterOptions,
|
||||
): NativeFileChangeReporter {
|
||||
@@ -101,7 +140,7 @@ export function createNativeFileChangeReporter(
|
||||
const timeoutMs = options.timeoutMs ?? AGENT_FILE_CHANGE_REPORT_TIMEOUT_MS;
|
||||
const requestIds = new Set<string>();
|
||||
|
||||
const publish = async (state: FileChangeReportTurnState, result: AgentFileChangeReportResult) => {
|
||||
async function publish(state: FileChangeReportTurnState, result: AgentFileChangeReportResult) {
|
||||
if (state.phase === "finished") return;
|
||||
state.phase = "finished";
|
||||
try {
|
||||
@@ -109,24 +148,55 @@ export function createNativeFileChangeReporter(
|
||||
} catch (error) {
|
||||
options.logError(`Failed to publish file-change report ${state.requestId}: ${error}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const finishUnavailable = async (
|
||||
async function finishUnavailable(
|
||||
state: FileChangeReportTurnState,
|
||||
reason: FileChangeReportUnavailableReason,
|
||||
) => {
|
||||
) {
|
||||
await publish(state, {
|
||||
version: 1,
|
||||
requestId: state.requestId,
|
||||
status: "unavailable",
|
||||
reason,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const report = async (
|
||||
async function publishReported(state: FileChangeReportTurnState, reportedPaths: string[]) {
|
||||
const normalized = normalizeReportedPaths(reportedPaths, workspace);
|
||||
await publish(
|
||||
state,
|
||||
fitReportedAgentFileChangeReport({
|
||||
version: 1,
|
||||
requestId: state.requestId,
|
||||
status: "reported",
|
||||
paths: normalized.paths,
|
||||
// Checkpoints miss Bash mutations. Subagent file-tool edits are added
|
||||
// from the message stream, but the list is still not exhaustive.
|
||||
declaredComplete: false,
|
||||
truncated: normalized.truncated,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Used when the checkpoint preview fails: the subagent edits seen in the
|
||||
// stream are still worth reporting.
|
||||
async function finishWithoutPreview(
|
||||
state: FileChangeReportTurnState,
|
||||
editedPaths: Set<string> | undefined,
|
||||
reason: FileChangeReportUnavailableReason,
|
||||
) {
|
||||
if (editedPaths && editedPaths.size > 0) {
|
||||
await publishReported(state, [...editedPaths]);
|
||||
} else {
|
||||
await finishUnavailable(state, reason);
|
||||
}
|
||||
}
|
||||
|
||||
async function report(
|
||||
turn: NativeFileChangeReportTurn | null | undefined,
|
||||
query: Pick<Query, "rewindFiles">,
|
||||
) => {
|
||||
) {
|
||||
const state = turn?.fileChangeReport;
|
||||
if (!state || state.phase !== "requested") return;
|
||||
state.phase = "collecting";
|
||||
@@ -145,35 +215,22 @@ export function createNativeFileChangeReporter(
|
||||
}),
|
||||
]);
|
||||
if (preview === null) {
|
||||
await finishUnavailable(state, "timeout");
|
||||
await finishWithoutPreview(state, turn.editedPaths, "timeout");
|
||||
return;
|
||||
}
|
||||
if (!preview.canRewind || !Array.isArray(preview.filesChanged)) {
|
||||
await finishUnavailable(state, "invalidOutput");
|
||||
await finishWithoutPreview(state, turn.editedPaths, "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,
|
||||
}),
|
||||
);
|
||||
await publishReported(state, [...preview.filesChanged, ...(turn.editedPaths ?? [])]);
|
||||
} catch (error) {
|
||||
options.logError(`Failed to inspect Claude file checkpoint ${turn.promptUuid}: ${error}`);
|
||||
await finishUnavailable(state, "providerError");
|
||||
await finishWithoutPreview(state, turn.editedPaths, "providerError");
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
request(meta) {
|
||||
|
||||
@@ -11,7 +11,13 @@ import {
|
||||
import type { Pushable } from "../utils.js";
|
||||
|
||||
type Scenario =
|
||||
"reported" | "providerError" | "localOnly" | "waitForCancel" | "waitForCheckpointCancel";
|
||||
| "reported"
|
||||
| "providerError"
|
||||
| "localOnly"
|
||||
| "waitForCancel"
|
||||
| "waitForCheckpointCancel"
|
||||
| "subagentEdit"
|
||||
| "subagentEditError";
|
||||
let scenario: Scenario = "reported";
|
||||
let observedOptions: Options | undefined;
|
||||
let replayMessages: unknown[] = [];
|
||||
@@ -47,7 +53,10 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
}
|
||||
return Promise.resolve({
|
||||
canRewind: true,
|
||||
filesChanged: ["src/changed.ts"],
|
||||
filesChanged:
|
||||
scenario === "subagentEdit" || scenario === "subagentEditError"
|
||||
? ["README.md"]
|
||||
: ["src/changed.ts"],
|
||||
insertions: 4,
|
||||
deletions: 1,
|
||||
});
|
||||
@@ -172,6 +181,48 @@ async function* runTurn(input: Pushable<SDKUserMessage>, interrupted: Promise<vo
|
||||
return;
|
||||
}
|
||||
|
||||
if (scenario === "subagentEdit" || scenario === "subagentEditError") {
|
||||
const parentToolUseId = "toolu_agent_1";
|
||||
const editToolUseId = "toolu_subagent_edit";
|
||||
yield {
|
||||
...assistantMessage("Editing"),
|
||||
parent_tool_use_id: parentToolUseId,
|
||||
message: {
|
||||
...assistantMessage("Editing").message,
|
||||
stop_reason: "tool_use",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use" as const,
|
||||
id: editToolUseId,
|
||||
name: "Edit",
|
||||
input: {
|
||||
file_path: path.join(process.cwd(), "GEMINI.md"),
|
||||
old_string: "a",
|
||||
new_string: "b",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: "user" as const,
|
||||
parent_tool_use_id: parentToolUseId,
|
||||
uuid: randomUUID(),
|
||||
session_id: "sdk-session",
|
||||
message: {
|
||||
role: "user" as const,
|
||||
content: [
|
||||
{
|
||||
type: "tool_result" as const,
|
||||
tool_use_id: editToolUseId,
|
||||
content: scenario === "subagentEditError" ? "Edit failed" : "Edited",
|
||||
is_error: scenario === "subagentEditError",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
yield assistantMessage("Visible answer");
|
||||
yield successResult();
|
||||
}
|
||||
@@ -350,4 +401,46 @@ describe("native file-change report integration", () => {
|
||||
resolveCheckpointPreview();
|
||||
await agent.dispose();
|
||||
});
|
||||
|
||||
/** A subagent's successful file-tool edit is added to the checkpoint paths. */
|
||||
it("reports files that only a subagent edited alongside the checkpoint paths", async () => {
|
||||
scenario = "subagentEdit";
|
||||
const updates: SessionNotification[] = [];
|
||||
const { agent, sessionId } = await createAgent(updates);
|
||||
|
||||
await agent.prompt(prompt(sessionId, "request-subagent", "Delegate an edit"));
|
||||
|
||||
expect(reports(updates)).toEqual([
|
||||
{
|
||||
version: 1,
|
||||
requestId: "request-subagent",
|
||||
status: "reported",
|
||||
paths: [path.join(process.cwd(), "README.md"), path.join(process.cwd(), "GEMINI.md")],
|
||||
declaredComplete: false,
|
||||
truncated: false,
|
||||
},
|
||||
]);
|
||||
await agent.dispose();
|
||||
});
|
||||
|
||||
/** A subagent edit whose tool result is an error did not change the file. */
|
||||
it("does not report a subagent edit that failed", async () => {
|
||||
scenario = "subagentEditError";
|
||||
const updates: SessionNotification[] = [];
|
||||
const { agent, sessionId } = await createAgent(updates);
|
||||
|
||||
await agent.prompt(prompt(sessionId, "request-subagent-error", "Delegate an edit"));
|
||||
|
||||
expect(reports(updates)).toEqual([
|
||||
{
|
||||
version: 1,
|
||||
requestId: "request-subagent-error",
|
||||
status: "reported",
|
||||
paths: [path.join(process.cwd(), "README.md")],
|
||||
declaredComplete: false,
|
||||
truncated: false,
|
||||
},
|
||||
]);
|
||||
await agent.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
createNativeFileChangeReporter,
|
||||
type AgentFileChangeReportResult,
|
||||
type NativeFileChangeReporter,
|
||||
recordSubagentFileEdits,
|
||||
} from "../file-change-audit.js";
|
||||
|
||||
function createSupport(options: {
|
||||
@@ -26,7 +27,11 @@ function createSupport(options: {
|
||||
});
|
||||
}
|
||||
|
||||
function requestedTurn(reporter: NativeFileChangeReporter, requestId: string) {
|
||||
function requestedTurn(
|
||||
reporter: NativeFileChangeReporter,
|
||||
requestId: string,
|
||||
editedPaths?: string[],
|
||||
) {
|
||||
const fileChangeReport = reporter.request({
|
||||
jetbrains: {
|
||||
air: {
|
||||
@@ -35,7 +40,11 @@ function requestedTurn(reporter: NativeFileChangeReporter, requestId: string) {
|
||||
},
|
||||
});
|
||||
expect(fileChangeReport).toBeDefined();
|
||||
return { promptUuid: "prompt-uuid", fileChangeReport };
|
||||
return {
|
||||
promptUuid: "prompt-uuid",
|
||||
fileChangeReport,
|
||||
...(editedPaths ? { editedPaths: new Set(editedPaths) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("native agent file-change report", () => {
|
||||
@@ -235,4 +244,163 @@ describe("native agent file-change report", () => {
|
||||
expect(turn.fileChangeReport?.phase).toBe("finished");
|
||||
expect(logError).toHaveBeenCalledWith(expect.stringContaining("transport closed"));
|
||||
});
|
||||
|
||||
/** Subagent-edited paths are unioned with the checkpoint paths and deduped. */
|
||||
it("adds subagent edited paths to the checkpoint paths", async () => {
|
||||
const published: AgentFileChangeReportResult[] = [];
|
||||
const cwd = process.cwd();
|
||||
const support = createSupport({ cwd, publish: async (result) => void published.push(result) });
|
||||
const turn = requestedTurn(support, "request-union", [path.join(cwd, "GEMINI.md"), "src/a.ts"]);
|
||||
|
||||
await support.report(turn, {
|
||||
rewindFiles: vi.fn(async () => ({
|
||||
canRewind: true,
|
||||
filesChanged: ["src/a.ts", "README.md"],
|
||||
})),
|
||||
});
|
||||
|
||||
expect(published).toMatchObject([
|
||||
{
|
||||
status: "reported",
|
||||
paths: [
|
||||
path.join(cwd, "src/a.ts"),
|
||||
path.join(cwd, "README.md"),
|
||||
path.join(cwd, "GEMINI.md"),
|
||||
],
|
||||
declaredComplete: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
/** When the checkpoint cannot be previewed, the subagent edits are still reported. */
|
||||
it("reports subagent edited paths when the checkpoint cannot be previewed", async () => {
|
||||
const published: AgentFileChangeReportResult[] = [];
|
||||
const cwd = process.cwd();
|
||||
const support = createSupport({ cwd, publish: async (result) => void published.push(result) });
|
||||
const turn = requestedTurn(support, "request-fallback", ["GEMINI.md"]);
|
||||
|
||||
await support.report(turn, {
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: false, error: "checkpoint missing" })),
|
||||
});
|
||||
|
||||
expect(published).toEqual([
|
||||
{
|
||||
version: 1,
|
||||
requestId: "request-fallback",
|
||||
status: "reported",
|
||||
paths: [path.join(cwd, "GEMINI.md")],
|
||||
declaredComplete: false,
|
||||
truncated: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
/** A checkpoint preview timeout falls back to the subagent edited paths. */
|
||||
it("reports subagent edited paths when the checkpoint preview times out", async () => {
|
||||
const published: AgentFileChangeReportResult[] = [];
|
||||
const cwd = process.cwd();
|
||||
const support = createSupport({
|
||||
cwd,
|
||||
publish: async (result) => void published.push(result),
|
||||
timeoutMs: 1,
|
||||
});
|
||||
const turn = requestedTurn(support, "request-timeout-fallback", ["GEMINI.md"]);
|
||||
const never = new Promise<never>(() => {});
|
||||
|
||||
await support.report(turn, { rewindFiles: vi.fn(() => never) });
|
||||
|
||||
expect(published).toMatchObject([
|
||||
{ status: "reported", paths: [path.join(cwd, "GEMINI.md")], declaredComplete: false },
|
||||
]);
|
||||
});
|
||||
|
||||
/** A thrown checkpoint error falls back to the subagent edited paths. */
|
||||
it("reports subagent edited paths when the checkpoint preview throws", async () => {
|
||||
const published: AgentFileChangeReportResult[] = [];
|
||||
const cwd = process.cwd();
|
||||
const support = createSupport({ cwd, publish: async (result) => void published.push(result) });
|
||||
const turn = requestedTurn(support, "request-throw-fallback", ["GEMINI.md"]);
|
||||
|
||||
await support.report(turn, {
|
||||
rewindFiles: vi.fn(async () => {
|
||||
throw new Error("boom");
|
||||
}),
|
||||
});
|
||||
|
||||
expect(published).toMatchObject([{ status: "reported", paths: [path.join(cwd, "GEMINI.md")] }]);
|
||||
});
|
||||
|
||||
/** Without subagent edits an unusable checkpoint is still unavailable. */
|
||||
it("stays unavailable when there are no subagent edited paths", async () => {
|
||||
const published: AgentFileChangeReportResult[] = [];
|
||||
const support = createSupport({ publish: async (result) => void published.push(result) });
|
||||
const turn = requestedTurn(support, "request-empty", []);
|
||||
|
||||
await support.report(turn, {
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: false, error: "checkpoint missing" })),
|
||||
});
|
||||
|
||||
expect(published).toEqual([
|
||||
{ version: 1, requestId: "request-empty", status: "unavailable", reason: "invalidOutput" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordSubagentFileEdits", () => {
|
||||
const toolUses = {
|
||||
edit: { name: "Edit", input: { file_path: "/work/a.ts" } },
|
||||
write: { name: "Write", input: { file_path: "/work/b.ts" } },
|
||||
notebook: { name: "NotebookEdit", input: { notebook_path: "/work/n.ipynb" } },
|
||||
read: { name: "Read", input: { file_path: "/work/c.ts" } },
|
||||
};
|
||||
const results = (...ids: string[]) =>
|
||||
ids.map((id) => ({ type: "tool_result", tool_use_id: id, content: "ok" }));
|
||||
const reportTurn = () => ({
|
||||
promptUuid: "prompt-uuid",
|
||||
fileChangeReport: { requestId: "request", phase: "requested" as const },
|
||||
settled: false,
|
||||
editedPaths: undefined as Set<string> | undefined,
|
||||
});
|
||||
|
||||
/** Edit, Write and NotebookEdit results of a subagent add their paths; Read does not. */
|
||||
it("collects the paths of subagent file-tool edits", () => {
|
||||
const turn = reportTurn();
|
||||
recordSubagentFileEdits(
|
||||
turn,
|
||||
"toolu_agent",
|
||||
results("edit", "write", "notebook", "read"),
|
||||
toolUses,
|
||||
);
|
||||
expect([...(turn.editedPaths ?? [])]).toEqual(["/work/a.ts", "/work/b.ts", "/work/n.ipynb"]);
|
||||
});
|
||||
|
||||
/** A failed subagent Edit changed no file, so its path is skipped. */
|
||||
it("skips error results", () => {
|
||||
const turn = reportTurn();
|
||||
recordSubagentFileEdits(
|
||||
turn,
|
||||
"toolu_agent",
|
||||
[{ type: "tool_result", tool_use_id: "edit", content: "failed", is_error: true }],
|
||||
toolUses,
|
||||
);
|
||||
expect(turn.editedPaths).toBeUndefined();
|
||||
});
|
||||
|
||||
/** Root-agent edits are left to the checkpoint, so a root message adds nothing. */
|
||||
it("skips root-agent messages", () => {
|
||||
const turn = reportTurn();
|
||||
recordSubagentFileEdits(turn, null, results("edit"), toolUses);
|
||||
expect(turn.editedPaths).toBeUndefined();
|
||||
});
|
||||
|
||||
/** A settled turn, or one without a requested report, collects nothing. */
|
||||
it("skips turns that are settled or have no report", () => {
|
||||
const settled = { ...reportTurn(), settled: true };
|
||||
recordSubagentFileEdits(settled, "toolu_agent", results("edit"), toolUses);
|
||||
expect(settled.editedPaths).toBeUndefined();
|
||||
|
||||
const unrequested = { ...reportTurn(), fileChangeReport: undefined };
|
||||
recordSubagentFileEdits(unrequested, "toolu_agent", results("edit"), toolUses);
|
||||
expect(unrequested.editedPaths).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user