8d48a038c7
Three defects that all showed up as "the dashboard's numbers are wrong", plus the notification and brand work asked for alongside them. Tokens: Claude Code writes ONE JSONL line per content block of an assistant message, and every one of those lines repeats the same message-level `usage`. Both parsers summed them, so a tool-heavy session's tokens — and its cost — ran 1.5-2x high (one 4h session: $45.82 stored against $27.72 real). `lastUsageMsgId` counts a message once, carried across the transcript cache's incremental chunk boundaries. `replaceTokenUsage`'s high-water baselines mean the fixed parser cannot correct what is already stored, so `setTokenUsage` (exact, baselines cleared) is added for the deliberate re-parse path and `reconcileTokens` now covers every session with a transcript on disk rather than imported ones only. Turns: every `TurnDuration` row was stamped NOW while its dedup guard matched on the transcript's timestamp, so each hook re-inserted the session's whole turn history — 587k rows, 75% of a real events table, one session holding 68,747 copies of the same turns. The row now carries the transcript timestamp, which fixes the guard and the feed's ordering together. Same class of bug in the session metadata: `thinking_blocks` / `turn_count` / `total_turn_duration_ms` are assigned from the full parse instead of `+=`, since `extract()` returns whole-transcript totals on every call. Events: leftover checkout hook entries in `~/.claude/settings.json` double every event, every subagent row and every token figure. `stripLegacyHooks` has always removed them — but only inside `bootstrap()`, which `fastPathOk` skips on exactly the healthy installs where nothing else forces a full pass, so they survived indefinitely. `hasLegacyHooks` (pure fs) now blocks the fast path. A one-time repair at the first start after upgrading (`user_version` 3) deletes the duplicated `TurnDuration` rows and re-parses every transcript with exact writes. Marker 3 and not 2: an install that ran the earlier pass had its corrected rows re-inflated by whatever old build was still ingesting hooks. Router: switching a lane's AI Router while a pane is live only half-applies — Claude Code re-applies `env` to a running session but keeps the model it resolved at startup, so the old router's model name reaches the new endpoint and the next prompt fails with "model not found". The apply route reports `restart_required` / `run_id`, and the picker offers a Restart that respawns the pane from the lane id alone (replaying the old `--model` would re-create the mismatch). Notifications (#29): the two moments Claude is BLOCKED on the user now have their own switches and default to on — `onAwaitingInput` (a permission prompt or a question) and `onTurnComplete` (end of turn). Before, the first was unswitchable and the second hung off a preference that defaults to off, so the one thing a user walks away from went unannounced. Prefs shape/defaults/storage move to `client/src/lib/notifPrefs.ts`; Settings and the hook had private copies. Brand: the sidebar shows the SmartGift wordmark with the AgentHub caption under it, the collapsed rail shows the mark, and the favicon is vendored to match.
126 lines
4.1 KiB
JavaScript
126 lines
4.1 KiB
JavaScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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");
|
|
});
|
|
});
|