diff --git a/src/wrapper/cli.mjs b/src/wrapper/cli.mjs index 9eeaaa4..47c5ff6 100644 --- a/src/wrapper/cli.mjs +++ b/src/wrapper/cli.mjs @@ -10,6 +10,10 @@ async function sleep(ms) { await new Promise((resolve) => setTimeout(resolve, ms)); } +function isTerminalAssistantStopReason(stopReason) { + return stopReason === "stop" || stopReason === "length" || stopReason === "aborted" || stopReason === "error"; +} + async function appendJsonLine(path, value) { await appendBestEffort(path, `${JSON.stringify(value)}\n`); } @@ -41,6 +45,7 @@ function makeResult(meta, startedAt, input = {}) { finishedAt: new Date().toISOString(), exitCode, stopReason: input.stopReason ?? (exitCode === 0 ? undefined : "error"), + rawStopReason: input.rawStopReason, finalText: input.finalText ?? "", usage: input.usage, stdoutPath: meta.stdoutPath, @@ -71,6 +76,7 @@ async function runWrapper(meta, startedAt) { let finalText = ""; let resolvedModel = meta.resolvedModel; let stopReason; + let rawStopReason; let usage = undefined; let stdoutBuffer = ""; let stderrText = ""; @@ -122,9 +128,10 @@ async function runWrapper(meta, startedAt) { finalText = normalized.text; resolvedModel = normalized.model ?? resolvedModel; stopReason = normalized.stopReason ?? stopReason; + rawStopReason = normalized.rawStopReason ?? rawStopReason; usage = normalized.usage ?? usage; - if (normalized.stopReason) { + if (isTerminalAssistantStopReason(normalized.stopReason)) { void forceChildExitAfterSemanticCompletion(); } } @@ -208,6 +215,7 @@ async function runWrapper(meta, startedAt) { return makeResult(meta, startedAt, { exitCode, stopReason, + rawStopReason, finalText, usage, resolvedModel, diff --git a/src/wrapper/cli.test.ts b/src/wrapper/cli.test.ts index 91114ac..30a4954 100644 --- a/src/wrapper/cli.test.ts +++ b/src/wrapper/cli.test.ts @@ -191,6 +191,72 @@ test("wrapper exits and writes result.json when the pi child cannot be spawned", assert.match(result.errorMessage ?? "", /ENOENT|not found|spawn pi/i); }); +test("wrapper does not exit early on non-terminal toolUse assistant messages", async () => { + const dir = await mkdtemp(join(tmpdir(), "pi-subagents-wrapper-")); + const metaPath = join(dir, "meta.json"); + const resultPath = join(dir, "result.json"); + const eventsPath = join(dir, "events.jsonl"); + const piPath = join(dir, "pi"); + + await writeFile( + piPath, + [ + `#!${process.execPath}`, + "console.log(JSON.stringify({type:'message_end',message:{role:'assistant',content:[{type:'text',text:'starting'}],model:'openai/gpt-5',stopReason:'toolUse'}}));", + "setTimeout(() => console.log(JSON.stringify({type:'tool_execution_start',toolName:'read',args:{path:'src/auth.ts'}})), 400);", + "setTimeout(() => console.log(JSON.stringify({type:'message_end',message:{role:'assistant',content:[{type:'text',text:'final'}],model:'openai/gpt-5',stopReason:'stop'}})), 700);", + "setTimeout(() => process.exit(0), 1200);", + ].join("\n"), + "utf8", + ); + await chmod(piPath, 0o755); + + await writeFile( + metaPath, + JSON.stringify( + { + runId: "run-1", + mode: "single", + agent: "scout", + agentSource: "builtin", + task: "inspect auth", + cwd: dir, + requestedModel: "openai/gpt-5", + resolvedModel: "openai/gpt-5", + startedAt: "2026-04-09T00:00:00.000Z", + sessionPath: join(dir, "child-session.jsonl"), + eventsPath, + resultPath, + stdoutPath: join(dir, "stdout.log"), + stderrPath: join(dir, "stderr.log"), + transcriptPath: join(dir, "transcript.log"), + systemPromptPath: join(dir, "system-prompt.md"), + }, + null, + 2, + ), + "utf8", + ); + + const wrapperPath = join(dirname(fileURLToPath(import.meta.url)), "cli.mjs"); + const child = spawn(process.execPath, [wrapperPath, metaPath], { + env: { ...process.env, PATH: dir }, + stdio: ["ignore", "pipe", "pipe"], + }); + + const exitCode = await waitForExit(child, 2500); + assert.equal(exitCode, 0); + + const result = JSON.parse(await readFile(resultPath, "utf8")); + assert.equal(result.exitCode, 0); + assert.equal(result.stopReason, "stop"); + assert.equal(result.rawStopReason, "stop"); + assert.equal(result.finalText, "final"); + + const eventsText = await readFile(eventsPath, "utf8"); + assert.match(eventsText, /"type":"tool_call"/); +}); + test("wrapper exits and writes result.json after terminal output even if the pi child lingers", async () => { const dir = await mkdtemp(join(tmpdir(), "pi-subagents-wrapper-")); const metaPath = join(dir, "meta.json");