fix(office-ui): stop progress-row flicker and surface native role replies end to end

Two user-visible defects in company mode, root-caused via project 6666/8888
DB forensics:

Progress-row flicker (thinking preview appearing/disappearing): progress
entries were broadcast to clients before reaching the persistence buffer,
so a tool_call-triggered session_detail snapshot rebuilt from the DB erased
freshly streamed entries from the live log. Buffer now fills before the
broadcast and session_detail flushes it before reading.

Native role transcripts incomplete (thinking only at start, no narration,
no final summary — external agents unaffected):
- thinking deltas shared one stream id per conversation turn while seq
  reset per iteration, collapsing all iterations into one entry and
  silently dropping live thinking from iteration 2 on; now keyed per
  iteration like assistant deltas
- assistant_delta events were mapped to None; company mode now surfaces
  them as streaming 'assistant' progress entries (rendered as Reply cards,
  merged like thinking, excluded from inline chat rows)
- thinking was persisted one row per token, flooding the 1000-entry cap
  and evicting interleaved tool history; append_progress now folds
  streaming deltas per (type, turn, stream) with seq dedup
- the terminal company turn was hidden at summary detail and, worse, its
  id-keyed backfill merge kept the first-inserted intermediate content, so
  the final reply never reached any channel; terminal turns are now
  flagged company_final_turn, visible at summary detail, and carry their
  own ui_message_id so they insert as fresh rows
- appendProgressEntry applied its seq guard against unrelated entries when
  the stream key was absent from the log, killing the first delta of any
  fresh stream; the guard now only applies within the same stream

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
LZH-YS1998
2026-07-09 20:46:14 +08:00
parent 35fa717cf6
commit a0402522da
13 changed files with 461 additions and 274 deletions
+53 -1
View File
@@ -1599,6 +1599,58 @@ class ChatStore:
# concern, swap this in-row JSON blob for a proper rolling table.
_PROGRESS_MAX_ENTRIES = 1000
# Streaming text types arrive as one entry per token-sized delta. Without
# folding, thinking floods the entry cap (forensics: 929/1000 entries were
# single-token thinking rows) and evicts the interleaved tool history.
_PROGRESS_STREAM_MERGE_TYPES = frozenset({"thinking", "assistant"})
@staticmethod
def _progress_stream_key(entry: dict[str, Any]) -> tuple[str, str, str] | None:
entry_type = str(entry.get("type", "") or "")
if entry_type not in ChatStore._PROGRESS_STREAM_MERGE_TYPES:
return None
item_id = str(entry.get("item_id") or entry.get("stream_id") or "").strip()
if not item_id:
return None
return (entry_type, str(entry.get("turn_id", "") or ""), item_id)
@classmethod
def _fold_progress_entries(
cls,
existing: list[dict[str, Any]],
new_entries: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Fold streaming deltas into their stream's entry (mirrors the
frontend ``appendProgressEntry`` merge so persisted state equals what
the live client built)."""
merged = list(existing)
index_by_key: dict[tuple[str, str, str], int] = {}
for i, entry in enumerate(merged):
key = cls._progress_stream_key(entry)
if key is not None:
index_by_key[key] = i
for entry in new_entries:
key = cls._progress_stream_key(entry)
if key is None or key not in index_by_key:
if key is not None:
index_by_key[key] = len(merged)
merged.append(entry)
continue
target = merged[index_by_key[key]]
last_seq = target.get("seq")
new_seq = entry.get("seq")
if isinstance(last_seq, (int, float)) and isinstance(new_seq, (int, float)) and new_seq <= last_seq:
continue
# Deltas are disjoint token fragments — concatenate raw, no strip.
detail = f"{target.get('detail') or ''}{entry.get('detail') or ''}"
preview = " ".join(detail.split())
folded = dict(target)
folded.update(entry)
folded["detail"] = detail
folded["summary"] = preview[:120].rstrip() + ("..." if len(preview) > 120 else "")
merged[index_by_key[key]] = folded
return merged
async def append_progress(
self,
task_id: str,
@@ -1611,7 +1663,7 @@ class ChatStore:
the first call creates the row and subsequent calls update it.
"""
existing = await self.get_progress(task_id, project_id=project_id)
merged = (existing + new_entries)[-self._PROGRESS_MAX_ENTRIES:]
merged = self._fold_progress_entries(existing, new_entries)[-self._PROGRESS_MAX_ENTRIES:]
async def _write() -> None:
await self._db.execute(