mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-06 12:59:09 +00:00
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>
313 lines
10 KiB
Python
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),
|
|
),
|
|
)
|