mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-05 10:41:58 +03:00
feat(webui): unify turn observability
This commit is contained in:
+13
-6
@@ -425,7 +425,7 @@ class AgentRunner:
|
||||
) -> AgentRunResult:
|
||||
final_content: str | None = None
|
||||
tools_used: list[str] = []
|
||||
usage: dict[str, int] = {"prompt_tokens": 0, "completion_tokens": 0}
|
||||
usage = {"prompt_tokens": 0, "completion_tokens": 0}
|
||||
error: str | None = None
|
||||
stop_reason = "completed"
|
||||
tool_events: list[dict[str, str]] = []
|
||||
@@ -1384,11 +1384,6 @@ class AgentRunner:
|
||||
usage.get("prompt_tokens", 0) + usage.get("completion_tokens", 0)
|
||||
))
|
||||
|
||||
@staticmethod
|
||||
def _accumulate_usage(target: dict[str, int], addition: dict[str, int]) -> None:
|
||||
for key, value in addition.items():
|
||||
target[key] = target.get(key, 0) + value
|
||||
|
||||
@staticmethod
|
||||
def _merge_usage(left: dict[str, int], right: dict[str, int]) -> dict[str, int]:
|
||||
merged = dict(left)
|
||||
@@ -1396,6 +1391,18 @@ class AgentRunner:
|
||||
merged[key] = merged.get(key, 0) + value
|
||||
return merged
|
||||
|
||||
@staticmethod
|
||||
def _accumulate_usage(total: dict[str, int], request: dict[str, int]) -> None:
|
||||
"""Fold one model request into the current turn's usage."""
|
||||
total["request_count"] = total.get("request_count", 0) + 1
|
||||
prompt_tokens = request.get("prompt_tokens")
|
||||
if prompt_tokens is not None and prompt_tokens >= 0:
|
||||
total["context_tokens"] = prompt_tokens
|
||||
for key, value in request.items():
|
||||
if key in {"context_tokens", "request_count"} or value < 0:
|
||||
continue
|
||||
total[key] = total.get(key, 0) + value
|
||||
|
||||
async def _execute_tools(
|
||||
self,
|
||||
spec: AgentRunSpec,
|
||||
|
||||
@@ -100,6 +100,7 @@ class TurnModelUpdatedEvent(OutboundEvent):
|
||||
model: str
|
||||
model_preset: str | None = None
|
||||
context_window_tokens: int | None = None
|
||||
fallback: bool = False
|
||||
|
||||
|
||||
def outbound_message_for_event(
|
||||
|
||||
@@ -426,6 +426,7 @@ class WebSocketChannel(BaseChannel):
|
||||
)
|
||||
|
||||
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
|
||||
self._reasoning_text_buffers: dict[tuple[str, str], list[str]] = {}
|
||||
|
||||
# -- Subscription bookkeeping -------------------------------------------
|
||||
|
||||
@@ -482,6 +483,9 @@ class WebSocketChannel(BaseChannel):
|
||||
for key in tuple(self._stream_text_buffers):
|
||||
if key[0] == chat_id:
|
||||
self._stream_text_buffers.pop(key, None)
|
||||
for key in tuple(self._reasoning_text_buffers):
|
||||
if key[0] == chat_id:
|
||||
self._reasoning_text_buffers.pop(key, None)
|
||||
|
||||
async def _discard_connection_owned_chat(
|
||||
self,
|
||||
@@ -1641,11 +1645,22 @@ class WebSocketChannel(BaseChannel):
|
||||
include_source=include_source,
|
||||
transcript_overrides=transcript_overrides,
|
||||
)
|
||||
if (
|
||||
not persisted
|
||||
and phase in {"answer", "complete"}
|
||||
and (metadata or {}).get("webui") is True
|
||||
):
|
||||
return self._retain_turn_on_transcript_failure(
|
||||
chat_id,
|
||||
persisted=persisted,
|
||||
metadata=metadata,
|
||||
phase=phase,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _retain_turn_on_transcript_failure(
|
||||
chat_id: str,
|
||||
*,
|
||||
persisted: bool,
|
||||
metadata: dict[str, Any] | None,
|
||||
phase: str,
|
||||
) -> bool:
|
||||
if not persisted and phase in {"answer", "complete"} and (metadata or {}).get("webui") is True:
|
||||
owner = (metadata or {}).get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
|
||||
mark_websocket_turn_transcript_persistence_failed(
|
||||
chat_id,
|
||||
@@ -1653,6 +1668,34 @@ class WebSocketChannel(BaseChannel):
|
||||
)
|
||||
return persisted
|
||||
|
||||
def _persist_turn_stream_event(
|
||||
self,
|
||||
chat_id: str,
|
||||
event: dict[str, Any],
|
||||
*,
|
||||
completed_text: str | None,
|
||||
metadata: dict[str, Any] | None,
|
||||
phase: str,
|
||||
include_source: bool = False,
|
||||
) -> bool:
|
||||
"""Persist the canonical end of a live stream, never its wire chunks."""
|
||||
if not self._temporary_chats.should_persist_transcript(chat_id):
|
||||
return True
|
||||
persisted = self._transcripts.prepare_and_append_stream_event(
|
||||
chat_id,
|
||||
event,
|
||||
completed_text=completed_text,
|
||||
metadata=metadata,
|
||||
phase=phase,
|
||||
include_source=include_source,
|
||||
)
|
||||
return self._retain_turn_on_transcript_failure(
|
||||
chat_id,
|
||||
persisted=persisted,
|
||||
metadata=metadata,
|
||||
phase=phase,
|
||||
)
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
event = outbound_event_from_message(msg)
|
||||
progress_event = event if isinstance(event, ProgressEvent) else None
|
||||
@@ -1685,6 +1728,7 @@ class WebSocketChannel(BaseChannel):
|
||||
model_name=event.model,
|
||||
model_preset=event.model_preset,
|
||||
context_window_tokens=event.context_window_tokens,
|
||||
fallback=event.fallback,
|
||||
)
|
||||
return
|
||||
if isinstance(event, UserInputEvent):
|
||||
@@ -1834,9 +1878,12 @@ class WebSocketChannel(BaseChannel):
|
||||
}
|
||||
if stream_id is not None:
|
||||
body["stream_id"] = stream_id
|
||||
self._persist_turn_transcript_event(
|
||||
stream_key = (chat_id, str(stream_id or ""))
|
||||
self._reasoning_text_buffers.setdefault(stream_key, []).append(delta)
|
||||
self._persist_turn_stream_event(
|
||||
chat_id,
|
||||
body,
|
||||
completed_text=None,
|
||||
metadata=meta,
|
||||
phase="reasoning",
|
||||
)
|
||||
@@ -1862,9 +1909,12 @@ class WebSocketChannel(BaseChannel):
|
||||
}
|
||||
if stream_id is not None:
|
||||
body["stream_id"] = stream_id
|
||||
self._persist_turn_transcript_event(
|
||||
stream_key = (chat_id, str(stream_id or ""))
|
||||
reasoning_text = "".join(self._reasoning_text_buffers.pop(stream_key, []))
|
||||
self._persist_turn_stream_event(
|
||||
chat_id,
|
||||
body,
|
||||
completed_text=reasoning_text or None,
|
||||
metadata=meta,
|
||||
phase="reasoning",
|
||||
)
|
||||
@@ -1912,6 +1962,7 @@ class WebSocketChannel(BaseChannel):
|
||||
conns = list(self._subs.get(chat_id, ()))
|
||||
meta = metadata or {}
|
||||
stream_key = (chat_id, str(stream_id or ""))
|
||||
completed_text: str | None = None
|
||||
if stream_end:
|
||||
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
|
||||
buffered = (
|
||||
@@ -1923,6 +1974,7 @@ class WebSocketChannel(BaseChannel):
|
||||
buffered.append(delta)
|
||||
full_text = "".join(buffered)
|
||||
rewritten = self._media.rewrite_local_markdown_images(full_text)
|
||||
completed_text = rewritten
|
||||
if delta or rewritten != full_text:
|
||||
body["text"] = rewritten
|
||||
else:
|
||||
@@ -1938,9 +1990,10 @@ class WebSocketChannel(BaseChannel):
|
||||
body["resuming"] = True
|
||||
if stream_end and merge_next:
|
||||
body["merge_next"] = True
|
||||
self._persist_turn_transcript_event(
|
||||
self._persist_turn_stream_event(
|
||||
chat_id,
|
||||
body,
|
||||
completed_text=completed_text,
|
||||
metadata=meta,
|
||||
phase="answer",
|
||||
include_source=True,
|
||||
@@ -1997,6 +2050,7 @@ class WebSocketChannel(BaseChannel):
|
||||
# carries a durable incomplete marker. The HTTP replay path can
|
||||
# recover the latter from session history after a gateway restart.
|
||||
clear_websocket_turn_if_current(chat_id, turn_owner)
|
||||
self._clear_stream_buffers(chat_id)
|
||||
raw = json.dumps(body, ensure_ascii=False)
|
||||
if not conns:
|
||||
return
|
||||
@@ -2102,6 +2156,7 @@ class WebSocketChannel(BaseChannel):
|
||||
model_name: Any,
|
||||
model_preset: Any = None,
|
||||
context_window_tokens: Any = None,
|
||||
fallback: bool = False,
|
||||
) -> None:
|
||||
"""Notify one chat's subscribers which model is handling its current request."""
|
||||
conns = list(self._subs.get(chat_id, ()))
|
||||
@@ -2120,6 +2175,8 @@ class WebSocketChannel(BaseChannel):
|
||||
body["model_preset"] = model_preset.strip()
|
||||
if isinstance(context_window_tokens, int) and context_window_tokens > 0:
|
||||
body["context_window_tokens"] = context_window_tokens
|
||||
if fallback:
|
||||
body["fallback"] = True
|
||||
raw = json.dumps(body, ensure_ascii=False)
|
||||
for connection in conns:
|
||||
await self._safe_send_to(connection, raw, label=" turn_model_updated ")
|
||||
|
||||
@@ -2073,6 +2073,21 @@ async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None:
|
||||
"model_preset": "Deep Research",
|
||||
"context_window_tokens": 128_000,
|
||||
}
|
||||
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
content="",
|
||||
event=TurnModelUpdatedEvent(
|
||||
model="deepseek/deepseek-chat",
|
||||
model_preset="Deep Research",
|
||||
fallback=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
fallback_payload = json.loads(chat_one.send.call_args.args[0])
|
||||
assert fallback_payload["fallback"] is True
|
||||
chat_two.send.assert_not_awaited()
|
||||
|
||||
|
||||
@@ -2348,8 +2363,9 @@ async def test_send_delta_preserves_webui_source_metadata() -> None:
|
||||
assert second["event"] == "stream_end"
|
||||
assert second["source"] == source
|
||||
lines = read_transcript_lines("websocket:chat-source-stream")
|
||||
assert lines[-2]["source"] == source
|
||||
assert lines[-1]["source"] == source
|
||||
assert lines[-1]["event"] == "stream_end"
|
||||
assert lines[-1]["text"] == "done"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -2374,6 +2390,8 @@ async def test_send_delta_marks_resuming_stream_end() -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delta_keeps_buffer_across_merged_stream_boundary() -> None:
|
||||
from nanobot.webui.transcript import build_webui_thread_response, read_transcript_lines
|
||||
|
||||
bus = MagicMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "streaming": True},
|
||||
@@ -2403,6 +2421,12 @@ async def test_send_delta_keeps_buffer_across_merged_stream_boundary() -> None:
|
||||
"second",
|
||||
]
|
||||
assert ("chat-1", "sid") not in channel._stream_text_buffers
|
||||
lines = read_transcript_lines("websocket:chat-1")
|
||||
assert [line["event"] for line in lines] == ["stream_end", "stream_end"]
|
||||
assert [line["text"] for line in lines] == ["first ", "first second"]
|
||||
body = build_webui_thread_response("websocket:chat-1")
|
||||
assert body is not None
|
||||
assert body["messages"][-1]["content"] == "first second"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -2596,7 +2620,8 @@ async def test_stream_transcript_persists_without_subscribers() -> None:
|
||||
|
||||
assert channel._subs == {}
|
||||
lines = read_transcript_lines("websocket:chat-1")
|
||||
assert [line["event"] for line in lines] == ["delta", "delta", "stream_end", "turn_end"]
|
||||
assert [line["event"] for line in lines] == ["stream_end", "turn_end"]
|
||||
assert lines[0]["text"] == "hello world"
|
||||
body = build_webui_thread_response("websocket:chat-1")
|
||||
assert body is not None
|
||||
assert body["messages"][-1]["role"] == "assistant"
|
||||
@@ -2604,6 +2629,77 @@ async def test_stream_transcript_persists_without_subscribers() -> None:
|
||||
assert body["messages"][-1]["latencyMs"] == 42
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_transcript_writes_once_per_completed_segment(monkeypatch) -> None:
|
||||
bus = MagicMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "streaming": True},
|
||||
bus,
|
||||
gateway=_basic_handler(bus),
|
||||
)
|
||||
append = MagicMock()
|
||||
monkeypatch.setattr("nanobot.webui.transcript.append_transcript_object", append)
|
||||
|
||||
await channel.send_delta("chat-write-rate", "one", stream_id="s1")
|
||||
await channel.send_delta("chat-write-rate", " two", stream_id="s1")
|
||||
await channel.send_delta("chat-write-rate", " three", stream_id="s1")
|
||||
|
||||
append.assert_not_called()
|
||||
|
||||
await channel.send_delta("chat-write-rate", "", stream_id="s1", stream_end=True)
|
||||
|
||||
append.assert_called_once()
|
||||
persisted = append.call_args.args[1]
|
||||
assert persisted["event"] == "stream_end"
|
||||
assert persisted["text"] == "one two three"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reasoning_transcript_persists_one_canonical_record(monkeypatch) -> None:
|
||||
bus = MagicMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus),
|
||||
)
|
||||
append = MagicMock()
|
||||
monkeypatch.setattr("nanobot.webui.transcript.append_transcript_object", append)
|
||||
|
||||
await channel.send_reasoning_delta("chat-reasoning-write-rate", "plan ", stream_id="r1")
|
||||
await channel.send_reasoning_delta("chat-reasoning-write-rate", "then act", stream_id="r1")
|
||||
|
||||
append.assert_not_called()
|
||||
|
||||
await channel.send_reasoning_end("chat-reasoning-write-rate", stream_id="r1")
|
||||
|
||||
append.assert_called_once()
|
||||
persisted = append.call_args.args[1]
|
||||
assert persisted["event"] == "reasoning_end"
|
||||
assert persisted["text"] == "plan then act"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_turn_end_discards_unclosed_stream_buffers() -> None:
|
||||
bus = MagicMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "streaming": True},
|
||||
bus,
|
||||
gateway=_basic_handler(bus),
|
||||
)
|
||||
|
||||
await channel.send_delta("chat-unclosed", "partial", stream_id="s1")
|
||||
await channel.send_reasoning_delta("chat-unclosed", "thinking", stream_id="r1")
|
||||
await channel.send(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id="chat-unclosed",
|
||||
content="",
|
||||
event=TurnEndEvent(),
|
||||
))
|
||||
|
||||
assert channel._stream_text_buffers == {}
|
||||
assert channel._reasoning_text_buffers == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_turn_end_emits_turn_end_event() -> None:
|
||||
bus = MagicMock()
|
||||
|
||||
@@ -509,8 +509,6 @@ class FallbackProvider(LLMProvider):
|
||||
)
|
||||
continue
|
||||
|
||||
await self._notify_fallback_model(fallback_model)
|
||||
|
||||
fallback_kwargs = {
|
||||
**kwargs,
|
||||
"model": fallback_model,
|
||||
@@ -541,6 +539,11 @@ class FallbackProvider(LLMProvider):
|
||||
fallback_response = await call(fallback_provider, fallback_kwargs)
|
||||
|
||||
if fallback_response.finish_reason != "error":
|
||||
# Do not publish a model switch merely because a fallback was
|
||||
# attempted. A fallback can fail just like the primary, and
|
||||
# the WebUI would otherwise show a misleading success signal.
|
||||
# Publish only after this response is known to be usable.
|
||||
await self._notify_fallback_model(fallback_model)
|
||||
logger.info(
|
||||
"Fallback '{}' succeeded after primary '{}' failed",
|
||||
fallback_model, primary_model,
|
||||
|
||||
@@ -251,6 +251,26 @@ def _refusal_event_key(
|
||||
)
|
||||
|
||||
|
||||
def _reasoning_summary_event_key(
|
||||
item_id: object,
|
||||
summary_index: object,
|
||||
) -> tuple[str | None, int] | None:
|
||||
"""Identify one reasoning summary part across its text deltas."""
|
||||
if not isinstance(summary_index, int) or isinstance(summary_index, bool):
|
||||
return None
|
||||
return (
|
||||
item_id if isinstance(item_id, str) else None,
|
||||
summary_index,
|
||||
)
|
||||
|
||||
|
||||
def _separate_reasoning_part(content: str | None, part: str) -> str:
|
||||
"""Separate summary parts only when the provider supplied no whitespace."""
|
||||
if content and not content[-1].isspace() and not part[0].isspace():
|
||||
return "\n" + part
|
||||
return part
|
||||
|
||||
|
||||
def _remaining_refusal_text(streamed_text: str, refusal_text: str) -> str:
|
||||
"""Return only text not already surfaced by refusal deltas."""
|
||||
if not streamed_text:
|
||||
@@ -342,6 +362,7 @@ async def consume_sse_with_reasoning(
|
||||
usage: dict[str, int] = {}
|
||||
reasoning_content: str | None = None
|
||||
streamed_reasoning = False
|
||||
reasoning_summary_key: tuple[str | None, int] | None = None
|
||||
refusal_seen = False
|
||||
refusal_deltas: dict[tuple[str | None, int | None], str] = {}
|
||||
emitted_refusal_text = ""
|
||||
@@ -406,6 +427,18 @@ async def consume_sse_with_reasoning(
|
||||
elif event_type == "response.reasoning_summary_text.delta":
|
||||
delta_text = event.get("delta") or ""
|
||||
if delta_text:
|
||||
summary_key = _reasoning_summary_event_key(
|
||||
event.get("item_id"),
|
||||
event.get("summary_index"),
|
||||
)
|
||||
if (
|
||||
summary_key is not None
|
||||
and reasoning_summary_key is not None
|
||||
and summary_key != reasoning_summary_key
|
||||
):
|
||||
delta_text = _separate_reasoning_part(reasoning_content, delta_text)
|
||||
if summary_key is not None:
|
||||
reasoning_summary_key = summary_key
|
||||
reasoning_content = (reasoning_content or "") + delta_text
|
||||
streamed_reasoning = True
|
||||
if on_reasoning_delta:
|
||||
@@ -538,7 +571,10 @@ def _extract_reasoning_summary_from_output(output: object) -> str | None:
|
||||
text = summary.get("text")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
return "".join(parts) or None
|
||||
content = ""
|
||||
for part in parts:
|
||||
content += _separate_reasoning_part(content, part)
|
||||
return content or None
|
||||
|
||||
|
||||
def parse_response_output(
|
||||
|
||||
@@ -495,6 +495,7 @@ def build_webui_fallback_model_observer(bus: MessageBus) -> FallbackModelObserve
|
||||
if context.runtime is not None
|
||||
else None
|
||||
),
|
||||
fallback=True,
|
||||
),
|
||||
metadata=context.metadata,
|
||||
)
|
||||
|
||||
+89
-23
@@ -770,6 +770,36 @@ class WebUITranscriptRecorder:
|
||||
record.update(transcript_overrides)
|
||||
return self.append(chat_id, record)
|
||||
|
||||
def prepare_and_append_stream_event(
|
||||
self,
|
||||
chat_id: str,
|
||||
event: dict[str, Any],
|
||||
*,
|
||||
completed_text: str | None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
phase: str | None = None,
|
||||
include_source: bool = False,
|
||||
) -> bool:
|
||||
"""Annotate every live stream event, but persist only completed segments.
|
||||
|
||||
Delta frames are a transport concern: retaining each token-sized chunk
|
||||
would turn rendering cadence into disk-write cadence. The matching end
|
||||
event carries the canonical segment text used by history replay.
|
||||
"""
|
||||
self.prepare_event(
|
||||
chat_id,
|
||||
event,
|
||||
metadata=metadata,
|
||||
phase=phase,
|
||||
include_source=include_source,
|
||||
)
|
||||
if event.get("event") in {"delta", "reasoning_delta"}:
|
||||
return True
|
||||
record = dict(event)
|
||||
if completed_text is not None:
|
||||
record["text"] = completed_text
|
||||
return self.append(chat_id, record)
|
||||
|
||||
def append_user_message(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -1903,13 +1933,24 @@ def replay_transcript_to_ui_messages(
|
||||
kept.append(m)
|
||||
messages = kept
|
||||
|
||||
def stamp_latency(latency_ms: int) -> None:
|
||||
def stamp_completion(
|
||||
*,
|
||||
latency_ms: int | None = None,
|
||||
usage: dict[str, int] | None = None,
|
||||
context_window_tokens: int | None = None,
|
||||
) -> None:
|
||||
for i in range(len(messages) - 1, -1, -1):
|
||||
if messages[i].get("role") == "assistant" and messages[i].get("kind") != "trace":
|
||||
completion: dict[str, Any] = {"isStreaming": False}
|
||||
if latency_ms is not None:
|
||||
completion["latencyMs"] = latency_ms
|
||||
if usage:
|
||||
completion["usage"] = usage
|
||||
if context_window_tokens is not None:
|
||||
completion["contextWindowTokens"] = context_window_tokens
|
||||
messages[i] = {
|
||||
**messages[i],
|
||||
"latencyMs": latency_ms,
|
||||
"isStreaming": False,
|
||||
**completion,
|
||||
}
|
||||
return
|
||||
|
||||
@@ -2215,30 +2256,27 @@ def replay_transcript_to_ui_messages(
|
||||
turn_fields = _turn_fields(rec, "answer")
|
||||
source_fields = _source_fields(rec)
|
||||
if isinstance(final_text, str):
|
||||
if buffer_message_id is None:
|
||||
buffer_message_id = find_active_placeholder(messages, turn_fields)
|
||||
if buffer_message_id is None:
|
||||
buffer_message_id = _new_id("buf", idx)
|
||||
messages.append(
|
||||
{
|
||||
"id": buffer_message_id,
|
||||
"role": "assistant",
|
||||
messages.append({
|
||||
"id": buffer_message_id,
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"isStreaming": True,
|
||||
"createdAt": _created_at_ms(rec, idx),
|
||||
})
|
||||
for i, m in enumerate(messages):
|
||||
if m.get("id") == buffer_message_id:
|
||||
messages[i] = {
|
||||
**m,
|
||||
"content": final_text,
|
||||
"isStreaming": True,
|
||||
**turn_fields,
|
||||
**source_fields,
|
||||
"createdAt": _created_at_ms(rec, idx),
|
||||
},
|
||||
)
|
||||
else:
|
||||
for i, m in enumerate(messages):
|
||||
if m.get("id") == buffer_message_id:
|
||||
messages[i] = {
|
||||
**m,
|
||||
"content": final_text,
|
||||
"isStreaming": True,
|
||||
**turn_fields,
|
||||
**source_fields,
|
||||
}
|
||||
break
|
||||
}
|
||||
break
|
||||
if merge_next:
|
||||
buffer_parts = [final_text]
|
||||
elif source_fields and buffer_message_id is not None:
|
||||
@@ -2274,6 +2312,16 @@ def replay_transcript_to_ui_messages(
|
||||
if ev == "reasoning_end":
|
||||
if suppress_until_turn_end:
|
||||
continue
|
||||
text = rec.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
close_file_edit_phase_before_activity()
|
||||
attach_reasoning_chunk(
|
||||
messages,
|
||||
text,
|
||||
idx,
|
||||
_turn_fields(rec, "reasoning"),
|
||||
_created_at_ms(rec, idx),
|
||||
)
|
||||
close_reasoning(messages)
|
||||
continue
|
||||
|
||||
@@ -2401,8 +2449,26 @@ def replay_transcript_to_ui_messages(
|
||||
messages[i] = {**m, "isStreaming": False}
|
||||
prune_reasoning_only()
|
||||
lat = rec.get("latency_ms")
|
||||
if isinstance(lat, (int, float)) and lat >= 0:
|
||||
stamp_latency(int(lat))
|
||||
usage = rec.get("usage")
|
||||
sanitized_usage = (
|
||||
{
|
||||
key: value
|
||||
for key, value in cast(dict[object, object], usage).items()
|
||||
if isinstance(key, str) and type(value) is int and value >= 0
|
||||
}
|
||||
if isinstance(usage, dict)
|
||||
else None
|
||||
)
|
||||
context_window = rec.get("context_window_tokens")
|
||||
stamp_completion(
|
||||
latency_ms=int(lat) if isinstance(lat, (int, float)) and lat >= 0 else None,
|
||||
usage=sanitized_usage,
|
||||
context_window_tokens=(
|
||||
int(context_window)
|
||||
if isinstance(context_window, (int, float)) and context_window >= 0
|
||||
else None
|
||||
),
|
||||
)
|
||||
buffer_message_id = None
|
||||
buffer_parts = []
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user