From 5ccbaf5c2300228863dc3d599c1f7bec89f868b1 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 23 Oct 2026 14:26:29 +0700 Subject: [PATCH] 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-.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). --- .../components/conversation/MessageList.tsx | 29 +++- .../__tests__/MessageList.edits.test.tsx | 49 ++++++- client/src/lib/types.ts | 4 + docs/API.md | 2 +- server/__tests__/agent-edits.test.js | 112 +++++++++++++++ server/lib/agent-edits.js | 132 ++++++++++++++++++ server/routes/sessions.js | 6 + 7 files changed, 330 insertions(+), 4 deletions(-) create mode 100644 server/__tests__/agent-edits.test.js create mode 100644 server/lib/agent-edits.js diff --git a/client/src/components/conversation/MessageList.tsx b/client/src/components/conversation/MessageList.tsx index 051e411c..e956dc1c 100644 --- a/client/src/components/conversation/MessageList.tsx +++ b/client/src/components/conversation/MessageList.tsx @@ -491,6 +491,8 @@ type Item = | { kind: "command"; key: string; name: string | null; output: string | null; error: boolean }; const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]); +// A subagent's result carries the files IT changed (`edits`): they count too. +const AGENT_TOOLS = new Set(["Agent", "Task"]); /** A line that is only Claude Code's local-command plumbing — the caveat it * writes first, the command, what the command printed — or null for @@ -592,7 +594,11 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[] if (msg.usage) output.add(msg.usage); msg.content.forEach((block, b) => { const last = items[items.length - 1]; - if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block); + if ( + block.type === "tool_use" && + (EDIT_TOOLS.has(block.name ?? "") || AGENT_TOOLS.has(block.name ?? "")) + ) + edits.push(block); // A question to the user is a turn of the conversation, not hidden work. if (block.type === "tool_use" && block.name === "AskUserQuestion") { items.push({ kind: "ask", key: `${key}:${b}`, block }); @@ -672,19 +678,38 @@ function EditedFilesCard({ const files = new Map(); // Written whole in this turn (a Write): a component made here is an artifact. const created = new Set(); + // Changed only by a subagent: listed, never an artifact card — the chat + // rebuilds an artifact from this transcript's Write/Edit inputs, which a + // subagent's are not. + const byAgent = new Set(); for (const b of blocks) { + const r0 = b.id ? toolResultMap.get(b.id) : undefined; + if (AGENT_TOOLS.has(b.name ?? "")) { + for (const e of r0?.edits ?? []) { + if (!files.has(e.path)) byAgent.add(e.path); + const f = files.get(e.path) ?? { added: 0, removed: 0, counted: false }; + f.added += e.added; + f.removed += e.removed; + f.counted ||= e.counted; + files.set(e.path, f); + } + continue; + } const input = (b.input ?? {}) as Record; const p = input.file_path ?? input.notebook_path; const r = b.id ? toolResultMap.get(b.id) : undefined; if (typeof p !== "string" || !r || r.is_error) continue; if (b.name === "Write") created.add(p); + byAgent.delete(p); const f = files.get(p) ?? { added: 0, removed: 0, counted: false }; f.added += r.diff?.added ?? 0; f.removed += r.diff?.removed ?? 0; f.counted ||= !!r.diff; files.set(p, f); } - const artifacts = [...files.keys()].filter((p) => isArtifact(p, created.has(p))); + const artifacts = [...files.keys()].filter( + (p) => !byAgent.has(p) && isArtifact(p, created.has(p)) + ); const cards = artifacts.length > 0 && (
{artifacts.map((p) => ( diff --git a/client/src/components/conversation/__tests__/MessageList.edits.test.tsx b/client/src/components/conversation/__tests__/MessageList.edits.test.tsx index 6dced3fa..a9443e7e 100644 --- a/client/src/components/conversation/__tests__/MessageList.edits.test.tsx +++ b/client/src/components/conversation/__tests__/MessageList.edits.test.tsx @@ -4,7 +4,9 @@ * Desktop's "Edited N files" card: one row per file with the lines added and * removed summed over the turn, a failed call left out, a row click handed * to the caller, and a row (or the header, every file) dragged as `@path` - * attachments for a prompt box. + * attachments for a prompt box. Files a subagent changed (its Agent + * result's `edits`, read from its own transcript) are counted in the same + * card, never as an artifact. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi } from "vitest"; @@ -127,4 +129,49 @@ describe("the Edited N files card", () => { JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path) ).toEqual(["@/w/a.ts", "@/w/b.json"]); }); + + it("counts the files a subagent changed, beside the turn's own edits", () => { + render( + + ); + const card = screen.getByTestId("edited-files"); + expect(card.textContent).toContain("Edited 3 files"); + expect(within(card).getByText("b_test.go")).toBeTruthy(); + expect(within(card).getByTitle("/w/a.go").textContent).toContain("+5"); // a.go: 1 + 4 + // A page only a subagent wrote is listed, not shown as an artifact card. + expect(within(card).getByText("report.html")).toBeTruthy(); + expect(screen.queryByTestId("artifact-card")).toBeNull(); + }); }); diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index 1bfd1088..71e06b0f 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -3236,6 +3236,10 @@ export interface TranscriptContent { /** Present on a Write/Edit/MultiEdit "tool_result": the lines it added and * removed (from Claude Code's `structuredPatch`). */ diff?: { added: number; removed: number }; + /** Present on an Agent/Task "tool_result" whose subagent (or one it started) + * wrote or edited files: each file with its +/- lines (`counted`: the + * results carried them). Read from the subagent's own transcript. */ + edits?: { path: string; added: number; removed: number; counted: boolean }[]; /** Present on an AskUserQuestion "tool_result": question text → the option * the user picked, or what they typed. */ answers?: Record; diff --git a/docs/API.md b/docs/API.md index 6d176763..d38f1b19 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2081,7 +2081,7 @@ GET /api/sessions/:id/transcript?agent_id=&run_id=&limit=&after=&before=&offset= `transcripts` lists the session's conversation files — the main one first, then each subagent's and compaction's — as `{ transcripts: [{ id, name, type: "main"|"subagent"|"compaction", subagent_type?, db_agent_id, … }] }`; `db_agent_id` links an entry to its `agents` row (`null` when none matches). A Codex, Antigravity, Cursor, omp or OpenCode session lists its own tool's journal instead. -`transcript` reads one of them as structured messages: no `agent_id` = the main conversation, `agent_id` (+ `run_id` for a Workflow-tool agent) = a subagent's — each one plain name, never a path. `limit` defaults to 50 (max 200); `after=` returns only messages past that JSONL line (the chat's tail), `before=` the page before it (history), `offset` is the legacy pager. Answers `{ messages, total, has_more, last_line, first_line }` (empty when the file is gone). The live file under the Claude projects folder is preferred, else the snapshot taken at import. `404 NOT_FOUND` for an unknown session. +`transcript` reads one of them as structured messages: no `agent_id` = the main conversation, `agent_id` (+ `run_id` for a Workflow-tool agent) = a subagent's — each one plain name, never a path. `limit` defaults to 50 (max 200); `after=` returns only messages past that JSONL line (the chat's tail), `before=` the page before it (history), `offset` is the legacy pager. Answers `{ messages, total, has_more, last_line, first_line }` (empty when the file is gone). A Claude Agent/Task `tool_result` whose subagent (or one it started, up to 3 levels) wrote or edited files carries them as `edits: [{ path, added, removed, counted }]`, read from the subagent's own transcript (`server/lib/agent-edits.js`, cached by size + mtime) — the chat's "Edited N files" card counts them with the turn's own edits. The live file under the Claude projects folder is preferred, else the snapshot taken at import. `404 NOT_FOUND` for an unknown session. --- diff --git a/server/__tests__/agent-edits.test.js b/server/__tests__/agent-edits.test.js new file mode 100644 index 00000000..cefd2d4d --- /dev/null +++ b/server/__tests__/agent-edits.test.js @@ -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ĩ + */ + +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); + }); +}); diff --git a/server/lib/agent-edits.js b/server/lib/agent-edits.js new file mode 100644 index 00000000..0b9e8ace --- /dev/null +++ b/server/lib/agent-edits.js @@ -0,0 +1,132 @@ +/** + * @file agent-edits.js + * @description The files a Claude subagent wrote or edited, read from its own + * transcript (`agent-.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ĩ + */ + +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: ":", 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 }; diff --git a/server/routes/sessions.js b/server/routes/sessions.js index 2cf16e0a..7df10d66 100644 --- a/server/routes/sessions.js +++ b/server/routes/sessions.js @@ -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;