feat(webui): unify turn observability

This commit is contained in:
Xubin Ren
2026-08-22 20:51:24 +08:00
parent dbc1801d3c
commit 48eea29313
44 changed files with 1871 additions and 930 deletions
+2
View File
@@ -1070,6 +1070,8 @@ async def test_runner_accumulates_usage_and_preserves_cached_tokens():
assert result.usage["prompt_tokens"] == 300 # 100 + 200
assert result.usage["completion_tokens"] == 30 # 10 + 20
assert result.usage["cached_tokens"] == 230 # 80 + 150
assert result.usage["context_tokens"] == 200
assert result.usage["request_count"] == 2
@pytest.mark.asyncio
+9 -5
View File
@@ -584,9 +584,10 @@ class TestFallbackOnPrimaryError:
assert restored.payload == state.payload
@pytest.mark.asyncio
async def test_reports_the_fallback_model_before_its_request(self) -> None:
async def test_reports_only_the_successful_fallback_model(self) -> None:
primary = _FakeProvider("primary", _error_response())
fallback = _FakeProvider("fallback", _make_response("fallback ok"))
failed_fallback = _FakeProvider("failed", _error_response("backup overloaded"))
successful_fallback = _FakeProvider("fallback", _make_response("fallback ok"))
fallback_models: list[str] = []
async def _observe(model: str) -> None:
@@ -594,8 +595,11 @@ class TestFallbackOnPrimaryError:
fb = FallbackProvider(
primary=primary,
fallback_presets=[_fallback("fallback-a", provider="backup")],
provider_factory=MagicMock(return_value=fallback),
fallback_presets=[
_fallback("fallback-a", provider="backup"),
_fallback("fallback-b", provider="backup"),
],
provider_factory=MagicMock(side_effect=[failed_fallback, successful_fallback]),
fallback_model_observer=_observe,
)
@@ -605,7 +609,7 @@ class TestFallbackOnPrimaryError:
)
assert result.content == "fallback ok"
assert fallback_models == ["fallback-a"]
assert fallback_models == ["fallback-b"]
@pytest.mark.asyncio
async def test_logs_primary_error_before_fallback(self) -> None:
+8 -6
View File
@@ -379,12 +379,14 @@ async def test_runner_calls_run_level_hooks_on_success():
"done",
"completed",
None,
{
"prompt_tokens": 3,
"completion_tokens": 2,
"total_tokens": 5,
"provider_tokens": 5,
},
{
"prompt_tokens": 3,
"completion_tokens": 2,
"total_tokens": 5,
"provider_tokens": 5,
"request_count": 1,
"context_tokens": 3,
},
["user", "assistant"],
),
("on_finally", "completed", None),
+22 -6
View File
@@ -1056,8 +1056,24 @@ class TestConsumeSse:
@pytest.mark.asyncio
async def test_reasoning_summary_delta_extracted(self):
response = _SseResponse([
{"type": "response.reasoning_summary_text.delta", "delta": "thinking "},
{"type": "response.reasoning_summary_text.delta", "delta": "briefly"},
{
"type": "response.reasoning_summary_text.delta",
"item_id": "rs_1",
"summary_index": 0,
"delta": "thinking ",
},
{
"type": "response.reasoning_summary_text.delta",
"item_id": "rs_1",
"summary_index": 0,
"delta": "briefly",
},
{
"type": "response.reasoning_summary_text.delta",
"item_id": "rs_1",
"summary_index": 1,
"delta": "Checking result",
},
{"type": "response.output_text.delta", "delta": "answer"},
{"type": "response.completed", "response": {"status": "completed"}},
])
@@ -1075,8 +1091,8 @@ class TestConsumeSse:
assert tool_calls == []
assert finish_reason == "stop"
assert usage == {}
assert reasoning == "thinking briefly"
assert deltas == ["thinking ", "briefly"]
assert reasoning == "thinking briefly\nChecking result"
assert deltas == ["thinking ", "briefly", "\nChecking result"]
@pytest.mark.asyncio
async def test_reasoning_summary_from_completed_response(self):
@@ -1087,7 +1103,7 @@ class TestConsumeSse:
"status": "completed",
"output": [
{"type": "reasoning", "summary": [
{"type": "summary_text", "text": "cached "},
{"type": "summary_text", "text": "cached"},
{"type": "summary_text", "text": "summary"},
]},
],
@@ -1097,7 +1113,7 @@ class TestConsumeSse:
_, _, _, _, reasoning = await consume_sse_with_reasoning(response)
assert reasoning == "cached summary"
assert reasoning == "cached\nsummary"
@pytest.mark.asyncio
async def test_capture_commits_exact_items_only_after_completed_event(self):
+49
View File
@@ -393,6 +393,55 @@ def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
assert msgs[1]["latencyMs"] == 42
def test_replay_canonical_completed_stream_records() -> None:
msgs = replay_transcript_to_ui_messages([
{"event": "user", "chat_id": "canonical", "text": "q"},
{"event": "reasoning_end", "chat_id": "canonical", "text": "think"},
{"event": "stream_end", "chat_id": "canonical", "text": "answer"},
{"event": "turn_end", "chat_id": "canonical", "latency_ms": 42},
])
assert len(msgs) == 2
assert msgs[1]["content"] == "answer"
assert msgs[1]["reasoning"] == "think"
assert msgs[1]["latencyMs"] == 42
def test_replay_turn_end_preserves_usage_semantics(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-usage"
for event in (
{"event": "user", "chat_id": "t-usage", "text": "q"},
{"event": "message", "chat_id": "t-usage", "text": "a"},
{
"event": "turn_end",
"chat_id": "t-usage",
"latency_ms": 18_200,
"usage": {
"prompt_tokens": 12_400,
"completion_tokens": 823,
"cached_tokens": 9_672,
"context_tokens": 8_200,
"request_count": 3,
},
"context_window_tokens": 128_000,
},
):
append_transcript_object(key, event)
messages = replay_transcript_to_ui_messages(read_transcript_lines(key))
assert messages[-1]["usage"] == {
"prompt_tokens": 12_400,
"completion_tokens": 823,
"cached_tokens": 9_672,
"context_tokens": 8_200,
"request_count": 3,
}
assert messages[-1]["contextWindowTokens"] == 128_000
assert messages[-1]["latencyMs"] == 18_200
def test_replay_uses_persisted_created_at_ms() -> None:
msgs = replay_transcript_to_ui_messages(
[