clear awaitingAnswer in the input handler, not just on agent_start. had a bug where a master agent's reply, sent while the subagent was still mid-turn, was being consumed as a steer => subagent was left open waiting indefinitely. fixed now

This commit is contained in:
Amos
2026-08-20 18:32:03 +03:00
parent d9d01c30ff
commit 865648c4f1
4 changed files with 111 additions and 6 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
name: researcher name: researcher
description: Web researcher — searches the web and synthesizes findings description: Web researcher — searches the web and synthesizes findings
tools: web_search, web_fetch, safe_bash tools: web_search, web_fetch, safe_bash
model: openrouter/qwen/qwen3.8-max model: openrouter/qwen/qwen3.8-2.4t-a95b
thinking: medium thinking: medium
system-prompt: append system-prompt: append
auto-exit: true auto-exit: true
+2 -2
View File
@@ -3,8 +3,8 @@ name: worker
description: General-purpose worker — reads, writes, and edits code description: General-purpose worker — reads, writes, and edits code
tools: read, write, edit, safe_bash, web_search, web_fetch tools: read, write, edit, safe_bash, web_search, web_fetch
subagent_agents: scout, researcher subagent_agents: scout, researcher
model: openrouter/z-ai/glm-5.2 model: openrouter/qwen/qwen3.8-2.4t-a95b
thinking: medium thinking: high
system-prompt: append system-prompt: append
auto-exit: true auto-exit: true
--- ---
+13 -3
View File
@@ -178,9 +178,10 @@ export default function (pi: ExtensionAPI) {
let userTookOver = false; let userTookOver = false;
let agentStarted = false; let agentStarted = false;
// Set when ask_question is called; suppresses auto-exit for that turn so the // Set when ask_question is called; suppresses auto-exit so the session stays
// session stays open while it waits for the orchestrator's reply. Cleared at // open while it waits for the orchestrator's reply. Cleared when the reply
// the start of the next turn (when the reply lands). // lands — on `input` (covers a reply steered into the current run) and on
// `agent_start` (covers a reply that starts a fresh turn after parking).
let awaitingAnswer = false; let awaitingAnswer = false;
// Show widget + status bar on session start // Show widget + status bar on session start
@@ -195,6 +196,15 @@ export default function (pi: ExtensionAPI) {
pi.on("input", () => { pi.on("input", () => {
recorder.input(); recorder.input();
// A submitted message is the orchestrator's (or a human's) reply — the
// pending ask_question has been answered, however it was delivered. Clear
// here, not only on agent_start, because a reply steered in *mid-run* is
// absorbed into the current run (pi's `steer` behavior injects it before
// the next LLM call): no new agent_start fires, so without this the flag
// would stay set and agent_end would park the session as `waiting` even
// though the answer already arrived and was consumed. (The `input` event
// fires for mid-run steers because prompt() emits it before queueing.)
awaitingAnswer = false;
// Ignore the initial task message that starts an autonomous subagent. // Ignore the initial task message that starts an autonomous subagent.
// Only inputs after the first agent run has started count as user takeover. // Only inputs after the first agent run has started count as user takeover.
if (!shouldMarkUserTookOver(agentStarted)) return; if (!shouldMarkUserTookOver(agentStarted)) return;
+95
View File
@@ -1656,6 +1656,101 @@ describe("subagent-done.ts", () => {
rmSync(dir, { recursive: true, force: true }); rmSync(dir, { recursive: true, force: true });
} }
}); });
// Regression tests for the mid-run reply race: a reply steered in while the
// asking run is still open fires `input` but NOT `agent_start`, so the flag
// must be cleared on `input` or the session parks forever.
function setupCapturingExtension(sessionFile: string) {
const handlers = new Map<string, Array<(...args: any[]) => void>>();
const tools: any[] = [];
const api = {
on(event: string, handler: (...args: any[]) => void) {
if (!handlers.has(event)) handlers.set(event, []);
handlers.get(event)!.push(handler);
},
registerTool(t: any) { tools.push(t); },
registerCommand() {}, registerMessageRenderer() {}, registerShortcut() {},
sendUserMessage() {}, sendMessage() {}, getAllTools() { return []; },
} as any;
const saved = {
session: process.env.PI_SUBAGENT_SESSION,
name: process.env.PI_SUBAGENT_NAME,
agent: process.env.PI_SUBAGENT_AGENT,
autoExit: process.env.PI_SUBAGENT_AUTO_EXIT,
};
process.env.PI_SUBAGENT_SESSION = sessionFile;
process.env.PI_SUBAGENT_NAME = "scout-2";
process.env.PI_SUBAGENT_AGENT = "scout";
process.env.PI_SUBAGENT_AUTO_EXIT = "1";
subagentDoneExtension(api);
const emit = (event: string, ...args: any[]) =>
(handlers.get(event) ?? []).forEach((h) => h(...args));
const restore = () => {
restoreEnvVar("PI_SUBAGENT_SESSION", saved.session);
restoreEnvVar("PI_SUBAGENT_NAME", saved.name);
restoreEnvVar("PI_SUBAGENT_AGENT", saved.agent);
restoreEnvVar("PI_SUBAGENT_AUTO_EXIT", saved.autoExit);
};
const ask = async () => {
const tool = tools.find((t) => t.name === "ask_question");
await tool.execute("c1", { question: "v1 or v2?" }, undefined, undefined, { shutdown() {} });
};
return { emit, ask, restore };
}
it("exits (does not park) when the reply arrives mid-run via input", async () => {
const dir = createTestDir();
const { emit, ask, restore } = setupCapturingExtension(join(dir, "s.jsonl"));
try {
emit("agent_start");
await ask(); // sets awaitingAnswer mid-run
// Reply arrives MID-RUN as a steer: input fires, no new agent_start.
emit("input");
let shutdown = false;
emit("agent_end", { messages: [] }, { shutdown() { shutdown = true; } });
assert.equal(shutdown, true, "reply consumed mid-run → agent_end should exit, not park");
} finally {
restore();
rmSync(dir, { recursive: true, force: true });
}
});
it("parks as waiting at agent_end while the reply is still pending (no input yet)", async () => {
const dir = createTestDir();
const { emit, ask, restore } = setupCapturingExtension(join(dir, "s.jsonl"));
try {
emit("agent_start");
await ask();
// No input yet — the orchestrator has not replied.
let shutdown = false;
emit("agent_end", { messages: [] }, { shutdown() { shutdown = true; } });
assert.equal(shutdown, false, "pending question with no reply must park, not exit");
} finally {
restore();
rmSync(dir, { recursive: true, force: true });
}
});
it("exits when the reply arrives as a new turn (agent_start also clears the flag)", async () => {
const dir = createTestDir();
const { emit, ask, restore } = setupCapturingExtension(join(dir, "s.jsonl"));
try {
emit("agent_start");
await ask();
let shutdown1 = false;
emit("agent_end", { messages: [] }, { shutdown() { shutdown1 = true; } });
assert.equal(shutdown1, false, "parks while waiting");
// Reply arrives as a fresh turn after the subagent had parked.
emit("input");
emit("agent_start");
let shutdown2 = false;
emit("agent_end", { messages: [] }, { shutdown() { shutdown2 = true; } });
assert.equal(shutdown2, true, "after the reply turn, agent_end should exit");
} finally {
restore();
rmSync(dir, { recursive: true, force: true });
}
});
}); });
}); });