fix(interactive-subagents): harden parallel lifecycle delivery
This commit is contained in:
@@ -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 ──
|
||||
|
||||
Reference in New Issue
Block a user