/** * @file Regression test: a turn the transcript says has ended lands the main * agent in Waiting even when Claude Code never sends the Stop hook (it skips * it in some turns — only the prompt-suggestion helper's SubagentStop * arrives). Before, the agent stayed `working` until the idle Notification a * minute later and the chat showed Stop on a finished answer. A turn end OLDER * than the last prompt, or one read on new work (UserPromptSubmit), must not. * @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 STAMP = `turn-end-${Date.now()}-${process.pid}`; const TMP = path.join(os.tmpdir(), STAMP); process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db"); process.env.CLAUDE_HOME = path.join(TMP, "home"); process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data"); const { createApp, startServer } = require("../index"); const { stmts } = require("../db"); let server; let BASE; function post(body) { return new Promise((resolve, reject) => { const url = new URL("/api/hooks/event", BASE); const payload = JSON.stringify(body); const r = http.request( { hostname: url.hostname, port: url.port, path: url.pathname, method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }, }, (res) => { res.resume(); res.on("end", () => resolve(res.statusCode)); } ); r.on("error", reject); r.write(payload); r.end(); }); } const line = (o) => JSON.stringify(o) + "\n"; const user = (text) => ({ type: "user", message: { role: "user", content: text } }); const reply = (id) => ({ type: "assistant", message: { id, model: "claude-sonnet-5", content: [{ type: "text", text: "ok" }], usage: { input_tokens: 1, output_tokens: 1 } }, }); const turnEnd = (ts) => ({ type: "system", subtype: "turn_duration", durationMs: 900, timestamp: ts }); before(async () => { fs.mkdirSync(TMP, { recursive: true }); server = await startServer(createApp(), 0); BASE = `http://127.0.0.1:${server.address().port}`; }); after(() => { server?.close(); fs.rmSync(TMP, { recursive: true, force: true }); }); describe("a finished turn without a Stop hook", () => { it("lands in Waiting on the next hook that reads the turn's end", async () => { const cwd = path.join(TMP, "work"); fs.mkdirSync(cwd, { recursive: true }); const sid = "12121212-3434-5656-7878-909090909090"; const tpath = path.join(TMP, `${sid}.jsonl`); const event = (extra = {}) => ({ session_id: sid, cwd, transcript_path: tpath, ...extra }); const main = () => stmts.getAgent.get(`${sid}-main`); fs.writeFileSync(tpath, line(user("hello"))); assert.equal(await post({ hook_type: "UserPromptSubmit", data: event({ prompt: "hello" }) }), 200); assert.equal(main().status, "working"); // The turn ends; Claude Code writes turn_duration but sends no Stop — // only a helper agent's SubagentStop arrives. fs.appendFileSync(tpath, line(reply("m1")) + line(turnEnd(new Date().toISOString()))); assert.equal( await post({ hook_type: "SubagentStop", data: event({ agent_id: "a1", agent_type: "" }) }), 200 ); assert.equal(main().status, "waiting"); assert.equal(stmts.getSession.get(sid).awaiting_reason, "stop"); // A new prompt: that turn's old end must not end the new one. await new Promise((r) => setTimeout(r, 5)); fs.appendFileSync(tpath, line(user("again"))); assert.equal(await post({ hook_type: "UserPromptSubmit", data: event({ prompt: "again" }) }), 200); assert.equal(main().status, "working"); assert.equal( await post({ hook_type: "SubagentStop", data: event({ agent_id: "a2", agent_type: "" }) }), 200 ); assert.equal(main().status, "working", "no turn end newer than the prompt yet"); }); });