From 4658662c3509f8064839882eec2ce3cfbf4f3a37 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 13 Aug 2026 01:08:21 +0200 Subject: [PATCH] fix(write): keep streaming preview append checks bounded --- packages/coding-agent/CHANGELOG.md | 3 ++ packages/coding-agent/src/tools/write.ts | 30 +++++++++--- .../test/write-streaming-incremental.test.ts | 48 +++++++++++++++---- packages/utils/CHANGELOG.md | 3 ++ 4 files changed, 69 insertions(+), 15 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b387e76fd..6bd4423b8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -88,6 +88,9 @@ - Removed the `resolveAgentModelSource` model-resolver export, whose only use was being fed to `resolveExplicitModelRole`. Replaced by `resolveAgentModelSelection`, which returns the expanded `patterns` and the pre-expansion `role` together so a spawn path cannot derive one without the other ([#7910](https://github.com/can1357/oh-my-pi/pull/7910) by [@enieuwy](https://github.com/enieuwy)). - A run is now attributed to the model that actually produced its output, not whichever model the session was last pointed at. A retry fallback that errored on its first request — an exhausted quota, a hard provider error — was credited with the whole run in the Agent Hub row and the settled task result, even when the previous model did every turn. Sessions expose the serving model directly, holding the last model that produced output while a candidate is armed but unproven, and transcript-derived history stops at the newest turn that produced output. +### Fixed + +- Fixed long streaming `write` previews repeatedly scanning and splitting the full accumulated file content, which could stall the TUI. ## [17.2.12] - 2026-08-08 diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index 5551a1dbc..6eeff4202 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -1415,14 +1415,19 @@ const WRITE_GUTTER_MIN_WIDTH = 3; * window makes each tick O(delta + preview lines). */ interface WriteStreamingLineIndex { - /** Full content string as of the last scan. */ - content: string; - /** `1 + count("\n")` over {@link content}. */ + /** Number of content code units scanned so far. */ + length: number; + /** Bounded suffix used to detect a restarted/non-append stream. */ + suffix: string; + /** `1 + count("\n")` over the scanned content. */ lineCount: number; } const writeStreamingLineIndex = new WeakMap(); +/** Keep append validation constant-time instead of comparing the entire prior payload. */ +const WRITE_STREAMING_APPEND_GUARD_LENGTH = 64; + /** Total logical line count of `content`, resuming from the cached prefix scan when append-only. */ function streamingTotalLines(streamKey: object | undefined, content: string): number { if (streamKey === undefined) { @@ -1431,16 +1436,25 @@ function streamingTotalLines(streamKey: object | undefined, content: string): nu return lines; } let entry = writeStreamingLineIndex.get(streamKey); - if (entry !== undefined && content.startsWith(entry.content)) { + const continuesPrevious = + entry !== undefined && + content.length >= entry.length && + content.startsWith(entry.suffix, entry.length - entry.suffix.length); + if (entry !== undefined && continuesPrevious) { let lines = entry.lineCount; - for (let i = entry.content.length; i < content.length; i++) if (content.charCodeAt(i) === 10) lines++; - entry.content = content; + for (let i = entry.length; i < content.length; i++) if (content.charCodeAt(i) === 10) lines++; + entry.length = content.length; + entry.suffix = content.slice(-WRITE_STREAMING_APPEND_GUARD_LENGTH); entry.lineCount = lines; return lines; } let lines = 1; for (let i = 0; i < content.length; i++) if (content.charCodeAt(i) === 10) lines++; - entry = { content, lineCount: lines }; + entry = { + length: content.length, + suffix: content.slice(-WRITE_STREAMING_APPEND_GUARD_LENGTH), + lineCount: lines, + }; writeStreamingLineIndex.set(streamKey, entry); return lines; } @@ -1493,6 +1507,7 @@ function formatStreamingContent( startIndex === 0 ? content : content.slice(tailWindowStart(content, WRITE_STREAMING_PREVIEW_LINES)); visibleText = tail.replace(/\r/g, ""); } + if (visibleText.length === 0) return ""; const hidden = startIndex; const highlighted = highlightCode(visibleText, language); const lineNumberWidth = Math.max(WRITE_GUTTER_MIN_WIDTH, String(totalLines).length); @@ -1509,6 +1524,7 @@ function formatStreamingContent( } return text; }); + if (bodyText.length === 0) return ""; // The animated glyph lives on this trailing line — inside the transcript's // volatile-tail holdback — never in the header: an animating head row pins // the native-scrollback commit boundary at the top of the block, so a long diff --git a/packages/coding-agent/test/write-streaming-incremental.test.ts b/packages/coding-agent/test/write-streaming-incremental.test.ts index 904514031..a99258cda 100644 --- a/packages/coding-agent/test/write-streaming-incremental.test.ts +++ b/packages/coding-agent/test/write-streaming-incremental.test.ts @@ -80,6 +80,29 @@ describe("write streaming preview incremental line tracking", () => { } }); + it("does not compare the full accumulated payload when validating append-only growth", async () => { + const uiTheme = await getUiTheme(); + const options = { expanded: false, isPartial: true, spinnerFrame: 0 }; + const first = Array.from({ length: 2_000 }, () => "x".repeat(64)).join("\n"); + writeToolRenderer.renderCall({ path: "/tmp/inc.ts", content: first }, options, uiTheme)?.render(120); + + const originalStartsWith = String.prototype.startsWith; + let wholePrefixComparisons = 0; + String.prototype.startsWith = function (this: string, searchString: string, position?: number): boolean { + if (searchString === first) wholePrefixComparisons++; + return originalStartsWith.call(this, searchString, position); + }; + try { + writeToolRenderer + .renderCall({ path: "/tmp/inc.ts", content: `${first}\nlast` }, options, uiTheme) + ?.render(120); + } finally { + String.prototype.startsWith = originalStartsWith; + } + + expect(wholePrefixComparisons).toBe(0); + }); + it("normalizes CRLF only in the rendered tail, with correct line numbers", async () => { const options = { expanded: false, isPartial: true, spinnerFrame: 0 }; const content = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\r\n"); @@ -106,20 +129,29 @@ describe("write streaming preview incremental line tracking", () => { expect(hasLine(rendered, 2)).toBe(false); }); - it("resets cleanly when streamed content is not append-only", async () => { - // A restarted stream reuses the component's render state with a buffer - // that no longer extends the previous one; the index must not corrupt - // the count or the window. + it("renders carriage-return-only content like the previous normalized empty payload", async () => { const options = { expanded: false, isPartial: true, spinnerFrame: 0 }; - const first = Array.from({ length: 20 }, (_, i) => `alpha ${i + 1}`).join("\n"); + const empty = await renderCollapsed("", options); + const carriageReturns = await renderCollapsed("\r\r", { + expanded: false, + isPartial: true, + spinnerFrame: 0, + }); + expect(carriageReturns).toEqual(empty); + }); + + it("resets cleanly when a restarted stream is longer but not append-only", async () => { + // A restarted stream can reuse the component render state with a longer + // replacement buffer; the bounded suffix guard must reset the index. + const options = { expanded: false, isPartial: true, spinnerFrame: 0 }; + const first = "alpha 1\nalpha 2"; await renderCollapsed(first, options); - const restarted = "beta 1\nbeta 2"; + const restarted = `beta ${"x".repeat(100)}\nbeta 2`; const rendered = await renderCollapsed(restarted, options); const text = stripAnsi(rendered.join("\n")); expect(text).not.toContain("earlier line"); - expect(text).toContain("beta 1"); - expect(text).toContain("beta 2"); + expect(text).toContain("beta"); expect(text).not.toContain("alpha"); }); diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index d0beaada1..4f9c0e3fa 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -18,6 +18,9 @@ ### Fixed - Fixed Mermaid ASCII state pseudostates rendering empty boxes, miscoloring final-state borders, and inverting rounded corners in bottom-to-top diagrams. +### Fixed + +- Reduced partial JSON parsing work for long streaming tool-call arguments by scaling the parse throttle geometrically with buffer size. ## [17.2.11] - 2026-08-07