diff --git a/src/agents/bash-tools.exec-approval-output.test.ts b/src/agents/bash-tools.exec-approval-output.test.ts index cb58fb18083e..a72dde66c960 100644 --- a/src/agents/bash-tools.exec-approval-output.test.ts +++ b/src/agents/bash-tools.exec-approval-output.test.ts @@ -74,6 +74,33 @@ describe("formatExecApprovalContinuationSourceOutput", () => { expect(formatted.endsWith("b")).toBe(true); }); + it.each([ + { name: "stderr", label: "stderr", stdoutUnits: 200_000, streamUnits: 100_000 }, + { name: "error", label: "error", stdoutUnits: 200_000, streamUnits: 100_000 }, + { name: "head header cut", label: "stderr", stdoutUnits: 191_907, streamUnits: 100_000 }, + { name: "tail header cut", label: "stderr", stdoutUnits: 200_000, streamUnits: 63_970 }, + { name: "already retained header", label: "stderr", stdoutUnits: 200_000, streamUnits: 63_960 }, + ])("preserves the retained stream label across $name", ({ label, stdoutUnits, streamUnits }) => { + const formatted = formatExecApprovalContinuationSourceOutput([ + { label: "stdout", value: "a".repeat(stdoutUnits) }, + { label, value: "b".repeat(streamUnits) }, + ]); + + expect(formatted.length).toBeLessThanOrEqual(MAX_SOURCE_UTF16_UNITS); + expect(formatted).toContain("[stdout]\n"); + expect(formatted).toContain(`${MARKER}\n[${label}]\n`); + expect(formatted.split(`[${label}]\n`)).toHaveLength(2); + expect(formatted.endsWith("b")).toBe(true); + }); + + it("does not mistake stream-like text in single-stream output for a stream boundary", () => { + const formatted = formatExecApprovalContinuationSourceOutput([ + { label: "stdout", value: `${"a".repeat(200_000)}\n[stderr]\n${"b".repeat(100_000)}` }, + ]); + + expect(formatted).not.toContain(`${MARKER}\n[stderr]\n`); + }); + it("uses an honest marker because capture may already have dropped output", () => { const formatted = formatExecApprovalContinuationSourceOutput([ { label: "stdout", value: "z".repeat(MAX_SOURCE_UTF16_UNITS + 1) }, @@ -193,6 +220,25 @@ describe("resizeExecApprovalContinuationPrompt", () => { expect(resized.split(OUTPUT_BEGIN)).toHaveLength(2); expect(resized.split(OUTPUT_END)).toHaveLength(2); }); + + it("preserves the retained stream label when the resumed model applies its output budget", () => { + const output = formatExecApprovalContinuationSourceOutput([ + { label: "stdout", value: "a".repeat(12_000) }, + { label: "stderr", value: "b".repeat(10_000) }, + ]); + const built = buildExecApprovalContinuationPrompt( + `Exec finished (node=node-1 id=approval-1, code 0)\n${output}`, + ); + const resized = resizeExecApprovalContinuationPrompt({ + prompt: built.message, + range: built.resultRange, + maxOutputUtf16Units: 16_000, + }); + + expect(resized).toContain(`${MARKER}\n[stderr]\n`); + expect(resized).toContain(OUTPUT_BEGIN); + expect(resized).toContain(OUTPUT_END); + }); }); describe("exec output rendering", () => { diff --git a/src/agents/bash-tools.exec-approval-output.ts b/src/agents/bash-tools.exec-approval-output.ts index b3ebf9fc5be0..27ee465a2c79 100644 --- a/src/agents/bash-tools.exec-approval-output.ts +++ b/src/agents/bash-tools.exec-approval-output.ts @@ -74,8 +74,18 @@ function capContinuationOutput(text: string, maxUtf16Units: number): string { return truncateUtf16Safe(TRUNCATION_MARKER, boundedMax); } const headBudget = Math.floor(cutBudget * HEAD_SHARE); - const head = alignHeadToLineBreak(truncateUtf16Safe(text, headBudget)); + let head = alignHeadToLineBreak(truncateUtf16Safe(text, headBudget)); const tail = alignTailToLineBreak(sliceUtf16Safe(text, text.length - (cutBudget - headBudget))); + // Recapping can omit a stream header while retaining its contents; restore its + // label from the omitted span so stderr/error never masquerades as stdout. + const omittedHeaders = /^\[(?:stdout|stderr|error)\]\n/m.test(head) + ? text.slice(head.length, text.length - tail.length).match(/^\[(?:stdout|stderr|error)\]\n/gm) + : null; + const tailHeader = omittedHeaders?.at(-1) ?? ""; + if (tailHeader && tailHeader.length <= headBudget) { + head = alignHeadToLineBreak(truncateUtf16Safe(text, headBudget - tailHeader.length)); + return `${head}\n${TRUNCATION_MARKER}\n${tailHeader}${tail}`; + } return `${head}\n${TRUNCATION_MARKER}\n${tail}`; }