Files
AgentHub/server/__tests__/api.test.js
T
nntrivi2001 764b967856 fix(dashboard): measure V8 heap against its limit, not its current size
The Health Score's heap component divided heapUsed by heapTotal, which is
only what V8 has grown the heap to so far, so a healthy server read 70-90%
and dragged the score down. GET /api/settings/info now also reports
server.memory.heapLimit (v8 heap_size_limit, the ceiling the process dies
at); the V8 Heap bar and the Health Score divide by it, falling back to
heapTotal against an older server.
2026-09-23 16:46:00 +07:00

2936 lines
105 KiB
JavaScript

/**
* @file Tests for the Dashboard API endpoints, covering session and agent management, event recording, stats aggregation, and hook event processing. Uses Node's built-in test runner and assertions to validate API behavior and edge cases.
* @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 pkg = require("../../package.json");
// Set up test database BEFORE requiring any server modules
const TEST_DB = path.join(os.tmpdir(), `dashboard-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
// Disable the watchdog's process-liveness reap: these suites exercise the
// idle-timeout / interrupt / error-recovery paths with synthetic sessions that
// have no real `claude` process, which the probe would otherwise (correctly)
// complete out from under the assertions. Liveness has its own test file.
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db, stmts } = require("../db");
let server;
let BASE;
const EXPECTED_API_PATHS = [
"/api/health",
"/api/sessions",
"/api/sessions/{id}",
"/api/sessions/facets",
"/api/sessions/{id}/stats",
"/api/sessions/{id}/transcripts",
"/api/sessions/{id}/transcript",
"/api/agents",
"/api/agents/{id}",
"/api/events",
"/api/events/facets",
"/api/stats",
"/api/metrics",
"/api/analytics",
"/api/hooks/event",
"/api/pricing",
"/api/pricing/{pattern}",
"/api/pricing/cost",
"/api/pricing/cost/{sessionId}",
"/api/workflows",
"/api/workflows/session/{id}",
"/api/workflows/runs",
"/api/workflows/runs/{runId}",
"/api/settings/info",
"/api/settings/clear-data",
"/api/settings/reimport",
"/api/settings/reinstall-hooks",
"/api/settings/reset-pricing",
"/api/settings/export",
"/api/settings/cleanup",
"/api/settings/claude-home",
"/api/import/guide",
"/api/import/rescan",
"/api/import/scan-path",
"/api/import/upload",
"/api/updates/status",
"/api/updates/check",
"/api/alerts",
"/api/alerts/rules",
"/api/alerts/rules/{id}",
"/api/alerts/{id}/ack",
"/api/alerts/ack-all",
"/api/push/vapid-public-key",
"/api/push/subscribe",
"/api/push/send",
"/api/cc-config/overview",
"/api/cc-config/skills",
"/api/cc-config/agents",
"/api/cc-config/commands",
"/api/cc-config/output-styles",
"/api/cc-config/plugins",
"/api/cc-config/mcp",
"/api/cc-config/hooks",
"/api/cc-config/settings",
"/api/cc-config/memory",
"/api/cc-config/marketplaces",
"/api/cc-config/keybindings",
"/api/cc-config/statusline",
"/api/cc-config/hook-scripts",
"/api/cc-config/file",
"/api/cc-config/backups",
"/api/run",
"/api/run/history",
"/api/run/cwds",
"/api/run/files",
"/api/run/binary",
"/api/run/{id}",
"/api/run/{id}/message",
"/api/webhooks",
"/api/webhooks/providers",
"/api/webhooks/{id}",
"/api/webhooks/{id}/test",
"/api/webhooks/{id}/deliveries",
"/api/openapi.json",
"/api/docs",
"/api/redoc",
];
function fetch(urlPath, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const opts = {
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: options.method || "GET",
headers: { "Content-Type": "application/json", ...options.headers },
};
const req = http.request(opts, (res) => {
let body = "";
res.on("data", (chunk) => (body += chunk));
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(body);
} catch {
parsed = body;
}
resolve({ status: res.statusCode, body: parsed, headers: res.headers });
});
});
req.on("error", reject);
if (options.body) req.write(JSON.stringify(options.body));
req.end();
});
}
function post(urlPath, body) {
return fetch(urlPath, { method: "POST", body });
}
function patch(urlPath, body) {
return fetch(urlPath, { method: "PATCH", body });
}
before(async () => {
const app = createApp();
server = await startServer(app, 0); // port 0 = random available port
const addr = server.address();
BASE = `http://127.0.0.1:${addr.port}`;
});
after(() => {
if (server) server.close();
if (db) db.close();
try {
fs.unlinkSync(TEST_DB);
fs.unlinkSync(TEST_DB + "-wal");
fs.unlinkSync(TEST_DB + "-shm");
} catch {
// ignore cleanup errors
}
});
// ============================================================
// Health
// ============================================================
describe("GET /api/health", () => {
it("should return ok status", async () => {
const res = await fetch("/api/health");
assert.equal(res.status, 200);
assert.equal(res.body.status, "ok");
assert.equal(res.body.version, pkg.version);
assert.ok(res.body.timestamp);
});
});
describe("OpenAPI / Swagger", () => {
it("should expose OpenAPI spec with complete endpoint coverage", async () => {
const res = await fetch("/api/openapi.json");
assert.equal(res.status, 200);
assert.equal(res.body.openapi, "3.0.3");
assert.equal(res.body.info.version, pkg.version);
// No license block: this is an internal build with no public licence file,
// so the spec must not advertise one (a LICENSE url would 404).
assert.equal(res.body.info.license, undefined);
assert.equal(res.body["x-issues-url"], pkg.bugs.url);
assert.match(res.body.info.contact.url, /git\.smartgift\.io\.vn\/Smartgift-AI\/AgentHub/);
for (const pathName of EXPECTED_API_PATHS) {
assert.ok(res.body.paths[pathName], `Expected path ${pathName} to be documented`);
}
});
it("should serve Swagger UI", async () => {
const res = await fetch("/api/docs/");
assert.equal(res.status, 200);
assert.match(res.headers["content-type"], /text\/html/);
assert.match(res.body, /swagger/i);
});
it("should serve the ReDoc reference page", async () => {
const res = await fetch("/api/redoc");
assert.equal(res.status, 200);
assert.match(res.headers["content-type"], /text\/html/);
// References the spec and the locally-served bundle (no CDN).
assert.match(res.body, /spec-url="\/api\/openapi\.json"/);
assert.match(res.body, /src="\/api\/redoc\/redoc\.standalone\.js"/);
});
it("should serve the self-hosted ReDoc bundle", async () => {
const res = await fetch("/api/redoc/redoc.standalone.js");
assert.equal(res.status, 200);
assert.match(res.headers["content-type"], /javascript/);
});
});
// ============================================================
// Sessions CRUD
// ============================================================
describe("Sessions API", () => {
it("should create a session", async () => {
const res = await post("/api/sessions", {
id: "sess-1",
name: "Test Session",
cwd: "/home/test",
model: "claude-opus-4-6",
});
assert.equal(res.status, 201);
assert.equal(res.body.session.id, "sess-1");
assert.equal(res.body.session.name, "Test Session");
assert.equal(res.body.session.status, "active");
assert.equal(res.body.session.cwd, "/home/test");
assert.equal(res.body.created, true);
});
it("should return existing session on duplicate create (idempotent)", async () => {
const res = await post("/api/sessions", {
id: "sess-1",
name: "Different Name",
});
assert.equal(res.status, 200);
assert.equal(res.body.session.name, "Test Session"); // original name preserved
assert.equal(res.body.created, false);
});
it("should reject session without id", async () => {
const res = await post("/api/sessions", { name: "No ID" });
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "INVALID_INPUT");
});
it("should get a session by id", async () => {
const res = await fetch("/api/sessions/sess-1");
assert.equal(res.status, 200);
assert.equal(res.body.session.id, "sess-1");
assert.ok(Array.isArray(res.body.agents));
assert.ok(Array.isArray(res.body.events));
});
it("should return 404 for nonexistent session", async () => {
const res = await fetch("/api/sessions/nonexistent");
assert.equal(res.status, 404);
assert.equal(res.body.error.code, "NOT_FOUND");
});
it("should list sessions", async () => {
await post("/api/sessions", { id: "sess-2", name: "Session Two" });
const res = await fetch("/api/sessions");
assert.equal(res.status, 200);
assert.ok(res.body.sessions.length >= 2);
});
it("should filter sessions by status", async () => {
const res = await fetch("/api/sessions?status=active");
assert.equal(res.status, 200);
res.body.sessions.forEach((s) => assert.equal(s.status, "active"));
});
it("should paginate sessions", async () => {
const res = await fetch("/api/sessions?limit=1&offset=0");
assert.equal(res.body.sessions.length, 1);
assert.equal(res.body.limit, 1);
assert.equal(res.body.offset, 0);
});
it("should update a session", async () => {
const res = await patch("/api/sessions/sess-1", {
status: "completed",
ended_at: new Date().toISOString(),
});
assert.equal(res.status, 200);
assert.equal(res.body.session.status, "completed");
assert.ok(res.body.session.ended_at);
});
it("should return 404 when updating nonexistent session", async () => {
const res = await patch("/api/sessions/nonexistent", { status: "error" });
assert.equal(res.status, 404);
});
});
// ============================================================
// Agents CRUD
// ============================================================
describe("Agents API", () => {
it("should create an agent", async () => {
const res = await post("/api/agents", {
id: "agent-1",
session_id: "sess-2",
name: "Main Agent",
type: "main",
status: "working",
});
assert.equal(res.status, 201);
assert.equal(res.body.agent.id, "agent-1");
assert.equal(res.body.agent.name, "Main Agent");
assert.equal(res.body.agent.type, "main");
assert.equal(res.body.created, true);
});
it("should return existing agent on duplicate create (idempotent)", async () => {
const res = await post("/api/agents", {
id: "agent-1",
session_id: "sess-2",
name: "Different",
});
assert.equal(res.status, 200);
assert.equal(res.body.agent.name, "Main Agent");
assert.equal(res.body.created, false);
});
it("should reject agent without required fields", async () => {
const res = await post("/api/agents", { id: "x" });
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "INVALID_INPUT");
});
it("should create a subagent with parent", async () => {
const res = await post("/api/agents", {
id: "agent-2",
session_id: "sess-2",
name: "Explorer",
type: "subagent",
subagent_type: "Explore",
status: "working",
task: "Searching for patterns",
parent_agent_id: "agent-1",
});
assert.equal(res.status, 201);
assert.equal(res.body.agent.type, "subagent");
assert.equal(res.body.agent.subagent_type, "Explore");
assert.equal(res.body.agent.parent_agent_id, "agent-1");
});
it("should get an agent by id", async () => {
const res = await fetch("/api/agents/agent-1");
assert.equal(res.status, 200);
assert.equal(res.body.agent.id, "agent-1");
});
it("should return 404 for nonexistent agent", async () => {
const res = await fetch("/api/agents/nonexistent");
assert.equal(res.status, 404);
});
it("should list all agents", async () => {
const res = await fetch("/api/agents");
assert.ok(res.body.agents.length >= 2);
});
it("should filter agents by status", async () => {
const res = await fetch("/api/agents?status=working");
assert.equal(res.status, 200);
res.body.agents.forEach((a) => assert.equal(a.status, "working"));
});
it("should filter agents by session_id", async () => {
const res = await fetch("/api/agents?session_id=sess-2");
assert.equal(res.status, 200);
res.body.agents.forEach((a) => assert.equal(a.session_id, "sess-2"));
});
it("should update an agent", async () => {
const res = await patch("/api/agents/agent-1", {
status: "working",
current_tool: "Bash",
task: "Running tests",
});
assert.equal(res.status, 200);
assert.equal(res.body.agent.status, "working");
assert.equal(res.body.agent.current_tool, "Bash");
assert.equal(res.body.agent.task, "Running tests");
});
it("should clear current_tool on update", async () => {
const res = await patch("/api/agents/agent-1", {
status: "working",
current_tool: null,
});
assert.equal(res.status, 200);
assert.equal(res.body.agent.current_tool, null);
});
it("should return 404 when updating nonexistent agent", async () => {
const res = await patch("/api/agents/nonexistent", { status: "error" });
assert.equal(res.status, 404);
});
});
// ============================================================
// Events
// ============================================================
describe("Events API", () => {
it("should list events (empty initially)", async () => {
const res = await fetch("/api/events");
assert.equal(res.status, 200);
assert.ok(Array.isArray(res.body.events));
});
it("should respect limit parameter", async () => {
const res = await fetch("/api/events?limit=5");
assert.equal(res.status, 200);
assert.ok(res.body.events.length <= 5);
});
it("should return a total count matching the filter", async () => {
const res = await fetch("/api/events?limit=1");
assert.equal(res.status, 200);
assert.equal(typeof res.body.total, "number");
});
it("should cap limit at 500", async () => {
const res = await fetch("/api/events?limit=999999");
assert.equal(res.status, 200);
assert.equal(res.body.limit, 500);
});
});
// ============================================================
// Events filtering
// ============================================================
describe("Events API — filters", () => {
// Seed events across two sessions, two agents, two tools, two event types.
const SESSION_A = "filter-sess-a";
const SESSION_B = "filter-sess-b";
const AGENT_A = `${SESSION_A}-main`;
const AGENT_B = `${SESSION_B}-main`;
before(async () => {
// Bootstrap the sessions/agents via hook events so agent rows exist.
await post("/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: SESSION_A, cwd: "/a" },
});
await post("/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: SESSION_B, cwd: "/b" },
});
const now = Date.now();
const mk = (offsetSec, session, agent, type, tool, summary, data) => {
stmts.insertEvent.run(
session,
agent,
type,
tool,
summary,
JSON.stringify(data || { session_id: session })
);
// Backdate created_at deterministically so date-range filters work.
const ts = new Date(now - offsetSec * 1000).toISOString().replace("Z", "000Z");
db.prepare(
"UPDATE events SET created_at = ? WHERE id = (SELECT MAX(id) FROM events WHERE session_id = ?)"
).run(ts, session);
};
mk(100, SESSION_A, AGENT_A, "PreToolUse", "Bash", "run curl", { command: "curl https://x" });
mk(80, SESSION_A, AGENT_A, "PostToolUse", "Bash", "ran curl", { stdout: "ok" });
mk(60, SESSION_A, AGENT_A, "PreToolUse", "Edit", "edit file", { file_path: "/x.ts" });
mk(40, SESSION_B, AGENT_B, "PreToolUse", "Read", "read file", { file_path: "/y.ts" });
mk(20, SESSION_B, AGENT_B, "Stop", null, "session stopped", null);
});
it("filters by single event_type", async () => {
const res = await fetch("/api/events?event_type=Stop&session_id=filter-sess-b");
assert.equal(res.status, 200);
assert.ok(res.body.events.every((e) => e.event_type === "Stop"));
assert.ok(res.body.total >= 1);
});
it("filters by csv event_type (multi-select)", async () => {
const res = await fetch(
`/api/events?event_type=PreToolUse,PostToolUse&session_id=${SESSION_A}`
);
assert.equal(res.status, 200);
assert.ok(
res.body.events.every((e) => e.event_type === "PreToolUse" || e.event_type === "PostToolUse")
);
});
it("filters by tool_name", async () => {
const res = await fetch(`/api/events?tool_name=Bash&session_id=${SESSION_A}`);
assert.equal(res.status, 200);
assert.ok(res.body.events.every((e) => e.tool_name === "Bash"));
});
it("filters by session_id csv", async () => {
const res = await fetch(`/api/events?session_id=${SESSION_A},${SESSION_B}`);
assert.equal(res.status, 200);
assert.ok(
res.body.events.every((e) => e.session_id === SESSION_A || e.session_id === SESSION_B)
);
});
it("filters by agent_id", async () => {
const res = await fetch(`/api/events?agent_id=${AGENT_A}`);
assert.equal(res.status, 200);
assert.ok(res.body.events.every((e) => e.agent_id === AGENT_A));
});
it("filters by text search (q) across summary and data", async () => {
const res = await fetch(`/api/events?q=curl&session_id=${SESSION_A}`);
assert.equal(res.status, 200);
assert.ok(res.body.events.length >= 1);
assert.ok(
res.body.events.every(
(e) =>
(e.summary && e.summary.includes("curl")) ||
(e.data && e.data.includes("curl")) ||
(e.tool_name && e.tool_name.includes("curl"))
)
);
});
it("filters by date range (from / to)", async () => {
const from = new Date(Date.now() - 50 * 1000).toISOString();
const res = await fetch(`/api/events?from=${encodeURIComponent(from)}&session_id=${SESSION_B}`);
assert.equal(res.status, 200);
for (const e of res.body.events) {
assert.ok(e.created_at >= from, `event ${e.id} older than from bound`);
}
});
it("combines multiple filters with AND semantics", async () => {
const res = await fetch(
`/api/events?event_type=PreToolUse&tool_name=Bash&session_id=${SESSION_A}`
);
assert.equal(res.status, 200);
assert.ok(
res.body.events.every((e) => e.event_type === "PreToolUse" && e.tool_name === "Bash")
);
});
it("ignores malformed date values rather than rejecting the request", async () => {
const res = await fetch("/api/events?from=not-a-date&limit=1");
assert.equal(res.status, 200);
assert.ok(Array.isArray(res.body.events));
});
it("returns distinct event_types and tool_names from /facets", async () => {
const res = await fetch("/api/events/facets");
assert.equal(res.status, 200);
assert.ok(Array.isArray(res.body.event_types));
assert.ok(Array.isArray(res.body.tool_names));
assert.ok(res.body.event_types.includes("PreToolUse"));
assert.ok(res.body.tool_names.includes("Bash"));
});
});
// ============================================================
// Stats
// ============================================================
describe("Stats API", () => {
it("should return aggregate statistics", async () => {
const res = await fetch("/api/stats");
assert.equal(res.status, 200);
assert.equal(typeof res.body.total_sessions, "number");
assert.equal(typeof res.body.active_sessions, "number");
assert.equal(typeof res.body.active_agents, "number");
assert.equal(typeof res.body.total_agents, "number");
assert.equal(typeof res.body.total_events, "number");
assert.equal(typeof res.body.events_today, "number");
assert.equal(typeof res.body.ws_connections, "number");
assert.equal(typeof res.body.agents_by_status, "object");
assert.equal(typeof res.body.sessions_by_status, "object");
});
it("should reflect created data in stats", async () => {
const res = await fetch("/api/stats");
assert.ok(res.body.total_sessions >= 2);
assert.ok(res.body.total_agents >= 2);
});
});
// ============================================================
// Hook Event Processing
// ============================================================
describe("Hook Event Processing", () => {
it("should reject missing hook_type", async () => {
const res = await post("/api/hooks/event", { data: { session_id: "x" } });
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "INVALID_INPUT");
});
it("should reject missing data", async () => {
const res = await post("/api/hooks/event", { hook_type: "PreToolUse" });
assert.equal(res.status, 400);
});
it("should reject missing session_id in data", async () => {
const res = await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: { tool_name: "Bash" },
});
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "MISSING_SESSION");
});
it("stores a hook delivered twice only once (double registration)", async () => {
const hook = {
hook_type: "PreToolUse",
data: {
session_id: "hook-dup-1",
tool_name: "Bash",
tool_use_id: "toolu_dup_1",
tool_input: { command: "ls", description: "List files" },
},
};
const first = await post("/api/hooks/event", hook);
const second = await post("/api/hooks/event", hook);
assert.equal(first.status, 200);
assert.equal(second.status, 200);
assert.equal(second.body.duplicate, true);
const { n } = db
.prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = 'hook-dup-1'")
.get();
assert.equal(n, 1);
// A DIFFERENT payload in the same session still lands.
await post("/api/hooks/event", {
...hook,
data: { ...hook.data, tool_use_id: "toolu_dup_2" },
});
const after = db
.prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = 'hook-dup-1'")
.get();
assert.equal(after.n, 2);
});
it("should auto-create session and main agent on first PreToolUse", async () => {
const res = await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: {
session_id: "hook-sess-1",
tool_name: "Read",
tool_input: { file_path: "/test.ts" },
},
});
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
assert.equal(res.body.event.event_type, "PreToolUse");
assert.equal(res.body.event.tool_name, "Read");
// Verify session was created
const sessRes = await fetch("/api/sessions/hook-sess-1");
assert.equal(sessRes.status, 200);
assert.equal(sessRes.body.session.status, "active");
// Verify main agent was created
const agentRes = await fetch("/api/agents/hook-sess-1-main");
assert.equal(agentRes.status, 200);
assert.equal(agentRes.body.agent.type, "main");
assert.equal(agentRes.body.agent.status, "working");
assert.equal(agentRes.body.agent.current_tool, "Read");
});
it("should keep main agent working on PostToolUse and clear current_tool", async () => {
const res = await post("/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "hook-sess-1",
tool_name: "Read",
},
});
assert.equal(res.status, 200);
const agentRes = await fetch("/api/agents/hook-sess-1-main");
// Status stays "working" — only Stop transitions it
assert.equal(agentRes.body.agent.status, "working");
assert.equal(agentRes.body.agent.current_tool, null);
});
it("should create subagent when Agent tool is used", async () => {
const res = await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: {
session_id: "hook-sess-1",
tool_name: "Agent",
tool_input: {
description: "Search codebase",
subagent_type: "Explore",
prompt: "Find all TypeScript files with error handling",
},
},
});
assert.equal(res.status, 200);
assert.ok(res.body.event.summary.includes("Subagent spawned"));
// Verify subagent exists
const agentsRes = await fetch("/api/agents?session_id=hook-sess-1");
const subagents = agentsRes.body.agents.filter((a) => a.type === "subagent");
assert.ok(subagents.length >= 1);
const sub = subagents[0];
assert.equal(sub.name, "Search codebase");
assert.equal(sub.subagent_type, "Explore");
assert.equal(sub.status, "working");
assert.ok(sub.task.includes("Find all TypeScript"));
assert.equal(sub.parent_agent_id, "hook-sess-1-main");
});
it("should mark subagent completed on SubagentStop", async () => {
const res = await post("/api/hooks/event", {
hook_type: "SubagentStop",
data: { session_id: "hook-sess-1" },
});
assert.equal(res.status, 200);
const agentsRes = await fetch("/api/agents?session_id=hook-sess-1");
const subagents = agentsRes.body.agents.filter((a) => a.type === "subagent");
const completed = subagents.filter((a) => a.status === "completed");
assert.ok(completed.length >= 1);
assert.ok(completed[0].ended_at);
});
it("SubagentStop attributes subagent tokens to their own model via the HTTP path (issue #185)", async () => {
// End-to-end through the /event handler (the unit tests call the importer
// directly, so this guards the handler wiring — e.g. parentModels scope).
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "hooks-sa-185-"));
const sid = "hook-sess-185";
const mainPath = path.join(baseDir, `${sid}.jsonl`);
// Main transcript: orchestrator on Opus.
fs.writeFileSync(
mainPath,
[
{
type: "assistant",
timestamp: "2026-05-01T09:00:00.000Z",
message: {
model: "claude-opus-4-8",
content: [{ type: "text", text: "orchestrating" }],
usage: {
input_tokens: 100,
output_tokens: 50,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
},
]
.map((o) => JSON.stringify(o))
.join("\n")
);
// Subagent on Haiku, in <base>/<sid>/subagents/.
const subDir = path.join(baseDir, sid, "subagents");
fs.mkdirSync(subDir, { recursive: true });
fs.writeFileSync(
path.join(subDir, "agent-h1.jsonl"),
[
{
type: "user",
timestamp: "2026-05-01T09:00:01.000Z",
message: { content: [{ type: "text", text: "go" }] },
},
{
type: "assistant",
timestamp: "2026-05-01T09:00:02.000Z",
message: {
model: "claude-haiku-4-5-20251001",
content: [{ type: "tool_use", id: "tu1", name: "Read", input: {} }],
usage: {
input_tokens: 700,
output_tokens: 300,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
},
{
type: "user",
timestamp: "2026-05-01T09:00:03.000Z",
message: { content: [{ type: "tool_result", tool_use_id: "tu1", content: "ok" }] },
},
]
.map((o) => JSON.stringify(o))
.join("\n")
);
fs.writeFileSync(path.join(subDir, "agent-h1.meta.json"), JSON.stringify({ agentType: "qa" }));
try {
const res = await post("/api/hooks/event", {
hook_type: "SubagentStop",
data: { session_id: sid, transcript_path: mainPath },
});
assert.equal(res.status, 200); // must not 500 — guards the handler scope wiring
// The subagent scan is fire-and-forget; poll for the Haiku bucket.
let haiku = null;
for (let i = 0; i < 100 && !haiku; i++) {
haiku = db
.prepare("SELECT * FROM token_usage WHERE session_id = ? AND model = ?")
.get(sid, "claude-haiku-4-5-20251001");
if (!haiku) await new Promise((r) => setTimeout(r, 20));
}
assert.ok(haiku, "subagent tokens must be attributed under the subagent's own model");
assert.equal(haiku.input_tokens, 700);
assert.equal(haiku.output_tokens, 300);
// Orchestrator's Opus bucket comes from the main transcript (not the sub).
const opus = db
.prepare("SELECT * FROM token_usage WHERE session_id = ? AND model = ?")
.get(sid, "claude-opus-4-8");
assert.ok(opus, "orchestrator opus bucket from the main transcript");
assert.equal(opus.input_tokens, 100);
const sess = db.prepare("SELECT model FROM sessions WHERE id = ?").get(sid);
assert.equal(sess.model, "claude-opus-4-8");
} finally {
fs.rmSync(baseDir, { recursive: true, force: true });
}
});
it("should handle Notification events", async () => {
const res = await post("/api/hooks/event", {
hook_type: "Notification",
data: {
session_id: "hook-sess-1",
message: "Task completed successfully",
},
});
assert.equal(res.status, 200);
assert.equal(res.body.event.summary, "Task completed successfully");
// A non-waiting Notification should NOT stamp awaiting_input_since.
const sessRes = await fetch("/api/sessions/hook-sess-1");
assert.equal(sessRes.body.session.awaiting_input_since, null);
assert.equal(sessRes.body.session.awaiting_reason, null);
});
it("should mark session and main agent as awaiting input on permission-prompt Notification", async () => {
// Bootstrap a fresh session so prior tests don't taint the assertion.
await post("/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "hook-sess-wait" },
});
const res = await post("/api/hooks/event", {
hook_type: "Notification",
data: {
session_id: "hook-sess-wait",
message: "Claude needs your permission to use Bash",
},
});
assert.equal(res.status, 200);
const sessRes = await fetch("/api/sessions/hook-sess-wait");
assert.ok(
sessRes.body.session.awaiting_input_since,
"session should be flagged as awaiting input"
);
assert.equal(sessRes.body.session.awaiting_reason, "notification");
const agentsRes = await fetch("/api/agents?session_id=hook-sess-wait");
const main = agentsRes.body.agents.find((a) => a.type === "main");
assert.ok(main.awaiting_input_since, "main agent should be flagged as awaiting input");
assert.equal(main.awaiting_reason, "notification");
});
it("should clear awaiting_input_since when the user resumes (next PreToolUse)", async () => {
// Re-arm the waiting state — previous test may have left it set, but be
// explicit so this test stands on its own.
await post("/api/hooks/event", {
hook_type: "Notification",
data: {
session_id: "hook-sess-wait",
message: "Claude is waiting for your input",
},
});
const before = await fetch("/api/sessions/hook-sess-wait");
assert.ok(before.body.session.awaiting_input_since);
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: { session_id: "hook-sess-wait", tool_name: "Bash" },
});
const after = await fetch("/api/sessions/hook-sess-wait");
assert.equal(after.body.session.awaiting_input_since, null);
assert.equal(after.body.session.awaiting_reason, null);
const agentsRes = await fetch("/api/agents?session_id=hook-sess-wait");
const main = agentsRes.body.agents.find((a) => a.type === "main");
assert.equal(main.awaiting_input_since, null);
assert.equal(main.awaiting_reason, null);
});
it("should keep session active and set main agent waiting on Stop", async () => {
// First make sure main agent is in a working state
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: { session_id: "hook-sess-1", tool_name: "Write" },
});
const res = await post("/api/hooks/event", {
hook_type: "Stop",
data: {
session_id: "hook-sess-1",
stop_reason: "end_turn",
},
});
assert.equal(res.status, 200);
// Session should stay active — Stop means Claude finished responding, not session closed
const sessRes = await fetch("/api/sessions/hook-sess-1");
assert.equal(sessRes.body.session.status, "active");
// Main agent should be waiting (waiting for user input)
const agentsRes = await fetch("/api/agents?session_id=hook-sess-1");
const main = agentsRes.body.agents.find((a) => a.type === "main");
assert.equal(main.status, "waiting");
// Non-error Stop also stamps awaiting_input_since so the dashboard
// surfaces a Waiting badge — Claude finished its turn, ball is now in
// the user's court. Cleared by the next PreToolUse/PostToolUse.
assert.ok(
sessRes.body.session.awaiting_input_since,
"session should be flagged waiting after non-error Stop"
);
assert.ok(
main.awaiting_input_since,
"main agent should be flagged waiting after non-error Stop"
);
assert.equal(sessRes.body.session.awaiting_reason, "stop");
assert.equal(main.awaiting_reason, "stop");
});
it("should mark a brand-new SessionStart as Waiting (sitting at the prompt)", async () => {
// A just-launched Claude Code session has nothing to do yet — it's
// sitting at a prompt waiting for the user's first message. The
// dashboard should reflect that immediately rather than parking it in
// Active until Stop fires.
const sid = "hook-sess-fresh-start";
await post("/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: sid, source: "startup" },
});
const sessRes = await fetch(`/api/sessions/${sid}`);
assert.equal(sessRes.body.session.status, "active");
assert.ok(
sessRes.body.session.awaiting_input_since,
"fresh session should be flagged Waiting at SessionStart"
);
assert.equal(sessRes.body.session.awaiting_reason, "session_start");
const agentsRes = await fetch(`/api/agents?session_id=${sid}`);
const main = agentsRes.body.agents.find((a) => a.type === "main");
assert.ok(main.awaiting_input_since, "fresh main agent should be flagged Waiting");
assert.equal(main.awaiting_reason, "session_start");
});
it("should NOT flip a genuinely-working session to Waiting on SessionStart source=compact", async () => {
// Auto-compaction fires SessionStart(source=compact) MID-TURN while Claude
// is actively working. The session is genuinely Active and must stay Active
// — a compact must not re-stamp it 'session_start' Waiting. This is the
// "in-progress session shows as Waiting" bug.
const sid = "hook-sess-compact-active";
await post("/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: sid, source: "startup" },
});
// User submits a prompt → clears the session_start flag, main → working.
await post("/api/hooks/event", {
hook_type: "UserPromptSubmit",
data: { session_id: sid, prompt: "do a big task" },
});
const working = await fetch(`/api/sessions/${sid}`);
assert.equal(
working.body.session.awaiting_input_since,
null,
"precondition: session is Active (working) before compaction"
);
// Auto-compaction kicks in mid-turn.
await post("/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: sid, source: "compact" },
});
const afterSess = await fetch(`/api/sessions/${sid}`);
assert.equal(afterSess.body.session.status, "active");
assert.equal(
afterSess.body.session.awaiting_input_since,
null,
"mid-turn compact must NOT stamp the Waiting flag on a working session"
);
assert.equal(afterSess.body.session.awaiting_reason, null);
const agentsRes = await fetch(`/api/agents?session_id=${sid}`);
const main = agentsRes.body.agents.find((a) => a.type === "main");
assert.equal(main.status, "working", "main agent should stay working through a compaction");
assert.equal(main.awaiting_input_since, null);
});
it("should PRESERVE the Waiting flag on SessionStart source=compact when idle at the prompt", async () => {
// /compact run while the session sits idle at the prompt: it was already
// Waiting (from the prior Stop) and must stay Waiting. Compact must neither
// promote the agent to working nor overwrite the awaiting_reason.
const sid = "hook-sess-compact-idle";
await post("/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: sid, source: "startup" },
});
await post("/api/hooks/event", {
hook_type: "UserPromptSubmit",
data: { session_id: sid, prompt: "answer this" },
});
await post("/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, stop_reason: "end_turn" },
});
const before = await fetch(`/api/sessions/${sid}`);
assert.ok(before.body.session.awaiting_input_since, "precondition: Waiting after Stop");
assert.equal(before.body.session.awaiting_reason, "stop");
// /compact at idle.
await post("/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: sid, source: "compact" },
});
const after = await fetch(`/api/sessions/${sid}`);
assert.ok(
after.body.session.awaiting_input_since,
"idle compact must preserve the Waiting flag"
);
assert.equal(
after.body.session.awaiting_reason,
"stop",
"reason must remain 'stop', not be overwritten by 'session_start'"
);
const agentsRes = await fetch(`/api/agents?session_id=${sid}`);
const main = agentsRes.body.agents.find((a) => a.type === "main");
assert.equal(
main.status,
"waiting",
"main agent should stay waiting through an idle compaction"
);
assert.ok(main.awaiting_input_since);
});
it("should clear awaiting_input_since and promote main to working on UserPromptSubmit", async () => {
// The bug this guards: text-only assistant turns emit no PreToolUse,
// so without UserPromptSubmit the Waiting badge would persist for the
// entire generation. This test simulates: Stop (waiting set) → user
// types another message → UserPromptSubmit must clear waiting and
// promote the main agent back to working immediately, before Claude
// does anything.
const sid = "hook-sess-userprompt";
await post("/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: sid },
});
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: { session_id: sid, tool_name: "Read" },
});
await post("/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, stop_reason: "end_turn" },
});
// Confirm waiting state exists before the user types again.
const before = await fetch(`/api/sessions/${sid}`);
assert.ok(before.body.session.awaiting_input_since, "session should be waiting after Stop");
// User submits a new prompt — Claude hasn't done anything yet.
await post("/api/hooks/event", {
hook_type: "UserPromptSubmit",
data: { session_id: sid, prompt: "follow-up question" },
});
const afterSess = await fetch(`/api/sessions/${sid}`);
assert.equal(
afterSess.body.session.awaiting_input_since,
null,
"session waiting flag should be cleared by UserPromptSubmit"
);
const agentsRes = await fetch(`/api/agents?session_id=${sid}`);
const main = agentsRes.body.agents.find((a) => a.type === "main");
assert.equal(main.awaiting_input_since, null);
assert.equal(main.status, "working", "main agent should be promoted to working");
});
it("should NOT clear awaiting_input_since when SubagentStop fires after Stop", async () => {
// Regression: backgrounded subagents that finish *after* a non-error
// Stop used to flip the session out of Waiting because the blanket
// auto-clear ran on every non-Notification event. SubagentStop tells
// us nothing about whether the human responded, so it must leave the
// flag alone.
const sid = "hook-sess-subagent-late";
await post("/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: sid },
});
// Spawn a subagent so SubagentStop has something to match.
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: {
session_id: sid,
tool_name: "Agent",
tool_input: { description: "background-worker", subagent_type: "general-purpose" },
},
});
// Main turn ends — session enters Waiting.
await post("/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, stop_reason: "end_turn" },
});
const beforeSess = await fetch(`/api/sessions/${sid}`);
assert.ok(beforeSess.body.session.awaiting_input_since, "session should be Waiting after Stop");
const beforeAgents = await fetch(`/api/agents?session_id=${sid}`);
const beforeMain = beforeAgents.body.agents.find((a) => a.type === "main");
assert.ok(beforeMain.awaiting_input_since, "main agent should be Waiting after Stop");
// Backgrounded subagent finishes — must NOT flip session out of Waiting.
await post("/api/hooks/event", {
hook_type: "SubagentStop",
data: { session_id: sid, agent_type: "general-purpose" },
});
const afterSess = await fetch(`/api/sessions/${sid}`);
assert.ok(
afterSess.body.session.awaiting_input_since,
"session should STILL be Waiting after SubagentStop"
);
const afterAgents = await fetch(`/api/agents?session_id=${sid}`);
const afterMain = afterAgents.body.agents.find((a) => a.type === "main");
assert.ok(
afterMain.awaiting_input_since,
"main agent should STILL be Waiting after SubagentStop"
);
});
it("should NOT stamp awaiting_input_since when Stop has stop_reason=error", async () => {
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: { session_id: "hook-sess-stop-err", tool_name: "Bash" },
});
await post("/api/hooks/event", {
hook_type: "Stop",
data: { session_id: "hook-sess-stop-err", stop_reason: "error" },
});
const sessRes = await fetch("/api/sessions/hook-sess-stop-err");
assert.equal(sessRes.body.session.status, "error");
assert.equal(sessRes.body.session.awaiting_input_since, null);
});
it("should clear awaiting_input_since on the next PreToolUse after Stop", async () => {
// Confirm the flag carried forward from the previous Stop test.
const before = await fetch("/api/sessions/hook-sess-1");
assert.ok(before.body.session.awaiting_input_since);
assert.equal(before.body.session.awaiting_reason, "stop");
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: { session_id: "hook-sess-1", tool_name: "Read" },
});
const after = await fetch("/api/sessions/hook-sess-1");
assert.equal(after.body.session.awaiting_input_since, null);
assert.equal(after.body.session.awaiting_reason, null);
const agentsRes = await fetch("/api/agents?session_id=hook-sess-1");
const main = agentsRes.body.agents.find((a) => a.type === "main");
assert.equal(main.awaiting_input_since, null);
assert.equal(main.awaiting_reason, null);
});
it("should mark session as error when stop_reason is error", async () => {
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: { session_id: "hook-sess-err", tool_name: "Bash" },
});
await post("/api/hooks/event", {
hook_type: "Stop",
data: { session_id: "hook-sess-err", stop_reason: "error" },
});
const sessRes = await fetch("/api/sessions/hook-sess-err");
assert.equal(sessRes.body.session.status, "error");
assert.equal(sessRes.body.session.awaiting_input_since, null);
assert.equal(sessRes.body.session.awaiting_reason, null);
});
it("should not create duplicate session on repeated events", async () => {
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: { session_id: "hook-sess-dup", tool_name: "Read" },
});
await post("/api/hooks/event", {
hook_type: "PostToolUse",
data: { session_id: "hook-sess-dup", tool_name: "Read" },
});
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: { session_id: "hook-sess-dup", tool_name: "Write" },
});
const agentsRes = await fetch("/api/agents?session_id=hook-sess-dup");
const mainAgents = agentsRes.body.agents.filter((a) => a.type === "main");
assert.equal(mainAgents.length, 1, "Should have exactly one main agent");
});
it("should keep background subagents working on Stop", async () => {
// Spawn a subagent (may be running in background)
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: {
session_id: "hook-sess-bg",
tool_name: "Agent",
tool_input: { prompt: "Analyze code", description: "BG-analyzer" },
},
});
// Stop fires — background subagents stay working, main goes idle, session stays active
await post("/api/hooks/event", {
hook_type: "Stop",
data: { session_id: "hook-sess-bg", stop_reason: "end_turn" },
});
const agentsRes = await fetch("/api/agents?session_id=hook-sess-bg");
const subagent = agentsRes.body.agents.find((a) => a.type === "subagent");
assert.equal(subagent.status, "working", "Background subagent should stay working on Stop");
assert.equal(subagent.ended_at, null, "Subagent should not have ended_at");
const mainAgent = agentsRes.body.agents.find((a) => a.type === "main");
assert.equal(mainAgent.status, "waiting", "Main agent should be waiting");
const sessRes = await fetch("/api/sessions/hook-sess-bg");
assert.equal(sessRes.body.session.status, "active", "Session should stay active");
// SubagentStop completes the subagent individually
await post("/api/hooks/event", {
hook_type: "SubagentStop",
data: { session_id: "hook-sess-bg", description: "BG-analyzer" },
});
const agentsRes2 = await fetch("/api/agents?session_id=hook-sess-bg");
const subagent2 = agentsRes2.body.agents.find((a) => a.type === "subagent");
assert.equal(subagent2.status, "completed", "Subagent should complete on SubagentStop");
assert.ok(subagent2.ended_at, "Subagent should have ended_at after SubagentStop");
});
it("should NOT mark subagent completed on PostToolUse for Agent tool", async () => {
// Fresh session: spawn a subagent, then PostToolUse fires immediately (backgrounded)
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: {
session_id: "hook-sess-bg2",
tool_name: "Agent",
tool_input: { prompt: "Analyze code", description: "BG-analyzer-2" },
},
});
await post("/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "hook-sess-bg2",
tool_name: "Agent",
tool_input: { description: "BG-analyzer-2" },
},
});
const agentsRes = await fetch("/api/agents?session_id=hook-sess-bg2");
const subagent = agentsRes.body.agents.find((a) => a.type === "subagent");
assert.equal(subagent.status, "working", "Subagent should still be working after PostToolUse");
});
it("should complete subagent on SubagentStop before session Stop", async () => {
// SubagentStop fires when the background agent actually finishes
await post("/api/hooks/event", {
hook_type: "SubagentStop",
data: { session_id: "hook-sess-bg2", description: "BG-analyzer-2" },
});
const agentsRes = await fetch("/api/agents?session_id=hook-sess-bg2");
const subagent = agentsRes.body.agents.find((a) => a.type === "subagent");
assert.equal(subagent.status, "completed", "Subagent should be completed after SubagentStop");
assert.ok(subagent.ended_at, "Subagent should have ended_at timestamp");
});
it("should not flicker completed agent status on subsequent tool events", async () => {
// Create session with active subagent
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: {
session_id: "hook-sess-flicker",
tool_name: "Agent",
tool_input: { prompt: "Do work", description: "Worker" },
},
});
// Stop sets main to idle, background subagent stays working
await post("/api/hooks/event", {
hook_type: "Stop",
data: { session_id: "hook-sess-flicker", stop_reason: "end_turn" },
});
const agents0 = await fetch("/api/agents?session_id=hook-sess-flicker");
const main0 = agents0.body.agents.find((a) => a.type === "main");
assert.equal(main0.status, "waiting", "Main should be waiting after Stop");
const sub0 = agents0.body.agents.find((a) => a.type === "subagent");
assert.equal(sub0.status, "working", "Background subagent should stay working after Stop");
});
it("should record events in the events table", async () => {
const eventsRes = await fetch("/api/events?session_id=hook-sess-1");
assert.ok(
eventsRes.body.events.length >= 4,
"Should have multiple events from hook processing"
);
const types = eventsRes.body.events.map((e) => e.event_type);
assert.ok(types.includes("PreToolUse"));
assert.ok(types.includes("PostToolUse"));
assert.ok(types.includes("Stop"));
});
it("should reactivate error session on new work events (resume)", async () => {
// Create session and trigger error
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: { session_id: "hook-sess-resume", tool_name: "Read" },
});
await post("/api/hooks/event", {
hook_type: "Stop",
data: { session_id: "hook-sess-resume", stop_reason: "error" },
});
let sessRes = await fetch("/api/sessions/hook-sess-resume");
assert.equal(sessRes.body.session.status, "error", "Session should be error after error Stop");
// Resume: send a new PreToolUse for the same session
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: { session_id: "hook-sess-resume", tool_name: "Write" },
});
sessRes = await fetch("/api/sessions/hook-sess-resume");
assert.equal(sessRes.body.session.status, "active", "Session should be reactivated");
assert.equal(sessRes.body.session.ended_at, null, "ended_at should be cleared");
const agentsRes = await fetch("/api/agents?session_id=hook-sess-resume");
const main = agentsRes.body.agents.find((a) => a.type === "main");
assert.equal(main.status, "working", "Main agent should be working after resume");
assert.equal(main.ended_at, null, "Main agent ended_at should be cleared");
});
it("should reactivate imported completed session on Stop event", async () => {
// Simulate a session that was imported as "completed" before the server started.
// This happens when a session is active but was imported from JSONL during startup.
const sessionId = "hook-sess-imported-reactivate";
const mainAgentId = `${sessionId}-main`;
// Manually insert a "completed" imported session + agent (mimics import-history.js)
stmts.insertSession.run(
sessionId,
"Imported Session",
"completed",
"/tmp",
"claude-sonnet-4-6",
null
);
stmts.insertAgent.run(
mainAgentId,
sessionId,
"Main Agent",
"main",
null,
"completed",
null,
null,
null
);
// Verify it starts as completed
let sessRes = await fetch(`/api/sessions/${sessionId}`);
assert.equal(sessRes.body.session.status, "completed");
let main = sessRes.body.agents.find((a) => a.type === "main");
assert.equal(main.status, "completed");
// A Stop event arrives — this proves the session is actually alive
await post("/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sessionId, stop_reason: "end_turn" },
});
// Session should be reactivated
sessRes = await fetch(`/api/sessions/${sessionId}`);
assert.equal(
sessRes.body.session.status,
"active",
"Completed session should reactivate on Stop"
);
main = sessRes.body.agents.find((a) => a.type === "main");
assert.equal(main.status, "waiting", "Main agent should be waiting after Stop reactivation");
});
it("should NOT reactivate error session on Stop event", async () => {
// Error sessions should only reactivate on work events, not Stop
const sessionId = "hook-sess-error-stop";
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: { session_id: sessionId, tool_name: "Read" },
});
await post("/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sessionId, stop_reason: "error" },
});
let sessRes = await fetch(`/api/sessions/${sessionId}`);
assert.equal(sessRes.body.session.status, "error");
// Another Stop should NOT reactivate an error session
await post("/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sessionId, stop_reason: "end_turn" },
});
sessRes = await fetch(`/api/sessions/${sessionId}`);
assert.equal(
sessRes.body.session.status,
"error",
"Error session should NOT reactivate on Stop"
);
});
it("should keep session active across multiple Stop events (multi-turn)", async () => {
// Turn 1: user asks something, Claude responds
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: { session_id: "hook-sess-multiturn", tool_name: "Read" },
});
await post("/api/hooks/event", {
hook_type: "Stop",
data: { session_id: "hook-sess-multiturn", stop_reason: "end_turn" },
});
let sessRes = await fetch("/api/sessions/hook-sess-multiturn");
assert.equal(sessRes.body.session.status, "active", "Session should stay active after turn 1");
let agentsRes = await fetch("/api/agents?session_id=hook-sess-multiturn");
let main = agentsRes.body.agents.find((a) => a.type === "main");
assert.equal(main.status, "waiting", "Main agent should be waiting after turn 1 Stop");
// Turn 2: user asks something else — PreToolUse should transition idle → working
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: { session_id: "hook-sess-multiturn", tool_name: "Write" },
});
agentsRes = await fetch("/api/agents?session_id=hook-sess-multiturn");
main = agentsRes.body.agents.find((a) => a.type === "main");
assert.equal(main.status, "working", "Main agent should be working during turn 2");
await post("/api/hooks/event", {
hook_type: "Stop",
data: { session_id: "hook-sess-multiturn", stop_reason: "end_turn" },
});
sessRes = await fetch("/api/sessions/hook-sess-multiturn");
assert.equal(sessRes.body.session.status, "active", "Session should stay active after turn 2");
agentsRes = await fetch("/api/agents?session_id=hook-sess-multiturn");
main = agentsRes.body.agents.find((a) => a.type === "main");
assert.equal(main.status, "waiting", "Main agent should be waiting after turn 2 Stop");
});
it("should mark session completed on SessionEnd", async () => {
// Create session with some activity
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: { session_id: "hook-sess-end", tool_name: "Read" },
});
await post("/api/hooks/event", {
hook_type: "Stop",
data: { session_id: "hook-sess-end", stop_reason: "end_turn" },
});
// Session should still be active after Stop
let sessRes = await fetch("/api/sessions/hook-sess-end");
assert.equal(sessRes.body.session.status, "active");
// SessionEnd fires when CLI exits
await post("/api/hooks/event", {
hook_type: "SessionEnd",
data: { session_id: "hook-sess-end", reason: "prompt_input_exit" },
});
sessRes = await fetch("/api/sessions/hook-sess-end");
assert.equal(
sessRes.body.session.status,
"completed",
"Session should be completed after SessionEnd"
);
assert.ok(sessRes.body.session.ended_at, "Session should have ended_at");
// SessionEnd clears any leftover waiting flag (and its reason) so the
// row lands in its final column without a stale awaiting_reason.
assert.equal(sessRes.body.session.awaiting_input_since, null);
assert.equal(sessRes.body.session.awaiting_reason, null);
const agentsRes = await fetch("/api/agents?session_id=hook-sess-end");
agentsRes.body.agents.forEach((a) => {
assert.equal(a.status, "completed", `Agent ${a.name} should be completed`);
});
});
it("should extract token usage from transcript_path on Stop", async () => {
// Create a temporary JSONL transcript file
const transcriptPath = path.join(os.tmpdir(), `transcript-test-${Date.now()}.jsonl`);
// Real Claude Code transcript format: model/usage are nested inside entry.message
const lines = [
JSON.stringify({ type: "user", message: { role: "user", content: "Hello" } }),
JSON.stringify({
type: "assistant",
message: {
model: "claude-sonnet-4-6",
role: "assistant",
usage: {
input_tokens: 100,
output_tokens: 50,
cache_read_input_tokens: 200,
cache_creation_input_tokens: 10,
},
},
}),
JSON.stringify({
type: "assistant",
message: {
model: "claude-sonnet-4-6",
role: "assistant",
usage: {
input_tokens: 150,
output_tokens: 75,
cache_read_input_tokens: 300,
cache_creation_input_tokens: 0,
},
},
}),
JSON.stringify({ type: "progress" }), // Non-message entries should be skipped
JSON.stringify({
type: "assistant",
message: {
model: "claude-opus-4-6",
role: "assistant",
usage: {
input_tokens: 500,
output_tokens: 200,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 50,
},
},
}),
];
fs.writeFileSync(transcriptPath, lines.join("\n") + "\n");
// Send Stop event with transcript_path
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: { session_id: "hook-sess-transcript", tool_name: "Read" },
});
const res = await post("/api/hooks/event", {
hook_type: "Stop",
data: { session_id: "hook-sess-transcript", transcript_path: transcriptPath },
});
assert.equal(res.status, 200);
// Check token_usage was written
const costRes = await fetch("/api/pricing/cost/hook-sess-transcript");
assert.equal(costRes.status, 200);
assert.ok(Array.isArray(costRes.body.daily_costs));
assert.equal(costRes.body.daily_costs.length, 1);
assert.equal(costRes.body.daily_costs[0].cost, costRes.body.total_cost);
const sonnet = costRes.body.breakdown.find((b) => b.model === "claude-sonnet-4-6");
assert.ok(sonnet, "Should have sonnet token data");
assert.equal(sonnet.input_tokens, 250);
assert.equal(sonnet.output_tokens, 125);
assert.equal(sonnet.cache_read_tokens, 500);
assert.equal(sonnet.cache_write_tokens, 10);
const opus = costRes.body.breakdown.find((b) => b.model === "claude-opus-4-6");
assert.ok(opus, "Should have opus token data");
assert.equal(opus.input_tokens, 500);
assert.equal(opus.output_tokens, 200);
// Clean up
fs.unlinkSync(transcriptPath);
});
it("should update token usage on every event, not just Stop", async () => {
// Create a transcript that grows over time (simulating mid-session reads)
const transcriptPath = path.join(os.tmpdir(), `transcript-mid-${Date.now()}.jsonl`);
const line1 = JSON.stringify({
type: "assistant",
message: {
model: "claude-sonnet-4-6",
role: "assistant",
usage: {
input_tokens: 100,
output_tokens: 50,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
});
fs.writeFileSync(transcriptPath, line1 + "\n");
// PreToolUse event with transcript_path should trigger token extraction
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: { session_id: "hook-sess-mid", tool_name: "Read", transcript_path: transcriptPath },
});
const midRes = await fetch("/api/pricing/cost/hook-sess-mid");
assert.equal(midRes.status, 200);
assert.ok(Array.isArray(midRes.body.daily_costs));
assert.equal(midRes.body.daily_costs.length, 1);
const midSonnet = midRes.body.breakdown.find((b) => b.model === "claude-sonnet-4-6");
assert.ok(midSonnet, "Should have token data after PreToolUse");
assert.equal(midSonnet.input_tokens, 100);
assert.equal(midSonnet.output_tokens, 50);
// Transcript grows — second assistant response added
const line2 = JSON.stringify({
type: "assistant",
message: {
model: "claude-sonnet-4-6",
role: "assistant",
usage: {
input_tokens: 200,
output_tokens: 80,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
});
fs.appendFileSync(transcriptPath, line2 + "\n");
// PostToolUse event should pick up the updated transcript
await post("/api/hooks/event", {
hook_type: "PostToolUse",
data: { session_id: "hook-sess-mid", tool_name: "Read", transcript_path: transcriptPath },
});
const updatedRes = await fetch("/api/pricing/cost/hook-sess-mid");
assert.ok(Array.isArray(updatedRes.body.daily_costs));
assert.equal(updatedRes.body.daily_costs.length, 1);
assert.equal(updatedRes.body.daily_costs[0].cost, updatedRes.body.total_cost);
const updatedSonnet = updatedRes.body.breakdown.find((b) => b.model === "claude-sonnet-4-6");
assert.ok(updatedSonnet, "Should have updated token data after PostToolUse");
// replaceTokenUsage overwrites with totals from full transcript (100+200=300, 50+80=130)
assert.equal(updatedSonnet.input_tokens, 300);
assert.equal(updatedSonnet.output_tokens, 130);
fs.unlinkSync(transcriptPath);
});
});
// ============================================================
// Database Integrity
// ============================================================
describe("Database Integrity", () => {
it("should enforce session status CHECK constraint", () => {
assert.throws(() => {
stmts.insertSession.run("bad-status", "test", "invalid_status", null, null, null);
});
});
it("should enforce agent status CHECK constraint", () => {
assert.throws(() => {
stmts.insertAgent.run(
"bad-agent",
"sess-2",
"Test",
"main",
null,
"invalid_status",
null,
null,
null
);
});
});
it("should enforce agent type CHECK constraint", () => {
assert.throws(() => {
stmts.insertAgent.run(
"bad-agent2",
"sess-2",
"Test",
"invalid_type",
null,
"waiting",
null,
null,
null
);
});
});
it("should cascade delete agents when session is deleted", () => {
// Create a session with agents
stmts.insertSession.run("cascade-test", "Cascade Test", "active", null, null, null);
stmts.insertAgent.run(
"cascade-agent",
"cascade-test",
"Agent",
"main",
null,
"waiting",
null,
null,
null
);
// Verify agent exists
assert.ok(stmts.getAgent.get("cascade-agent"));
// Delete session
db.prepare("DELETE FROM sessions WHERE id = ?").run("cascade-test");
// Agent should be gone
assert.equal(stmts.getAgent.get("cascade-agent"), undefined);
});
it("should have all expected indexes", () => {
const indexes = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_%'")
.all()
.map((r) => r.name);
assert.ok(indexes.includes("idx_agents_session"));
assert.ok(indexes.includes("idx_agents_status"));
assert.ok(indexes.includes("idx_events_session"));
assert.ok(indexes.includes("idx_events_type"));
assert.ok(indexes.includes("idx_events_created"));
assert.ok(indexes.includes("idx_sessions_status"));
assert.ok(indexes.includes("idx_sessions_started"));
});
it("should use WAL journal mode", () => {
const mode = db.pragma("journal_mode", { simple: true });
assert.equal(mode, "wal");
});
it("should have foreign keys enabled", () => {
const fk = db.pragma("foreign_keys", { simple: true });
assert.equal(fk, 1);
});
});
describe("Transcript cache integration", () => {
it("should extract and cache tokens from transcript file via hook event", async () => {
const tmpTranscript = path.join(os.tmpdir(), `test-transcript-${Date.now()}.jsonl`);
const entries = [
JSON.stringify({
message: {
model: "claude-sonnet-4-20250514",
usage: {
input_tokens: 100,
output_tokens: 50,
cache_read_input_tokens: 10,
cache_creation_input_tokens: 5,
},
},
}),
JSON.stringify({
message: {
model: "claude-sonnet-4-20250514",
usage: {
input_tokens: 200,
output_tokens: 75,
cache_read_input_tokens: 20,
cache_creation_input_tokens: 10,
},
},
}),
];
fs.writeFileSync(tmpTranscript, entries.join("\n") + "\n");
try {
const sessionId = `cache-test-${Date.now()}`;
// First event — cache miss, full read
const r1 = await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: {
session_id: sessionId,
transcript_path: tmpTranscript,
tool_name: "Read",
cwd: "/tmp",
},
});
assert.strictEqual(r1.status, 200);
// Verify token usage was stored
const tokenRow = stmts.getTokensBySession.all(sessionId);
assert.ok(tokenRow.length > 0, "token_usage row should exist");
const sonnet = tokenRow.find((r) => r.model.includes("sonnet"));
assert.ok(sonnet, "should have sonnet model entry");
assert.strictEqual(sonnet.input_tokens, 300);
assert.strictEqual(sonnet.output_tokens, 125);
assert.strictEqual(sonnet.cache_read_tokens, 30);
assert.strictEqual(sonnet.cache_write_tokens, 15);
// Second event — same file, should be a cache hit (stat unchanged)
const r2 = await post("/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: sessionId,
transcript_path: tmpTranscript,
tool_name: "Read",
cwd: "/tmp",
},
});
assert.strictEqual(r2.status, 200);
// Tokens should still be the same (no double-counting)
const tokenRow2 = stmts.getTokensBySession.all(sessionId);
const sonnet2 = tokenRow2.find((r) => r.model.includes("sonnet"));
assert.strictEqual(sonnet2.input_tokens, 300);
// Append new data — simulates Claude writing more to transcript
fs.appendFileSync(
tmpTranscript,
JSON.stringify({
message: {
model: "claude-sonnet-4-20250514",
usage: {
input_tokens: 400,
output_tokens: 150,
cache_read_input_tokens: 40,
cache_creation_input_tokens: 20,
},
},
}) + "\n"
);
// Third event — file grew, incremental read should pick up new data
const r3 = await post("/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sessionId, transcript_path: tmpTranscript, cwd: "/tmp" },
});
assert.strictEqual(r3.status, 200);
const tokenRow3 = stmts.getTokensBySession.all(sessionId);
const sonnet3 = tokenRow3.find((r) => r.model.includes("sonnet"));
assert.strictEqual(sonnet3.input_tokens, 700);
assert.strictEqual(sonnet3.output_tokens, 275);
} finally {
try {
fs.unlinkSync(tmpTranscript);
} catch {
// ignore cleanup errors
}
}
});
it("should include transcript_cache in settings info", async () => {
const res = await fetch("/api/settings/info");
assert.strictEqual(res.status, 200);
assert.ok(res.body.transcript_cache, "response should include transcript_cache");
assert.ok(typeof res.body.transcript_cache.size === "number", "should have size count");
assert.ok(Array.isArray(res.body.transcript_cache.keys), "should have keys array");
});
it("should include server version in settings info", async () => {
const res = await fetch("/api/settings/info");
assert.strictEqual(res.status, 200);
assert.equal(res.body.server.version, pkg.version);
});
it("reports the V8 heap LIMIT, not just the currently allocated heapTotal", async () => {
const res = await fetch("/api/settings/info");
const { heapUsed, heapTotal, heapLimit } = res.body.server.memory;
assert.ok(Number.isFinite(heapLimit), "heapLimit should be a number");
// heapTotal is what V8 has grown to so far; the limit is the ceiling.
assert.ok(heapLimit >= heapTotal && heapLimit > heapUsed);
});
it("should evict cache entry on SessionEnd", async () => {
const tmpTranscript = path.join(os.tmpdir(), `test-evict-${Date.now()}.jsonl`);
fs.writeFileSync(
tmpTranscript,
JSON.stringify({ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } }) +
"\n"
);
try {
const sessionId = `evict-test-${Date.now()}`;
const { transcriptCache } = require("../routes/hooks");
// Hook event populates cache
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: {
session_id: sessionId,
transcript_path: tmpTranscript,
tool_name: "Read",
cwd: "/tmp",
},
});
assert.ok(
transcriptCache.stats().keys.includes(tmpTranscript),
"cache should contain transcript path after event"
);
// SessionEnd should evict
await post("/api/hooks/event", {
hook_type: "SessionEnd",
data: { session_id: sessionId, transcript_path: tmpTranscript, cwd: "/tmp" },
});
assert.ok(
!transcriptCache.stats().keys.includes(tmpTranscript),
"cache should NOT contain transcript path after SessionEnd"
);
} finally {
try {
fs.unlinkSync(tmpTranscript);
} catch {
// ignore
}
}
});
});
// ============================================================
// Compaction ingestion (regression: issue #156)
// ============================================================
describe("Compaction agent ingestion", () => {
it("should stamp started_at == ended_at == transcript timestamp (no negative duration)", async () => {
const pastTs = new Date(Date.now() - 60_000).toISOString(); // 60s in past
const compactUuid = `compact-uuid-${Date.now()}`;
const tmpTranscript = path.join(os.tmpdir(), `compact-test-${Date.now()}.jsonl`);
fs.writeFileSync(
tmpTranscript,
JSON.stringify({
isCompactSummary: true,
uuid: compactUuid,
timestamp: pastTs,
message: { model: "claude-sonnet-4-5", usage: { input_tokens: 100, output_tokens: 50 } },
}) + "\n"
);
const sessionId = `compact-sess-${Date.now()}`;
const hooks = require("../routes/hooks");
try {
hooks.transcriptCache.invalidate(tmpTranscript);
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: {
session_id: sessionId,
transcript_path: tmpTranscript,
tool_name: "Read",
cwd: "/tmp",
},
});
const compactId = `${sessionId}-compact-${compactUuid}`;
const agent = stmts.getAgent.get(compactId);
assert.ok(agent, "compaction agent should be created");
assert.equal(agent.subagent_type, "compaction");
assert.equal(
agent.started_at,
pastTs,
"started_at must equal transcript timestamp, not ingestion wall clock"
);
assert.equal(agent.ended_at, pastTs, "ended_at must equal transcript timestamp");
const durRow = db
.prepare(
`SELECT (julianday(ended_at) - julianday(started_at)) * 86400 AS dur
FROM agents WHERE id = ?`
)
.get(compactId);
assert.ok(durRow.dur >= 0, `compaction duration must be >= 0, got ${durRow.dur}`);
assert.equal(durRow.dur, 0, "compaction is instantaneous → duration must be exactly 0");
} finally {
try {
fs.unlinkSync(tmpTranscript);
} catch {
// ignore
}
}
});
it("workflows avgDuration for compaction must never be negative", async () => {
// Seed a few broken-historical rows (simulating pre-fix corrupt data) and
// a fresh-ingestion row, then assert the API never reports a negative avg.
const sid = `compact-avg-sess-${Date.now()}`;
stmts.insertSession.run(sid, "compact-avg", "completed", "/tmp", "test-model", null);
stmts.insertAgent.run(`${sid}-main`, sid, "main", "main", null, "completed", null, null, null);
// Insert a compaction row with the broken invariant explicitly, then run
// the same repair the startup migration runs and assert it is healed.
const brokenId = `${sid}-compact-broken`;
stmts.insertAgent.run(
brokenId,
sid,
"Context Compaction",
"subagent",
"compaction",
"completed",
null,
`${sid}-main`,
null
);
const pastTs = new Date(Date.now() - 30_000).toISOString();
db.prepare("UPDATE agents SET ended_at = ? WHERE id = ?").run(pastTs, brokenId);
const before = db.prepare("SELECT started_at, ended_at FROM agents WHERE id = ?").get(brokenId);
assert.ok(
before.ended_at < before.started_at,
"precondition: row should have ended_at < started_at"
);
// Apply repair (mirrors startup migration in server/db.js)
db.prepare(
`UPDATE agents SET started_at = ended_at, updated_at = ended_at
WHERE subagent_type = 'compaction' AND ended_at IS NOT NULL
AND julianday(ended_at) < julianday(started_at)`
).run();
const after = db.prepare("SELECT started_at, ended_at FROM agents WHERE id = ?").get(brokenId);
assert.equal(after.started_at, pastTs, "repair should collapse started_at to ended_at");
assert.equal(after.ended_at, pastTs);
const res = await fetch("/api/workflows");
assert.equal(res.status, 200);
// Subagent effectiveness (incl. avgDuration per type) is at .effectiveness,
// not .types — the prior key never matched, so this assertion was dead.
const compactionType = (res.body.effectiveness || []).find(
(t) => t.subagent_type === "compaction"
);
if (compactionType && compactionType.avgDuration !== null) {
assert.ok(
compactionType.avgDuration >= 0,
`compaction avgDuration must be >= 0, got ${compactionType.avgDuration}`
);
}
});
it("workflows avgDuration for non-compaction subagents is clamped to >= 0", async () => {
// The #156 startup repair only heals compaction rows. A non-compaction
// subagent whose ended_at < started_at (clock skew, replayed/synced
// transcripts, or fleet/Workflow-tool ingestion) is NOT repaired, so the
// per-type avgDuration query must clamp negative durations itself.
const sid = `noncompact-avg-sess-${Date.now()}`;
stmts.insertSession.run(sid, "noncompact-avg", "completed", "/tmp", "test-model", null);
stmts.insertAgent.run(`${sid}-main`, sid, "main", "main", null, "completed", null, null, null);
// One valid Explore row (+10s) and one broken Explore row (ended 30s before
// started). The broken row is deliberately left un-repaired.
const goodId = `${sid}-explore-good`;
stmts.insertAgent.run(
goodId,
sid,
"Explore",
"subagent",
"Explore",
"completed",
null,
`${sid}-main`,
null
);
db.prepare("UPDATE agents SET started_at = ?, ended_at = ? WHERE id = ?").run(
new Date(Date.now() - 10_000).toISOString(),
new Date().toISOString(),
goodId
);
const brokenId = `${sid}-explore-broken`;
stmts.insertAgent.run(
brokenId,
sid,
"Explore",
"subagent",
"Explore",
"completed",
null,
`${sid}-main`,
null
);
db.prepare("UPDATE agents SET ended_at = ? WHERE id = ?").run(
new Date(Date.now() - 30_000).toISOString(),
brokenId
);
const before = db.prepare("SELECT started_at, ended_at FROM agents WHERE id = ?").get(brokenId);
assert.ok(
before.ended_at < before.started_at,
"precondition: broken row should have ended_at < started_at"
);
const res = await fetch("/api/workflows");
assert.equal(res.status, 200);
const exploreType = (res.body.effectiveness || []).find((t) => t.subagent_type === "Explore");
assert.ok(exploreType, "Explore subagent type should appear in effectiveness");
assert.ok(
exploreType.avgDuration >= 0,
`non-compaction avgDuration must be >= 0, got ${exploreType.avgDuration}`
);
});
});
// ============================================================
// Watchdog: stale-error idempotence
// ============================================================
describe("Watchdog API-error detection", () => {
function writeTranscriptWithError(p) {
fs.writeFileSync(
p,
JSON.stringify({
isApiErrorMessage: true,
error: "rate_limit_error",
message: { content: [{ text: "Rate limit exceeded" }] },
timestamp: new Date().toISOString(),
}) + "\n"
);
}
it("should NOT re-flip a recovered session to error when only pre-existing transcript errors remain", async () => {
const tmpTranscript = path.join(os.tmpdir(), `watchdog-stale-${Date.now()}.jsonl`);
writeTranscriptWithError(tmpTranscript);
const sessionId = `watchdog-stale-${Date.now()}`;
const hooks = require("../routes/hooks");
try {
// Initial event creates the session and triggers error capture via processEvent.
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: {
session_id: sessionId,
transcript_path: tmpTranscript,
tool_name: "Read",
cwd: "/tmp",
},
});
// SessionEnd path is what reads errors out of the transcript inside processEvent;
// simulate the watchdog instead by manually marking the session as error.
stmts.updateSession.run(null, "error", null, null, sessionId);
const errored = stmts.getSession.get(sessionId);
assert.strictEqual(errored.status, "error", "precondition: session marked error");
// User retries → reactivation logic flips back to active.
await post("/api/hooks/event", {
hook_type: "UserPromptSubmit",
data: { session_id: sessionId, transcript_path: tmpTranscript, cwd: "/tmp" },
});
const recovered = stmts.getSession.get(sessionId);
assert.strictEqual(recovered.status, "active", "UserPromptSubmit should reactivate");
// Backdate updated_at so the watchdog considers the session stale.
db.prepare("UPDATE sessions SET updated_at = ? WHERE id = ?").run(
new Date(Date.now() - 60_000).toISOString(),
sessionId
);
// Invalidate the cache so the watchdog actually re-reads the transcript on this tick.
hooks.transcriptCache.invalidate(tmpTranscript);
hooks.watchdogCheck();
const after = stmts.getSession.get(sessionId);
assert.strictEqual(
after.status,
"active",
"watchdog must NOT revert recovered session to error when no new transcript errors arrived"
);
} finally {
try {
fs.unlinkSync(tmpTranscript);
} catch {
// ignore
}
}
});
it("should still mark session as error when a NEW transcript error appears", async () => {
const tmpTranscript = path.join(os.tmpdir(), `watchdog-new-${Date.now()}.jsonl`);
fs.writeFileSync(tmpTranscript, ""); // start empty
const sessionId = `watchdog-new-${Date.now()}`;
const hooks = require("../routes/hooks");
try {
// Establish an active session against a clean transcript.
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: {
session_id: sessionId,
transcript_path: tmpTranscript,
tool_name: "Read",
cwd: "/tmp",
},
});
assert.strictEqual(stmts.getSession.get(sessionId).status, "active");
// A new API error appears in the transcript afterwards.
writeTranscriptWithError(tmpTranscript);
// Make session look stale to the watchdog.
db.prepare("UPDATE sessions SET updated_at = ? WHERE id = ?").run(
new Date(Date.now() - 60_000).toISOString(),
sessionId
);
hooks.transcriptCache.invalidate(tmpTranscript);
hooks.watchdogCheck();
const after = stmts.getSession.get(sessionId);
assert.strictEqual(
after.status,
"error",
"watchdog must flip session to error when a new transcript error is detected"
);
} finally {
try {
fs.unlinkSync(tmpTranscript);
} catch {
// ignore
}
}
});
it("must NOT flip a remote-source session to error from its mirrored transcript", async () => {
// Remote Data Source sessions are monitored via a delayed rsync mirror on
// this host, so the transcript-tail error heuristic must never act on them —
// their lifecycle is owned by remote-sync's reconciliation, not the watchdog.
const tmpTranscript = path.join(os.tmpdir(), `watchdog-remote-${Date.now()}.jsonl`);
fs.writeFileSync(tmpTranscript, "");
const sessionId = `watchdog-remote-${Date.now()}`;
const hooks = require("../routes/hooks");
try {
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: {
session_id: sessionId,
transcript_path: tmpTranscript,
tool_name: "Read",
cwd: "/tmp",
},
});
// Tag it as remote AFTER creation (mirrors what remote-sync does).
db.prepare("UPDATE sessions SET source = ? WHERE id = ?").run("src_remotebox", sessionId);
writeTranscriptWithError(tmpTranscript);
db.prepare("UPDATE sessions SET updated_at = ? WHERE id = ?").run(
new Date(Date.now() - 60_000).toISOString(),
sessionId
);
hooks.transcriptCache.invalidate(tmpTranscript);
hooks.watchdogCheck();
assert.strictEqual(
stmts.getSession.get(sessionId).status,
"active",
"watchdog must leave remote-source sessions untouched"
);
} finally {
try {
fs.unlinkSync(tmpTranscript);
} catch {
// ignore
}
}
});
});
// ============================================================
// Watchdog: user-interrupt (Esc) recovery
// ============================================================
describe("Watchdog user-interrupt recovery", () => {
function interruptEntry(ts) {
return JSON.stringify({
type: "user",
interruptedMessageId: "msg-int",
message: { role: "user", content: [{ type: "text", text: "[Request interrupted by user]" }] },
timestamp: ts,
});
}
function promptEntry(ts, text = "do something") {
return JSON.stringify({
type: "user",
message: { role: "user", content: [{ type: "text", text }] },
timestamp: ts,
});
}
function getMain(sessionId) {
return db
.prepare("SELECT * FROM agents WHERE session_id = ? AND type = 'main' LIMIT 1")
.get(sessionId);
}
function makeStale(sessionId) {
db.prepare("UPDATE sessions SET updated_at = ? WHERE id = ?").run(
new Date(Date.now() - 60_000).toISOString(),
sessionId
);
}
// The headline case: Esc pressed BEFORE any model output. Transcript order is
// [prompt, interrupt] with the interrupt landing a hair after the prompt, and
// the UserPromptSubmit hook event is stamped (server clock) AFTER the
// transcript interrupt. A clock comparison would miss this; pendingInterrupt
// (transcript-internal) must still flip the session.
it("should recover a session interrupted BEFORE any output (pre-output Esc)", async () => {
const tmpTranscript = path.join(os.tmpdir(), `watchdog-int-pre-${Date.now()}.jsonl`);
const sessionId = `watchdog-int-pre-${Date.now()}`;
const hooks = require("../routes/hooks");
try {
await post("/api/hooks/event", {
hook_type: "UserPromptSubmit",
data: { session_id: sessionId, transcript_path: tmpTranscript, cwd: "/tmp" },
});
assert.strictEqual(getMain(sessionId).status, "working", "precondition: main working");
// Prompt then interrupt 1ms later, both BEFORE the now-stamped hook event.
const base = new Date(Date.now() - 5_000);
fs.writeFileSync(
tmpTranscript,
promptEntry(base.toISOString()) +
"\n" +
interruptEntry(new Date(base.getTime() + 1).toISOString()) +
"\n"
);
makeStale(sessionId);
hooks.transcriptCache.invalidate(tmpTranscript);
hooks.watchdogCheck();
const sess = stmts.getSession.get(sessionId);
const main = getMain(sessionId);
assert.strictEqual(sess.status, "active", "session stays active (not closed)");
assert.ok(sess.awaiting_input_since, "session should now be awaiting input");
assert.strictEqual(sess.awaiting_reason, "interrupted");
assert.strictEqual(main.status, "waiting", "main agent should be waiting after interrupt");
assert.ok(main.awaiting_input_since, "main agent should be flagged awaiting input");
assert.strictEqual(main.awaiting_reason, "interrupted");
const evt = db
.prepare("SELECT * FROM events WHERE session_id = ? AND event_type = 'Interrupted'")
.get(sessionId);
assert.ok(evt, "an Interrupted event should be recorded");
} finally {
try {
fs.unlinkSync(tmpTranscript);
} catch {
// ignore
}
}
});
it("should NOT touch a session where the user resumed after the interrupt", async () => {
const tmpTranscript = path.join(os.tmpdir(), `watchdog-int-resumed-${Date.now()}.jsonl`);
const sessionId = `watchdog-int-resumed-${Date.now()}`;
const hooks = require("../routes/hooks");
try {
await post("/api/hooks/event", {
hook_type: "UserPromptSubmit",
data: { session_id: sessionId, transcript_path: tmpTranscript, cwd: "/tmp" },
});
assert.strictEqual(getMain(sessionId).status, "working");
// Transcript shows the interrupt was superseded by a fresh prompt: the
// user came back. pendingInterrupt must be false → no flip.
const base = new Date(Date.now() - 10_000);
fs.writeFileSync(
tmpTranscript,
interruptEntry(base.toISOString()) +
"\n" +
promptEntry(new Date(base.getTime() + 2_000).toISOString(), "actually do this") +
"\n"
);
makeStale(sessionId);
hooks.transcriptCache.invalidate(tmpTranscript);
hooks.watchdogCheck();
const main = getMain(sessionId);
assert.strictEqual(main.status, "working", "resumed session must stay working");
assert.strictEqual(main.awaiting_input_since, null, "no awaiting flag for a resumed session");
} finally {
try {
fs.unlinkSync(tmpTranscript);
} catch {
// ignore
}
}
});
// Age the session's hook events, updated_at, AND the transcript file mtime past
// the idle-working timeout so the no-marker fallback considers it dead.
function ageIdle(sessionId, transcriptPath, ms = 300_000) {
const old = new Date(Date.now() - ms).toISOString();
db.prepare("UPDATE events SET created_at = ? WHERE session_id = ?").run(old, sessionId);
db.prepare("UPDATE sessions SET updated_at = ? WHERE id = ?").run(old, sessionId);
const oldSec = (Date.now() - ms) / 1000;
fs.utimesSync(transcriptPath, oldSec, oldSec);
}
// The case that was actually stuck "working" forever: prompt submitted, Esc
// pressed BEFORE any output. Claude Code writes NO interrupt marker and fires
// NO hook, so pendingInterrupt is false — only the idle timeout can recover it.
it("should recover a stuck 'working' session via the idle timeout when there is no marker", async () => {
const tmpTranscript = path.join(os.tmpdir(), `watchdog-idle-${Date.now()}.jsonl`);
const sessionId = `watchdog-idle-${Date.now()}`;
const hooks = require("../routes/hooks");
try {
// Transcript holds only the user's prompt — no marker, no errors, no output.
fs.writeFileSync(
tmpTranscript,
promptEntry(new Date(Date.now() - 300_000).toISOString(), "hi") + "\n"
);
await post("/api/hooks/event", {
hook_type: "UserPromptSubmit",
data: { session_id: sessionId, transcript_path: tmpTranscript, cwd: "/tmp" },
});
const before = getMain(sessionId);
assert.strictEqual(before.status, "working");
assert.ok(!before.current_tool, "no tool in flight");
assert.strictEqual(
hooks.transcriptCache.extract(tmpTranscript).pendingInterrupt,
false,
"no marker → pendingInterrupt false"
);
ageIdle(sessionId, tmpTranscript);
hooks.transcriptCache.invalidate(tmpTranscript);
hooks.watchdogCheck();
const sess = stmts.getSession.get(sessionId);
const main = getMain(sessionId);
assert.ok(sess.awaiting_input_since, "session should be awaiting input after idle timeout");
assert.strictEqual(sess.awaiting_reason, "interrupted");
assert.strictEqual(main.status, "waiting", "main agent should be waiting after idle timeout");
assert.ok(main.awaiting_input_since);
assert.strictEqual(main.awaiting_reason, "interrupted");
} finally {
try {
fs.unlinkSync(tmpTranscript);
} catch {
// ignore
}
}
});
it("should NOT idle-timeout a session with a tool in flight", async () => {
const tmpTranscript = path.join(os.tmpdir(), `watchdog-tool-${Date.now()}.jsonl`);
const sessionId = `watchdog-tool-${Date.now()}`;
const hooks = require("../routes/hooks");
try {
fs.writeFileSync(
tmpTranscript,
promptEntry(new Date(Date.now() - 300_000).toISOString(), "run it") + "\n"
);
// PreToolUse sets current_tool — a long-running tool, not an interrupt.
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: {
session_id: sessionId,
transcript_path: tmpTranscript,
tool_name: "Bash",
cwd: "/tmp",
},
});
const before = getMain(sessionId);
assert.strictEqual(before.status, "working");
assert.ok(before.current_tool, "precondition: a tool is in flight");
ageIdle(sessionId, tmpTranscript);
hooks.transcriptCache.invalidate(tmpTranscript);
hooks.watchdogCheck();
const main = getMain(sessionId);
assert.strictEqual(main.status, "working", "a session mid-tool must not be idle-timed-out");
assert.strictEqual(main.awaiting_input_since, null);
} finally {
try {
fs.unlinkSync(tmpTranscript);
} catch {
// ignore
}
}
});
});
// ============================================================
// Nested Agent Spawning (agents spawning agents spawning agents)
// ============================================================
describe("Nested Agent Spawning", () => {
const SID = "hook-sess-nested";
it("should parent subagent to main when main is working (depth 0→1)", async () => {
// Main agent is working (auto-created on first event) and spawns a subagent
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: {
session_id: SID,
tool_name: "Agent",
tool_input: {
description: "Level-1 explorer",
subagent_type: "Explore",
prompt: "Explore the codebase",
},
},
});
const agentsRes = await fetch(`/api/agents?session_id=${SID}`);
const sub1 = agentsRes.body.agents.find((a) => a.name === "Level-1 explorer");
assert.ok(sub1, "Level-1 subagent should exist");
assert.equal(sub1.parent_agent_id, `${SID}-main`, "Level-1 parent should be main agent");
assert.equal(sub1.status, "working");
});
it("should parent sub-subagent to working subagent when main is waiting (depth 1→2)", async () => {
// Stop main agent so it goes idle — simulates main waiting for subagent results
await post("/api/hooks/event", {
hook_type: "Stop",
data: { session_id: SID, stop_reason: "end_turn" },
});
// Verify main is waiting
const mainRes = await fetch(`/api/agents/${SID}-main`);
assert.equal(mainRes.body.agent.status, "waiting", "Main should be waiting");
// Now a new Agent tool call arrives — since main is waiting, this must be from the working subagent
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: {
session_id: SID,
tool_name: "Agent",
tool_input: {
description: "Level-2 researcher",
subagent_type: "general-purpose",
prompt: "Research the topic",
},
},
});
const agentsRes = await fetch(`/api/agents?session_id=${SID}`);
const sub1 = agentsRes.body.agents.find((a) => a.name === "Level-1 explorer");
const sub2 = agentsRes.body.agents.find((a) => a.name === "Level-2 researcher");
assert.ok(sub2, "Level-2 subagent should exist");
assert.equal(
sub2.parent_agent_id,
sub1.id,
"Level-2 parent should be level-1 subagent, not main"
);
assert.equal(sub2.status, "working");
});
it("should parent sub-sub-subagent to deepest working agent (depth 2→3)", async () => {
// Level-2 is working, level-1 is working, main is waiting → deepest working is level-2
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: {
session_id: SID,
tool_name: "Agent",
tool_input: {
description: "Level-3 specialist",
subagent_type: "test-engineer",
prompt: "Write tests",
},
},
});
const agentsRes = await fetch(`/api/agents?session_id=${SID}`);
const sub2 = agentsRes.body.agents.find((a) => a.name === "Level-2 researcher");
const sub3 = agentsRes.body.agents.find((a) => a.name === "Level-3 specialist");
assert.ok(sub3, "Level-3 subagent should exist");
assert.equal(sub3.parent_agent_id, sub2.id, "Level-3 parent should be level-2 subagent");
});
it("should complete deepest agent first and shift parenting on SubagentStop", async () => {
// Complete level-3 first
await post("/api/hooks/event", {
hook_type: "SubagentStop",
data: { session_id: SID, description: "Level-3 specialist" },
});
const agentsRes = await fetch(`/api/agents?session_id=${SID}`);
const sub3 = agentsRes.body.agents.find((a) => a.name === "Level-3 specialist");
assert.equal(sub3.status, "completed", "Level-3 should be completed");
// Now spawn another agent — with level-3 completed, deepest working is level-2
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: {
session_id: SID,
tool_name: "Agent",
tool_input: {
description: "Level-3b sibling",
subagent_type: "Explore",
prompt: "Another task",
},
},
});
const agentsRes2 = await fetch(`/api/agents?session_id=${SID}`);
const sub2 = agentsRes2.body.agents.find((a) => a.name === "Level-2 researcher");
const sub3b = agentsRes2.body.agents.find((a) => a.name === "Level-3b sibling");
assert.ok(sub3b, "Level-3b sibling should exist");
assert.equal(sub3b.parent_agent_id, sub2.id, "Level-3b should be parented to level-2");
});
it("should return correct tree structure from workflows endpoint", async () => {
// Complete remaining agents so tree is stable
await post("/api/hooks/event", {
hook_type: "SubagentStop",
data: { session_id: SID, description: "Level-3b sibling" },
});
await post("/api/hooks/event", {
hook_type: "SubagentStop",
data: { session_id: SID, description: "Level-2 researcher" },
});
await post("/api/hooks/event", {
hook_type: "SubagentStop",
data: { session_id: SID, description: "Level-1 explorer" },
});
const res = await fetch(`/api/workflows/session/${SID}`);
assert.equal(res.status, 200);
const { tree } = res.body;
assert.ok(tree.length >= 1, "Tree should have root nodes");
// Find main agent in tree
const mainNode = tree.find((n) => n.type === "main");
assert.ok(mainNode, "Main agent should be a root node");
assert.ok(mainNode.children.length >= 1, "Main should have children");
// Find level-1 in main's children
const l1 = mainNode.children.find((c) => c.name === "Level-1 explorer");
assert.ok(l1, "Level-1 should be child of main");
assert.ok(l1.children.length >= 1, "Level-1 should have children");
// Find level-2 in level-1's children
const l2 = l1.children.find((c) => c.name === "Level-2 researcher");
assert.ok(l2, "Level-2 should be child of level-1");
assert.ok(l2.children.length >= 1, "Level-2 should have children");
// Level-3 and level-3b should be children of level-2
const l3names = l2.children.map((c) => c.name);
assert.ok(l3names.includes("Level-3 specialist"), "Level-3 should be child of level-2");
assert.ok(l3names.includes("Level-3b sibling"), "Level-3b should be child of level-2");
});
it("should complete all nested agents on SessionEnd", async () => {
// Create a fresh session with deep nesting, then SessionEnd
const sid = "hook-sess-nested-end";
// Main spawns level-1
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: {
session_id: sid,
tool_name: "Agent",
tool_input: { description: "End-L1", prompt: "task" },
},
});
await post("/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, stop_reason: "end_turn" },
});
// Level-1 spawns level-2
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: {
session_id: sid,
tool_name: "Agent",
tool_input: { description: "End-L2", prompt: "subtask" },
},
});
// SessionEnd should complete everything
await post("/api/hooks/event", {
hook_type: "SessionEnd",
data: { session_id: sid },
});
const agentsRes = await fetch(`/api/agents?session_id=${sid}`);
for (const agent of agentsRes.body.agents) {
assert.equal(
agent.status,
"completed",
`Agent ${agent.name} should be completed after SessionEnd`
);
assert.ok(agent.ended_at, `Agent ${agent.name} should have ended_at`);
}
});
it("should handle orphaned subagents when parent is missing", async () => {
// Create session with main, a legitimate subagent, then delete the parent to orphan it
stmts.insertSession.run("orphan-sess", "Orphan Test", "active", null, null, null);
stmts.insertAgent.run(
"orphan-main",
"orphan-sess",
"Main",
"main",
null,
"working",
null,
null,
null
);
// Create a subagent parented to main, then we'll check tree structure
stmts.insertAgent.run(
"orphan-real-parent",
"orphan-sess",
"Real Parent",
"subagent",
"Explore",
"completed",
null,
"orphan-main",
null
);
// Create a child of the real parent — this will become orphaned when we NULL its parent
stmts.insertAgent.run(
"orphan-sub",
"orphan-sess",
"Orphan Sub",
"subagent",
"Explore",
"working",
null,
"orphan-real-parent",
null
);
// Delete the real parent — FK ON DELETE SET NULL means orphan-sub.parent_agent_id becomes NULL
db.prepare("DELETE FROM agents WHERE id = 'orphan-real-parent'").run();
const res = await fetch("/api/workflows/session/orphan-sess");
assert.equal(res.status, 200);
// The orphan should appear as a root (parent was deleted, FK set to NULL)
const { tree } = res.body;
const orphanNode = tree.find((n) => n.name === "Orphan Sub");
assert.ok(orphanNode, "Orphaned subagent should appear as a root node in tree");
});
it("should parent to main when main is working and subagents also working (parallel)", async () => {
const sid = "hook-sess-parallel";
// Main spawns first subagent
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: {
session_id: sid,
tool_name: "Agent",
tool_input: { description: "Parallel-A", prompt: "task A" },
},
});
// Main is still working — spawns another subagent
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: {
session_id: sid,
tool_name: "Agent",
tool_input: { description: "Parallel-B", prompt: "task B" },
},
});
const agentsRes = await fetch(`/api/agents?session_id=${sid}`);
const subA = agentsRes.body.agents.find((a) => a.name === "Parallel-A");
const subB = agentsRes.body.agents.find((a) => a.name === "Parallel-B");
assert.equal(subA.parent_agent_id, `${sid}-main`, "Parallel-A should be parented to main");
assert.equal(
subB.parent_agent_id,
`${sid}-main`,
"Parallel-B should be parented to main (main was working)"
);
});
it("should verify depth calculation in workflows stats", async () => {
const res = await fetch("/api/workflows");
assert.equal(res.status, 200);
// Our nested session (hook-sess-nested) has depth 3, so avg should be > 0
assert.ok(typeof res.body.stats.avgDepth === "number", "avgDepth should be a number");
assert.ok(res.body.stats.avgDepth > 0, "avgDepth should be > 0 with nested agents");
});
it("should support arbitrary depth (depth 7 chain)", async () => {
const sid = "hook-sess-deep7";
const DEPTH = 7;
// Spawn level-1 from main (main is working on first event)
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: {
session_id: sid,
tool_name: "Agent",
tool_input: { description: "Deep-L1", prompt: "task" },
},
});
// Stop main so subagent events get parented correctly
await post("/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, stop_reason: "end_turn" },
});
// Spawn levels 2 through DEPTH — each parented to the previous
for (let i = 2; i <= DEPTH; i++) {
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: {
session_id: sid,
tool_name: "Agent",
tool_input: { description: `Deep-L${i}`, prompt: `task at depth ${i}` },
},
});
}
// Verify the chain: each level should be parented to the previous
const agentsRes = await fetch(`/api/agents?session_id=${sid}`);
const agents = agentsRes.body.agents;
const byName = {};
for (const a of agents) byName[a.name] = a;
assert.equal(byName["Deep-L1"].parent_agent_id, `${sid}-main`, "L1 parent = main");
for (let i = 2; i <= DEPTH; i++) {
const child = byName[`Deep-L${i}`];
const parent = byName[`Deep-L${i - 1}`];
assert.ok(child, `Deep-L${i} should exist`);
assert.ok(parent, `Deep-L${i - 1} should exist`);
assert.equal(
child.parent_agent_id,
parent.id,
`Deep-L${i} should be parented to Deep-L${i - 1}`
);
}
// Verify tree structure from workflows endpoint
const treeRes = await fetch(`/api/workflows/session/${sid}`);
assert.equal(treeRes.status, 200);
let node = treeRes.body.tree.find((n) => n.type === "main");
assert.ok(node, "Main should be root");
for (let i = 1; i <= DEPTH; i++) {
assert.ok(node.children.length >= 1, `Node at depth ${i - 1} should have children`);
node = node.children.find((c) => c.name === `Deep-L${i}`);
assert.ok(node, `Deep-L${i} should be in tree at depth ${i}`);
}
assert.equal(node.children.length, 0, "Deepest node should be a leaf");
});
it("should unwind correctly when inner agents stop (depth 5, then spawn sibling)", async () => {
const sid = "hook-sess-unwind";
// Build chain: main → L1 → L2 → L3 → L4 → L5
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: {
session_id: sid,
tool_name: "Agent",
tool_input: { description: "UW-L1", prompt: "t" },
},
});
await post("/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, stop_reason: "end_turn" },
});
for (let i = 2; i <= 5; i++) {
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: {
session_id: sid,
tool_name: "Agent",
tool_input: { description: `UW-L${i}`, prompt: "t" },
},
});
}
// Now complete L5 and L4
await post("/api/hooks/event", {
hook_type: "SubagentStop",
data: { session_id: sid, description: "UW-L5" },
});
await post("/api/hooks/event", {
hook_type: "SubagentStop",
data: { session_id: sid, description: "UW-L4" },
});
// Deepest working should now be L3. Spawn a new agent — should parent to L3.
await post("/api/hooks/event", {
hook_type: "PreToolUse",
data: {
session_id: sid,
tool_name: "Agent",
tool_input: { description: "UW-L4b", prompt: "t" },
},
});
const agentsRes = await fetch(`/api/agents?session_id=${sid}`);
const agents = agentsRes.body.agents;
const byName = {};
for (const a of agents) byName[a.name] = a;
assert.equal(byName["UW-L5"].status, "completed");
assert.equal(byName["UW-L4"].status, "completed");
assert.equal(byName["UW-L3"].status, "working");
assert.equal(
byName["UW-L4b"].parent_agent_id,
byName["UW-L3"].id,
"After unwinding to L3, new spawn should parent to L3"
);
});
});