/** * @file Regression test for transcript-derived turn bookkeeping. Every hook * event re-reads the whole transcript, so anything derived from it must be * idempotent: `TurnDuration` events are inserted once per real turn (their * dedup guard matches on `created_at`, which therefore has to be the * TRANSCRIPT's timestamp, not the moment we read the file), and the session * metadata counters are ASSIGNED from the full parse rather than accumulated. * Before this, one session held 68,747 copies of the same handful of turns and * reported tens of thousands of thinking blocks. * @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 STAMP = `turn-dur-${Date.now()}-${process.pid}`; const TMP = path.join(os.tmpdir(), STAMP); 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, stmts } = require("../db"); let server; let BASE; function post(urlPath, body) { return new Promise((resolve, reject) => { const url = new URL(urlPath, 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(); }); } before(async () => { fs.mkdirSync(TMP, { recursive: true }); 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("turn bookkeeping is idempotent across re-reads", () => { it("inserts each turn once and does not accumulate the metadata counters", async () => { const cwd = path.join(TMP, "work"); const sid = "77777777-8888-9999-aaaa-bbbbbbbbbbbb"; const tpath = path.join(TMP, `${sid}.jsonl`); fs.mkdirSync(cwd, { recursive: true }); fs.writeFileSync( tpath, [ { type: "assistant", message: { id: "msg_1", model: "claude-sonnet-5", content: [{ type: "thinking" }, { type: "text", text: "hi" }], usage: { input_tokens: 5, output_tokens: 7 }, }, }, { type: "system", subtype: "turn_duration", durationMs: 1200, timestamp: "2026-09-18T01:00:00.000Z", }, { type: "system", subtype: "turn_duration", durationMs: 3400, timestamp: "2026-09-18T01:05:00.000Z", }, ] .map((o) => JSON.stringify(o)) .join("\n") + "\n" ); const event = { session_id: sid, cwd, transcript_path: tpath }; for (let i = 0; i < 3; i++) { assert.equal(await post("/api/hooks/event", { hook_type: "Stop", data: event }), 200); } const turns = db .prepare("SELECT created_at FROM events WHERE session_id = ? AND event_type = 'TurnDuration'") .all(sid); assert.equal(turns.length, 2, "three hooks over two turns must leave two rows"); // Stamped from the transcript — both the dedup guard and the feed's // ordering depend on it. assert.deepEqual(turns.map((t) => t.created_at).sort(), [ "2026-09-18T01:00:00.000Z", "2026-09-18T01:05:00.000Z", ]); const meta = JSON.parse(stmts.getSession.get(sid).metadata || "{}"); assert.equal(meta.turn_count, 2); assert.equal(meta.total_turn_duration_ms, 4600); assert.equal(meta.thinking_blocks, 1, "one thinking block, however often it is re-read"); }); });