Skip to content

Hold unfinished spans across soft line breaks, and scan only appended text - #14

Merged
arpan404 merged 2 commits into
mainfrom
ace/stream-hold-paragraphs
Nov 1, 2026
Merged

arpan404 merged 2 commits into
mainfrom
ace/stream-hold-paragraphs

Conversation

@arpan404

@arpan404 arpan404 commented Nov 1, 2026

Copy link
Copy Markdown
Owner

Follow-up to #13, fixing two review findings.

1. Multiline spans still flickered

The hold looked only at the open line. Streaming `hello and then a line break released the opening backtick, and the closing world` took it back when the span became code.

Inline spans now carry across soft line breaks and end with their paragraph, as in CommonMark. The hold starts where an unfinished span opens in the open paragraph. That paragraph starts after a blank line, a fence, a heading, a table row, or a rule or setext underline, or at a new list item or quote.

2. Every delta rescanned the history

shownLength compared the whole previous answer with startsWith and rescanned the open line on every update, so small deltas added up to quadratic work.

  • StreamHold.append(source) handles the last source with text added. It processes the lines completed since the last call once each, and the open paragraph from its last settled point.
  • StreamHold.replace(source) is the full pass for new text.
  • Checks on line shape only look at short lines, so a long open line isn't sliced on every update.
  • MarkitRenderer.render decides append vs. replace with one comparison. That decision also picks the worker's append path, replacing the startsWith that #renderPatch made on its own. Callers that know they're appending can pass render(source, { appended: true }) to skip it.
  • Finalized renders do no hold work.

Time spent in the hold for 3,000 appends, measured with each string flattened before the call:

history previous hold this PR
20 KB 30 ms ~1.4 ms
2 MB 2,263 ms ~1.8 ms

Tests

  • tests/node/stream-hold.test.mjs:
    • `hello\n stays held and becomes hello world code once world` arrives;
    • an answer with code, bold and links that cross soft breaks never takes back text, character by character;
    • a paragraph break releases a span that never closed.
  • tests/node/stream-hold-cost.test.mjs checks that 3,000 appends cost about the same after 2 MB as after 20 KB (3/3 runs pass), and that a replacement still reads the whole source.
  • node --test tests/node/*.test.mjs: 15/15.
  • Playwright shared, renderer, inline, code, github, react: 40/40, twice.
  • Streaming 257 real agent answers 3 characters at a time: 8 updates take back text, all in one answer about footnotes, the same as Hold unfinished inline markup while a message streams #13.

arpan404 and others added 2 commits November 1, 2026 09:16
… text

Inline spans run on across soft line breaks and end with their paragraph,
as in CommonMark. The hold looked only at the open line, so `` `hello``
then a line break released the backtick, and the closing `` world` ``
took it back. It now holds from where an unfinished span opens in the open
paragraph, which starts after a blank line, a fence, a heading, a table
row or a rule, or at a new list item or quote.

Each streamed update compared the whole previous answer and rescanned the
open line, so small deltas added up to quadratic work. `StreamHold` now
has `append` (the last source with text added) and `replace` (new text,
a full pass). An append processes the lines completed since the last one
and the open paragraph from its last settled point. The renderer tells an
append from a replacement with one comparison, which also picks the
worker's append path in place of the comparison it made on its own, and
`render(source, { appended: true })` skips it for callers that know.
Finalized renders do no hold work.

3,000 appends after 2 MB of history now spend about as long in the hold
as after 20 KB (about 2 ms either way; the previous hold took 2.3 s).

Co-Authored-By: ace <bot@acehq.dev>
…rammar.rs

Co-Authored-By: ace <bot@acehq.dev>
@arpan404
arpan404 merged commit 32c65e7 into main Nov 1, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant