198fbb7980
- GET /api/workflows: one LEAD() window pass in a read worker (read-pool.js, workflow-stats.js); idx_events_session restored, idx_events_tool added - compat-sqlite gains function/pluck/readonly so a plugin install without a better-sqlite3 build boots again; antigravity-usage uses db.js's Database - install-hooks adds no checkout hooks while the agenthub plugin is enabled (events were doubled) - terminal attach runs the live tmux server's own binary; the pane scrollbar gets a visible 2-column style, and claude panes start with CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 so tmux keeps history to scroll - Codex sessions' conversation in GET /api/sessions/:id/transcript - Codex/Antigravity sessions claimed by their workspace without hooks - UI-audit follow-ups, docs and OpenAPI
157 lines
5.9 KiB
JavaScript
157 lines
5.9 KiB
JavaScript
/**
|
|
* @file Tests for hook-payload trimming (`server/lib/event-payload.js`): what is
|
|
* dropped and kept, that every event insert goes through `trim_event_data`, that
|
|
* the duplicate guard still matches a trimmed payload, and the one-time sweep.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const path = require("path");
|
|
const os = require("os");
|
|
|
|
const TMP = path.join(os.tmpdir(), `event-payload-${Date.now()}-${process.pid}`);
|
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
|
process.env.CLAUDE_HOME = path.join(TMP, "home");
|
|
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
|
|
|
const {
|
|
trimHookPayload,
|
|
trimEventData,
|
|
sweepEventPayloads,
|
|
STR_CAP,
|
|
} = require("../lib/event-payload");
|
|
const { db, stmts } = require("../db");
|
|
|
|
const big = (n, ch = "x") => ch.repeat(n);
|
|
|
|
describe("trimHookPayload", () => {
|
|
it("drops an Edit's whole-file mirror and keeps the patch", () => {
|
|
const patch = [{ oldStart: 1, lines: ["-a", "+b"] }];
|
|
const out = trimHookPayload({
|
|
tool_name: "Edit",
|
|
tool_input: { file_path: "/a.js", old_string: "a", new_string: "b" },
|
|
tool_response: {
|
|
originalFile: big(50),
|
|
oldString: "a",
|
|
newString: "b",
|
|
structuredPatch: patch,
|
|
},
|
|
});
|
|
assert.equal(out.tool_response.originalFile, undefined);
|
|
assert.equal(out.tool_response.oldString, undefined);
|
|
assert.deepEqual(out.tool_response.structuredPatch, patch);
|
|
assert.equal(out.tool_input.old_string, "a");
|
|
assert.deepEqual(out._trimmed.sort(), [
|
|
"tool_response.newString",
|
|
"tool_response.oldString",
|
|
"tool_response.originalFile",
|
|
]);
|
|
});
|
|
|
|
it("drops a Read's file body but keeps its counts", () => {
|
|
const out = trimHookPayload({
|
|
tool_name: "Read",
|
|
tool_response: {
|
|
type: "text",
|
|
file: { filePath: "/a", content: "hi", numLines: 1, totalLines: 9 },
|
|
},
|
|
});
|
|
assert.deepEqual(out.tool_response.file, { filePath: "/a", numLines: 1, totalLines: 9 });
|
|
assert.deepEqual(out._trimmed, ["tool_response.file.content"]);
|
|
});
|
|
|
|
it("keeps another tool's `content` (Grep matches), only capping it", () => {
|
|
const out = trimHookPayload({ tool_name: "Grep", tool_response: { content: "a:1:x" } });
|
|
assert.equal(out.tool_response.content, "a:1:x");
|
|
assert.equal(out._trimmed, undefined);
|
|
});
|
|
|
|
it("caps long strings to head + tail, idempotently, and leaves other fields", () => {
|
|
const stdout = `HEAD${big(STR_CAP * 3)}TAIL`;
|
|
const data = {
|
|
tool_name: "Bash",
|
|
prompt_id: "p",
|
|
tool_response: { stdout, stderr: "", interrupted: false },
|
|
tool_input: { command: "ls" },
|
|
};
|
|
const once = trimHookPayload(data);
|
|
assert.ok(once.tool_response.stdout.length <= STR_CAP);
|
|
assert.ok(once.tool_response.stdout.startsWith("HEAD"));
|
|
assert.ok(once.tool_response.stdout.endsWith("TAIL"));
|
|
assert.equal(once.tool_response.interrupted, false);
|
|
assert.equal(once.prompt_id, "p");
|
|
assert.deepEqual(trimHookPayload(once), once);
|
|
assert.equal(data.tool_response.stdout, stdout, "input not mutated");
|
|
});
|
|
|
|
it("caps a string-typed tool_response", () => {
|
|
const out = trimHookPayload({ tool_name: "Read", tool_response: big(STR_CAP * 2) });
|
|
assert.ok(out.tool_response.length <= STR_CAP);
|
|
});
|
|
});
|
|
|
|
describe("trimEventData", () => {
|
|
it("passes short, null and non-JSON input through", () => {
|
|
assert.equal(trimEventData(null), null);
|
|
assert.equal(trimEventData('{"a":1}'), '{"a":1}');
|
|
const junk = big(STR_CAP + 10, "{");
|
|
assert.equal(trimEventData(junk), junk);
|
|
});
|
|
|
|
it("returns a long payload it has nothing to trim byte for byte (no number rounding)", () => {
|
|
// 12345678901234567890 does not survive a JSON.parse/stringify round trip.
|
|
const json = `{"tool_name":"Bash","id":12345678901234567890,"tool_input":{"a":"${big(
|
|
STR_CAP - 10
|
|
)}","b":"${big(STR_CAP - 10)}"}}`;
|
|
assert.equal(trimEventData(json), json);
|
|
});
|
|
});
|
|
|
|
describe("event inserts", () => {
|
|
const payload = JSON.stringify({
|
|
session_id: "s-trim",
|
|
tool_name: "Edit",
|
|
tool_response: { originalFile: big(STR_CAP * 4) },
|
|
});
|
|
|
|
it("store the trimmed payload and the duplicate guard still matches it", () => {
|
|
db.prepare("INSERT OR IGNORE INTO sessions (id) VALUES ('s-trim')").run();
|
|
stmts.insertEvent.run("s-trim", null, "PostToolUse", "Edit", "x", payload);
|
|
const row = db.prepare("SELECT data FROM events WHERE session_id = 's-trim'").get();
|
|
assert.ok(row.data.length < 500);
|
|
assert.ok(stmts.findRecentDuplicateEvent.get("s-trim", "PostToolUse", payload));
|
|
});
|
|
|
|
it("the sweep trims rows stored untrimmed", async () => {
|
|
db.prepare(
|
|
"INSERT INTO events (session_id, event_type, data, created_at) VALUES ('s-trim', 'PostToolUse', ?, '2026-01-01T00:00:00Z')"
|
|
).run(payload);
|
|
const r = await sweepEventPayloads(db, { chunk: 1 });
|
|
assert.equal(r.rows, 1);
|
|
assert.ok(r.bytesSaved > STR_CAP * 3);
|
|
const again = await sweepEventPayloads(db);
|
|
assert.equal(again.rows, 0);
|
|
});
|
|
|
|
it("the sweep commits once a chunk's bytes pass the budget, and still trims every row", async () => {
|
|
db.prepare("INSERT OR IGNORE INTO sessions (id) VALUES ('s-sweep')").run();
|
|
const ins = db.prepare(
|
|
"INSERT INTO events (session_id, event_type, data, created_at) VALUES ('s-sweep', 'PostToolUse', ?, '2026-01-01T00:00:00Z')"
|
|
);
|
|
for (let i = 0; i < 5; i++) ins.run(payload);
|
|
let txns = 0;
|
|
const counting = {
|
|
prepare: (sql) => db.prepare(sql),
|
|
transaction: (fn) => {
|
|
const t = db.transaction(fn);
|
|
return (...a) => (txns++, t(...a));
|
|
},
|
|
};
|
|
// Each payload is ~32 KB; a 1-byte budget closes the transaction after every row.
|
|
const r = await sweepEventPayloads(counting, { chunk: 200, maxBytes: 1 });
|
|
assert.equal(r.rows, 5);
|
|
assert.ok(txns >= 5, `expected a transaction per row, got ${txns}`);
|
|
});
|
|
});
|