Skip to content

Commit 6ef0aa2

Browse files
fix(tui): stop empty-id assistant deltas from merging across turns (#4201)
_apply_assistant_delta matched AssistantDelta.id anywhere in the transcript, which is correct for a genuine per-message id but not for an empty one. runtime._as_str() coerces a missing/None chunk id to "", and that value is shared by every id-less chunk from every turn, not just the current one. Once one assistant row had id="", every later, unrelated AssistantDelta that also carried id="" (e.g. from a provider that never stamps per-chunk ids) matched that same stale row instead of starting a fresh one, silently folding a second turn's answer backward into the first turn's bubble. Route empty-id deltas to a dedicated path that tracks the current turn's row by position (streaming_anonymous_row_index, reset on RunStarted/ RunEnded/ClearRows) instead of by id, mirroring the existing empty-id guards in _apply_tool_started/_apply_tool_result but adapted for assistant text: unlike a tool call, an id-less assistant delta still needs to be displayed, so it starts a new row rather than being dropped. Multiple id-less chunks legitimately arrive within one turn (per-token streaming), so they keep coalescing into that row -- but only while it is still the transcript tail; once a tool card is appended after it (the same way a genuine id naturally changes across a tool round-trip), the next empty-id delta starts fresh instead of reaching backward past the tool card. Add regression coverage for the cross-turn merge, same-turn coalescing, the tool-call-interleaved edge case, and non-interference with the existing id-keyed path.
1 parent 3247f61 commit 6ef0aa2

3 files changed

Lines changed: 253 additions & 10 deletions

File tree

backend/packages/harness/deerflow/tui/view_state.py

Lines changed: 88 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,15 @@ class ViewState:
140140
# Id of the message currently being generated this turn. Only this row renders
141141
# as plain text while streaming; everything else (history) stays Markdown.
142142
streaming_id: str | None = None
143+
# Row index of the *anonymous* (empty-id) assistant row receiving deltas this
144+
# turn, if any. A genuine id is a reliable cross-chunk key (see
145+
# `_apply_assistant_delta`'s whole-transcript id scan), but an empty id ("" —
146+
# see `runtime._as_str`) is shared by every id-less chunk from every turn, so
147+
# it cannot be matched the same way: scanning for `row.id == ""` would fold a
148+
# brand new turn's text into whatever earlier turn's row happened to be
149+
# id-less too. This index instead pins "this turn's" anonymous row by
150+
# position, reset alongside `streaming_id` at the start/end of every turn.
151+
streaming_anonymous_row_index: int | None = None
143152

144153

145154
def initial_state(rows: tuple[Row, ...] = ()) -> ViewState:
@@ -164,13 +173,14 @@ def reduce(state: ViewState, action: Action) -> ViewState:
164173
if isinstance(action, RunStarted):
165174
# New turn: no message is actively streaming yet (the client re-emits
166175
# prior messages first; those must not be treated as the active one).
167-
return replace(state, streaming=True, streaming_id=None)
176+
return replace(state, streaming=True, streaming_id=None, streaming_anonymous_row_index=None)
168177

169178
if isinstance(action, RunEnded):
170179
return replace(
171180
state,
172181
streaming=False,
173182
streaming_id=None,
183+
streaming_anonymous_row_index=None,
174184
usage=action.usage if action.usage is not None else state.usage,
175185
)
176186

@@ -193,20 +203,28 @@ def reduce(state: ViewState, action: Action) -> ViewState:
193203
return replace(state, title=action.title)
194204

195205
if isinstance(action, ClearRows):
196-
return replace(state, rows=(), title=None, streaming_id=None)
206+
return replace(state, rows=(), title=None, streaming_id=None, streaming_anonymous_row_index=None)
197207

198208
return state
199209

200210

201211
def _apply_assistant_delta(state: ViewState, action: AssistantDelta) -> ViewState:
202-
"""Update the assistant row with this id (anywhere in the transcript), or
203-
start a new one.
204-
205-
On a thread with history, the client re-emits every prior message on each
206-
new turn (its dedup is per-turn), and a re-emitted *older* message can arrive
207-
after a newer one has started — so we must match by id across the whole
208-
transcript, not just the most recent assistant row, or prior answers get
209-
duplicated.
212+
"""Update the assistant row for this delta, or start a new one.
213+
214+
A genuine (non-empty) id is matched anywhere in the transcript, not just
215+
the most recent assistant row: on a thread with history, the client
216+
re-emits every prior message on each new turn (its dedup is per-turn), and
217+
a re-emitted *older* message can arrive after a newer one has started — so
218+
matching only the tail row would duplicate prior answers.
219+
220+
An empty id ("" — some providers/paths never stamp per-chunk ids, see
221+
``runtime._as_str``) is NOT a reliable key for that same scan: unlike a
222+
genuine id, it is shared by every id-less chunk from *every* turn, so
223+
matching `row.id == ""` across the whole transcript would fold a brand
224+
new turn's text into whatever earlier turn's row happened to be id-less
225+
too — silently vanishing the new turn's answer into a stale row. Empty-id
226+
deltas are therefore routed to `_apply_assistant_delta_anonymous`, which
227+
tracks "this turn's" row by position instead of by id.
210228
211229
Updates also merge by content rather than blindly concatenating, to absorb
212230
full re-sends / cumulative snapshots vs. genuine incremental deltas:
@@ -215,6 +233,8 @@ def _apply_assistant_delta(state: ViewState, action: AssistantDelta) -> ViewStat
215233
* accumulated starts with new text -> stale/shorter re-send: keep
216234
* otherwise -> a real delta: append
217235
"""
236+
if not action.id:
237+
return _apply_assistant_delta_anonymous(state, action)
218238

219239
rows = list(state.rows)
220240
for i, row in enumerate(rows):
@@ -234,13 +254,71 @@ def _apply_assistant_delta(state: ViewState, action: AssistantDelta) -> ViewStat
234254
return _mark_streaming(_append(state, AssistantRow(text=action.text, id=action.id)), action.id)
235255

236256

257+
def _apply_assistant_delta_anonymous(state: ViewState, action: AssistantDelta) -> ViewState:
258+
"""Handle an ``AssistantDelta`` whose id is empty (see `_apply_assistant_delta`).
259+
260+
Multiple id-less chunks legitimately arrive for a single turn — a provider
261+
that never stamps per-chunk ids still streams token by token, e.g.
262+
``"Hel"`` then ``"lo"`` — so the first empty-id delta of a turn starts a
263+
new row, and later empty-id deltas keep appending to that row (tracked by
264+
``state.streaming_anonymous_row_index``, reset on every
265+
``RunStarted``/``RunEnded``/``ClearRows``, not by id, so a later turn
266+
always starts its own new row instead of matching the previous turn's
267+
leftover id-less row — the bug this split exists to avoid).
268+
269+
The tracked row is only reused while it is still the LAST row in the
270+
transcript. A genuine id naturally changes across a tool round-trip
271+
(LangGraph gives the post-tool continuation a new AIMessage id), which is
272+
why an interleaved ``ToolStarted``/``ToolResult`` already starts a new row
273+
in the id-keyed path (see `test_assistant_delta_with_new_id_after_tool_
274+
creates_separate_row`). An empty id has no such natural signal — it is
275+
always ``""`` before and after the tool call — so this function uses row
276+
*position* as the substitute: once anything else has been appended (a
277+
tool card, in practice), the anonymous row is no longer the tail, and the
278+
next empty-id delta starts a fresh row rather than reaching backward past
279+
the tool card into stale text.
280+
"""
281+
index = state.streaming_anonymous_row_index
282+
if index is not None and index == len(state.rows) - 1:
283+
row = state.rows[index]
284+
if isinstance(row, AssistantRow) and not row.error:
285+
# Same no-op / merge semantics as the id-keyed path above.
286+
if row.text == action.text and len(action.text) > 1:
287+
return state
288+
rows = list(state.rows)
289+
merged = _merge_stream_text(row.text, action.text)
290+
rows[index] = replace(row, text=merged)
291+
return _mark_streaming_anonymous(replace(state, rows=tuple(rows)), index)
292+
293+
new_state = _append(state, AssistantRow(text=action.text, id=action.id))
294+
return _mark_streaming_anonymous(new_state, len(new_state.rows) - 1)
295+
296+
237297
def _mark_streaming(state: ViewState, message_id: str) -> ViewState:
238298
"""Record the actively-streaming message id (only while a run is active)."""
239299
if state.streaming:
240300
return replace(state, streaming_id=message_id)
241301
return state
242302

243303

304+
def _mark_streaming_anonymous(state: ViewState, index: int) -> ViewState:
305+
"""Record the active turn's anonymous-row index (only while a run is active).
306+
307+
Deliberately leaves ``streaming_id`` at ``None`` rather than ``""``: unlike
308+
a genuine id, ``""`` would be shared by every anonymous row across every
309+
turn, so using it as the render layer's "is this the row actively
310+
streaming" key (``render.render_transcript``) would flag every past
311+
anonymous row as actively streaming too, the moment a new one starts. The
312+
cost is purely cosmetic — an anonymous row never gets the
313+
raw-text-while-streaming treatment other rows get, only the id-less
314+
fallback path is affected — in exchange for not reintroducing a cross-turn
315+
ambiguity into the render layer that this fix removes from ``rows``.
316+
"""
317+
if state.streaming:
318+
return replace(state, streaming_id=None, streaming_anonymous_row_index=index)
319+
return state
320+
321+
244322
def _merge_stream_text(existing: str, incoming: str) -> str:
245323
if not existing:
246324
return incoming

backend/tests/test_tui_runtime.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,36 @@ def test_stream_actions_surfaces_exception_as_error_then_ends():
143143
actions = list(stream_actions(_BoomClient(), "go"))
144144
assert any(isinstance(a, AssistantError) and "model down" in a.text for a in actions)
145145
assert isinstance(actions[-1], RunEnded)
146+
147+
148+
def test_stream_actions_two_turns_with_none_ids_produce_separate_rows():
149+
"""Some providers/paths never stamp per-chunk ids: the raw chunk carries
150+
an explicit ``id: None``, which ``_as_str`` coerces to ``""``. Two
151+
separate turns from such a provider must not fold into one row -- see
152+
``_apply_assistant_delta_anonymous`` in view_state.py. Drives the real
153+
translate()/stream_actions() bridge, not just the reducer directly."""
154+
first_turn = _FakeClient(
155+
[
156+
StreamEvent(type="messages-tuple", data={"type": "ai", "content": "First turn answer.", "id": None}),
157+
StreamEvent(type="end", data={"usage": None}),
158+
]
159+
)
160+
second_turn = _FakeClient(
161+
[
162+
StreamEvent(type="messages-tuple", data={"type": "ai", "content": "Second turn answer.", "id": None}),
163+
StreamEvent(type="end", data={"usage": None}),
164+
]
165+
)
166+
167+
state = initial_state()
168+
for action in stream_actions(first_turn, "first question"):
169+
state = reduce(state, action)
170+
for action in stream_actions(second_turn, "second question"):
171+
state = reduce(state, action)
172+
173+
assistants = [r for r in state.rows if r.kind == "assistant"]
174+
# Pre-fix: both turns' AssistantDelta carry id="" and the second turn's
175+
# text is folded into the first turn's row instead of starting a new one.
176+
assert len(assistants) == 2
177+
assert assistants[0].text == "First turn answer."
178+
assert assistants[1].text == "Second turn answer."

backend/tests/test_tui_view_state.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,135 @@ def test_merge_stream_text_newline_split_across_chunks():
241241
def test_merge_stream_text_genuine_delta_append():
242242
"""Normal deltas that don't overlap still append."""
243243
assert _merge_stream_text("Hello ", "world") == "Hello world"
244+
245+
246+
# ---------------------------------------------------------------------------
247+
# Empty/missing-id assistant deltas: some providers/paths never stamp
248+
# per-chunk ids (runtime._as_str coerces a missing id to ""). Matching by id
249+
# like the normal path would fold EVERY id-less turn into whichever id-less
250+
# row happened to exist first, since "" is shared across turns -- unlike a
251+
# genuine id. These pin the fix: an empty id always keys off the CURRENT
252+
# turn (never a stale row from an earlier turn), while still coalescing
253+
# multiple id-less chunks that legitimately arrive within one turn.
254+
# ---------------------------------------------------------------------------
255+
256+
257+
def test_assistant_delta_empty_id_starts_new_row_per_turn_not_merged_with_prior_turn():
258+
state = initial_state()
259+
state = reduce(state, RunStarted())
260+
state = reduce(state, AssistantDelta(id="", text="First turn answer."))
261+
state = reduce(state, RunEnded())
262+
263+
state = reduce(state, UserSubmitted("second question"))
264+
state = reduce(state, RunStarted())
265+
state = reduce(state, AssistantDelta(id="", text="Second turn answer."))
266+
state = reduce(state, RunEnded())
267+
268+
assistants = [r for r in state.rows if r.kind == "assistant"]
269+
# Pre-fix: both turns share id="" so the second folds into the first via
270+
# the whole-transcript id scan, losing "First turn answer." entirely.
271+
assert len(assistants) == 2
272+
assert assistants[0].text == "First turn answer."
273+
assert assistants[1].text == "Second turn answer."
274+
275+
276+
def test_assistant_delta_empty_id_coalesces_multiple_chunks_within_same_turn():
277+
"""An id-less provider still streams token by token; chunks within ONE
278+
turn must accumulate into a single row, not fragment into many."""
279+
state = initial_state()
280+
state = reduce(state, RunStarted())
281+
state = reduce(state, AssistantDelta(id="", text="Hel"))
282+
state = reduce(state, AssistantDelta(id="", text="lo"))
283+
state = reduce(state, AssistantDelta(id="", text=" world"))
284+
state = reduce(state, RunEnded())
285+
286+
assistants = [r for r in state.rows if r.kind == "assistant"]
287+
assert len(assistants) == 1
288+
assert assistants[0].text == "Hello world"
289+
290+
291+
def test_assistant_delta_empty_id_starts_fresh_row_after_interleaved_tool_call():
292+
"""An empty id has no signal to distinguish "same message, paused for a
293+
tool call" from "a new message that happens to also be id-less" -- unlike
294+
a genuine id, which naturally changes across a tool round-trip (a new
295+
AIMessage gets a new id; see
296+
test_assistant_delta_with_new_id_after_tool_creates_separate_row). Once a
297+
tool card has been appended, the previous anonymous row is no longer the
298+
transcript tail, so the next empty-id delta must start a NEW row rather
299+
than reach backward past the tool card and silently prepend text that
300+
arrived after the tool ran."""
301+
state = initial_state()
302+
state = reduce(state, RunStarted())
303+
state = reduce(state, AssistantDelta(id="", text="Let me check. "))
304+
state = reduce(state, ToolStarted(tool_call_id="t1", tool_name="bash", args={}))
305+
state = reduce(state, ToolResult(tool_call_id="t1", content="ok", is_error=False))
306+
state = reduce(state, AssistantDelta(id="", text="Done."))
307+
state = reduce(state, RunEnded())
308+
309+
kinds = [r.kind for r in state.rows]
310+
assert kinds == ["assistant", "tool", "assistant"]
311+
assistants = [r for r in state.rows if r.kind == "assistant"]
312+
assert [a.text for a in assistants] == ["Let me check. ", "Done."]
313+
314+
315+
def test_assistant_delta_empty_id_coalesces_consecutive_chunks_before_a_tool_call():
316+
"""Multiple id-less chunks with NOTHING interleaved (the realistic
317+
per-token streaming case) still coalesce into one row up until a tool
318+
card breaks the streak."""
319+
state = initial_state()
320+
state = reduce(state, RunStarted())
321+
state = reduce(state, AssistantDelta(id="", text="Let me "))
322+
state = reduce(state, AssistantDelta(id="", text="check. "))
323+
state = reduce(state, ToolStarted(tool_call_id="t1", tool_name="bash", args={}))
324+
state = reduce(state, ToolResult(tool_call_id="t1", content="ok", is_error=False))
325+
state = reduce(state, RunEnded())
326+
327+
kinds = [r.kind for r in state.rows]
328+
assert kinds == ["assistant", "tool"]
329+
assistants = [r for r in state.rows if r.kind == "assistant"]
330+
assert assistants[0].text == "Let me check. "
331+
332+
333+
def test_assistant_delta_empty_id_does_not_disturb_legitimate_id_sequence():
334+
"""A normal, non-empty id sequence must keep coalescing correctly even
335+
after the transcript has already seen an earlier, unrelated empty-id
336+
turn (proves the two code paths -- id-keyed vs. anonymous -- don't
337+
interfere with each other)."""
338+
state = initial_state()
339+
state = reduce(state, RunStarted())
340+
state = reduce(state, AssistantDelta(id="", text="anonymous turn"))
341+
state = reduce(state, RunEnded())
342+
343+
state = reduce(state, UserSubmitted("question"))
344+
state = reduce(state, RunStarted())
345+
state = reduce(state, AssistantDelta(id="m1", text="Hel"))
346+
state = reduce(state, AssistantDelta(id="m1", text="lo"))
347+
state = reduce(state, RunEnded())
348+
349+
assistants = [r for r in state.rows if r.kind == "assistant"]
350+
assert [a.text for a in assistants] == ["anonymous turn", "Hello"]
351+
352+
353+
def test_assistant_delta_empty_id_resend_within_turn_is_noop():
354+
"""Same multi-char no-op re-send semantics apply to the anonymous path."""
355+
state = initial_state()
356+
state = reduce(state, RunStarted())
357+
state = reduce(state, AssistantDelta(id="", text="Hey there!"))
358+
state = reduce(state, AssistantDelta(id="", text="Hey there!"))
359+
assistants = [r for r in state.rows if r.kind == "assistant"]
360+
assert len(assistants) == 1
361+
assert assistants[0].text == "Hey there!"
362+
363+
364+
def test_clear_rows_resets_anonymous_streaming_index():
365+
"""A stale anonymous-row index must not resurrect after ClearRows."""
366+
state = initial_state()
367+
state = reduce(state, RunStarted())
368+
state = reduce(state, AssistantDelta(id="", text="before clear"))
369+
state = reduce(state, ClearRows())
370+
state = reduce(state, RunStarted())
371+
state = reduce(state, AssistantDelta(id="", text="after clear"))
372+
373+
assistants = [r for r in state.rows if r.kind == "assistant"]
374+
assert len(assistants) == 1
375+
assert assistants[0].text == "after clear"

0 commit comments

Comments
 (0)