57dc91585d
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
486 lines
17 KiB
JavaScript
486 lines
17 KiB
JavaScript
/**
|
|
* @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ĩ <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 = `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: "<local-command-caveat>Caveat: local commands</local-command-caveat>",
|
|
},
|
|
},
|
|
{
|
|
type: "user",
|
|
message: { role: "user", content: "<command-name>/model</command-name>" },
|
|
},
|
|
{
|
|
type: "user",
|
|
message: {
|
|
role: "user",
|
|
content: "<local-command-stdout>Set model</local-command-stdout>",
|
|
},
|
|
},
|
|
{
|
|
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");
|
|
});
|
|
});
|