fix(webui): reconcile threads after browser resume

This commit is contained in:
chengyongru
2026-07-28 16:25:08 +08:00
committed by chengyongru
parent 78cf68c291
commit ae089aa3ae
39 changed files with 6151 additions and 243 deletions
+227 -23
View File
@@ -25,6 +25,7 @@ from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN
WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3
WEBUI_FORK_MARKER_EVENT = "fork_marker"
WEBUI_TRANSCRIPT_INCOMPLETE_KEY = "transcript_incomplete"
_MAX_TRANSCRIPT_FILE_BYTES = 8 * 1024 * 1024
_TARGET_ACTIVE_TRANSCRIPT_BYTES = _MAX_TRANSCRIPT_FILE_BYTES // 2
_TRANSCRIPT_SEGMENT_MANIFEST_VERSION = 2
@@ -151,6 +152,12 @@ class _TranscriptChunkRef(NamedTuple):
user_count: int
class _SessionBackfillTurn(NamedTuple):
user_event: dict[str, Any]
assistant_signature: tuple[str, ...]
assistant_records: tuple[dict[str, Any], ...]
def _record_json_line(record: dict[str, Any]) -> str:
return json.dumps(record, ensure_ascii=False, separators=(",", ":"))
@@ -665,7 +672,7 @@ class WebUITranscriptRecorder:
phase: str | None = None,
include_source: bool = False,
transcript_overrides: dict[str, Any] | None = None,
) -> None:
) -> bool:
self.prepare_event(
chat_id,
event,
@@ -676,7 +683,7 @@ class WebUITranscriptRecorder:
record = dict(event)
if transcript_overrides:
record.update(transcript_overrides)
self.append(chat_id, record)
return self.append(chat_id, record)
def append_user_message(
self,
@@ -687,9 +694,9 @@ class WebUITranscriptRecorder:
media_paths: list[str] | None = None,
cli_apps: list[dict[str, Any]] | None = None,
mcp_presets: list[dict[str, Any]] | None = None,
) -> None:
) -> bool:
if text.strip() == "/stop" and not media_paths:
return
return False
payload = build_user_transcript_event(
chat_id,
text,
@@ -698,15 +705,17 @@ class WebUITranscriptRecorder:
mcp_presets=mcp_presets,
)
if payload is None:
return
self.prepare_and_append(chat_id, payload, metadata=metadata, phase="user")
return False
return self.prepare_and_append(chat_id, payload, metadata=metadata, phase="user")
def append(self, chat_id: str, event: dict[str, Any]) -> None:
def append(self, chat_id: str, event: dict[str, Any]) -> bool:
try:
dup = json.loads(json.dumps(event, ensure_ascii=False))
append_transcript_object(f"websocket:{chat_id}", dup)
except (OSError, ValueError, TypeError) as e:
self._log.warning("webui transcript append failed: {}", e)
return False
return True
def _next_turn_seq(self, chat_id: str, turn_id: str) -> int:
key = (chat_id, turn_id)
@@ -921,32 +930,69 @@ def _assistant_text_signature(value: Any) -> str:
return value.strip() if isinstance(value, str) else ""
def _session_assistant_event(
session_key: str,
message: dict[str, Any],
) -> dict[str, Any] | None:
if message.get("role") != "assistant" or is_hidden_history_message(message):
return None
message = public_history_message(message)
content = message.get("content")
text = content if isinstance(content, str) else ""
media = message.get("media")
media_paths = [str(path) for path in media] if isinstance(media, list) else []
media_paths = [path for path in media_paths if path]
if not text.strip() and not media_paths:
return None
chat_id = session_key.split(":", 1)[1] if ":" in session_key else session_key
event: dict[str, Any] = {
"event": "message",
"chat_id": chat_id,
"text": text,
}
if media_paths:
event["media"] = media_paths
latency_ms = message.get("latency_ms")
if isinstance(latency_ms, int | float) and latency_ms >= 0:
event["latency_ms"] = int(latency_ms)
return event
def _session_backfill_turns(
session_key: str,
session_messages: list[dict[str, Any]],
) -> list[tuple[dict[str, Any], tuple[str, ...]]]:
turns: list[tuple[dict[str, Any], tuple[str, ...]]] = []
) -> list[_SessionBackfillTurn]:
turns: list[_SessionBackfillTurn] = []
current_user: dict[str, Any] | None = None
assistant_texts: list[str] = []
assistant_records: list[dict[str, Any]] = []
def flush() -> None:
if current_user is None:
if current_user is None or not assistant_records:
return
signature = tuple(text for text in assistant_texts if text)
if signature:
turns.append((current_user, signature))
signature = tuple(
text
for record in assistant_records
if (text := _assistant_text_signature(record.get("text")))
)
turns.append(
_SessionBackfillTurn(
current_user,
signature,
tuple(dict(record) for record in assistant_records),
)
)
for message in session_messages:
role = message.get("role")
if role == "user":
flush()
current_user = _session_user_event(session_key, message)
assistant_texts = []
assistant_records = []
continue
if role == "assistant" and current_user is not None:
text = _assistant_text_signature(message.get("content"))
if text:
assistant_texts.append(text)
record = _session_assistant_event(session_key, message)
if record is not None:
assistant_records.append(record)
flush()
return turns
@@ -976,7 +1022,7 @@ def _transcript_turn_signature(records: list[dict[str, Any]]) -> tuple[str, ...]
def _find_unique_session_turn(
session_turns: list[tuple[dict[str, Any], tuple[str, ...]]],
session_turns: list[_SessionBackfillTurn],
signature: tuple[str, ...],
start: int,
) -> int | None:
@@ -984,7 +1030,7 @@ def _find_unique_session_turn(
return None
found: int | None = None
for index in range(start, len(session_turns)):
if session_turns[index][1] != signature:
if session_turns[index].assistant_signature != signature:
continue
if found is not None:
return None
@@ -992,6 +1038,101 @@ def _find_unique_session_turn(
return found
def _user_recovery_signature(event: dict[str, Any]) -> str:
fields = {
key: event[key]
for key in ("text", "media_paths", "cli_apps", "mcp_presets")
if key in event
}
return json.dumps(fields, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def _find_unique_session_turn_by_user(
session_turns: list[_SessionBackfillTurn],
user_event: dict[str, Any],
) -> _SessionBackfillTurn | None:
signature = _user_recovery_signature(user_event)
matches = [
turn
for turn in session_turns
if _user_recovery_signature(turn.user_event) == signature
]
return matches[0] if len(matches) == 1 else None
def _is_recoverable_answer_record(record: dict[str, Any]) -> bool:
event = record.get("event")
if event in {"delta", "stream_end"}:
return True
return event == "message" and record.get("kind") not in {
"tool_hint",
"progress",
"reasoning",
}
def recover_incomplete_turns_from_session(
lines: list[dict[str, Any]],
session_messages: list[dict[str, Any]] | None,
*,
session_key: str,
) -> list[dict[str, Any]]:
"""Recover marked transcript answers only when one durable session turn matches."""
if not lines or not session_messages:
return lines
session_turns = _session_backfill_turns(session_key, session_messages)
if not session_turns:
return lines
recovered: list[dict[str, Any]] = []
for turn in _split_transcript_turns(lines):
turn_end = turn[-1] if turn else None
if (
not isinstance(turn_end, dict)
or turn_end.get("event") != "turn_end"
or turn_end.get(WEBUI_TRANSCRIPT_INCOMPLETE_KEY) is not True
):
recovered.extend(turn)
continue
user_events = [record for record in turn if record.get("event") == "user"]
if len(user_events) != 1:
recovered.extend(turn)
continue
session_turn = _find_unique_session_turn_by_user(session_turns, user_events[0])
if session_turn is None or not session_turn.assistant_records:
recovered.extend(turn)
continue
stable_end_ms = _valid_created_at_ms(turn_end.get("created_at_ms"))
turn_id = turn_end.get("turn_id")
answer_records: list[dict[str, Any]] = []
for index, source in enumerate(session_turn.assistant_records):
answer = dict(source)
if isinstance(turn_id, str) and turn_id:
answer["turn_id"] = turn_id
answer["turn_phase"] = "answer"
if stable_end_ms is not None:
answer["created_at_ms"] = max(
0,
stable_end_ms - len(session_turn.assistant_records) + index,
)
answer_records.append(answer)
# Session history is the durable source of the completed answer. Keep
# traces/reasoning/file edits, but replace any partial answer fragments.
recovered.extend(
record
for record in turn[:-1]
if not _is_recoverable_answer_record(record)
)
recovered.extend(answer_records)
completed_end = dict(turn_end)
completed_end.pop(WEBUI_TRANSCRIPT_INCOMPLETE_KEY, None)
recovered.append(completed_end)
return recovered
def _with_backfilled_user(
records: list[dict[str, Any]],
user_event: dict[str, Any],
@@ -1972,8 +2113,36 @@ def fork_boundary_message_count(lines: list[dict[str, Any]]) -> int | None:
return None
def has_pending_tool_calls(lines: list[dict[str, Any]]) -> bool:
def has_pending_tool_calls(
lines: list[dict[str, Any]],
*,
active_turn_started_at: float | None = None,
active_turn_id: str | None = None,
active_turn_transcript_persistence_failed: bool = False,
) -> bool:
"""Return True when the selected transcript tail looks like an unfinished turn."""
# An older canonical turn can remain unsafe even after a later turn
# completes. Recovery removes this marker only after matching durable
# session history, so no later turn_end may hide it.
if any(
rec.get(WEBUI_TRANSCRIPT_INCOMPLETE_KEY) is True
for rec in lines
):
return True
if active_turn_started_at is not None:
if active_turn_transcript_persistence_failed:
return True
if active_turn_id is None:
return True
for rec in reversed(lines):
transcript_turn_id = rec.get("turn_id")
if not isinstance(transcript_turn_id, str) or not transcript_turn_id:
continue
if transcript_turn_id != active_turn_id:
return True
return rec.get("event") != "turn_end"
return True
for rec in reversed(lines):
ev = rec.get("event")
if ev == "turn_end":
@@ -1995,6 +2164,24 @@ def has_pending_tool_calls(lines: list[dict[str, Any]]) -> bool:
return False
def completed_turn_ids(lines: list[dict[str, Any]]) -> list[str]:
"""Return stable identities for turns with an explicitly persisted completion."""
completed: list[str] = []
seen: set[str] = set()
for rec in lines:
if (
rec.get("event") != "turn_end"
or rec.get(WEBUI_TRANSCRIPT_INCOMPLETE_KEY) is True
):
continue
turn_id = rec.get("turn_id")
if not isinstance(turn_id, str) or not turn_id or turn_id in seen:
continue
seen.add(turn_id)
completed.append(turn_id)
return completed
def build_webui_thread_response(
session_key: str,
*,
@@ -2002,6 +2189,9 @@ def build_webui_thread_response(
augment_assistant_media: Callable[[list[str]], list[dict[str, Any]]] | None = None,
augment_assistant_text: Callable[[str], str] | None = None,
session_messages: list[dict[str, Any]] | None = None,
active_turn_started_at: float | None = None,
active_turn_id: str | None = None,
active_turn_transcript_persistence_failed: bool = False,
limit: int | None = None,
direction: str | None = None,
before: str | None = None,
@@ -2013,9 +2203,14 @@ def build_webui_thread_response(
lines, page = _select_transcript_page(session_key, limit=limit, before=before)
else:
lines = read_transcript_lines(session_key)
if not lines:
if not lines and active_turn_started_at is None:
return None
lines = inject_missing_user_events_from_session(session_key, lines, session_messages)
lines = recover_incomplete_turns_from_session(
lines,
session_messages,
session_key=session_key,
)
fork_boundary = fork_boundary_message_count(lines)
msgs = replay_transcript_to_ui_messages(
lines,
@@ -2027,7 +2222,16 @@ def build_webui_thread_response(
"schemaVersion": WEBUI_TRANSCRIPT_SCHEMA_VERSION,
"sessionKey": session_key,
"messages": msgs,
"has_pending_tool_calls": has_pending_tool_calls(lines),
"completed_turn_ids": completed_turn_ids(lines),
"has_pending_tool_calls": has_pending_tool_calls(
lines,
active_turn_started_at=active_turn_started_at,
active_turn_id=active_turn_id,
active_turn_transcript_persistence_failed=(
active_turn_transcript_persistence_failed
),
),
"active_turn_id": active_turn_id,
}
if page is not None:
page["loaded_message_count"] = len(msgs)