Files
AgentHub/server/__tests__/agent-edits.test.js
T
nntrivi2001 f51a6a895e fix(chat): keep a long turn's Edited N files card whole across the page cut (#86)
The reported turn (/vp-auto, ~120 messages) showed "Edited 1 file": the
chat loads the newest 50 messages, and the turn's other Write
(rbac_routes_test.go, +176) was in the part before the page.

- turnCarry (agent-edits.js): every message the route's sliding window
  drops goes through it; it keeps the file changes since the last prompt it
  dropped (a dropped call whose result is on the page, and dropped
  subagents, included).
- A Claude page that starts mid-turn answers them as `turn_edits`; the
  chat keeps the one from the page that holds its oldest message and adds
  it to that turn's card.

Files a turn changed through Bash (python/sed writing a file) are still not
listed: nothing in the transcript says which files a command wrote.
2026-10-26 08:32:45 +07:00

168 lines
6.1 KiB
JavaScript

/**
* @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`;
* and `turnCarry` hands a page of the chat the edits its cut-off first turn
* made before the page began.
* @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, turnCarry, _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);
});
});
describe("turnCarry", () => {
const prompt = (text) => ({ type: "user", sender: "user", content: [{ type: "text", text }] });
const use = (id, name, file_path) => ({
type: "assistant",
sender: "assistant",
content: [{ type: "tool_use", id, name, input: file_path ? { file_path } : {} }],
});
const res = (id, extra = {}) => ({
type: "user",
sender: "tool",
content: [{ type: "tool_result", id, ...extra }],
});
it("answers the files the cut-off turn changed before the page", async () => {
const c = turnCarry();
for (const m of [
prompt("old turn"),
use("o1", "Write", "/w/old.go"),
res("o1", { diff: { added: 9, removed: 0 } }),
prompt("this turn"),
use("1", "Write", "/w/a_test.go"),
res("1", { diff: { added: 176, removed: 0 } }),
use("2", "Edit", "/w/bad.go"),
res("2", { is_error: true }),
use("3", "Edit", "/w/b.go"),
])
c.drop(m);
// The page: the result of a dropped call, then more of the same turn.
const page = [res("3", { diff: { added: 2, removed: 1 } }), use("4", "Write", "/w/c.go")];
assert.deepEqual(await c.result(page, SID, CWD), [
{ path: "/w/a_test.go", added: 176, removed: 0, counted: true },
{ path: "/w/b.go", added: 2, removed: 1, counted: true },
]);
});
it("answers null when the page starts on a prompt", async () => {
const c = turnCarry();
c.drop(prompt("p"));
c.drop(use("1", "Write", "/w/a.go"));
c.drop(res("1", { diff: { added: 1, removed: 0 } }));
assert.equal(await c.result([prompt("next")], SID, CWD), null);
});
it("folds in a dropped subagent's own edits", async () => {
const c = turnCarry();
c.drop(prompt("p"));
c.drop(use("9", "Agent"));
c.drop(res("9", { agent: { id: "a1" } }));
const got = await c.result([use("10", "Bash")], SID, CWD);
assert.ok(got.some((e) => e.path === "/work/repo/new_test.go"));
});
});