feat: Claude Code Monitor — lanes, pipelines and a merged workspace
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.
This commit is contained in:
@@ -0,0 +1,485 @@
|
||||
/**
|
||||
* @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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user