/** * @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ĩ */ 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\/Claude-Code-Monitor/ ); 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("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 //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("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" ); }); });