fix(chat): count a subagent's edits in the turn's Edited N files card (#86)

A skill that hands its work to subagents (vp-auto) showed "Edited 1 file"
for a turn that changed 4: a subagent's Write/Edit calls are in its own
agent-<id>.jsonl, so the card only saw the parent's.

- server/lib/agent-edits.js reads a subagent's transcript (readLines, a
  member's only as theirs) for the files it wrote or edited with their +/-
  lines, nested subagents folded in (3 levels), cached by size + mtime.
- The transcript route puts them on each Claude Agent/Task result as
  `edits`; the card adds them to the turn's own edits. A file only a
  subagent wrote is listed, never an artifact card (its versions are
  rebuilt from this transcript's inputs, which a subagent's are not).
This commit is contained in:
2026-10-23 14:26:29 +07:00
parent c8bc4a95a9
commit 5ccbaf5c23
7 changed files with 330 additions and 4 deletions
+112
View File
@@ -0,0 +1,112 @@
/**
* @file Tests for server/lib/agent-edits.js (issue #86): the files a
* subagent wrote or edited are read from its own transcript — +/- lines from
* `structuredPatch` or a created file's content, a failed call left out, a
* nested subagent folded in — and put on the parent's Agent result as `edits`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agent-edits-"));
process.env.CLAUDE_HOME = HOME;
const { encodeCwd } = require("../lib/claude-home");
const { agentEdits, attachAgentEdits, _cache } = require("../lib/agent-edits");
const CWD = "/work/repo";
const SID = "11111111-2222-3333-4444-555555555555";
const subDir = path.join(HOME, "projects", encodeCwd(CWD), SID, "subagents");
const call = (id, name, input) => ({
type: "assistant",
message: { content: [{ type: "tool_use", id, name, input }] },
});
const result = (id, toolUseResult, isError = false) => ({
type: "user",
message: { content: [{ type: "tool_result", tool_use_id: id, is_error: isError }] },
toolUseResult,
});
const write = (agentId, lines) =>
fs.writeFileSync(
path.join(subDir, `agent-${agentId}.jsonl`),
lines.map((l) => JSON.stringify(l)).join("\n")
);
before(() => {
fs.mkdirSync(subDir, { recursive: true });
write("a1", [
call("t1", "Edit", { file_path: "/work/repo/main.go" }),
result("t1", { structuredPatch: [{ lines: [" x", "-old", "+new", "+more"] }] }),
call("t2", "Write", { file_path: "/work/repo/new_test.go" }),
result("t2", { type: "create", content: "a\nb\nc\n" }),
call("t3", "Edit", { file_path: "/work/repo/broken.go" }),
result("t3", "String to replace not found", true),
call("t4", "Agent", { prompt: "go deeper" }),
result("t4", { agentId: "a2", status: "completed" }),
]);
write("a2", [
call("u1", "Edit", { file_path: "/work/repo/main.go" }),
result("u1", { structuredPatch: [{ lines: ["+x"] }] }),
call("u2", "Bash", { command: "go test ./..." }),
result("u2", { stdout: "ok" }),
]);
});
after(() => fs.rmSync(HOME, { recursive: true, force: true }));
describe("agentEdits", () => {
it("lists a subagent's files with their lines, nested subagents folded in", async () => {
const edits = await agentEdits(SID, CWD, "a1");
assert.deepEqual(edits, [
{ path: "/work/repo/main.go", added: 3, removed: 1, counted: true },
{ path: "/work/repo/new_test.go", added: 3, removed: 0, counted: true },
]);
});
it("answers an empty list for a subagent with no transcript", async () => {
assert.deepEqual(await agentEdits(SID, CWD, "nope"), []);
});
it("reads a transcript again only after it changed", async () => {
await agentEdits(SID, CWD, "a2");
const file = path.join(subDir, "agent-a2.jsonl");
const before = _cache.get(file);
await agentEdits(SID, CWD, "a2");
assert.equal(_cache.get(file), before);
fs.appendFileSync(
file,
"\n" +
[
call("u3", "Write", { file_path: "/work/repo/late.go" }),
result("u3", { type: "create", content: "z\n" }),
]
.map((l) => JSON.stringify(l))
.join("\n")
);
const edits = await agentEdits(SID, CWD, "a2");
assert.ok(edits.some((e) => e.path === "/work/repo/late.go"));
});
});
describe("attachAgentEdits", () => {
it("puts edits on an Agent result only", async () => {
const messages = [
{
content: [
{ type: "tool_result", id: "t9", agent: { id: "a1" } },
{ type: "tool_result", id: "t8", diff: { added: 1, removed: 0 } },
{ type: "tool_result", id: "t7", agent: { id: "a1" }, is_error: true },
],
},
];
await attachAgentEdits(messages, SID, CWD);
const [ok, plain, failed] = messages[0].content;
assert.ok(ok.edits.some((e) => e.path === "/work/repo/new_test.go"));
assert.equal(plain.edits, undefined);
assert.equal(failed.edits, undefined);
});
});
+132
View File
@@ -0,0 +1,132 @@
/**
* @file agent-edits.js
* @description The files a Claude subagent wrote or edited, read from its own
* transcript (`agent-<id>.jsonl`), so the parent's chat can count them in its
* turn's "Edited N files" card (issue #86: a skill that hands its work to
* subagents showed only the parent's own edits). Each file's +/- lines come
* from the same `patchCounts` the chat uses; a failed call counts nothing; a
* subagent's own subagents are folded in, `MAX_DEPTH` levels down. The file is
* read through `readLines` (a member's transcript only as that member's), and
* the answer is cached by path + size + mtime, so a finished subagent is read
* once however often its parent's chat is polled.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const { patchCounts, agentInfo } = require("./transcript-message");
const {
getSubagentTranscriptPath,
findSubagentTranscriptPath,
getSnapshotSubagentTranscriptPath,
} = require("./claude-home");
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
const MAX_DEPTH = 3;
const CACHE_MAX = 500;
// path → { key: "<size>:<mtime>", edits, agents }
const cache = new Map();
/** One transcript's own edits and the subagents it started. */
async function scan(file) {
const { readLines } = require("./run-as");
const pending = new Map(); // tool_use id → file path
const edits = new Map(); // path → { added, removed, counted }
const agents = [];
for await (const line of readLines(file)) {
if (!line.trim()) continue;
let entry;
try {
entry = JSON.parse(line);
} catch {
continue;
}
const content = entry.message?.content;
if (!Array.isArray(content)) continue;
if (entry.type === "assistant") {
for (const b of content) {
if (b?.type !== "tool_use" || !EDIT_TOOLS.has(b.name) || !b.id) continue;
const p = b.input?.file_path ?? b.input?.notebook_path;
if (typeof p === "string" && p) pending.set(b.id, p);
}
} else if (entry.type === "user") {
for (const b of content) {
if (b?.type !== "tool_result") continue;
const agent = agentInfo(entry.toolUseResult);
if (agent) agents.push(agent.id);
const p = pending.get(b.tool_use_id);
if (!p || b.is_error) continue;
const diff = patchCounts(entry.toolUseResult);
const f = edits.get(p) ?? { added: 0, removed: 0, counted: false };
f.added += diff?.added ?? 0;
f.removed += diff?.removed ?? 0;
f.counted ||= !!diff;
edits.set(p, f);
}
}
}
return { edits, agents };
}
async function scanCached(file) {
const st = await fs.promises.stat(file);
const key = `${st.size}:${st.mtimeMs}`;
const hit = cache.get(file);
if (hit && hit.key === key) return hit;
const fresh = { key, ...(await scan(file)) };
cache.delete(file);
cache.set(file, fresh);
if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
return fresh;
}
const transcriptOf = (sessionId, cwd, agentId) =>
getSubagentTranscriptPath(sessionId, cwd, agentId) ||
findSubagentTranscriptPath(sessionId, agentId, null, cwd) ||
getSnapshotSubagentTranscriptPath(sessionId, agentId);
/**
* The files subagent `agentId` of session `sessionId` (and its own subagents)
* changed, as `[{ path, added, removed, counted }]` in first-edit order; an
* empty list when its transcript cannot be found or read.
*/
async function agentEdits(sessionId, cwd, agentId, depth = 0, seen = new Set()) {
if (seen.has(agentId)) return [];
seen.add(agentId);
let file;
let got;
try {
file = transcriptOf(sessionId, cwd, agentId);
if (!file) return [];
got = await scanCached(file);
} catch {
return [];
}
const out = new Map();
const add = (p, f) => {
const o = out.get(p) ?? { added: 0, removed: 0, counted: false };
o.added += f.added;
o.removed += f.removed;
o.counted ||= f.counted;
out.set(p, o);
};
got.edits.forEach((f, p) => add(p, f));
if (depth < MAX_DEPTH) {
for (const id of got.agents) {
for (const f of await agentEdits(sessionId, cwd, id, depth + 1, seen)) add(f.path, f);
}
}
return [...out].map(([path, f]) => ({ path, ...f }));
}
/** Puts `edits` on every Agent/Task result in `messages` (in place). */
async function attachAgentEdits(messages, sessionId, cwd) {
for (const m of messages) {
for (const c of m.content ?? []) {
if (c.type !== "tool_result" || !c.agent?.id || c.is_error) continue;
const edits = await agentEdits(sessionId, cwd, c.agent.id);
if (edits.length) c.edits = edits;
}
}
}
module.exports = { agentEdits, attachAgentEdits, _cache: cache };
+6
View File
@@ -1290,6 +1290,12 @@ router.get("/:id/transcript", async (req, res) => {
hasMore = total > limit;
}
// A Claude subagent's own Write/Edit calls are in ITS transcript: put the
// files on its Agent result, so the turn's "Edited N files" counts them.
if (!isCodex && !isOmp && !isCursor && !isAgy) {
await require("../lib/agent-edits").attachAgentEdits(messages, req.params.id, session.cwd);
}
const lastLine = messages.length > 0 ? messages[messages.length - 1].line : 0;
const firstLine = messages.length > 0 ? messages[0].line : 0;