黄云龙 b650456c6d
fix(streaming): drop silent delta-discard in _merge_stream_text (#4085)
The dual-mode _merge_stream_text used short-circuits chunk==existing
and existing.endswith(chunk) that silently dropped legitimate delta
chunks in messages-tuple mode:

- CJK reduplication: '谢谢' tokenized as ['谢','谢'] -> buffer stays '谢'
- Repeated tokens: 'gogo' as ['go','go'] -> buffer stays 'go'
- Suffix-matching tails: 'hello' as ['hel','l','o'] -> drops 'l'

Delta payloads must always append; only strictly-longer cumulative
snapshots should replace. The fix gates the cumulative-replace branch
with len(chunk) > len(existing) and removes the dropped-shape guards.

Both copies are fixed:
- app/channels/manager.py (IM streaming to Feishu/Telegram/etc.)
- deerflow/tui/view_state.py (TUI client rendering)

The TUI reduce() caller now handles exact re-sends (values snapshot
re-emitting history) before the merge, and the len>1 guard prevents
single-char CJK deltas from being mistaken for re-sends.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-12 12:13:02 +08:00

313 lines
10 KiB
Python

"""Pure view-state reducer for the DeerFlow TUI.
This module has **no** Textual / rendering dependency. It models the visible
conversation as an immutable list of typed rows and a small set of actions,
and exposes a single pure ``reduce(state, action) -> state`` function.
Keeping this layer pure makes the interesting behaviour (streaming deltas,
tool cards, error rows) testable with plain ``pytest`` and a handful of
synthetic actions, independent of any terminal.
The runtime bridge (``deerflow.tui.runtime``) is responsible for translating
``DeerFlowClient`` ``StreamEvent`` objects into these actions; the Textual app
renders ``ViewState`` into widgets. Both sides depend on this module, not on
each other.
"""
from __future__ import annotations
from dataclasses import dataclass, field, replace
from typing import Literal
from .message_format import format_tool_detail, format_tool_result, summarize_tool_title
# --------------------------------------------------------------------------- #
# Rows — the immutable units the transcript is built from.
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class UserRow:
text: str
kind: Literal["user"] = "user"
@dataclass(frozen=True)
class AssistantRow:
text: str
id: str | None = None
error: bool = False
kind: Literal["assistant"] = "assistant"
@dataclass(frozen=True)
class ToolRow:
tool_call_id: str
tool_name: str
title: str
detail: str = ""
result: str = ""
status: Literal["running", "ok", "error"] = "running"
kind: Literal["tool"] = "tool"
@dataclass(frozen=True)
class SystemRow:
text: str
tone: Literal["info", "error"] = "info"
kind: Literal["system"] = "system"
Row = UserRow | AssistantRow | ToolRow | SystemRow
# --------------------------------------------------------------------------- #
# Actions — the only ways the state can change.
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class UserSubmitted:
text: str
@dataclass(frozen=True)
class RunStarted:
pass
@dataclass(frozen=True)
class RunEnded:
usage: dict | None = None
@dataclass(frozen=True)
class AssistantDelta:
id: str
text: str
@dataclass(frozen=True)
class AssistantError:
text: str
@dataclass(frozen=True)
class ToolStarted:
tool_call_id: str
tool_name: str
args: dict = field(default_factory=dict)
@dataclass(frozen=True)
class ToolResult:
tool_call_id: str
content: str
is_error: bool = False
tool_name: str = ""
@dataclass(frozen=True)
class SystemMessage:
text: str
tone: Literal["info", "error"] = "info"
@dataclass(frozen=True)
class ThreadTitle:
title: str
@dataclass(frozen=True)
class ClearRows:
pass
Action = UserSubmitted | RunStarted | RunEnded | AssistantDelta | AssistantError | ToolStarted | ToolResult | SystemMessage | ThreadTitle | ClearRows
# --------------------------------------------------------------------------- #
# State.
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class ViewState:
rows: tuple[Row, ...] = ()
streaming: bool = False
usage: dict | None = None
title: str | None = None
# Id of the message currently being generated this turn. Only this row renders
# as plain text while streaming; everything else (history) stays Markdown.
streaming_id: str | None = None
def initial_state(rows: tuple[Row, ...] = ()) -> ViewState:
return ViewState(rows=tuple(rows))
# --------------------------------------------------------------------------- #
# Reducer.
# --------------------------------------------------------------------------- #
def _append(state: ViewState, row: Row) -> ViewState:
return replace(state, rows=state.rows + (row,))
def reduce(state: ViewState, action: Action) -> ViewState:
"""Return a new ``ViewState`` after applying ``action``. Pure."""
if isinstance(action, UserSubmitted):
return _append(state, UserRow(text=action.text))
if isinstance(action, RunStarted):
# New turn: no message is actively streaming yet (the client re-emits
# prior messages first; those must not be treated as the active one).
return replace(state, streaming=True, streaming_id=None)
if isinstance(action, RunEnded):
return replace(
state,
streaming=False,
streaming_id=None,
usage=action.usage if action.usage is not None else state.usage,
)
if isinstance(action, AssistantDelta):
return _apply_assistant_delta(state, action)
if isinstance(action, AssistantError):
return _append(state, AssistantRow(text=action.text, error=True))
if isinstance(action, ToolStarted):
return _apply_tool_started(state, action)
if isinstance(action, ToolResult):
return _apply_tool_result(state, action)
if isinstance(action, SystemMessage):
return _append(state, SystemRow(text=action.text, tone=action.tone))
if isinstance(action, ThreadTitle):
return replace(state, title=action.title)
if isinstance(action, ClearRows):
return replace(state, rows=(), title=None, streaming_id=None)
return state
def _apply_assistant_delta(state: ViewState, action: AssistantDelta) -> ViewState:
"""Update the assistant row with this id (anywhere in the transcript), or
start a new one.
On a thread with history, the client re-emits every prior message on each
new turn (its dedup is per-turn), and a re-emitted *older* message can arrive
after a newer one has started — so we must match by id across the whole
transcript, not just the most recent assistant row, or prior answers get
duplicated.
Updates also merge by content rather than blindly concatenating, to absorb
full re-sends / cumulative snapshots vs. genuine incremental deltas:
* new text == accumulated, or starts with it -> cumulative/re-send: replace
* accumulated starts with new text -> stale/shorter re-send: keep
* otherwise -> a real delta: append
"""
rows = list(state.rows)
for i, row in enumerate(rows):
# ``not row.error``: error rows are appended without an id, so they never
# match here anyway — the guard is belt-and-suspenders to keep an error
# row from being merged into if a future change ever gives it an id.
if isinstance(row, AssistantRow) and row.id == action.id and not row.error:
# Exact re-send of the same full text (e.g. a values snapshot
# re-emitting history after reconnection): no-op. Only multi-char
# matches are treated as re-sends so single-char deltas that happen
# to equal the buffer (CJK reduplication) are NOT mistaken for no-ops.
if row.text == action.text and len(action.text) > 1:
return state
merged = _merge_stream_text(row.text, action.text)
rows[i] = replace(row, text=merged)
return _mark_streaming(replace(state, rows=tuple(rows)), action.id)
return _mark_streaming(_append(state, AssistantRow(text=action.text, id=action.id)), action.id)
def _mark_streaming(state: ViewState, message_id: str) -> ViewState:
"""Record the actively-streaming message id (only while a run is active)."""
if state.streaming:
return replace(state, streaming_id=message_id)
return state
def _merge_stream_text(existing: str, incoming: str) -> str:
if not existing:
return incoming
# Cumulative re-delivery: incoming strictly extends existing.
if len(incoming) > len(existing) and incoming.startswith(existing):
return incoming
# Stale/shorter re-send: existing already contains incoming as a prefix
# (e.g. a values snapshot re-emitting history that has already been
# accumulated from deltas). Only treat as stale when strictly shorter.
if len(existing) > len(incoming) and existing.startswith(incoming):
return existing
return existing + incoming # genuine incremental delta
def _apply_tool_started(state: ViewState, action: ToolStarted) -> ViewState:
"""Create or update a tool card, de-duplicated by ``tool_call_id``.
Streaming tool calls arrive as multiple chunks for one call id (name first,
then growing arguments), and the client may re-emit the call via a values
snapshot. Chunks with no id are argument-fragment noise and are dropped.
"""
if not action.tool_call_id:
return state
rows = list(state.rows)
for i, row in enumerate(rows):
if isinstance(row, ToolRow) and row.tool_call_id == action.tool_call_id:
name = action.tool_name or row.tool_name
detail = format_tool_detail(name, action.args) or row.detail
rows[i] = replace(row, tool_name=name, title=summarize_tool_title(name), detail=detail)
return replace(state, rows=tuple(rows))
return _append(
state,
ToolRow(
tool_call_id=action.tool_call_id,
tool_name=action.tool_name,
title=summarize_tool_title(action.tool_name),
detail=format_tool_detail(action.tool_name, action.args),
status="running",
),
)
def _apply_tool_result(state: ViewState, action: ToolResult) -> ViewState:
if not action.tool_call_id:
return state
rows = list(state.rows)
for i, row in enumerate(rows):
if isinstance(row, ToolRow) and row.tool_call_id == action.tool_call_id:
rows[i] = replace(
row,
status="error" if action.is_error else "ok",
result=format_tool_result(action.content),
)
return replace(state, rows=tuple(rows))
# No matching tool card (started chunks missed) -> surface the result anyway.
return _append(
state,
ToolRow(
tool_call_id=action.tool_call_id,
tool_name=action.tool_name,
title=summarize_tool_title(action.tool_name),
status="error" if action.is_error else "ok",
result=format_tool_result(action.content),
),
)