diff --git a/agents/researcher.md b/agents/researcher.md index 5d4d21f..35d6c28 100644 --- a/agents/researcher.md +++ b/agents/researcher.md @@ -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 diff --git a/agents/worker.md b/agents/worker.md index d237a7a..333982e 100644 --- a/agents/worker.md +++ b/agents/worker.md @@ -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 --- diff --git a/pi-extension/subagents/subagent-done.ts b/pi-extension/subagents/subagent-done.ts index e301d1b..ba37f06 100644 --- a/pi-extension/subagents/subagent-done.ts +++ b/pi-extension/subagents/subagent-done.ts @@ -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; diff --git a/test/test.ts b/test/test.ts index 56987be..5b98f42 100644 --- a/test/test.ts +++ b/test/test.ts @@ -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 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 }); + } + }); }); });