pi-mono/packages/coding-agent/test/fixtures/subagent-child.mjs
Michael Renner aba42ae6b9
fix(coding-agent): make subagent progress and failures reliable (#8250)
The shipped subagent example could say a child was done while it was
still working, lose the useful part of process failures, and return a
normal-looking tool result when the delegated work had actually failed.
Single and chain output could also exceed the normal tool limit.

This also adds a dedicated regression suite covering progress state,
cancellation, process failures, output limits, cwd-based agent
selection, and persisted tool-result error state.

After this change:

- queued, running, completed, failed, and aborted work stay distinct
throughout the run;
- interrupting the parent run preserves partial child results, stops
queued work from starting, and escalates when a child ignores SIGTERM;
- signal exits, spawn failures, provider errors, stderr, and assistant
output remain visible instead of collapsing to `(no output)`;
- failed subagents become failed tool results, so the parent model can
react instead of treating them as successful calls;
- single and chain results stay within the normal 50 KB / 2,000-line
limit;
- project agents are selected and confirmed from each task's actual
working directory rather than the dispatcher's directory.

## Further potential improvements

These are useful follow-ups, but they are separate product and storage
decisions rather than part of the failure-handling fixes in this PR.

<details>
<summary>Potential follow-ups</summary>

### Keep persisted transcripts bounded

Subagent tool results persist the full child message history in
`details`, which can make parent session files large and slow to reload.
A follow-up could compact that history or spill it to disk.

### Define an aggregate parallel-output budget

Parallel mode still gives every task its own 50 KB budget. A follow-up
could keep every task represented while enforcing one overall
tool-result limit.

### Report child usage to the calling session

Per-child token and cost usage is visible in result cards but is not
added to the calling session's totals.

### Isolate malformed agent definitions

One malformed YAML agent can still break discovery for every valid agent
in the same directory.

### Limit subagent recursion

A nesting limit would prevent accidental process and paid-request
multiplication when subagents can invoke the subagent tool again.

* https://github.com/earendil-works/pi/issues/8195

### Add a configurable timeout

Unattended runs would benefit from a generous opt-in timeout for
children that never complete.

</details>

## Related issues

### Spawn failures lose their diagnostics

This directly addresses the reported case where a nonexistent child
working directory produces `(no output)` instead of the actual spawn
error. The regression suite includes that same reproduction.

* https://github.com/earendil-works/pi/issues/8129

### Persisted child history can bloat sessions

This PR intentionally keeps full child history in tool-result details,
so session compaction or spill-to-disk remains follow-up work rather
than something fixed here.

* https://github.com/earendil-works/pi/issues/7452
2026-11-04 11:24:12 +01:00

80 lines
2.9 KiB
JavaScript

import fs from "node:fs";
const task = process.argv.find((arg) => arg.startsWith("Task: "))?.slice(6) ?? "success";
const requestedModelIndex = process.argv.indexOf("--model");
const requestedModel = requestedModelIndex >= 0 ? process.argv[requestedModelIndex + 1] : "fixture-model";
const zeroUsage = () => ({
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
});
const assistant = (text, options = {}) => ({
role: "assistant",
content: options.toolCall
? [
{ type: "text", text },
{ type: "toolCall", id: "call-1", name: "read", arguments: { path: "README.md" } },
]
: [{ type: "text", text }],
api: "anthropic-messages",
provider: "fixture",
model: options.model ?? requestedModel,
usage: options.usage ?? zeroUsage(),
stopReason: options.stopReason ?? "stop",
...(options.errorMessage ? { errorMessage: options.errorMessage } : {}),
timestamp: Date.now(),
});
const emit = (event) => process.stdout.write(`${JSON.stringify(event)}\n`);
const emitMessage = (message) => emit({ type: "message_end", message });
if (task.startsWith("record-argv:")) {
fs.writeFileSync(task.slice("record-argv:".length), JSON.stringify(process.argv));
emitMessage(assistant("done"));
} else if (task.startsWith("record-invocation:")) {
fs.writeFileSync(
task.slice("record-invocation:".length),
JSON.stringify({ argv: process.argv, cwd: process.cwd() }),
);
emitMessage(assistant("done"));
} else if (task === "tool-then-final") {
emitMessage(assistant("working", { stopReason: "toolUse", toolCall: true }));
setTimeout(() => emitMessage(assistant("finished")), 150);
} else if (task.startsWith("tool-wait:")) {
const releaseFile = task.slice("tool-wait:".length);
emitMessage(assistant("working", { stopReason: "toolUse", toolCall: true }));
const timer = setInterval(() => {
if (!fs.existsSync(releaseFile)) return;
clearInterval(timer);
emitMessage(assistant("finished"));
}, 10);
} else if (task === "signal") {
process.kill(process.pid, "SIGTERM");
} else if (task === "error" || task === "model-error") {
process.stderr.write("stderr evidence\n");
emitMessage(
assistant("assistant evidence", {
stopReason: "error",
errorMessage: "provider evidence",
}),
);
if (task === "error") process.exitCode = 1;
} else if (task.startsWith("large")) {
emitMessage(assistant(`${"x".repeat(60 * 1024)}\n${"line\n".repeat(2100)}`));
} else if (task.startsWith("ignore-sigterm:")) {
fs.writeFileSync(task.slice("ignore-sigterm:".length), String(process.pid));
process.on("SIGTERM", () => {});
emitMessage(assistant("partial", { stopReason: "toolUse" }));
setInterval(() => {}, 1000);
} else if (task.startsWith("hold:")) {
fs.appendFileSync(task.slice("hold:".length), `${process.pid}\n`);
setInterval(() => {}, 1000);
} else {
process.stderr.write(`Unknown fixture task: ${task}\n`);
process.exitCode = 2;
}