/** * @file Tests for the first-user-prompt fallback descriptor (issue #201). * Covers: * - TranscriptCache capturing the first real user message (tool-result, * meta/caveat, slash-command plumbing, and interrupt entries skipped), * normalized and length-capped, first value winning across incremental * re-reads. * - The hook ingestor filling placeholder session/main-agent names and the * main agent task from the descriptor — without clobbering real titles, * user-set names, or an in-flight current_tool — and staying idempotent. * - A later ai-title replacing a descriptor-filled session name. * - importSession using the descriptor for imported sessions (new rows and * the re-import backfill path). * Uses Node's built-in test runner with temp CLAUDE_HOME / DASHBOARD_DATA_DIR. * @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 = `first-user-desc-${Date.now()}-${process.pid}`; const TMP = path.join(os.tmpdir(), STAMP); const CLAUDE_HOME = path.join(TMP, "home"); const DATA_DIR = path.join(TMP, "data"); const TEST_DB = path.join(TMP, "dashboard.db"); process.env.DASHBOARD_DB_PATH = TEST_DB; process.env.CLAUDE_HOME = CLAUDE_HOME; process.env.DASHBOARD_DATA_DIR = DATA_DIR; const { createApp, startServer } = require("../index"); const dbModule = require("../db"); const { db, stmts } = dbModule; const TranscriptCache = require("../lib/transcript-cache"); const { parseSessionFile, importSession } = require("../../scripts/import-history"); const enc = (cwd) => cwd.replace(/[^a-zA-Z0-9]/g, "-"); const PROJECTS = path.join(CLAUDE_HOME, "projects"); function jsonl(lines) { return lines.map((o) => JSON.stringify(o)).join("\n") + "\n"; } function transcriptPath(cwd, sessionId) { return path.join(PROJECTS, enc(cwd), `${sessionId}.jsonl`); } function writeTranscript(cwd, sessionId, lines) { const p = transcriptPath(cwd, sessionId); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, jsonl(lines)); return p; } function appendTranscript(cwd, sessionId, lines) { const p = transcriptPath(cwd, sessionId); fs.appendFileSync(p, jsonl(lines)); return p; } function req(method, urlPath, body) { return new Promise((resolve, reject) => { const url = new URL(urlPath, BASE); const payload = body ? JSON.stringify(body) : null; const r = http.request( { hostname: url.hostname, port: url.port, path: url.pathname + url.search, method, headers: payload ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) } : {}, }, (res) => { let b = ""; res.on("data", (c) => (b += c)); res.on("end", () => { let parsed; try { parsed = JSON.parse(b || "{}"); } catch { parsed = b; } resolve({ status: res.statusCode, body: parsed }); }); } ); r.on("error", reject); if (payload) r.write(payload); r.end(); }); } let server; let BASE; before(async () => { const app = createApp(); server = await startServer(app, 0); BASE = `http://127.0.0.1:${server.address().port}`; }); after(() => { if (server) server.close(); if (db) db.close(); try { fs.rmSync(TMP, { recursive: true, force: true }); } catch { /* ignore */ } }); describe("TranscriptCache — first user message extraction", () => { it("captures the first real prompt, skipping caveat/command/tool-result entries", () => { const cwd = "/tmp/fud-cache-skip"; const sid = "cache-skip"; const p = writeTranscript(cwd, sid, [ { type: "user", isMeta: true, message: { role: "user", content: "Caveat: local commands", }, }, { type: "user", message: { role: "user", content: "/model" }, }, { type: "user", message: { role: "user", content: "Set model", }, }, { type: "user", message: { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "result text" }], }, }, { type: "user", message: { role: "user", content: "fix the login\nbug in auth.ts" }, }, { type: "user", message: { role: "user", content: "a later prompt" } }, ]); const r = new TranscriptCache().extract(p); assert.ok(r, "result should not be null"); // Whitespace runs / newlines collapse to single spaces; first prompt wins. assert.equal(r.firstUserMessage, "fix the login bug in auth.ts"); }); it("skips user-interrupt entries and caps the captured text at 500 chars", () => { const cwd = "/tmp/fud-cache-cap"; const sid = "cache-cap"; const long = "x".repeat(600); const p = writeTranscript(cwd, sid, [ { type: "user", message: { role: "user", content: [{ type: "text", text: "[Request interrupted by user]" }], }, }, { type: "user", message: { role: "user", content: long } }, ]); const r = new TranscriptCache().extract(p); assert.equal(r.firstUserMessage, "x".repeat(500)); }); it("returns a result for a transcript that has ONLY a user prompt", () => { const cwd = "/tmp/fud-cache-only"; const sid = "cache-only"; const p = writeTranscript(cwd, sid, [ { type: "user", message: { role: "user", content: "just a prompt" } }, ]); const r = new TranscriptCache().extract(p); assert.ok(r, "result should not be null"); assert.equal(r.firstUserMessage, "just a prompt"); }); it("keeps the FIRST prompt across incremental appends (first wins)", () => { const cwd = "/tmp/fud-cache-incr"; const sid = "cache-incr"; const cache = new TranscriptCache(); const p = writeTranscript(cwd, sid, [ { type: "user", message: { role: "user", content: "original prompt" } }, ]); assert.equal(cache.extract(p).firstUserMessage, "original prompt"); appendTranscript(cwd, sid, [ { type: "user", message: { role: "user", content: "second prompt" } }, ]); // Force a fresh stat (mtime granularity) by touching size — append above // already grew the file, so the incremental path runs. assert.equal(cache.extract(p).firstUserMessage, "original prompt"); }); }); describe("hook ingestor — descriptor fills placeholders", () => { it("fills placeholder session name and main agent name/task on UserPromptSubmit", async () => { const cwd = "/tmp/fud-hook-fill"; const sid = "10000000-0000-0000-0000-000000000001"; const tpath = writeTranscript(cwd, sid, [ { type: "user", message: { role: "user", content: "add dark mode to settings" } }, ]); const res = await req("POST", "/api/hooks/event", { hook_type: "UserPromptSubmit", data: { session_id: sid, cwd, transcript_path: tpath }, }); assert.equal(res.status, 200); const sess = stmts.getSession.get(sid); assert.equal(sess.name, "add dark mode to settings"); const main = stmts.getAgent.get(`${sid}-main`); assert.equal(main.name, "Main Agent - add dark mode to settings"); assert.equal(main.task, "add dark mode to settings"); }); it("truncates long prompts to 60 chars for names but keeps the task longer", async () => { const cwd = "/tmp/fud-hook-trunc"; const sid = "10000000-0000-0000-0000-000000000002"; const prompt = "p".repeat(100); const tpath = writeTranscript(cwd, sid, [ { type: "user", message: { role: "user", content: prompt } }, ]); await req("POST", "/api/hooks/event", { hook_type: "Stop", data: { session_id: sid, cwd, transcript_path: tpath }, }); const sess = stmts.getSession.get(sid); assert.equal(sess.name, "p".repeat(57) + "..."); const main = stmts.getAgent.get(`${sid}-main`); assert.equal(main.name, `Main Agent - ${"p".repeat(57)}...`); assert.equal(main.task, prompt); }); it("title still wins: ai-title takes the session name, descriptor fills the agent", async () => { const cwd = "/tmp/fud-hook-title"; const sid = "10000000-0000-0000-0000-000000000003"; const tpath = writeTranscript(cwd, sid, [ { type: "user", message: { role: "user", content: "investigate the flaky test" } }, { type: "ai-title", aiTitle: "Flaky test investigation", sessionId: sid }, ]); await req("POST", "/api/hooks/event", { hook_type: "Stop", data: { session_id: sid, cwd, transcript_path: tpath }, }); const sess = stmts.getSession.get(sid); assert.equal(sess.name, "Flaky test investigation"); const main = stmts.getAgent.get(`${sid}-main`); assert.equal(main.name, "Main Agent - investigate the flaky test"); assert.equal(main.task, "investigate the flaky test"); }); it("a later ai-title replaces a descriptor-filled session name", async () => { const cwd = "/tmp/fud-hook-later-title"; const sid = "10000000-0000-0000-0000-000000000004"; const tpath = writeTranscript(cwd, sid, [ { type: "user", message: { role: "user", content: "refactor the websocket layer" } }, ]); await req("POST", "/api/hooks/event", { hook_type: "UserPromptSubmit", data: { session_id: sid, cwd, transcript_path: tpath }, }); assert.equal(stmts.getSession.get(sid).name, "refactor the websocket layer"); appendTranscript(cwd, sid, [ { type: "ai-title", aiTitle: "WebSocket refactor", sessionId: sid }, ]); await req("POST", "/api/hooks/event", { hook_type: "Stop", data: { session_id: sid, cwd, transcript_path: tpath }, }); assert.equal(stmts.getSession.get(sid).name, "WebSocket refactor"); }); it("never clobbers a user-set session name or a renamed main agent", async () => { const cwd = "/tmp/fud-hook-user-set"; const sid = "10000000-0000-0000-0000-000000000005"; const tpath = writeTranscript(cwd, sid, [ { type: "user", message: { role: "user", content: "descriptor text" } }, ]); // Seed the session, then simulate user-chosen names on both rows. await req("POST", "/api/hooks/event", { hook_type: "SessionStart", data: { session_id: sid, cwd }, }); stmts.updateSessionName.run("my chosen name", sid, "my chosen name"); stmts.updateAgent.run("My Renamed Agent", null, "my task", null, null, null, `${sid}-main`); await req("POST", "/api/hooks/event", { hook_type: "Stop", data: { session_id: sid, cwd, transcript_path: tpath }, }); assert.equal(stmts.getSession.get(sid).name, "my chosen name"); const main = stmts.getAgent.get(`${sid}-main`); assert.equal(main.name, "My Renamed Agent"); assert.equal(main.task, "my task"); }); it("preserves an in-flight current_tool when filling the main agent", async () => { const cwd = "/tmp/fud-hook-tool"; const sid = "10000000-0000-0000-0000-000000000006"; const tpath = writeTranscript(cwd, sid, [ { type: "user", message: { role: "user", content: "run the test suite" } }, ]); // PreToolUse stamps current_tool = Bash in the same transaction that then // applies the descriptor — the fill must not wipe the in-flight tool. await req("POST", "/api/hooks/event", { hook_type: "PreToolUse", data: { session_id: sid, cwd, transcript_path: tpath, tool_name: "Bash" }, }); const main = stmts.getAgent.get(`${sid}-main`); assert.equal(main.current_tool, "Bash"); assert.equal(main.name, "Main Agent - run the test suite"); assert.equal(main.task, "run the test suite"); }); it("is idempotent — a second event changes nothing", async () => { const cwd = "/tmp/fud-hook-idem"; const sid = "10000000-0000-0000-0000-000000000007"; const tpath = writeTranscript(cwd, sid, [ { type: "user", message: { role: "user", content: "one prompt only" } }, ]); await req("POST", "/api/hooks/event", { hook_type: "UserPromptSubmit", data: { session_id: sid, cwd, transcript_path: tpath }, }); const before = { session: stmts.getSession.get(sid).name, agent: stmts.getAgent.get(`${sid}-main`), }; await req("POST", "/api/hooks/event", { hook_type: "Stop", data: { session_id: sid, cwd, transcript_path: tpath }, }); assert.equal(stmts.getSession.get(sid).name, before.session); const main = stmts.getAgent.get(`${sid}-main`); assert.equal(main.name, before.agent.name); assert.equal(main.task, before.agent.task); }); }); describe("import — descriptor for imported sessions", () => { it("parseSessionFile falls back to the first user prompt below titles", async () => { const cwd = "/tmp/fud-import-parse"; const sid = "20000000-0000-0000-0000-000000000001"; const p = writeTranscript(cwd, sid, [ { type: "user", cwd, timestamp: "2026-01-01T00:00:00.000Z", message: { role: "user", content: "build the CSV exporter" }, }, ]); const parsed = await parseSessionFile(p); assert.equal(parsed.firstUserMessage, "build the CSV exporter"); assert.equal(parsed.name, "build the CSV exporter"); }); it("a real title still outranks the descriptor in parseSessionFile", async () => { const cwd = "/tmp/fud-import-title"; const sid = "20000000-0000-0000-0000-000000000002"; const p = writeTranscript(cwd, sid, [ { type: "user", cwd, timestamp: "2026-01-01T00:00:00.000Z", message: { role: "user", content: "some prompt" }, }, { type: "ai-title", aiTitle: "Real title", sessionId: sid }, ]); const parsed = await parseSessionFile(p); assert.equal(parsed.name, "Real title"); assert.equal(parsed.firstUserMessage, "some prompt"); }); it("importSession names new rows and their main agent from the descriptor", async () => { const cwd = "/tmp/fud-import-new"; const sid = "20000000-0000-0000-0000-000000000003"; const p = writeTranscript(cwd, sid, [ { type: "user", cwd, timestamp: "2026-01-01T00:00:00.000Z", message: { role: "user", content: "wire up the pricing table" }, }, ]); const parsed = await parseSessionFile(p); importSession(dbModule, parsed); const sess = stmts.getSession.get(sid); assert.equal(sess.name, "wire up the pricing table"); const main = stmts.getAgent.get(`${sid}-main`); assert.equal(main.name, "Main Agent - wire up the pricing table"); assert.equal(main.task, "wire up the pricing table"); }); it("re-import backfills placeholder-named rows created by earlier imports", async () => { const cwd = "/tmp/fud-import-backfill"; const sid = "20000000-0000-0000-0000-000000000004"; const p = writeTranscript(cwd, sid, [ { type: "user", cwd, timestamp: "2026-01-01T00:00:00.000Z", message: { role: "user", content: "migrate the alerts schema" }, }, ]); // Simulate an old import: cwd-derived placeholder session + agent names. // metadata.imported = true is what routes importSession to its backfill // branch (hand-created sessions are never touched by re-imports). const base = path.basename(cwd); stmts.insertSession.run( sid, `${base} - ${sid.slice(0, 8)}`, "completed", cwd, null, JSON.stringify({ imported: true }) ); stmts.insertAgent.run( `${sid}-main`, sid, `Main Agent - ${base} - ${sid.slice(0, 8)}`, "main", null, "completed", null, null, null ); const parsed = await parseSessionFile(p); const result = importSession(dbModule, parsed); assert.equal(result.backfilled, true); assert.equal(stmts.getSession.get(sid).name, "migrate the alerts schema"); const main = stmts.getAgent.get(`${sid}-main`); assert.equal(main.name, "Main Agent - migrate the alerts schema"); assert.equal(main.task, "migrate the alerts schema"); }); it("re-import keeps user-picked names intact", async () => { const cwd = "/tmp/fud-import-keep"; const sid = "20000000-0000-0000-0000-000000000005"; const p = writeTranscript(cwd, sid, [ { type: "user", cwd, timestamp: "2026-01-01T00:00:00.000Z", message: { role: "user", content: "descriptor that must not apply" }, }, ]); stmts.insertSession.run( sid, "picked by hand", "completed", cwd, null, JSON.stringify({ imported: true }) ); stmts.insertAgent.run( `${sid}-main`, sid, "Main Agent - picked by hand", "main", null, "completed", "hand-written task", null, null ); const parsed = await parseSessionFile(p); importSession(dbModule, parsed); assert.equal(stmts.getSession.get(sid).name, "picked by hand"); const main = stmts.getAgent.get(`${sid}-main`); assert.equal(main.name, "Main Agent - picked by hand"); assert.equal(main.task, "hand-written task"); }); });