fix(interactive-subagents): harden parallel lifecycle delivery

This commit is contained in:
jb
2026-09-03 23:40:14 -04:00
parent d99dbce93d
commit 7a1043e5e0
11 changed files with 848 additions and 131 deletions
@@ -0,0 +1,9 @@
---
name: test-default-autonomous
description: Integration test agent with omitted auto-exit
model: anthropic/claude-haiku-4-5
tools: bash
disable-model-invocation: true
---
You are a test agent. Run the requested bash command immediately, then report completion concisely. Do not ask questions or do anything else.
@@ -18,6 +18,7 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import {
getAvailableBackends,
createTestEnv,
@@ -31,6 +32,7 @@ import {
trackTempFile,
readScreen,
sendCommand,
shellEscape,
PI_TIMEOUT,
type TestEnv,
} from "./harness.ts";
@@ -202,42 +204,115 @@ for (const backend of backends) {
// ── Parallel subagent spawn ──
it("spawns two subagents in parallel and both complete", async () => {
it("delivers two staggered sibling launches exactly once after the parent becomes idle", async () => {
const id = uniqueId();
const nameA = `ParaA-${id}`;
const nameB = `ParaB-${id}`;
const fileA = `/tmp/pi-integ-para-${id}-a.txt`;
const fileB = `/tmp/pi-integ-para-${id}-b.txt`;
const parentWorkFile = `/tmp/pi-integ-para-${id}-parent.txt`;
const parentSession = join(env.dir, `parallel-parent-${id}.jsonl`);
trackTempFile(env, fileA);
trackTempFile(env, fileB);
trackTempFile(env, parentWorkFile);
const surface = createTrackedSurface(env, `parallel-${id}`);
await sleep(1000);
const task = [
`You must call the subagent tool TWICE. Make both calls before waiting for results.`,
`Call the subagent tool twice in ONE assistant response so Pi executes them as parallel sibling tool calls.`,
`Use these exact parameters:`,
``,
`First call:`,
` name: "ParaA-${id}"`,
` agent: "test-echo"`,
` task: "Run: echo 'DONE_A_${id}' > '${fileA}'"`,
` name: "${nameA}"`,
` agent: "test-default-autonomous"`,
` cwd: "${env.dir}"`,
` task: "Run: sleep 6; echo 'DONE_A_${id}' > '${fileA}'"`,
``,
`Second call:`,
` name: "ParaB-${id}"`,
` agent: "test-echo"`,
` task: "Run: echo 'DONE_B_${id}' > '${fileB}'"`,
` name: "${nameB}"`,
` agent: "test-default-autonomous"`,
` cwd: "${env.dir}"`,
` task: "Run: sleep 12; echo 'DONE_B_${id}' > '${fileB}'"`,
``,
`Call both subagent tools NOW, do not wait between them.`,
`After both launch acknowledgements, use bash yourself to write PARENT_WORK_${id} to '${parentWorkFile}', then say you are waiting and stop.`,
`Never call subagent_message. When the first result arrives, acknowledge it and stop while the other child runs.`,
`When both results have arrived, say PARALLEL_IDLE_COMPLETE_${id} exactly once.`,
].join("\n");
startPi(surface, env.dir, task);
startPi(surface, env.dir, task, {
extraArgs: `--session ${shellEscape(parentSession)}`,
});
// Both marker files should appear
const [contentA, contentB] = await Promise.all([
waitForFile(fileA, PI_TIMEOUT, /DONE_A/),
waitForFile(fileB, PI_TIMEOUT, /DONE_B/),
]);
await waitForFile(parentWorkFile, PI_TIMEOUT, /PARENT_WORK_/);
const screen = await waitForScreen(
surface,
new RegExp(`^ PARALLEL_IDLE_COMPLETE_${id}\\s*$`, "m"),
PI_TIMEOUT,
400,
);
assert.equal(
screen.match(new RegExp(`^ PARALLEL_IDLE_COMPLETE_${id}\\s*$`, "gm"))?.length,
1,
);
assert.match(await waitForFile(fileA, PI_TIMEOUT, /DONE_A_/), new RegExp(`DONE_A_${id}`));
assert.match(await waitForFile(fileB, PI_TIMEOUT, /DONE_B_/), new RegExp(`DONE_B_${id}`));
assert.ok(contentA.includes(`DONE_A_${id}`), `File A should contain marker`);
assert.ok(contentB.includes(`DONE_B_${id}`), `File B should contain marker`);
const entries = readFileSync(parentSession, "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line));
const siblingCall = entries.find((entry) => {
const calls = entry.message?.role === "assistant"
? entry.message.content?.filter((part: any) => part.type === "toolCall" && part.name === "subagent")
: [];
return calls?.length === 2;
});
assert.ok(siblingCall, "both wrapped launches should reach Pi as sibling subagent calls");
const calls = siblingCall.message.content.filter(
(part: any) => part.type === "toolCall" && part.name === "subagent",
);
assert.deepEqual(calls.map((call: any) => call.arguments.name), [nameA, nameB]);
assert.deepEqual(calls.map((call: any) => call.arguments.cwd), [env.dir, env.dir]);
const acknowledgements = entries.filter(
(entry) => entry.message?.role === "toolResult" && entry.message.toolName === "subagent",
);
assert.deepEqual(
acknowledgements.map((entry) => entry.message.details?.name).sort(),
[nameA, nameB].sort(),
);
for (const acknowledgement of acknowledgements) {
assert.equal(acknowledgement.message.details?.status, "started");
const childHeader = JSON.parse(
readFileSync(acknowledgement.message.details.sessionFile, "utf8").split("\n")[0],
);
assert.equal(childHeader.cwd, env.dir);
}
const completions = entries.filter(
(entry) => entry.type === "custom_message" && entry.customType === "subagent_result",
);
assert.deepEqual(completions.map((entry) => entry.details?.name).sort(), [nameA, nameB].sort());
assert.equal(completions.length, 2, "each child result should be delivered exactly once");
assert.ok(completions.every((entry) => entry.details?.exitCode === 0));
const idleBeforeCompletion = entries.findIndex((entry, index) => {
if (index <= entries.indexOf(acknowledgements.at(-1))) return false;
if (entry.message?.role !== "assistant" || entry.message.stopReason !== "stop") return false;
return completions.every((completion) => Date.parse(completion.timestamp) > Date.parse(entry.timestamp));
});
assert.notEqual(idleBeforeCompletion, -1, "the parent should settle before either child completes");
for (const completion of completions) {
assert.ok(
entries.some(
(entry) => entry.message?.role === "assistant"
&& Date.parse(entry.timestamp) > Date.parse(completion.timestamp),
),
`${completion.details?.name} should trigger a parent turn while idle`,
);
}
});
// ── Fork mode ──