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.
417 lines
16 KiB
JavaScript
417 lines
16 KiB
JavaScript
/**
|
|
* @file Tests for human-readable session names and the transcript rename
|
|
* marker. Covers:
|
|
* - TranscriptCache surfacing the latest custom-title / ai-title.
|
|
* - The hook ingestor syncing sessions.name from the transcript title, with
|
|
* custom-title winning and ai-title only filling placeholder names.
|
|
* - GET /:id/transcript surfacing custom-title (/rename) as a synthetic
|
|
* `session_event` marker, deduped, with ai-title excluded.
|
|
* - GET /:id/transcript surfacing mid-turn queued user messages
|
|
* (attachment/queued_command) as user rows, with queue-operation
|
|
* bookkeeping and other attachment subtypes dropped.
|
|
* 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 = `sess-name-${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 { db, stmts } = require("../db");
|
|
const TranscriptCache = require("../lib/transcript-cache");
|
|
|
|
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 writeTranscript(cwd, sessionId, lines) {
|
|
const p = path.join(PROJECTS, enc(cwd), `${sessionId}.jsonl`);
|
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
fs.writeFileSync(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 — title extraction", () => {
|
|
it("returns the latest custom-title and ai-title (last value wins)", () => {
|
|
const cwd = "/tmp/cam-name-cache";
|
|
const sid = "cache-titles";
|
|
const p = writeTranscript(cwd, sid, [
|
|
{ type: "ai-title", aiTitle: "Auto one", sessionId: sid },
|
|
{ type: "user", message: { role: "user", content: "hi" } },
|
|
{ type: "ai-title", aiTitle: "Auto two", sessionId: sid },
|
|
{ type: "custom-title", customTitle: "my-feature", sessionId: sid },
|
|
]);
|
|
const r = new TranscriptCache().extract(p);
|
|
assert.equal(r.customTitle, "my-feature");
|
|
assert.equal(r.aiTitle, "Auto two");
|
|
});
|
|
|
|
it("returns a result for a transcript that has ONLY a title line", () => {
|
|
const cwd = "/tmp/cam-name-only";
|
|
const sid = "only-title";
|
|
const p = writeTranscript(cwd, sid, [
|
|
{ type: "custom-title", customTitle: "title-only", sessionId: sid },
|
|
]);
|
|
const r = new TranscriptCache().extract(p);
|
|
assert.ok(r, "result should not be null");
|
|
assert.equal(r.customTitle, "title-only");
|
|
});
|
|
});
|
|
|
|
describe("hook ingestor — sessions.name sync from transcript", () => {
|
|
it("sets the name to the custom-title on the next hook event", async () => {
|
|
const cwd = "/tmp/cam-name-custom";
|
|
const sid = "11111111-2222-3333-4444-555555555555";
|
|
const tpath = writeTranscript(cwd, sid, [
|
|
{ type: "user", message: { role: "user", content: "hello" } },
|
|
{ type: "custom-title", customTitle: "auth-refactor", sessionId: sid },
|
|
]);
|
|
const res = await req("POST", "/api/hooks/event", {
|
|
hook_type: "Stop",
|
|
data: { session_id: sid, cwd, transcript_path: tpath },
|
|
});
|
|
assert.equal(res.status, 200);
|
|
const row = stmts.getSession.get(sid);
|
|
assert.equal(row.name, "auth-refactor");
|
|
});
|
|
|
|
it("fills a placeholder name with the ai-title when there is no custom-title", async () => {
|
|
const cwd = "/tmp/cam-name-ai";
|
|
const sid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
|
const tpath = writeTranscript(cwd, sid, [
|
|
{ type: "user", message: { role: "user", content: "hello" } },
|
|
{ type: "ai-title", aiTitle: "Investigate flaky test", sessionId: sid },
|
|
]);
|
|
await req("POST", "/api/hooks/event", {
|
|
hook_type: "Stop",
|
|
data: { session_id: sid, cwd, transcript_path: tpath },
|
|
});
|
|
const row = stmts.getSession.get(sid);
|
|
assert.equal(row.name, "Investigate flaky test");
|
|
});
|
|
|
|
it("does not let an ai-title clobber a user-chosen name, but a custom-title does", async () => {
|
|
const cwd = "/tmp/cam-name-precedence";
|
|
const sid = "99999999-8888-7777-6666-555555555555";
|
|
// Seed a real, user-chosen name via the sessions API.
|
|
await req("POST", "/api/sessions", { id: sid, name: "keep-me", cwd });
|
|
|
|
// An ai-title must NOT overwrite the user-chosen name.
|
|
let tpath = writeTranscript(cwd, sid, [
|
|
{ type: "user", message: { role: "user", content: "x" } },
|
|
{ type: "ai-title", aiTitle: "Auto generated", 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, "keep-me");
|
|
|
|
// But an explicit /rename (custom-title) always wins.
|
|
tpath = writeTranscript(cwd, sid, [
|
|
{ type: "user", message: { role: "user", content: "x" } },
|
|
{ type: "ai-title", aiTitle: "Auto generated", sessionId: sid },
|
|
{ type: "custom-title", customTitle: "renamed-explicitly", 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, "renamed-explicitly");
|
|
});
|
|
});
|
|
|
|
describe("GET /:id/transcript — rename markers", () => {
|
|
it("surfaces custom-title as a deduped session_event and excludes ai-title", async () => {
|
|
const cwd = "/tmp/cam-rename-marker";
|
|
const sid = "deadbeef-0000-1111-2222-333333333333";
|
|
writeTranscript(cwd, sid, [
|
|
{ type: "user", message: { role: "user", content: "first" } },
|
|
{ type: "ai-title", aiTitle: "noise 1", sessionId: sid },
|
|
{ type: "custom-title", customTitle: "feature-x", sessionId: sid },
|
|
{ type: "ai-title", aiTitle: "noise 2", sessionId: sid },
|
|
// Duplicate custom-title with the SAME value — must be deduped away.
|
|
{ type: "custom-title", customTitle: "feature-x", sessionId: sid },
|
|
{
|
|
type: "assistant",
|
|
message: { role: "assistant", content: [{ type: "text", text: "ok" }] },
|
|
},
|
|
{ type: "custom-title", customTitle: "feature-y", sessionId: sid },
|
|
]);
|
|
// Register the session so the endpoint doesn't 404.
|
|
await req("POST", "/api/sessions", { id: sid, cwd });
|
|
|
|
const res = await req("GET", `/api/sessions/${sid}/transcript?limit=200`);
|
|
assert.equal(res.status, 200);
|
|
const events = res.body.messages.filter((m) => m.type === "session_event");
|
|
const titles = events.map((e) => e.title);
|
|
// feature-x once (dupe collapsed), then feature-y. No ai-title leaks in.
|
|
assert.deepEqual(titles, ["feature-x", "feature-y"]);
|
|
assert.ok(
|
|
events.every((e) => e.event_kind === "rename"),
|
|
"every marker is a rename"
|
|
);
|
|
assert.ok(
|
|
!res.body.messages.some((m) => m.type === "session_event" && /noise/.test(m.title || "")),
|
|
"ai-title values are never surfaced in the transcript stream"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("GET /:id/transcript — local slash-command output (system/local_command)", () => {
|
|
it("surfaces /color command + its stdout, skips empty + noise system lines", async () => {
|
|
const cwd = "/tmp/cam-local-cmd";
|
|
const sid = "c010rrrr-1111-2222-3333-444444444444";
|
|
writeTranscript(cwd, sid, [
|
|
{ type: "user", message: { role: "user", content: "hi" } },
|
|
// /color, current Claude Code shape: command + output as system/local_command
|
|
{
|
|
type: "system",
|
|
subtype: "local_command",
|
|
content:
|
|
"<command-name>/color</command-name>\n <command-message>color</command-message>\n <command-args></command-args>",
|
|
sessionId: sid,
|
|
},
|
|
{ type: "agent-color", agentColor: "cyan", sessionId: sid },
|
|
{
|
|
type: "system",
|
|
subtype: "local_command",
|
|
content: "<local-command-stdout>Session color set to: cyan</local-command-stdout>",
|
|
sessionId: sid,
|
|
},
|
|
// /clear writes a content-less local_command line — must NOT become a row
|
|
{ type: "system", subtype: "local_command", content: "", sessionId: sid },
|
|
// unrelated system subtype — pure noise, must be dropped
|
|
{ type: "system", subtype: "turn_duration", durationMs: 1200, sessionId: sid },
|
|
]);
|
|
await req("POST", "/api/sessions", { id: sid, cwd });
|
|
|
|
const res = await req("GET", `/api/sessions/${sid}/transcript?limit=200`);
|
|
assert.equal(res.status, 200);
|
|
const texts = res.body.messages.flatMap((m) =>
|
|
m.content.filter((c) => c.type === "text").map((c) => c.text)
|
|
);
|
|
assert.ok(
|
|
texts.some((tx) => tx.includes("<command-name>/color</command-name>")),
|
|
"the /color command invocation is surfaced"
|
|
);
|
|
assert.ok(
|
|
texts.some((tx) => tx.includes("Session color set to: cyan")),
|
|
"the /color stdout is surfaced"
|
|
);
|
|
// Empty local_command (/clear) and turn_duration noise produce no message.
|
|
assert.ok(
|
|
!texts.some((tx) => /turn_duration|durationMs/.test(tx)),
|
|
"non-local_command system subtypes are not surfaced"
|
|
);
|
|
// Exactly two surfaced rows from the system lines (command + stdout), plus
|
|
// the one real user message — the empty + noise lines add nothing.
|
|
assert.equal(res.body.messages.length, 3);
|
|
});
|
|
});
|
|
|
|
describe("GET /:id/transcript — mid-turn queued user messages (attachment/queued_command)", () => {
|
|
it("surfaces a queued_command attachment as a user message; drops queue-operation and other attachments", async () => {
|
|
const cwd = "/tmp/cam-queued-msg";
|
|
const sid = "aaaa1111-2222-3333-4444-555566667777";
|
|
writeTranscript(cwd, sid, [
|
|
{ type: "user", message: { role: "user", content: "start the docs sweep" } },
|
|
{
|
|
type: "assistant",
|
|
message: { role: "assistant", content: [{ type: "text", text: "working on it" }] },
|
|
},
|
|
// A message typed while Claude was mid-turn is journaled as queue-operation
|
|
// bookkeeping plus a queued_command attachment — there is NO user line.
|
|
{
|
|
type: "queue-operation",
|
|
operation: "enqueue",
|
|
content: "doc sweep too pls",
|
|
sessionId: sid,
|
|
},
|
|
{
|
|
type: "queue-operation",
|
|
operation: "remove",
|
|
content: "doc sweep too pls",
|
|
sessionId: sid,
|
|
},
|
|
{
|
|
type: "attachment",
|
|
attachment: {
|
|
type: "queued_command",
|
|
prompt: "doc sweep too pls",
|
|
commandMode: "prompt",
|
|
origin: { kind: "human" },
|
|
timestamp: "2026-07-16T02:40:45.596Z",
|
|
},
|
|
timestamp: "2026-07-16T02:40:45.596Z",
|
|
sessionId: sid,
|
|
},
|
|
// Non-queued_command attachments are harness noise — must NOT surface.
|
|
{
|
|
type: "attachment",
|
|
attachment: { type: "task_reminder", content: "reminder noise" },
|
|
sessionId: sid,
|
|
},
|
|
// A content-less queued_command must not become an empty row either.
|
|
{
|
|
type: "attachment",
|
|
attachment: { type: "queued_command", prompt: " ", origin: { kind: "human" } },
|
|
sessionId: sid,
|
|
},
|
|
]);
|
|
await req("POST", "/api/sessions", { id: sid, cwd });
|
|
|
|
const res = await req("GET", `/api/sessions/${sid}/transcript?limit=200`);
|
|
assert.equal(res.status, 200);
|
|
const queued = res.body.messages.filter((m) =>
|
|
m.content.some((c) => c.type === "text" && c.text === "doc sweep too pls")
|
|
);
|
|
assert.equal(queued.length, 1, "the mid-turn message surfaces exactly once");
|
|
assert.equal(queued[0].type, "user");
|
|
assert.equal(
|
|
queued[0].sender,
|
|
"user",
|
|
"a human-typed queued message is attributed to the user"
|
|
);
|
|
assert.equal(queued[0].timestamp, "2026-07-16T02:40:45.596Z");
|
|
assert.ok(
|
|
!res.body.messages.some((m) =>
|
|
m.content.some((c) => c.type === "text" && /reminder noise/.test(c.text || ""))
|
|
),
|
|
"non-queued_command attachments stay hidden"
|
|
);
|
|
// start + working + the queued message; queue-operation lines, the noise
|
|
// attachment, and the blank prompt add nothing.
|
|
assert.equal(res.body.messages.length, 3);
|
|
});
|
|
|
|
it("attributes harness-injected queued_command lines (task-notifications) to system, not the user", async () => {
|
|
const cwd = "/tmp/cam-queued-sys";
|
|
const sid = "bbbb1111-2222-3333-4444-555566667777";
|
|
writeTranscript(cwd, sid, [
|
|
{ type: "user", message: { role: "user", content: "kick off the agents" } },
|
|
// Background-agent task-notification delivered through the SAME queue as
|
|
// typed messages — real shape: attachment has NO origin field at all.
|
|
{
|
|
type: "attachment",
|
|
attachment: {
|
|
type: "queued_command",
|
|
prompt:
|
|
"<task-notification>\n<task-id>a30201bfc90e18271</task-id>\n<status>completed</status>\n</task-notification>",
|
|
commandMode: "prompt",
|
|
timestamp: "2026-07-16T02:47:36.000Z",
|
|
},
|
|
timestamp: "2026-07-16T02:47:36.000Z",
|
|
sessionId: sid,
|
|
},
|
|
// Banner-prefixed variant must also be system.
|
|
{
|
|
type: "attachment",
|
|
attachment: {
|
|
type: "queued_command",
|
|
prompt: "[SYSTEM NOTIFICATION - NOT USER INPUT]\nautomated background-task event",
|
|
commandMode: "prompt",
|
|
},
|
|
sessionId: sid,
|
|
},
|
|
// Explicit non-human origin → system too.
|
|
{
|
|
type: "attachment",
|
|
attachment: {
|
|
type: "queued_command",
|
|
prompt: "sdk enqueued follow-up",
|
|
origin: { kind: "sdk" },
|
|
},
|
|
sessionId: sid,
|
|
},
|
|
// Missing origin but plain human-looking text stays user (older builds).
|
|
{
|
|
type: "attachment",
|
|
attachment: { type: "queued_command", prompt: "and update the docs" },
|
|
sessionId: sid,
|
|
},
|
|
]);
|
|
await req("POST", "/api/sessions", { id: sid, cwd });
|
|
|
|
const res = await req("GET", `/api/sessions/${sid}/transcript?limit=200`);
|
|
assert.equal(res.status, 200);
|
|
const bySnippet = (s) =>
|
|
res.body.messages.find((m) =>
|
|
m.content.some((c) => c.type === "text" && (c.text || "").includes(s))
|
|
);
|
|
assert.equal(bySnippet("<task-notification>").sender, "system");
|
|
assert.equal(bySnippet("[SYSTEM NOTIFICATION").sender, "system");
|
|
assert.equal(bySnippet("sdk enqueued follow-up").sender, "system");
|
|
assert.equal(bySnippet("and update the docs").sender, "user");
|
|
});
|
|
});
|