fix(gateway/stream): no stray "(n/n)" in the live overflow preview

Two follow-ups to the re-split after _seal_overflow_heads (previous commit),
found by the exactly-once E2E suite driving the real GatewayRunner on
platforms whose limit a streamed reply overflows (Discord 2000):

1. _split_first_send kept the adapter's " (n/n)" chunk indicator on the
   tail chunk it reuses as the live preview, so later deltas were appended
   after it: the user saw "...wo (3/3)rd0575..." embedded mid-reply and the
   visible text no longer matched the transcript. Strip the indicator from
   the kept tail.

2. The re-split after a seal now `continue`s like the gate above it when the
   turn is not finished: the tail is still unsent, and falling through into
   the segment-break reset would clear it.

tests/gateway/test_stream_consumer.py::...fence_aware_split asserted the old
behaviour (the tail starts with the full indicator-suffixed chunk); it now
asserts the indicator is dropped from the tail and never appears in an edit.
This commit is contained in:
teknium1
2026-09-23 05:27:24 -07:00
committed by Teknium
parent 2f5f1a2c03
commit 4f24811617
2 changed files with 19 additions and 14 deletions

View File

@@ -581,21 +581,17 @@ class GatewayStreamConsumer(StreamTransportMixin, StreamFallbackMixin, StreamThi
return
continue
await self._seal_overflow_heads()
# A seal clears the edit target MID-ITERATION, so the overflow gate
# checked above is already stale. A buffer that still overflows then
# reaches ``_push_update`` -> ``_first_send`` with no message to edit,
# on a NON-final tick: the adapter publishes it as numbered, capped
# chunks, and the turn-final lane later publishes the same text again
# with a different denominator. Observed in production on Discord:
# a single 36k-char turn delivered as ``(i/10)`` and ``(i/9)``, with
# chunk 1 byte-identical between the two. Re-check the gate
# and hand the leftover to the consumer's own splitter, which owns
# sealing and the final ledger.
# Sealing clears the message id, so the gate above is stale: a remainder still
# over the limit must be split again. A plain first send lets the adapter split
# it and adopt only the LAST chunk as the preview; the next seal overwrites that
# chunk with the head of the whole remainder (duplicated + lost text, #25349).
# `continue` like the gate above: the tail is still unsent, and the segment-break
# reset below would clear it.
if not self._use_native_streaming and self._first_send_overflows():
if await self._split_first_send(tick):
return
else:
await self._push_update(tick)
continue
await self._push_update(tick)
if tick.got_done:
await self._finalize_turn(tick)
@@ -769,7 +765,11 @@ class GatewayStreamConsumer(StreamTransportMixin, StreamFallbackMixin, StreamThi
reply_to = new_id
if heads_delivered:
self._accumulated = chunks[-1]
# truncate_message suffixes multi-chunk output with " (n/n)"; the tail is the LIVE
# preview later deltas extend, so a kept indicator ends up embedded mid-reply.
tail = chunks[-1]
indicator = f" ({len(chunks)}/{len(chunks)})"
self._accumulated = tail[: -len(indicator)] if tail.endswith(indicator) else tail
# Flag BEFORE the tail send: fresh-final replaces every tracked preview
# with one message, which is only valid while the active message holds
# the whole answer — deleting sealed heads drops delivered text.

View File

@@ -736,7 +736,12 @@ class TestInitialOverflowRollingEdit:
assert all(text.count("```") % 2 == 0 for text in sent_texts + edited_texts)
assert len(sent_texts) == len(expected_chunks)
assert sent_texts[:-1] == expected_chunks[:-1]
assert sent_texts[-1].startswith(expected_chunks[-1])
# The tail is the live preview later deltas extend: it drops the " (n/n)" indicator, which
# would otherwise end up embedded mid-reply ("``` (2/2)\nTail after ...").
indicator = f" ({len(expected_chunks)}/{len(expected_chunks)})"
assert expected_chunks[-1].endswith(indicator)
assert sent_texts[-1].startswith(expected_chunks[-1][: -len(indicator)])
assert not any(indicator in text for text in edited_texts)
assert any("Tail after the fenced stream." in text for text in edited_texts)
assert all(utf16_len(text) <= safe_limit for text in sent_texts)