diff --git a/packages/coding-agent/src/session/streaming-output.ts b/packages/coding-agent/src/session/streaming-output.ts index a7fc34b40..1f87a0ac2 100644 --- a/packages/coding-agent/src/session/streaming-output.ts +++ b/packages/coding-agent/src/session/streaming-output.ts @@ -1082,10 +1082,18 @@ export class OutputSink { } /** - * Replay the rolling tail ring back into the artifact sink and inject a - * single notice line so a reader of `artifact://` sees - * `` + `[ARTIFACT TRUNCATED: …]` + ``. No-op when the cap was - * never hit (head budget never exhausted, tail ring empty). + * Replay the rolling tail ring back into the artifact sink. When bytes + * were actually dropped from the middle (the head budget was exhausted + * *and* the tail ring evicted), a single `[ARTIFACT TRUNCATED: …]` + * notice is injected between head and tail so a reader of + * `artifact://` understands the gap. When the total stream simply + * spilled past the head budget but still fits below `artifactMaxBytes`, + * `droppedBytes` is zero — head + tail together are the verbatim stream + * and the notice is suppressed so we don't corrupt the artifact with a + * misleading "0 B elided" marker (PR #2083 review by codex). + * + * No-op when the cap was never hit at all (head budget never exhausted, + * tail ring empty). */ #flushArtifactTailIfCapped(): void { if (!this.#file) return; @@ -1094,14 +1102,16 @@ export class OutputSink { const droppedBytes = Math.max(0, this.#artifactTailIncomingBytes - tailBytes); if (tailBytes === 0 && droppedBytes === 0) return; - const headWritten = this.#artifactHeadBytesWritten; - const totalCapped = headWritten + this.#artifactTailIncomingBytes; - const headSep = headWritten > 0 ? "\n" : ""; - const tailSep = tailBytes > 0 && !this.#artifactTailRing.startsWith("\n") ? "\n" : ""; - const notice = - `${headSep}[ARTIFACT TRUNCATED: kept first ${formatBytes(headWritten)} + last ${formatBytes(tailBytes)} ` + - `of ${formatBytes(totalCapped)}; ${formatBytes(droppedBytes)} elided from the middle]${tailSep}`; - this.#file.sink.write(notice); + if (droppedBytes > 0) { + const headWritten = this.#artifactHeadBytesWritten; + const totalCapped = headWritten + this.#artifactTailIncomingBytes; + const headSep = headWritten > 0 ? "\n" : ""; + const tailSep = tailBytes > 0 && !this.#artifactTailRing.startsWith("\n") ? "\n" : ""; + const notice = + `${headSep}[ARTIFACT TRUNCATED: kept first ${formatBytes(headWritten)} + last ${formatBytes(tailBytes)} ` + + `of ${formatBytes(totalCapped)}; ${formatBytes(droppedBytes)} elided from the middle]${tailSep}`; + this.#file.sink.write(notice); + } if (tailBytes > 0) { this.#file.sink.write(this.#artifactTailRing); } diff --git a/packages/coding-agent/test/streaming-output.test.ts b/packages/coding-agent/test/streaming-output.test.ts index 0b38cd29b..2f668e0f0 100644 --- a/packages/coding-agent/test/streaming-output.test.ts +++ b/packages/coding-agent/test/streaming-output.test.ts @@ -366,6 +366,31 @@ describe("OutputSink", () => { expect(artifactText).not.toContain("[ARTIFACT TRUNCATED:"); }); + test("artifact stays verbatim when spillover exceeds head budget but still fits inside the cap", async () => { + // Regression for the PR #2083 review: when the head budget is filled + // but the rest still fits in the tail ring, droppedBytes is zero — + // the file MUST be the verbatim stream with no `[ARTIFACT TRUNCATED: …]` + // marker spliced into the middle. + const dir = await createTempDir(); + const artifactPath = path.join(dir, "spilled.log"); + const sink = new OutputSink({ + artifactPath, + artifactId: "art-spilled", + spillThreshold: 8, + artifactMaxBytes: 32, + artifactHeadBytes: 16, + }); + + // 24 bytes total: head takes 16, tail ring receives 8 (fits, no eviction). + const payload = "0123456789ABCDEFghijklmn"; + await sink.push(payload); + await sink.dump(); + const artifactText = await Bun.file(artifactPath).text(); + + expect(artifactText).toBe(payload); + expect(artifactText).not.toContain("[ARTIFACT TRUNCATED:"); + }); + test("artifact cap stays bounded across many small streaming chunks", async () => { const dir = await createTempDir(); const artifactPath = path.join(dir, "stream.log");