diff --git a/packages/coding-agent/src/exec/bash-executor.ts b/packages/coding-agent/src/exec/bash-executor.ts index 0c60ccf6b..a4b48c746 100644 --- a/packages/coding-agent/src/exec/bash-executor.ts +++ b/packages/coding-agent/src/exec/bash-executor.ts @@ -252,7 +252,8 @@ export async function executeBash(command: string, options?: BashExecutorOptions outputBytes: minimized.outputBytes, }); if (artifactId) { - sink.push(`\n[raw output: artifact://${artifactId}]\n`); + const sep = minimized.text.endsWith("\n") ? "" : "\n"; + sink.push(`${sep}[raw output: artifact://${artifactId}]\n`); } } } diff --git a/packages/coding-agent/src/session/streaming-output.ts b/packages/coding-agent/src/session/streaming-output.ts index 2bca34476..fbfd125ba 100644 --- a/packages/coding-agent/src/session/streaming-output.ts +++ b/packages/coding-agent/src/session/streaming-output.ts @@ -644,6 +644,7 @@ export class OutputSink { #head = ""; #headBytes = 0; #headLines = 0; // newline count inside #head + #headRetentionDisabled = false; #totalLines = 0; // newline count #totalBytes = 0; #sawData = false; @@ -740,7 +741,7 @@ export class OutputSink { // exhausted, then forward any leftover to the tail buffer. let tailChunk = capped; let tailBytes = cappedBytes; - if (this.#headLimit > 0 && this.#headBytes < this.#headLimit) { + if (this.#headLimit > 0 && !this.#headRetentionDisabled && this.#headBytes < this.#headLimit) { const room = this.#headLimit - this.#headBytes; if (cappedBytes <= room) { this.#head += capped; @@ -919,12 +920,16 @@ export class OutputSink { } /** - * Replace the in-memory buffer with the given text while preserving the - * streaming counters (totalLines/totalBytes reflect the raw chunks that - * already reached the sink). Used when an upstream minimizer rewrites the - * captured output after the raw bytes have already been streamed. + * Replace the in-memory buffer with the given text. Used when an upstream + * minimizer rewrites the captured output after the raw bytes have already + * been streamed. * - * Clears any retained head window — the minimized text is authoritative. + * After this call the buffer is authoritative: streaming counters realign + * to the replacement, the retained head window is cleared, and head + * retention is disabled so subsequent `push()` calls append directly to the + * tail buffer instead of repopulating the (now meaningless) head window + * — which would otherwise reorder content and trip the middle-elision + * branch in `dump()` against stale totals. */ replace(text: string): void { this.#buffer = text; @@ -932,6 +937,11 @@ export class OutputSink { this.#head = ""; this.#headBytes = 0; this.#headLines = 0; + this.#headRetentionDisabled = true; + this.#totalBytes = this.#bufferBytes; + this.#totalLines = countNewlines(text); + this.#sawData = text.length > 0; + this.#truncated = false; this.#currentLineBytes = 0; this.#columnEllipsisAdded = false; this.#columnDroppedBytes = 0; diff --git a/packages/coding-agent/test/streaming-output.test.ts b/packages/coding-agent/test/streaming-output.test.ts index 134fe9a5c..01a7b44e7 100644 --- a/packages/coding-agent/test/streaming-output.test.ts +++ b/packages/coding-agent/test/streaming-output.test.ts @@ -419,6 +419,30 @@ describe("OutputSink head-retain mode", () => { expect(dumped.truncated).toBe(false); expect(dumped.elidedBytes).toBeUndefined(); }); + + test("replace + push appends to tail and emits no elision marker", async () => { + // Simulates the bash-minimizer flow: large raw stream is replaced with a + // short minimized text, then an artifact-link line is pushed. The push + // must land at the END of the buffer (after the minimized text), and the + // stale pre-replace totals must NOT trigger the middle-elision branch in + // dump(). + const sink = new OutputSink({ spillThreshold: 1024, headBytes: 64 }); + // Feed a long original stream so #totalBytes/#totalLines climb high. + const noisy = Array.from({ length: 50 }, (_, i) => `noise line ${i}`).join("\n"); + await sink.push(noisy); + + sink.replace("OK\n"); + await sink.push("[raw output: artifact://8]\n"); + + const dumped = await sink.dump(); + expect(dumped.output).toBe("OK\n[raw output: artifact://8]\n"); + expect(dumped.output).not.toContain("elided"); + expect(dumped.elidedBytes).toBeUndefined(); + expect(dumped.elidedLines).toBeUndefined(); + expect(dumped.truncated).toBe(false); + // Counters realign to the authoritative buffer + the subsequent push. + expect(dumped.totalBytes).toBe(byteLength("OK\n[raw output: artifact://8]\n")); + }); }); describe("OutputSink maxColumns (per-line cap)", () => {