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:
@@ -2,7 +2,7 @@
|
||||
name: researcher
|
||||
description: Web researcher — searches the web and synthesizes findings
|
||||
tools: web_search, web_fetch, safe_bash
|
||||
model: openrouter/qwen/qwen3.8-max
|
||||
model: openrouter/qwen/qwen3.8-2.4t-a95b
|
||||
thinking: medium
|
||||
system-prompt: append
|
||||
auto-exit: true
|
||||
|
||||
+2
-2
@@ -3,8 +3,8 @@ name: worker
|
||||
description: General-purpose worker — reads, writes, and edits code
|
||||
tools: read, write, edit, safe_bash, web_search, web_fetch
|
||||
subagent_agents: scout, researcher
|
||||
model: openrouter/z-ai/glm-5.2
|
||||
thinking: medium
|
||||
model: openrouter/qwen/qwen3.8-2.4t-a95b
|
||||
thinking: high
|
||||
system-prompt: append
|
||||
auto-exit: true
|
||||
---
|
||||
|
||||
@@ -178,9 +178,10 @@ export default function (pi: ExtensionAPI) {
|
||||
|
||||
let userTookOver = false;
|
||||
let agentStarted = false;
|
||||
// Set when ask_question is called; suppresses auto-exit for that turn so the
|
||||
// session stays open while it waits for the orchestrator's reply. Cleared at
|
||||
// the start of the next turn (when the reply lands).
|
||||
// Set when ask_question is called; suppresses auto-exit so the session stays
|
||||
// open while it waits for the orchestrator's reply. Cleared when the reply
|
||||
// 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;
|
||||
|
||||
// Show widget + status bar on session start
|
||||
@@ -195,6 +196,15 @@ export default function (pi: ExtensionAPI) {
|
||||
|
||||
pi.on("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.
|
||||
// Only inputs after the first agent run has started count as user takeover.
|
||||
if (!shouldMarkUserTookOver(agentStarted)) return;
|
||||
|
||||
@@ -1656,6 +1656,101 @@ describe("subagent-done.ts", () => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user