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:
@@ -491,6 +491,8 @@ type Item =
|
|||||||
| { kind: "command"; key: string; name: string | null; output: string | null; error: boolean };
|
| { kind: "command"; key: string; name: string | null; output: string | null; error: boolean };
|
||||||
|
|
||||||
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
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
|
/** 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
|
* 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);
|
if (msg.usage) output.add(msg.usage);
|
||||||
msg.content.forEach((block, b) => {
|
msg.content.forEach((block, b) => {
|
||||||
const last = items[items.length - 1];
|
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.
|
// A question to the user is a turn of the conversation, not hidden work.
|
||||||
if (block.type === "tool_use" && block.name === "AskUserQuestion") {
|
if (block.type === "tool_use" && block.name === "AskUserQuestion") {
|
||||||
items.push({ kind: "ask", key: `${key}:${b}`, block });
|
items.push({ kind: "ask", key: `${key}:${b}`, block });
|
||||||
@@ -672,19 +678,38 @@ function EditedFilesCard({
|
|||||||
const files = new Map<string, { added: number; removed: number; counted: boolean }>();
|
const files = new Map<string, { added: number; removed: number; counted: boolean }>();
|
||||||
// Written whole in this turn (a Write): a component made here is an artifact.
|
// Written whole in this turn (a Write): a component made here is an artifact.
|
||||||
const created = new Set<string>();
|
const created = new Set<string>();
|
||||||
|
// 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<string>();
|
||||||
for (const b of blocks) {
|
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<string, unknown>;
|
const input = (b.input ?? {}) as Record<string, unknown>;
|
||||||
const p = input.file_path ?? input.notebook_path;
|
const p = input.file_path ?? input.notebook_path;
|
||||||
const r = b.id ? toolResultMap.get(b.id) : undefined;
|
const r = b.id ? toolResultMap.get(b.id) : undefined;
|
||||||
if (typeof p !== "string" || !r || r.is_error) continue;
|
if (typeof p !== "string" || !r || r.is_error) continue;
|
||||||
if (b.name === "Write") created.add(p);
|
if (b.name === "Write") created.add(p);
|
||||||
|
byAgent.delete(p);
|
||||||
const f = files.get(p) ?? { added: 0, removed: 0, counted: false };
|
const f = files.get(p) ?? { added: 0, removed: 0, counted: false };
|
||||||
f.added += r.diff?.added ?? 0;
|
f.added += r.diff?.added ?? 0;
|
||||||
f.removed += r.diff?.removed ?? 0;
|
f.removed += r.diff?.removed ?? 0;
|
||||||
f.counted ||= !!r.diff;
|
f.counted ||= !!r.diff;
|
||||||
files.set(p, f);
|
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 && (
|
const cards = artifacts.length > 0 && (
|
||||||
<div data-testid="artifact-cards" className="flex flex-col gap-2">
|
<div data-testid="artifact-cards" className="flex flex-col gap-2">
|
||||||
{artifacts.map((p) => (
|
{artifacts.map((p) => (
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
* Desktop's "Edited N files" card: one row per file with the lines added and
|
* 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
|
* 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`
|
* 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ĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi } from "vitest";
|
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)
|
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
|
||||||
).toEqual(["@/w/a.ts", "@/w/b.json"]);
|
).toEqual(["@/w/a.ts", "@/w/b.json"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("counts the files a subagent changed, beside the turn's own edits", () => {
|
||||||
|
render(
|
||||||
|
<MessageList
|
||||||
|
loading={false}
|
||||||
|
messages={[
|
||||||
|
user("run the task"),
|
||||||
|
call("1", "Edit", "/w/a.go"),
|
||||||
|
result("1", { added: 1, removed: 0 }),
|
||||||
|
{
|
||||||
|
type: "assistant",
|
||||||
|
sender: "assistant",
|
||||||
|
timestamp: at,
|
||||||
|
content: [{ type: "tool_use", id: "2", name: "Agent", input: { prompt: "do it" } }],
|
||||||
|
} as TranscriptMessage,
|
||||||
|
{
|
||||||
|
type: "user",
|
||||||
|
sender: "tool",
|
||||||
|
timestamp: at,
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_result",
|
||||||
|
id: "2",
|
||||||
|
output: "done",
|
||||||
|
agent: { id: "x1" },
|
||||||
|
edits: [
|
||||||
|
{ path: "/w/a.go", added: 4, removed: 2, counted: true },
|
||||||
|
{ path: "/w/b_test.go", added: 9, removed: 0, counted: true },
|
||||||
|
{ path: "/w/report.html", added: 5, removed: 0, counted: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as TranscriptMessage,
|
||||||
|
user("thanks"),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3236,6 +3236,10 @@ export interface TranscriptContent {
|
|||||||
/** Present on a Write/Edit/MultiEdit "tool_result": the lines it added and
|
/** Present on a Write/Edit/MultiEdit "tool_result": the lines it added and
|
||||||
* removed (from Claude Code's `structuredPatch`). */
|
* removed (from Claude Code's `structuredPatch`). */
|
||||||
diff?: { added: number; removed: number };
|
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
|
/** Present on an AskUserQuestion "tool_result": question text → the option
|
||||||
* the user picked, or what they typed. */
|
* the user picked, or what they typed. */
|
||||||
answers?: Record<string, string>;
|
answers?: Record<string, string>;
|
||||||
|
|||||||
+1
-1
@@ -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.
|
`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=<line>` returns only messages past that JSONL line (the chat's tail), `before=<line>` 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=<line>` returns only messages past that JSONL line (the chat's tail), `before=<line>` 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -1290,6 +1290,12 @@ router.get("/:id/transcript", async (req, res) => {
|
|||||||
hasMore = total > limit;
|
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 lastLine = messages.length > 0 ? messages[messages.length - 1].line : 0;
|
||||||
const firstLine = messages.length > 0 ? messages[0].line : 0;
|
const firstLine = messages.length > 0 ? messages[0].line : 0;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user