Skip to content

Commit 079397b

Browse files
committed
fix(compaction): keep accepted sessions_spawn results out of tool-failure summaries
Accepted sessions_spawn launches can be persisted as a toolResult with isError:true while the payload carries status=accepted. The compaction safeguard collected every isError toolResult, so normal accepted subagent launches surfaced as repeated '## Tool Failures' lines in user-visible summaries. collectToolFailures now skips results whose toolName is sessions_spawn and whose payload normalizes as an accepted child spawn, mirroring the observer's existing detection; this covers legacy transcripts too. Genuinely failed spawns (status=error/forbidden) and non-spawn tools are still reported.
1 parent 19707cc commit 079397b

2 files changed

Lines changed: 111 additions & 0 deletions

File tree

src/agents/agent-hooks/compaction-safeguard.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import * as compactionModule from "../compaction.js";
1515
import { buildEmbeddedExtensionFactories } from "../embedded-agent-runner/extensions.js";
1616
import { castAgentMessage } from "../test-helpers/agent-message-fixtures.js";
17+
import { jsonResult } from "../tools/common.js";
1718
import {
1819
consumeCompactionSafeguardCancelReason,
1920
getCompactionSafeguardRuntime,
@@ -265,6 +266,104 @@ describe("compaction-safeguard tool failures", () => {
265266
expect(section).toContain("exec (status=failed exitCode=1): ENOENT: missing file");
266267
});
267268

269+
it("excludes accepted sessions_spawn results even when persisted with isError", () => {
270+
const messages: AgentMessage[] = [
271+
{
272+
role: "toolResult",
273+
toolCallId: "call-spawn-accepted",
274+
toolName: "sessions_spawn",
275+
isError: true,
276+
details: {
277+
status: "accepted",
278+
childSessionKey: "agent:watcher:subagent:abc",
279+
runId: "run-123",
280+
mode: "run",
281+
},
282+
content: [{ type: "text", text: "accepted" }],
283+
timestamp: Date.now(),
284+
},
285+
];
286+
287+
expect(collectToolFailures(messages)).toHaveLength(0);
288+
});
289+
290+
it("still reports sessions_spawn results that genuinely failed", () => {
291+
const messages: AgentMessage[] = [
292+
{
293+
role: "toolResult",
294+
toolCallId: "call-spawn-error",
295+
toolName: "sessions_spawn",
296+
isError: true,
297+
details: { status: "error" },
298+
content: [{ type: "text", text: "spawn rejected" }],
299+
timestamp: Date.now(),
300+
},
301+
{
302+
role: "toolResult",
303+
toolCallId: "call-spawn-forbidden",
304+
toolName: "sessions_spawn",
305+
isError: true,
306+
details: { status: "forbidden" },
307+
content: [{ type: "text", text: "not allowed" }],
308+
timestamp: Date.now(),
309+
},
310+
];
311+
312+
const failures = collectToolFailures(messages);
313+
expect(failures.map((failure) => failure.toolCallId)).toEqual([
314+
"call-spawn-error",
315+
"call-spawn-forbidden",
316+
]);
317+
});
318+
319+
it("only excludes the accepted spawn from a mixed batch and reports look-alike non-spawn tools", () => {
320+
// Build the accepted-spawn details via the production helper so the skip is
321+
// proven against the real sessions_spawn result shape, not a hand-authored stub.
322+
const acceptedDetails = jsonResult({
323+
status: "accepted",
324+
childSessionKey: "agent:watcher:subagent:abc",
325+
runId: "run-123",
326+
mode: "run",
327+
}).details;
328+
const messages: AgentMessage[] = [
329+
{
330+
role: "toolResult",
331+
toolCallId: "call-spawn-accepted",
332+
toolName: "sessions_spawn",
333+
isError: true,
334+
details: acceptedDetails,
335+
content: [{ type: "text", text: "accepted" }],
336+
timestamp: Date.now(),
337+
},
338+
{
339+
role: "toolResult",
340+
toolCallId: "call-exec-failed",
341+
toolName: "exec",
342+
isError: true,
343+
details: { status: "failed", exitCode: 1 },
344+
content: [{ type: "text", text: "boom" }],
345+
timestamp: Date.now(),
346+
},
347+
{
348+
// Same accepted-shaped details on a non-spawn tool must still be reported:
349+
// the skip is gated on toolName so look-alike payloads are not suppressed.
350+
role: "toolResult",
351+
toolCallId: "call-other-lookalike",
352+
toolName: "some_other_tool",
353+
isError: true,
354+
details: acceptedDetails,
355+
content: [{ type: "text", text: "real failure" }],
356+
timestamp: Date.now(),
357+
},
358+
];
359+
360+
const failures = collectToolFailures(messages);
361+
expect(failures.map((failure) => failure.toolCallId)).toEqual([
362+
"call-exec-failed",
363+
"call-other-lookalike",
364+
]);
365+
});
366+
268367
it("dedupes by toolCallId and handles empty output", () => {
269368
const messages: AgentMessage[] = [
270369
{

src/agents/agent-hooks/compaction-safeguard.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
getCompactionProvider,
1111
type CompactionProvider,
1212
} from "../../plugins/compaction-provider.js";
13+
import { normalizeAcceptedSessionSpawnResult } from "../accepted-session-spawn.js";
1314
import {
1415
buildHistoryPrunePlanWithWorker,
1516
computeAdaptiveChunkRatioWithWorker,
@@ -447,6 +448,17 @@ function collectToolFailures(messages: AgentMessage[]): ToolFailure[] {
447448
if (toolResult.isError !== true) {
448449
continue;
449450
}
451+
// Accepted sessions_spawn launches are successes, not failures, even when a legacy
452+
// transcript persisted them with isError:true. Mirror the observer's detection
453+
// (toolName + accepted child-run identity, see embedded-agent-subscribe.handlers.tools)
454+
// so only real failures stay in the summary and non-spawn tools are never matched by shape.
455+
if (
456+
typeof toolResult.toolName === "string" &&
457+
toolResult.toolName.trim() === "sessions_spawn" &&
458+
normalizeAcceptedSessionSpawnResult(toolResult)
459+
) {
460+
continue;
461+
}
450462
const toolCallId = typeof toolResult.toolCallId === "string" ? toolResult.toolCallId : "";
451463
if (!toolCallId || seen.has(toolCallId)) {
452464
continue;

0 commit comments

Comments
 (0)