Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions server/__tests__/awaiting-subagent-guard.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/**
* @file Regression: a BACKGROUND subagent's tool events must not clear a
* genuine 'notification' waiting flag held by the MAIN agent (blocked on the
* user via AskUserQuestion / permission). Before the fix, PreToolUse/PostToolUse
* cleared awaiting_input_since unconditionally, so a session that was truly
* "waiting for you" oscillated back to active on every subagent tool event —
* AI-Deck (12s poll) and deck-web (5s WS) then disagreed about the same session.
*
* The guard reuses the existing subagent-actor heuristic (findDeepestWorkingAgent
* while main is 'waiting'): when a subagent is the actor, only PASSIVE waits
* (stop/session_start/interrupted) are cleared; a 'notification' wait is
* preserved. When MAIN is the actor, clearing is unconditional (keeps the
* documented permission-mid-tool path intact).
*
* This test lives in the fork's own suite so a future upstream merge that
* silently reverts the guard fails loudly here (home-network monitor fork,
* see decisions/).
*/

const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const os = require("os");
const http = require("http");

const TEST_DB = path.join(os.tmpdir(), `awaiting-guard-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_LIVENESS_PROBE = "0";

const { createApp, startServer } = require("../index");

let server;
let BASE;

function fetch(urlPath, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: options.method || "GET",
headers: { "Content-Type": "application/json", ...options.headers },
},
(res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(body);
} catch {
parsed = body;
}
resolve({ status: res.statusCode, body: parsed });
});
}
);
req.on("error", reject);
if (options.body) req.write(JSON.stringify(options.body));
req.end();
});
}

const post = (p, body) => fetch(p, { method: "POST", body });
const hook = (hook_type, data) => post("/api/hooks/event", { hook_type, data });
const sessionOf = async (id) => (await fetch(`/api/sessions/${id}`)).body.session;

/**
* Drive a session into: main agent 'waiting' with the given reason, AND a live
* working subagent (so findDeepestWorkingAgent returns it). Returns nothing;
* asserts each precondition so a harness regression is obvious.
*/
async function sessionWaitingWithWorkingSubagent(sid, { notification }) {
await hook("SessionStart", { session_id: sid });
// UserPromptSubmit clears the session_start wait and promotes main → working.
await hook("UserPromptSubmit", { session_id: sid, prompt: "go" });
// Main (working) spawns a subagent → subagent row inserted with status working.
await hook("PreToolUse", {
session_id: sid,
tool_name: "Agent",
tool_input: { subagent_type: "reviewer", prompt: "review" },
});
// Now block the MAIN agent. A waiting-for-user Notification stamps
// reason='notification'; a Stop stamps the passive reason='stop'.
if (notification) {
await hook("Notification", { session_id: sid, message: "Claude is waiting for your input" });
} else {
await hook("Stop", { session_id: sid });
}
const sess = await sessionOf(sid);
assert.ok(sess.awaiting_input_since, "precondition: session should be awaiting");
assert.equal(
sess.awaiting_reason,
notification ? "notification" : "stop",
"precondition: expected awaiting_reason"
);
}

before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});

after(() => {
if (server) server.close();
});

describe("awaiting guard: subagent tool events vs. main-agent waiting", () => {
it("PRESERVES a 'notification' wait when a background subagent fires PreToolUse", async () => {
const sid = "guard-notif-pre";
await sessionWaitingWithWorkingSubagent(sid, { notification: true });

// Subagent (deepest working, main is waiting) runs a tool. This must NOT
// clear the main agent's genuine "waiting for you" flag.
await hook("PreToolUse", { session_id: sid, tool_name: "Bash" });

const sess = await sessionOf(sid);
assert.ok(sess.awaiting_input_since, "notification wait must survive subagent PreToolUse");
assert.equal(sess.awaiting_reason, "notification");
});

it("PRESERVES a 'notification' wait when a background subagent fires PostToolUse", async () => {
const sid = "guard-notif-post";
await sessionWaitingWithWorkingSubagent(sid, { notification: true });

await hook("PostToolUse", { session_id: sid, tool_name: "Bash" });

const sess = await sessionOf(sid);
assert.ok(sess.awaiting_input_since, "notification wait must survive subagent PostToolUse");
assert.equal(sess.awaiting_reason, "notification");
});

it("CLEARS a passive 'stop' wait when a background subagent fires a tool event", async () => {
// Passive-clear is desirable: a backgrounded subagent's activity should flip
// a merely-Stopped session back to active ("done/idle only while no agent works").
const sid = "guard-stop-pre";
await sessionWaitingWithWorkingSubagent(sid, { notification: false });

await hook("PreToolUse", { session_id: sid, tool_name: "Bash" });

const sess = await sessionOf(sid);
assert.equal(
sess.awaiting_input_since,
null,
"passive stop wait should be cleared by subagent activity"
);
assert.equal(sess.awaiting_reason, null);
});

it("CLEARS a 'notification' wait when MAIN (no working subagent) resumes with a tool", async () => {
// Control: main was waiting on the user, no subagent running. A PreToolUse
// means main itself resumed — clearing is correct (unchanged behaviour).
const sid = "guard-notif-mainactor";
await hook("SessionStart", { session_id: sid });
await hook("UserPromptSubmit", { session_id: sid, prompt: "go" });
await hook("Notification", { session_id: sid, message: "Claude is waiting for your input" });
let sess = await sessionOf(sid);
assert.ok(sess.awaiting_input_since && sess.awaiting_reason === "notification", "precondition");

await hook("PreToolUse", { session_id: sid, tool_name: "Bash" });

sess = await sessionOf(sid);
assert.equal(sess.awaiting_input_since, null, "main resuming must clear its own wait");
assert.equal(sess.awaiting_reason, null);
});
});
67 changes: 64 additions & 3 deletions server/routes/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,51 @@ function clearAwaitingInput(sessionId, mainAgentId, broadcastUpdates) {
}
}

// Reason-aware guarded clear (2026-07-17, AI-Deck/deck-web fork patch — see
// decisions/ in the home-network repo). PreToolUse/PostToolUse clear the
// waiting flag on the assumption that a tool event means the human resumed.
// That assumption breaks when the MAIN agent is blocked on the user
// (AskUserQuestion / permission) while a BACKGROUND subagent keeps firing
// PreToolUse/PostToolUse: the subagent's tool event is not the human
// responding, yet it wiped the flag — so AI-Deck/deck-web lost the "waiting
// for you" signal (it oscillated on/off in seconds).
//
// The existing subagent-actor heuristic (findDeepestWorkingAgent, used just
// below in each case) already tells us when a subagent — not main — is the
// actor. We reuse it here: when a subagent is the actor we clear ONLY passive
// waits (stop/session_start/interrupted); a genuine 'notification' wait (main
// blocked on the user) is preserved. When MAIN is the actor we clear
// unconditionally, exactly as before — this keeps the documented
// permission-mid-tool path (PostToolUse clearing an approved prompt) intact.
//
// Passive-clear-by-subagent is deliberate and desirable: it is what lets a
// backgrounded subagent's activity flip a session that merely Stop-ed
// (reason='stop') back to active — i.e. "done/idle only while no agent works".
function clearAwaitingInputRespectingActor(
sessionId,
mainAgentId,
subagentIsActor,
broadcastUpdates
) {
if (subagentIsActor) {
const sess = stmts.getSession.get(sessionId);
if (sess && sess.awaiting_reason === "notification") {
// Main is blocked on the user; a subagent tool event must not clear it.
return;
}
}
clearAwaitingInput(sessionId, mainAgentId, broadcastUpdates);
}

// True when the deepest currently-working agent is a subagent while the main
// agent is in 'waiting' — i.e. an incoming tool event is attributable to a
// subagent, not to the main agent resuming. Mirrors the inline heuristic the
// PreToolUse/PostToolUse cases already use for agent attribution.
function subagentIsActorNow(sessionId, mainAgent) {
if (!mainAgent || mainAgent.status !== "waiting") return false;
return !!stmts.findDeepestWorkingAgent.get(sessionId, sessionId);
}

// Land a session that was cancelled with no hook (Esc) in the same
// waiting + awaiting-input state a normal Stop produces, and log a timeline
// event. Used by both watchdog recovery paths: the transcript-marker path
Expand Down Expand Up @@ -334,8 +379,17 @@ const processEvent = db.transaction((hookType, data) => {

// PreToolUse means Claude is actively running a tool, ergo the user
// has resumed (Stop only fires at end of turn — Claude can't start a
// new tool call without fresh user input). Clear waiting now.
clearAwaitingInput(sessionId, mainAgentId, true);
// new tool call without fresh user input). Clear waiting now — UNLESS a
// background subagent is the actor and main is blocked on the user
// (reason='notification'), in which case the flag is preserved
// (clearAwaitingInputRespectingActor). Computed before the clear because
// the Agent-spawn branch below mutates agent rows.
clearAwaitingInputRespectingActor(
sessionId,
mainAgentId,
subagentIsActorNow(sessionId, mainAgent),
true
);

// If the tool is Agent, a subagent is being created
if (toolName === "Agent") {
Expand Down Expand Up @@ -414,7 +468,14 @@ const processEvent = db.transaction((hookType, data) => {
// Code prompts the user mid-tool). The Notification stamps waiting,
// the user approves, the tool completes, PostToolUse arrives. Without
// a clear here, we'd be stuck in waiting until the next PreToolUse.
clearAwaitingInput(sessionId, mainAgentId, true);
// Same actor-guard as PreToolUse: a background subagent's PostToolUse
// must not clear a genuine 'notification' wait held by the main agent.
clearAwaitingInputRespectingActor(
sessionId,
mainAgentId,
subagentIsActorNow(sessionId, mainAgent),
true
);

// NOTE: PostToolUse for "Agent" tool fires immediately when a subagent is
// backgrounded — it does NOT mean the subagent finished its work.
Expand Down
Loading