/** * @file Tests for the hook writer's per-transcript "already handled" marks * (`transcriptMarks` in server/routes/hooks.js): a hook whose transcript is * unchanged skips the token / compaction / API-error / turn-duration writes, * a grown transcript is walked only from the last entry handled, and the rows * left behind are exactly the ones a full re-walk would have written. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, before, after } = require("node:test"); const assert = require("node:assert/strict"); const path = require("path"); const fs = require("fs"); const os = require("os"); const http = require("http"); const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-marks-")); 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 { createApp, startServer } = require("../index"); const { db } = require("../db"); const hooksRouter = require("../routes/hooks"); let server; let BASE; function post(body) { return new Promise((resolve, reject) => { const url = new URL("/api/hooks/event", BASE); const payload = JSON.stringify(body); const r = http.request( { hostname: url.hostname, port: url.port, path: url.pathname, method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload), }, }, (res) => { res.resume(); res.on("end", () => resolve(res.statusCode)); } ); r.on("error", reject); r.write(payload); r.end(); }); } const line = (o) => JSON.stringify(o) + "\n"; const assistant = (id, inTok) => ({ type: "assistant", timestamp: "2026-09-18T01:00:00.000Z", message: { id, model: "claude-sonnet-5", content: [{ type: "text", text: "ok" }], usage: { input_tokens: inTok, output_tokens: 1 }, }, }); const turn = (ms, ts) => ({ type: "system", subtype: "turn_duration", durationMs: ms, timestamp: ts, }); const apiError = (text, ts) => ({ type: "assistant", isApiErrorMessage: true, error: "rate_limit", timestamp: ts, message: { content: [{ type: "text", text }] }, }); const compact = (uuid, ts) => ({ type: "user", isCompactSummary: true, uuid, timestamp: ts }); function rows(sid) { const ev = db .prepare( "SELECT event_type, summary, created_at FROM events WHERE session_id = ? AND event_type IN ('TurnDuration','APIError','Compaction') ORDER BY event_type, created_at, summary" ) .all(sid) .map((r) => r.event_type === "APIError" ? `${r.event_type}|${r.summary}` : `${r.event_type}|${r.created_at}|${r.summary}` ); const tokens = db .prepare("SELECT model, input_tokens, baseline_input FROM token_usage WHERE session_id = ?") .all(sid); return { ev, tokens }; } before(async () => { server = await startServer(createApp(), 0); BASE = `http://127.0.0.1:${server.address().port}`; }); after(() => { server?.close(); fs.rmSync(TMP, { recursive: true, force: true }); }); describe("transcript marks", () => { it("skips an unchanged transcript and walks only the new tail of a grown one", async () => { const sid = "abababab-1111-4111-8111-abababababab"; const cwd = path.join(TMP, "work"); fs.mkdirSync(cwd, { recursive: true }); const tpath = path.join(TMP, `${sid}.jsonl`); fs.writeFileSync( tpath, line(assistant("m1", 5)) + line(turn(1000, "2026-09-18T01:00:01.000Z")) + line(apiError("slow down", "2026-09-18T01:00:02.000Z")) + line(compact("c-1", "2026-09-18T01:00:03.000Z")) + line(assistant("m2", 7)) + line(turn(2000, "2026-09-18T01:00:04.000Z")) ); const data = { session_id: sid, cwd, transcript_path: tpath }; assert.equal(await post({ hook_type: "PostToolUse", data }), 200); const first = rows(sid); assert.equal(first.ev.filter((e) => e.startsWith("TurnDuration")).length, 2); assert.equal(first.ev.filter((e) => e.startsWith("APIError")).length, 1); assert.equal(first.ev.filter((e) => e.startsWith("Compaction")).length, 1); assert.ok(hooksRouter.transcriptMarks.size >= 1, "a mark is kept after commit"); // Proof of the skip: a row removed behind the writer's back is NOT put // back by a hook whose transcript did not change (before, every hook // re-walked the whole history and re-inserted it). db.prepare( "DELETE FROM events WHERE session_id = ? AND event_type = 'TurnDuration' AND created_at = '2026-09-18T01:00:01.000Z'" ).run(sid); assert.equal(await post({ hook_type: "PostToolUse", data: { ...data, tool_name: "x" } }), 200); assert.equal(rows(sid).ev.filter((e) => e.startsWith("TurnDuration")).length, 1); db.prepare("DELETE FROM events WHERE session_id = ? AND event_type = 'TurnDuration'").run(sid); hooksRouter.transcriptMarks.clear(); assert.equal(await post({ hook_type: "PostToolUse", data: { ...data, tool_name: "y" } }), 200); assert.deepEqual(rows(sid), first, "a cleared mark re-walks the whole history, as before"); // Grow it: one turn, one new error, a repeat of the old error, a compaction. fs.appendFileSync( tpath, line(assistant("m3", 11)) + line(turn(3000, "2026-09-18T01:00:05.000Z")) + line(apiError("slow down", "2026-09-18T01:00:06.000Z")) + line(apiError("overloaded", "2026-09-18T01:00:07.000Z")) + line(compact("c-2", "2026-09-18T01:00:08.000Z")) ); assert.equal(await post({ hook_type: "Stop", data }), 200); const grown = rows(sid); // The same transcript walked from scratch must produce the same rows. hooksRouter.transcriptMarks.clear(); assert.equal(await post({ hook_type: "Stop", data: { ...data, stop_hook_active: true } }), 200); assert.deepEqual(rows(sid), grown, "the delta wrote exactly what a full walk writes"); assert.equal(grown.ev.filter((e) => e.startsWith("TurnDuration")).length, 3); assert.equal(grown.ev.filter((e) => e.startsWith("APIError")).length, 2, "repeat deduped"); assert.equal(grown.ev.filter((e) => e.startsWith("Compaction")).length, 2); assert.equal(grown.tokens.length, 1); assert.equal(grown.tokens[0].input_tokens + grown.tokens[0].baseline_input, 23); }); });