f9869ada6d
Claude Code skips the Stop hook in roughly one turn in fifteen (only the prompt-suggestion helper's SubagentStop arrives), so the main agent stayed "working" until the idle Notification a minute later and the chat kept showing Stop on a finished answer. The transcript's turn_duration entry is now an end of turn too: one newer than the session's last prompt, read while main still works with no tool open, lands it in Waiting like Stop. The chat's Stop button now drops to Send only after every busy signal has been quiet for 2.5 s (Stop pressed still drops at once), so a spinner line that comes and goes while the CLI redraws no longer flips it back and forth, and only the newest "working" read is applied.
107 lines
4.0 KiB
JavaScript
107 lines
4.0 KiB
JavaScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, before, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const path = require("path");
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const http = require("http");
|
|
|
|
const STAMP = `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");
|
|
});
|
|
});
|