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.
This commit is contained in:
@@ -2,7 +2,9 @@
|
||||
* @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`.
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -16,7 +18,7 @@ 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 { agentEdits, attachAgentEdits, turnCarry, _cache } = require("../lib/agent-edits");
|
||||
|
||||
const CWD = "/work/repo";
|
||||
const SID = "11111111-2222-3333-4444-555555555555";
|
||||
@@ -110,3 +112,56 @@ describe("attachAgentEdits", () => {
|
||||
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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
* 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.
|
||||
* once however often its parent's chat is polled. `turnCarry` does the same
|
||||
* for the part of a turn the chat's page of messages cut off.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -21,6 +22,7 @@ const {
|
||||
} = require("./claude-home");
|
||||
|
||||
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
||||
const AGENT_TOOLS = new Set(["Agent", "Task"]);
|
||||
const MAX_DEPTH = 3;
|
||||
const CACHE_MAX = 500;
|
||||
// path → { key: "<size>:<mtime>", edits, agents }
|
||||
@@ -129,4 +131,77 @@ async function attachAgentEdits(messages, sessionId, cwd) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { agentEdits, attachAgentEdits, _cache: cache };
|
||||
/** A human prompt: the line that starts a turn (the chat's own rule). */
|
||||
const isPrompt = (m) =>
|
||||
(m.sender ?? (m.type === "assistant" ? "assistant" : "user")) === "user" &&
|
||||
(m.content ?? []).some((c) => c.type === "text");
|
||||
|
||||
/**
|
||||
* The edits of the turn a page of the chat starts in the middle of. The route
|
||||
* keeps only the newest `limit` messages; every message it drops goes through
|
||||
* `drop()`, which keeps the file changes since the last prompt it dropped.
|
||||
* `result(window)` then answers the files that turn changed BEFORE the page's
|
||||
* first message — `[{ path, added, removed, counted }]`, or null when the page
|
||||
* starts on a prompt or nothing was changed — so the turn's "Edited N files"
|
||||
* card is whole although its start was never sent (issue #86: a 120-message
|
||||
* turn showed 1 of its files).
|
||||
*/
|
||||
function turnCarry() {
|
||||
let pending = new Map(); // dropped Write/Edit call id → path
|
||||
let pendingAgents = new Set(); // dropped Agent/Task call ids
|
||||
let files = new Map();
|
||||
let agents = [];
|
||||
const add = (p, f) => {
|
||||
const o = files.get(p) ?? { added: 0, removed: 0, counted: false };
|
||||
o.added += f?.added ?? 0;
|
||||
o.removed += f?.removed ?? 0;
|
||||
o.counted ||= !!f && f.counted !== false;
|
||||
files.set(p, o);
|
||||
};
|
||||
return {
|
||||
drop(m) {
|
||||
if (!m) return;
|
||||
if (isPrompt(m)) {
|
||||
pending = new Map();
|
||||
pendingAgents = new Set();
|
||||
files = new Map();
|
||||
agents = [];
|
||||
return;
|
||||
}
|
||||
for (const c of m.content ?? []) {
|
||||
if (c.type === "tool_use" && c.id) {
|
||||
if (AGENT_TOOLS.has(c.name)) pendingAgents.add(c.id);
|
||||
if (!EDIT_TOOLS.has(c.name)) continue;
|
||||
const p = c.input?.file_path ?? c.input?.notebook_path;
|
||||
if (typeof p === "string" && p) pending.set(c.id, p);
|
||||
} else if (c.type === "tool_result") {
|
||||
if (c.agent?.id && !c.is_error) agents.push(c.agent.id);
|
||||
pendingAgents.delete(c.id);
|
||||
const p = pending.get(c.id);
|
||||
if (!p) continue;
|
||||
pending.delete(c.id);
|
||||
if (!c.is_error) add(p, c.diff ?? null);
|
||||
}
|
||||
}
|
||||
},
|
||||
async result(window, sessionId, cwd) {
|
||||
if (!window.length || isPrompt(window[0])) return null;
|
||||
// A call dropped whose result is on the page: the page shows the result
|
||||
// with no call to hang it on, so it counts here.
|
||||
for (const m of window) {
|
||||
for (const c of m.content ?? []) {
|
||||
if (c.type !== "tool_result" || c.is_error) continue;
|
||||
const p = pending.get(c.id);
|
||||
if (p) add(p, c.diff ?? null);
|
||||
if (pendingAgents.has(c.id)) for (const e of c.edits ?? []) add(e.path, e);
|
||||
}
|
||||
}
|
||||
for (const id of agents) {
|
||||
for (const e of await agentEdits(sessionId, cwd, id)) add(e.path, e);
|
||||
}
|
||||
return files.size ? [...files].map(([path, f]) => ({ path, ...f })) : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { agentEdits, attachAgentEdits, turnCarry, _cache: cache };
|
||||
|
||||
@@ -1177,6 +1177,9 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
: (e, n) => parseMessage(e, n);
|
||||
|
||||
const parseMessage = createClaudeLineParser(isSubagentFile);
|
||||
const claudeFile = !isCodex && !isOmp && !isCursor && !isAgy;
|
||||
// The edits of the turn the page starts inside of (agent-edits.js).
|
||||
const carry = claudeFile ? require("../lib/agent-edits").turnCarry() : null;
|
||||
|
||||
if (afterLine !== null) {
|
||||
// Incremental mode: skip lines until after afterLine, collect up to limit, then stop.
|
||||
@@ -1232,7 +1235,7 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
messages.push(message);
|
||||
// Sliding window: only keep the last `limit` messages
|
||||
if (messages.length > limit) {
|
||||
messages.shift();
|
||||
carry?.drop(messages.shift());
|
||||
}
|
||||
}
|
||||
if (total > limit) hasMore = true;
|
||||
@@ -1283,7 +1286,7 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
messages.push(message);
|
||||
// Sliding window: only keep the last `limit` messages in memory
|
||||
if (messages.length > limit) {
|
||||
messages.shift();
|
||||
carry?.drop(messages.shift());
|
||||
}
|
||||
}
|
||||
// If we shifted any messages out, there are more
|
||||
@@ -1292,8 +1295,10 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
|
||||
// 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) {
|
||||
let turnEdits = null;
|
||||
if (claudeFile) {
|
||||
await require("../lib/agent-edits").attachAgentEdits(messages, req.params.id, session.cwd);
|
||||
turnEdits = await carry.result(messages, req.params.id, session.cwd);
|
||||
}
|
||||
|
||||
const lastLine = messages.length > 0 ? messages[messages.length - 1].line : 0;
|
||||
@@ -1310,6 +1315,7 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
has_more: hasMore,
|
||||
last_line: lastLine,
|
||||
first_line: firstLine,
|
||||
...(turnEdits ? { turn_edits: turnEdits } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
res.json({ messages: [], total: 0, has_more: false, last_line: 0, first_line: 0 });
|
||||
|
||||
Reference in New Issue
Block a user