diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 2310e7e53..fddc4bbba 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -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, diff --git a/nanobot/bus/outbound_events.py b/nanobot/bus/outbound_events.py index 24e36daea..7bbe1949b 100644 --- a/nanobot/bus/outbound_events.py +++ b/nanobot/bus/outbound_events.py @@ -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( diff --git a/nanobot/channels/websocket/runtime.py b/nanobot/channels/websocket/runtime.py index 606a33d9d..88be1f76b 100644 --- a/nanobot/channels/websocket/runtime.py +++ b/nanobot/channels/websocket/runtime.py @@ -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 ") diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index c1519afe6..8be1c16c0 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -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() diff --git a/nanobot/providers/fallback_provider.py b/nanobot/providers/fallback_provider.py index 7881249aa..141bc496f 100644 --- a/nanobot/providers/fallback_provider.py +++ b/nanobot/providers/fallback_provider.py @@ -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, diff --git a/nanobot/providers/openai_responses/parsing.py b/nanobot/providers/openai_responses/parsing.py index 5ea213d89..b06c93211 100644 --- a/nanobot/providers/openai_responses/parsing.py +++ b/nanobot/providers/openai_responses/parsing.py @@ -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( diff --git a/nanobot/session/webui_turns.py b/nanobot/session/webui_turns.py index e7d9af6a3..6bb719096 100644 --- a/nanobot/session/webui_turns.py +++ b/nanobot/session/webui_turns.py @@ -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, ) diff --git a/nanobot/webui/transcript.py b/nanobot/webui/transcript.py index d86703da0..5bacf142c 100644 --- a/nanobot/webui/transcript.py +++ b/nanobot/webui/transcript.py @@ -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 diff --git a/tests/agent/test_runner_core.py b/tests/agent/test_runner_core.py index e2da80fd7..abbd2c55b 100644 --- a/tests/agent/test_runner_core.py +++ b/tests/agent/test_runner_core.py @@ -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 diff --git a/tests/agent/test_runner_fallback.py b/tests/agent/test_runner_fallback.py index 38eb824b0..649f15811 100644 --- a/tests/agent/test_runner_fallback.py +++ b/tests/agent/test_runner_fallback.py @@ -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: diff --git a/tests/agent/test_runner_hooks.py b/tests/agent/test_runner_hooks.py index 84c2e1c00..71c4e12e6 100644 --- a/tests/agent/test_runner_hooks.py +++ b/tests/agent/test_runner_hooks.py @@ -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), diff --git a/tests/providers/test_openai_responses.py b/tests/providers/test_openai_responses.py index 68747094f..b25e51592 100644 --- a/tests/providers/test_openai_responses.py +++ b/tests/providers/test_openai_responses.py @@ -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): diff --git a/tests/utils/test_webui_transcript.py b/tests/utils/test_webui_transcript.py index 45ba5252d..bd4893d35 100644 --- a/tests/utils/test_webui_transcript.py +++ b/tests/utils/test_webui_transcript.py @@ -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( [ diff --git a/webui/src/components/ChatList.tsx b/webui/src/components/ChatList.tsx index 8b4eb72ab..9cfb92654 100644 --- a/webui/src/components/ChatList.tsx +++ b/webui/src/components/ChatList.tsx @@ -50,7 +50,7 @@ import { } from "@/components/ui/tooltip"; import { MAX_WORKBENCH_PANES } from "@/components/workbench/workbench-model"; import { SIDEBAR_SELECTION_ITEM_CLASS } from "@/components/SidebarSelectionHighlight"; -import { deriveTitle, relativeTime, visibleSessionPreview } from "@/lib/format"; +import { relativeTime, visibleSessionPreview } from "@/lib/format"; import { COLLAPSED_CHATS_VISIBLE_COUNT, displayTitle, @@ -906,14 +906,6 @@ export const ChatList = memo(function ChatList({ ); } - const fallbackTitle = t("chat.fallbackTitle", { - id: s.chatId.slice(0, 6), - }); - const generatedTitle = s.title?.trim() || ""; - const tooltipTitle = - titleOverrides[s.key]?.trim() || - generatedTitle || - deriveTitle(s.preview, fallbackTitle); const isPinned = pinned.has(s.key); const isArchived = archived.has(s.key); const preview = visibleSessionPreview(s.preview); @@ -959,8 +951,7 @@ export const ChatList = memo(function ChatList({ && "bg-sidebar-accent/55 text-sidebar-accent-foreground", )} > - - - + {!deleteSelectionMode ? ( - - - + {!deleteSelectionMode ? - - - + {onClose ? ( + + { + event.preventDefault(); + const content = event.currentTarget; + if (!(content instanceof HTMLElement)) return; + const selected = content.querySelector( + '[role="option"][aria-selected="true"]', + ); + selected?.focus(); + }} + className={cn( + pickerWidthClassName, + "origin-[var(--radix-popover-content-transform-origin)] p-1.5 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-bottom-1 data-[state=open]:slide-in-from-bottom-1 duration-200 ease-out motion-reduce:animate-none", + )} + > +
+ {presets.map((preset) => ( + + ))} +
+
+ + ); +} + +function PresetOption({ + preset, + selected, + onSelect, +}: { + preset: ModelPresetOption; + selected: boolean; + onSelect: (name: string) => void; +}) { + const detail = compactModelName(preset.model); + return ( + ); } function PresetPill({ - className, label, modelDetail, provider, + providerLabel, needsSetup = false, fallbackModelName, + fallbackFromLabel, isHero, offset, scale, }: { - className?: string | false | null; label: string; modelDetail?: string | null; provider?: string | null; + providerLabel?: string | null; needsSetup?: boolean; fallbackModelName?: string | null; + fallbackFromLabel?: string | null; isHero: boolean; offset?: number; scale?: number; @@ -406,13 +492,10 @@ function PresetPill({ const inferredProvider = needsSetup ? null : provider || inferProviderFromModelName(modelDetail || label); - const brand = providerBrand(inferredProvider); - const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls); - const logoTestId = offset !== undefined - ? undefined - : needsSetup - ? "composer-model-setup-icon" - : `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`; + const title = [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · "); + const fallbackTitle = fallbackModelName + ? `${fallbackFromLabel || label} · using ${fallbackModelName}` + : title; useLayoutEffect(() => { const node = labelRef.current; @@ -428,14 +511,14 @@ function PresetPill({ - - {needsSetup ? ( - - ) : logoUrl ? ( - - ) : brand ? ( - - {brand.initials.slice(0, 2)} - - ) : ( - - )} - + ); } + +function PresetProviderIcon({ + label, + modelDetail, + provider, + needsSetup = false, + testId, + isHero, +}: { + label: string; + modelDetail?: string | null; + provider?: string | null; + needsSetup?: boolean; + testId?: string; + isHero: boolean; +}) { + const inferredProvider = needsSetup + ? null + : provider || inferProviderFromModelName(modelDetail || label); + const brand = providerBrand(inferredProvider); + const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls); + return ( + + {needsSetup ? ( + + ) : logoUrl ? ( + + ) : brand ? ( + + {brand.initials.slice(0, 2)} + + ) : ( + + )} + + ); +} diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index 65e08ddb7..f420a161a 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -112,6 +112,7 @@ import { readDraggedSession, } from "@/lib/session-drag"; import { formatQuotedUserMessage } from "@/lib/user-message-quote"; +import { formatCompactTokenCount } from "@/lib/format"; import { cn } from "@/lib/utils"; const VOICE_SHORTCUT_CODE = "KeyD"; @@ -178,6 +179,107 @@ function getVoiceShortcutLabel(): string { } } +export interface ComposerContextUsage { + contextTokens: number; + contextWindowTokens?: number; +} + +function ComposerContextBadge({ usage }: { usage: ComposerContextUsage | null }) { + const { t } = useTranslation(); + if (!usage || !Number.isFinite(usage.contextTokens) || usage.contextTokens < 0) return null; + + const context = formatCompactTokenCount(usage.contextTokens); + const contextWindow = typeof usage.contextWindowTokens === "number" + && Number.isFinite(usage.contextWindowTokens) + && usage.contextWindowTokens > 0 + ? usage.contextWindowTokens + : null; + const capacity = contextWindow !== null + ? ` / ${formatCompactTokenCount(contextWindow)}` + : ""; + // The meter is meaningful only with a known capacity. Do not turn an + // otherwise unknown total into a second, text-heavy control beside the + // model picker. + if (contextWindow === null) return null; + const percentage = Math.min(100, Math.round(usage.contextTokens / contextWindow * 100)); + const status = percentage >= 90 + ? "critical" + : percentage >= 75 + ? "caution" + : "normal"; + const contextDescription = t("thread.composer.context.tooltip", { + defaultValue: "Context · {{tokens}}{{capacity}}", + tokens: context, + capacity, + }); + const meterDescription = t("thread.composer.context.meterDescription", { + defaultValue: "{{context}}. {{percent}}% used.", + context: contextDescription, + percent: percentage, + }); + const ringCircumference = 2 * Math.PI * 6; + const ringLength = ringCircumference * percentage / 100; + + return ( + + + + + + + {contextDescription} + + + + ); +} + interface ThreadComposerProps { onSend: ( content: string, @@ -198,6 +300,7 @@ interface ThreadComposerProps { modelNeedsSetup?: boolean; fallbackModelName?: string | null; onModelBadgeClick?: () => void; + contextUsage?: ComposerContextUsage | null; variant?: "thread" | "hero"; slashCommands?: SlashCommand[]; cliApps?: CliAppInfo[]; @@ -895,6 +998,7 @@ export function ThreadComposer({ modelNeedsSetup = false, fallbackModelName = null, onModelBadgeClick, + contextUsage = null, variant = "thread", slashCommands = [], cliApps = [], @@ -2435,6 +2539,7 @@ export function ThreadComposer({ modelPreset={modelPreset} modelPresets={modelPresets} onPresetChange={onModelPresetChange} + onRequestComposerFocus={() => textareaRef.current?.focus()} provider={modelProvider} providerLabel={modelProviderLabel} needsSetup={modelNeedsSetup} @@ -2443,6 +2548,7 @@ export function ThreadComposer({ onClick={modelNeedsSetup ? onModelBadgeClick : undefined} /> ) : null} + {!voiceRecorder.isRecording ? : null} {showVoiceButton ? ( diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx index fcd516086..dd8601b4a 100644 --- a/webui/src/components/thread/ThreadMessages.tsx +++ b/webui/src/components/thread/ThreadMessages.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { MessageBubble } from "@/components/MessageBubble"; import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster"; import { AssistantSelectionAction } from "@/components/thread/AssistantSelectionAction"; -import { normalizeActivityTimeline, type TurnUnit } from "@/lib/activity-timeline"; +import { projectActivityTimeline, type TurnUnit } from "@/lib/activity-timeline"; import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types"; interface ThreadMessagesProps { @@ -29,10 +29,9 @@ export type DisplayUnit = TurnUnit; export function buildDisplayUnits( messages: UIMessage[], isStreaming = false, + activeTurnId: string | null = null, ): DisplayUnit[] { - return normalizeActivityTimeline(messages, { - preserveTrailingActivity: isStreaming, - }); + return projectActivityTimeline(messages, isStreaming ? activeTurnId : undefined); } export function assistantForkFlags(units: DisplayUnit[]): boolean[] { @@ -69,7 +68,10 @@ export function ThreadMessages({ }: ThreadMessagesProps) { const { t } = useTranslation(); const messageListRef = useRef(null); - const units = useMemo(() => buildDisplayUnits(messages, isStreaming), [isStreaming, messages]); + const units = useMemo( + () => buildDisplayUnits(messages, isStreaming, activeTurnId), + [activeTurnId, isStreaming, messages], + ); const forkBoundaryAfterUnitIndex = useMemo( () => unitIndexAfterMessageCount(units, forkBoundaryMessageCount), [forkBoundaryMessageCount, units], diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index 98ecbe3d6..172271601 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -8,7 +8,10 @@ import { FilePreviewPanel } from "@/components/FilePreviewPanel"; import { SessionHandleLabel } from "@/components/SessionHandleLabel"; import { PromptNavigator } from "@/components/thread/PromptNavigator"; import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover"; -import { ThreadComposer } from "@/components/thread/ThreadComposer"; +import { + ThreadComposer, + type ComposerContextUsage, +} from "@/components/thread/ThreadComposer"; import type { ModelPresetOption } from "@/components/thread/ModelPresetBadge"; import { ThreadHeader } from "@/components/thread/ThreadHeader"; import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice"; @@ -83,6 +86,30 @@ function sameMessageShape(a: MessageShape, b: MessageShape): boolean { ); } +function latestComposerContextUsage(messages: UIMessage[]): ComposerContextUsage | null { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + const contextTokens = message.usage?.context_tokens; + if ( + message.role !== "assistant" + || message.kind === "trace" + || message.isStreaming + || typeof contextTokens !== "number" + || !Number.isFinite(contextTokens) + || contextTokens < 0 + ) { + continue; + } + return { + contextTokens, + ...(typeof message.contextWindowTokens === "number" + ? { contextWindowTokens: message.contextWindowTokens } + : {}), + }; + } + return null; +} + function snapshotPreservesMessage( current: MessageShape, candidate: MessageShape, @@ -400,7 +427,7 @@ function toModelBadgeInfo( ); return { label, - model: model?.trim() || null, + model: toModelBadgeLabel(model), provider, providerLabel: provider ? providerDisplayLabel(settings?.providers ?? [], provider) : null, needsSetup, @@ -803,8 +830,12 @@ export function ThreadShell({ }, []); const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]); - const currentRunStartedAt = messagesReady ? runStartedAt : null; + const composerContextUsage = useMemo( + () => latestComposerContextUsage(displayMessages), + [displayMessages], + ); const currentGoalState = messagesReady ? goalState : undefined; + const currentRunStartedAt = messagesReady ? runStartedAt : null; const turnActive = messagesReady && (isStreaming || currentRunStartedAt !== null); const restoredViewportTurnId = useMemo( () => turnActive ? latestActiveTurnId(displayMessages, currentRunStartedAt) : null, @@ -882,9 +913,17 @@ export function ThreadShell({ useEffect(() => { setLocalModelPreset(null); }, [session?.key, sessionModelPreset]); + const configuredPresetNames = useMemo( + () => new Set(settings?.model_presets.map((preset) => preset.name) ?? []), + [settings], + ); const activeModelPreset = ( - localModelPreset - || sessionModelPreset + (localModelPreset && (!settings || configuredPresetNames.has(localModelPreset)) + ? localModelPreset + : null) + || (sessionModelPreset && (!settings || configuredPresetNames.has(sessionModelPreset)) + ? sessionModelPreset + : null) || settings?.agent.model_preset || "default" ); @@ -958,13 +997,10 @@ export function ThreadShell({ } setFallbackModelName(null); return client.onChat(chatId, (event) => { - if (event.event !== "turn_model_updated") return; - const activeModel = event.model_name.trim(); - setFallbackModelName( - modelBadge.model && activeModel !== modelBadge.model ? activeModel : null, - ); + if (event.event !== "turn_model_updated" || event.fallback !== true) return; + setFallbackModelName(event.model_name); }); - }, [chatId, client, modelBadge.model]); + }, [chatId, client]); useEffect(() => { if (!historyKey || !chatId || loading) return; @@ -1454,7 +1490,7 @@ export function ThreadShell({ : t("thread.composer.placeholderThread") } modelLabel={modelBadgeLabel} - modelDetail={toModelBadgeLabel(modelBadge.model)} + modelDetail={modelBadge.model} modelPreset={activeModelPreset} modelPresets={modelPresetOptions} onModelPresetChange={handleModelPresetChange} @@ -1463,6 +1499,7 @@ export function ThreadShell({ modelNeedsSetup={modelBadge.needsSetup} fallbackModelName={fallbackModelName} onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined} + contextUsage={composerContextUsage} variant={composerVariant} slashCommands={availableSlashCommands} cliApps={cliApps} @@ -1501,7 +1538,7 @@ export function ThreadShell({ : t("thread.composer.placeholderHero") } modelLabel={modelBadgeLabel} - modelDetail={toModelBadgeLabel(modelBadge.model)} + modelDetail={modelBadge.model} modelPreset={activeModelPreset} modelPresets={modelPresetOptions} onModelPresetChange={handleModelPresetChange} @@ -1510,6 +1547,7 @@ export function ThreadShell({ modelNeedsSetup={modelBadge.needsSetup} fallbackModelName={fallbackModelName} onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined} + contextUsage={composerContextUsage} variant="hero" slashCommands={availableSlashCommands} cliApps={cliApps} diff --git a/webui/src/globals.css b/webui/src/globals.css index a7212cd55..06bfc3f7b 100644 --- a/webui/src/globals.css +++ b/webui/src/globals.css @@ -165,11 +165,19 @@ pointer-events: none; background-color: rgb(236 141 49); opacity: 0; - transition: opacity 220ms ease-out; + transition: opacity 180ms ease-out; + } + + .composer-model-badge[data-fallback="true"] { + border-color: rgb(236 141 49 / 0.38); } .composer-model-badge[data-fallback="true"]::before { - opacity: 1; + opacity: 0.1; + } + + .dark .composer-model-badge[data-fallback="true"]::before { + opacity: 0.15; } .composer-model-badge > * { @@ -695,11 +703,38 @@ mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 0.75rem), transparent); } -.thread-composer-model-badge:not([data-switching="true"]):active - > .composer-model-pill { +.thread-composer-model-badge:not([data-switching="true"]):active > .composer-model-pill { transform: scale(0.98); } +@keyframes composer-model-pill-viewport-enter { + from { + transform: scale(0.9074); + } + + to { + transform: scale(1); + } +} + +.composer-model-pill-viewport { + transform-origin: right center; + animation: composer-model-pill-viewport-enter 210ms cubic-bezier(0.2, 0.8, 0.2, 1) both; + -webkit-mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent); + mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent); +} + +.composer-model-pill-dock { + transform-origin: right center; + transition-property: none; + will-change: transform; +} + +.composer-model-pill-track[data-settling="true"], +.composer-model-pill-track[data-settling="true"] .composer-model-pill-dock { + transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1); +} + @keyframes preset-name-shake { 0%, 100% { @@ -715,37 +750,8 @@ } } -@keyframes composer-model-pill-viewport-enter { - from { - transform: scale(0.9074); - } - - to { - transform: scale(1); - } -} - -.composer-model-pill-viewport { - transform-origin: right center; - animation: composer-model-pill-viewport-enter 210ms - cubic-bezier(0.2, 0.8, 0.2, 1) both; - -webkit-mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent); - mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent); -} - -.composer-model-pill-dock { - transform-origin: right center; - transition-property: none; - will-change: transform; -} - -.composer-model-pill-track[data-settling="true"], -.composer-model-pill-track[data-settling="true"] .composer-model-pill-dock { - transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1); -} - @media (prefers-reduced-motion: reduce) { - .thread-composer-model-badge:active > .composer-model-pill { + .thread-composer-model-badge:not([data-switching="true"]):active > .composer-model-pill { transform: none !important; } diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index 0ee306f26..cc553ad64 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -55,7 +55,6 @@ type PendingStreamEvent = | { kind: "delta"; text: string; turn: UIMessageTurnFields; source?: UIMessage["source"] } | { kind: "reasoning"; text: string; turn: UIMessageTurnFields }; -const STREAM_END_IDLE_DELAY_MS = 1000; const BACKGROUND_STREAM_FLUSH_INTERVAL_MS = 1_000; /** @@ -63,8 +62,8 @@ const BACKGROUND_STREAM_FLUSH_INTERVAL_MS = 1_000; * * Lookup rule: reasoning can only extend the current reasoning placeholder. * Once ordinary answer text has appeared, the next reasoning chunk starts a - * fresh Thought block so streamed output stays in arrival order: - * Thought -> answer -> Thought -> answer. + * fresh activity surface so streamed output stays in arrival order while the + * final answer remains the only visible answer bubble. */ function attachReasoningChunk( prev: UIMessage[], @@ -183,16 +182,6 @@ export interface SubmittedTurn { sideChannel: boolean; } -function eventExtendsModelActivity(ev: InboundEvent): boolean { - if ( - ev.event === "delta" - || ev.event === "reasoning_delta" - || ev.event === "file_edit" - ) return true; - return ev.event === "message" - && (ev.kind === "tool_hint" || ev.kind === "progress" || ev.kind === "reasoning"); -} - function eventTurnId(ev: InboundEvent): string | undefined { return "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : undefined; } @@ -296,14 +285,6 @@ export function useNanobotStream( const streamTimerRef = useRef(null); const suppressStreamUntilTurnEndRef = useRef(false); const sideChannelTurnIdsRef = useRef>(new Set()); - /** Timer that defers ``isStreaming = false`` after ``stream_end``. - * - * When the model finishes a text segment and calls a tool, the server - * sends ``stream_end`` but the agent is still "thinking" while the tool - * executes. By deferring the flag reset by a short window (1 s) we keep - * the loading spinner alive across tool-call boundaries without needing - * backend changes. */ - const streamEndTimerRef = useRef | null>(null); const dismissStreamError = useCallback(() => setStreamError(null), []); @@ -319,26 +300,11 @@ export function useNanobotStream( pendingStreamEventsRef.current = []; }, []); - const cancelStreamEndTimer = useCallback(() => { - if (streamEndTimerRef.current === null) return; - clearTimeout(streamEndTimerRef.current); - streamEndTimerRef.current = null; - }, []); - const isSideChannelEvent = useCallback((ev: InboundEvent) => { const turnId = eventTurnId(ev); return turnId !== undefined && sideChannelTurnIdsRef.current.has(turnId); }, []); - const scheduleStreamEndTimer = useCallback((turn: UIMessageTurnFields = {}) => { - cancelStreamEndTimer(); - streamEndTimerRef.current = setTimeout(() => { - streamEndTimerRef.current = null; - setIsStreaming(false); - setMessages((prev) => finalizeStreamedTurn(prev, turn)); - }, STREAM_END_IDLE_DELAY_MS); - }, [cancelStreamEndTimer]); - const createActivitySegmentId = useCallback((activate = true) => { activitySegmentCounterRef.current += 1; const id = `activity-${activitySegmentCounterRef.current}`; @@ -387,7 +353,6 @@ export function useNanobotStream( (event) => event.turn.turnId !== rejectedTurnId, ); sideChannelTurnIdsRef.current.delete(rejectedTurnId); - cancelStreamEndTimer(); setMessages((prev) => { const rejectedRows = prev.filter((message) => message.turnId === rejectedTurnId); if (rejectedRows.length === 0) return prev; @@ -438,7 +403,7 @@ export function useNanobotStream( setRunStartedAt(remainingStartedAt); setIsStreaming(hasRemainingRun); if (!hasRemainingRun) suppressStreamUntilTurnEndRef.current = false; - }, [cancelStreamEndTimer, chatId, client]); + }, [chatId, client]); useEffect(() => client.onError(applyStreamError), [applyStreamError, client]); @@ -659,15 +624,6 @@ export function useNanobotStream( return () => document.removeEventListener("visibilitychange", flushOnReturn); }, [flushPendingStreamEvents]); - useEffect(() => { - return client.onStatus((status) => { - if (status !== "reconnecting" && status !== "closed") return; - // A transport drop does not prove the backend turn completed. Keep the - // semantic running state intact so queued guidance is not flushed early. - cancelStreamEndTimer(); - }); - }, [cancelStreamEndTimer, client]); - // Reset local state when switching chats. Do not reset on every // ``initialMessages`` update: a brand-new chat can receive an empty/404 // history response after the optimistic first message has already rendered. @@ -690,9 +646,8 @@ export function useNanobotStream( clearPendingStreamWork(); sideChannelTurnIdsRef.current.clear(); suppressStreamUntilTurnEndRef.current = false; - cancelStreamEndTimer(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chatId, client, cancelStreamEndTimer, clearActivitySegment, clearPendingStreamWork]); + }, [chatId, client, clearActivitySegment, clearPendingStreamWork]); useEffect(() => { if (hasPendingToolCalls) setIsStreaming(true); @@ -774,12 +729,6 @@ export function useNanobotStream( return; } const sideChannelEvent = isSideChannelEvent(ev); - if ( - streamEndTimerRef.current !== null - && !sideChannelEvent - && eventExtendsModelActivity(ev) - ) cancelStreamEndTimer(); - if (ev.event === "delta") { if (suppressStreamUntilTurnEndRef.current) return; const chunk = typeof ev.text === "string" ? ev.text : ""; @@ -822,14 +771,13 @@ export function useNanobotStream( }); if (suppressStreamUntilTurnEndRef.current) return; if (ev.resuming) { - cancelStreamEndTimer(); setIsStreaming(true); - if (!mergeNext) { - setMessages((prev) => finalizeStreamedTurn(prev, turn)); - } return; } - scheduleStreamEndTimer(turn); + // ``stream_end`` closes the current answer segment, not the turn. + // Tools and follow-up model segments may still arrive before the + // definitive ``turn_end`` event. + setIsStreaming(true); return; } @@ -868,9 +816,8 @@ export function useNanobotStream( setGoalState(ev.goal_state); } setRunStartedAt(null); - // Definitive signal that the turn is fully complete. Cancel any - // pending debounce timer and stop the loading indicator immediately. - cancelStreamEndTimer(); + // Definitive signal that the turn is fully complete, so stop the + // loading indicator immediately. setIsStreaming(false); const completedAt = Date.now(); setMessages((prev) => { @@ -884,6 +831,10 @@ export function useNanobotStream( finalized, { ...(latencyMs !== undefined ? { latencyMs } : {}), + ...(ev.usage ? { usage: ev.usage } : {}), + ...(typeof ev.context_window_tokens === "number" + ? { contextWindowTokens: ev.context_window_tokens } + : {}), completedAt, }, ev.turn_id, @@ -1013,8 +964,9 @@ export function useNanobotStream( // A complete (non-streamed) assistant message. If a stream was in // flight, drop the placeholder so we don't render the text twice. - // Streaming state is closed by ``stream_end`` when present, or by - // ``turn_end`` for non-streamed and tool-heavy turns. + // ``turn_end`` is the turn boundary. ``stream_end`` only closes the + // current text segment so a following tool/reasoning segment remains + // part of the same live activity surface. clearActivitySegment(); setMessages((prev) => { const activeId = buffer.current?.messageId; @@ -1088,7 +1040,6 @@ export function useNanobotStream( }); return; } - // ``attached`` frames aren't actionable here. }; const unsub = client.onChat(chatId, handle); @@ -1099,12 +1050,11 @@ export function useNanobotStream( closedAssistantStreamIdsRef.current.clear(); clearActivitySegment(); clearPendingStreamWork(); - cancelStreamEndTimer(); }; }, [ applyStreamError, - cancelStreamEndTimer, chatId, + closeActiveAssistantStream, client, clearActivitySegment, clearPendingStreamWork, @@ -1114,7 +1064,6 @@ export function useNanobotStream( isSideChannelEvent, onTurnEnd, schedulePendingStreamFlush, - scheduleStreamEndTimer, ]); const send = useCallback( @@ -1133,7 +1082,6 @@ export function useNanobotStream( : content; flushPendingStreamEvents(); if (finalizeActiveTurn) { - cancelStreamEndTimer(); setIsStreaming(false); } const turnId = crypto.randomUUID(); @@ -1188,7 +1136,7 @@ export function useNanobotStream( client.sendMessage(chatId, outboundContent, wireMedia, clientOptions); return { turnId, userMessageId, sideChannel }; }, - [cancelStreamEndTimer, chatId, clearActivitySegment, client, flushPendingStreamEvents], + [chatId, clearActivitySegment, client, flushPendingStreamEvents], ); const stop = useCallback(() => { @@ -1209,7 +1157,6 @@ export function useNanobotStream( }, [chatId, clearActivitySegment, client, flushPendingStreamEvents]); const reconcileTurnComplete = useCallback(() => { - cancelStreamEndTimer(); clearPendingStreamWork(); buffer.current = null; activeAssistantRef.current = null; @@ -1218,7 +1165,7 @@ export function useNanobotStream( suppressStreamUntilTurnEndRef.current = false; setRunStartedAt(null); setIsStreaming(false); - }, [cancelStreamEndTimer, clearActivitySegment, clearPendingStreamWork]); + }, [clearActivitySegment, clearPendingStreamWork]); const transcribeAudio = useCallback( (dataUrl: string, options?: { durationMs?: number }) => diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 128a5d04c..a31c8866c 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -1192,6 +1192,11 @@ "removeQuotedContext": "Remove quoted context", "modelNotConfigured": "Model not configured", "configureModel": "Configure model", + "switchModel": "Switch model for this chat", + "context": { + "tooltip": "Context · {{tokens}}{{capacity}}", + "meterDescription": "{{context}}. {{percent}}% used." + }, "queued": { "label": "Queued guidance", "guide": "Guide", @@ -1424,7 +1429,12 @@ "fileEditShowMoreLines": "Show {{count}} more lines", "fileEditShowFewerLines": "Show fewer lines", "fileEditOpenFile": "Open file", - "fileEditDiffTruncated": "Diff truncated. Open the file for the full change." + "fileEditDiffTruncated": "Diff truncated. Open the file for the full change.", + "usage": { + "context": "Context now: {{tokens}}{{capacity}}", + "requests": "{{count}} calls this turn", + "estimated": "Includes estimated usage" + } }, "lightbox": { "title": "Image preview", diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 268d0344a..5dc215773 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -1179,6 +1179,11 @@ "removeQuotedContext": "Quitar contexto citado", "modelNotConfigured": "Modelo no configurado", "configureModel": "Configurar modelo", + "switchModel": "Cambiar el modelo de este chat", + "context": { + "tooltip": "Contexto · {{tokens}}{{capacity}}", + "meterDescription": "{{context}}. {{percent}} % usado." + }, "queued": { "label": "Guía en cola", "guide": "Guiar", @@ -1395,6 +1400,11 @@ "fileEditShowFewerLines": "Mostrar menos líneas", "fileEditOpenFile": "Abrir archivo", "fileEditDiffTruncated": "Diferencias truncadas. Abre el archivo para ver el cambio completo.", + "usage": { + "context": "Contexto más reciente: {{tokens}}{{capacity}} tokens", + "requests": "{{count}} solicitudes al modelo", + "estimated": "Incluye uso estimado" + }, "activityThinkingFor": "Pensando durante {{duration}}", "activityThought": "Pensamiento completado", "activityThoughtFor": "Pensó durante {{duration}}", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 30225cc60..360ebed75 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -1178,6 +1178,11 @@ "removeQuotedContext": "Supprimer le contexte cité", "modelNotConfigured": "Modèle non configuré", "configureModel": "Configurer le modèle", + "switchModel": "Changer le modèle de cette conversation", + "context": { + "tooltip": "Contexte · {{tokens}}{{capacity}}", + "meterDescription": "{{context}}. {{percent}} % utilisé." + }, "queued": { "label": "Guidage en attente", "guide": "Guider", @@ -1394,6 +1399,11 @@ "fileEditShowFewerLines": "Afficher moins de lignes", "fileEditOpenFile": "Ouvrir le fichier", "fileEditDiffTruncated": "Différences tronquées. Ouvrez le fichier pour voir la modification complète.", + "usage": { + "context": "Dernier contexte : {{tokens}}{{capacity}} tokens", + "requests": "{{count}} requêtes au modèle", + "estimated": "Inclut une estimation de l’utilisation" + }, "activityThinkingFor": "Réflexion pendant {{duration}}", "activityThought": "Réflexion terminée", "activityThoughtFor": "Réflexion terminée en {{duration}}", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index fe6636610..fa4708f95 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -1178,6 +1178,11 @@ "removeQuotedContext": "Hapus konteks kutipan", "modelNotConfigured": "Model belum dikonfigurasi", "configureModel": "Konfigurasi model", + "switchModel": "Ganti model untuk percakapan ini", + "context": { + "tooltip": "Konteks · {{tokens}}{{capacity}}", + "meterDescription": "{{context}}. {{percent}}% digunakan." + }, "queued": { "label": "Panduan antrean", "guide": "Pandu", @@ -1394,6 +1399,11 @@ "fileEditShowFewerLines": "Tampilkan lebih sedikit baris", "fileEditOpenFile": "Buka file", "fileEditDiffTruncated": "Perbedaan dipotong. Buka file untuk melihat perubahan lengkap.", + "usage": { + "context": "Konteks terbaru: {{tokens}}{{capacity}} token", + "requests": "{{count}} permintaan model", + "estimated": "Termasuk penggunaan perkiraan" + }, "activityThinkingFor": "Berpikir selama {{duration}}", "activityThought": "Selesai berpikir", "activityThoughtFor": "Selesai berpikir dalam {{duration}}", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index a28c659cb..4622ca056 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -1178,6 +1178,11 @@ "removeQuotedContext": "引用したコンテキストを削除", "modelNotConfigured": "モデルが未設定です", "configureModel": "モデルを設定", + "switchModel": "この会話で使うモデルを切り替える", + "context": { + "tooltip": "コンテキスト · {{tokens}}{{capacity}}", + "meterDescription": "{{context}}。{{percent}}% 使用中" + }, "queued": { "label": "保留中のガイド", "guide": "ガイド", @@ -1394,6 +1399,11 @@ "fileEditShowFewerLines": "表示行数を減らす", "fileEditOpenFile": "ファイルを開く", "fileEditDiffTruncated": "差分は切り詰められました。完全な変更はファイルを開いて確認してください。", + "usage": { + "context": "最新のコンテキスト: {{tokens}}{{capacity}} トークン", + "requests": "モデルリクエスト {{count}} 回", + "estimated": "推定使用量を含みます" + }, "activityThinkingFor": "{{duration}}考えています", "activityThought": "思考しました", "activityThoughtFor": "{{duration}}考えました", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index f3bbd31ad..7cff407ec 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -1178,6 +1178,11 @@ "removeQuotedContext": "인용한 문맥 제거", "modelNotConfigured": "모델이 설정되지 않음", "configureModel": "모델 설정", + "switchModel": "이 대화에서 사용할 모델 전환", + "context": { + "tooltip": "컨텍스트 · {{tokens}}{{capacity}}", + "meterDescription": "{{context}}. {{percent}}% 사용 중." + }, "queued": { "label": "대기 중인 안내", "guide": "안내", @@ -1394,6 +1399,11 @@ "fileEditShowFewerLines": "줄 줄이기", "fileEditOpenFile": "파일 열기", "fileEditDiffTruncated": "변경 사항이 잘렸습니다. 전체 변경은 파일을 열어 확인하세요.", + "usage": { + "context": "최근 컨텍스트: {{tokens}}{{capacity}} 토큰", + "requests": "모델 요청 {{count}}회", + "estimated": "예상 사용량 포함" + }, "activityThinkingFor": "{{duration}} 동안 생각 중", "activityThought": "생각함", "activityThoughtFor": "{{duration}} 동안 생각함", diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json index f4a2d7307..f6e0b0329 100644 --- a/webui/src/i18n/locales/pt-BR/common.json +++ b/webui/src/i18n/locales/pt-BR/common.json @@ -1192,6 +1192,11 @@ "removeQuotedContext": "Remover contexto citado", "modelNotConfigured": "Modelo não configurado", "configureModel": "Configurar modelo", + "switchModel": "Alternar o modelo desta conversa", + "context": { + "tooltip": "Contexto · {{tokens}}{{capacity}}", + "meterDescription": "{{context}}. {{percent}}% usado." + }, "queued": { "label": "Guia em fila", "guide": "Guiar", @@ -1424,7 +1429,12 @@ "fileEditShowMoreLines": "Mostrar mais {{count}} linhas", "fileEditShowFewerLines": "Mostrar menos linhas", "fileEditOpenFile": "Abrir arquivo", - "fileEditDiffTruncated": "Diferenças truncadas. Abra o arquivo para ver a alteração completa." + "fileEditDiffTruncated": "Diferenças truncadas. Abra o arquivo para ver a alteração completa.", + "usage": { + "context": "Contexto atual: {{tokens}}{{capacity}}", + "requests": "{{count}} chamadas nesta rodada", + "estimated": "Inclui uso estimado" + } }, "lightbox": { "title": "Pré-visualização de imagem", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 50b2eafb7..b17017947 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -1178,6 +1178,11 @@ "removeQuotedContext": "Xóa ngữ cảnh được trích dẫn", "modelNotConfigured": "Chưa cấu hình mô hình", "configureModel": "Cấu hình mô hình", + "switchModel": "Chuyển mô hình cho cuộc trò chuyện này", + "context": { + "tooltip": "Ngữ cảnh · {{tokens}}{{capacity}}", + "meterDescription": "{{context}}. Đã dùng {{percent}}%." + }, "queued": { "label": "Hướng dẫn đang chờ", "guide": "Hướng dẫn", @@ -1394,6 +1399,11 @@ "fileEditShowFewerLines": "Hiển thị ít dòng hơn", "fileEditOpenFile": "Mở tệp", "fileEditDiffTruncated": "Khác biệt đã bị cắt bớt. Mở tệp để xem toàn bộ thay đổi.", + "usage": { + "context": "Ngữ cảnh gần nhất: {{tokens}}{{capacity}} token", + "requests": "{{count}} yêu cầu mô hình", + "estimated": "Bao gồm mức sử dụng ước tính" + }, "activityThinkingFor": "Đang suy nghĩ trong {{duration}}", "activityThought": "Đã suy nghĩ", "activityThoughtFor": "Đã suy nghĩ trong {{duration}}", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 4e334ee95..93cc48ec6 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -1191,6 +1191,11 @@ "removeQuotedContext": "移除引用内容", "modelNotConfigured": "模型未配置", "configureModel": "配置模型", + "switchModel": "切换本次对话所用模型", + "context": { + "tooltip": "上下文 · {{tokens}}{{capacity}}", + "meterDescription": "{{context}}。已使用 {{percent}}%。" + }, "queued": { "label": "排队中的引导消息", "guide": "引导", @@ -1424,7 +1429,12 @@ "fileEditShowMoreLines": "显示剩余 {{count}} 行", "fileEditShowFewerLines": "收起部分行", "fileEditOpenFile": "打开文件", - "fileEditDiffTruncated": "差异内容已截断。打开文件可查看完整更改。" + "fileEditDiffTruncated": "差异内容已截断。打开文件可查看完整更改。", + "usage": { + "context": "当前上下文:{{tokens}}{{capacity}}", + "requests": "本轮 {{count}} 次模型调用", + "estimated": "包含估算用量" + } }, "lightbox": { "title": "图片预览", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 7d39631de..99e05d33d 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -1178,6 +1178,11 @@ "removeQuotedContext": "移除引用內容", "modelNotConfigured": "尚未設定模型", "configureModel": "設定模型", + "switchModel": "切換此對話使用的模型", + "context": { + "tooltip": "上下文 · {{tokens}}{{capacity}}", + "meterDescription": "{{context}}。已使用 {{percent}}%。" + }, "queued": { "label": "佇列中的引導訊息", "guide": "引導", @@ -1393,6 +1398,11 @@ "fileEditShowFewerLines": "收起部分行", "fileEditOpenFile": "開啟檔案", "fileEditDiffTruncated": "差異內容已截斷。請開啟檔案以檢視完整變更。", + "usage": { + "context": "最近一次上下文:{{tokens}}{{capacity}} tokens", + "requests": "{{count}} 次模型請求", + "estimated": "包含估算用量" + }, "activityThinkingFor": "思考中,已 {{duration}}", "activityThought": "已思考", "activityThoughtFor": "已思考 {{duration}}", diff --git a/webui/src/lib/activity-timeline.ts b/webui/src/lib/activity-timeline.ts index bf1101aea..a209d4463 100644 --- a/webui/src/lib/activity-timeline.ts +++ b/webui/src/lib/activity-timeline.ts @@ -1,5 +1,8 @@ import type { UIMessage } from "@/lib/types"; +/** A completed turn has two surfaces: one activity container and one final + * answer. An active turn temporarily preserves arrival order so visible + * Markdown never moves when a later tool starts. */ export type TurnUnit = | { type: "activity"; @@ -9,106 +12,103 @@ export type TurnUnit = } | { type: "message"; message: UIMessage }; -interface NormalizeActivityTimelineOptions { - preserveTrailingActivity?: boolean; -} - export function isReasoningOnlyAssistant(message: UIMessage): boolean { if (message.role !== "assistant" || message.kind === "trace") return false; - if (message.content.trim().length > 0) return false; + if (message.activityKind === "model" || message.content.trim().length > 0) return false; return !!(message.reasoning?.length || message.reasoningStreaming || message.isStreaming); } export function isAgentActivityMember(message: UIMessage): boolean { - return isReasoningOnlyAssistant(message) || message.kind === "trace"; + return isReasoningOnlyAssistant(message) || message.kind === "trace" || message.activityKind === "model"; } export function hasPendingAgentActivity(messages: UIMessage[]): boolean { - if (messages.length === 0) return false; - const last = messages[messages.length - 1]; - if (!isAgentActivityMember(last)) return false; + const last = messages.at(-1); + if (!last || !isAgentActivityMember(last)) return false; + if (last.isStreaming || last.reasoningStreaming) return true; - let trailingStart = messages.length - 1; - while ( - trailingStart > 0 - && isAgentActivityMember(messages[trailingStart - 1]) - ) { - trailingStart -= 1; - } - - const trailing = messages.slice(trailingStart); - if (trailing.some((message) => message.isStreaming || message.reasoningStreaming)) { - return true; - } - - const previous = messages[trailingStart - 1]; - if (!previous || previous.role !== "assistant" || isAgentActivityMember(previous)) { - return true; - } - - const trailingTurnIds = new Set( - trailing - .map((message) => message.turnId) - .filter((turnId): turnId is string => typeof turnId === "string" && turnId.length > 0), - ); - if (!previous.turnId) return trailingTurnIds.size > 0; - return trailingTurnIds.size > 0 && !trailingTurnIds.has(previous.turnId); + const lastTurnId = last.turnId; + const previous = messages.at(-2); + // A trace without a visible answer is an unfinished turn on replay. Once a + // final assistant answer exists after it, the activity is simply history. + return !previous + || previous.role !== "assistant" + || isAgentActivityMember(previous) + || previous.turnId !== lastTurnId; } +/** + * Project completed or replayed gateway rows into the stable shape: + * + * user → [one live/completed activity surface] → [one final answer] + * + * Assistant text that is followed by another activity is an intermediate model + * segment. It remains in the activity timeline, where the renderer keeps its + * normal Markdown surface, but it never creates a second answer bubble. This + * is the same causal model used by Codex-style transcripts. + */ export function normalizeActivityTimeline( messages: UIMessage[], - options: NormalizeActivityTimelineOptions = {}, ): TurnUnit[] { const units: TurnUnit[] = []; let turnMessages: UIMessage[] = []; let activeTurnId: string | undefined; let activeTurnStartedAtMs: number | undefined; - const flushTurn = (flushOptions: NormalizeActivityTimelineOptions = {}) => { - if (turnMessages.length === 0) { + const flushTurn = () => { + if (!turnMessages.length) { activeTurnId = undefined; + activeTurnStartedAtMs = undefined; return; } - const turnUnits: TurnUnit[] = []; - const turnStartedAtMs = activeTurnStartedAtMs; - const orderedTurnMessages = orderMessagesByTurnSeq(turnMessages); - const visibleMessages = visibleMessagesForTurn(orderedTurnMessages); - let visibleIndex = 0; - let activityMessages: UIMessage[] = []; + const ordered = orderMessagesByTurnSeq(turnMessages); + const lastActivityIndex = ordered.reduce( + (index, message, current) => isRawActivity(message) ? current : index, + -1, + ); + const answerIndices = ordered + .map((message, index) => ({ message, index })) + .filter(({ message }) => isAssistantAnswer(message)); + const finalAnswerIndex = answerIndices.at(-1)?.index; + // A replay can deliver a completed answer before a late trace row. Keep + // that answer visible, but place the late activity in the single activity + // surface before it. An answer followed by more activity is an + // intermediate model segment and stays in that surface in turn order. + const hasFinalAnswer = finalAnswerIndex !== undefined + && (finalAnswerIndex > lastActivityIndex || ordered[finalAnswerIndex].isStreaming !== true); - const flushActivityMessages = () => { - if (!activityMessages.length) return; - pushActivityUnits( - turnUnits, - activityMessages, - visibleMessages.slice(visibleIndex), - turnStartedAtMs, - ); - activityMessages = []; - }; - - for (const message of orderedTurnMessages) { - if (isAgentActivityMember(message)) { - activityMessages.push(message); - continue; + const activity: UIMessage[] = []; + const answers: UIMessage[] = []; + ordered.forEach((message, index) => { + if (isRawActivity(message)) { + activity.push(message); + } else if (isAssistantAnswer(message)) { + if (message.reasoning?.trim() || message.reasoningStreaming) { + activity.push(reasoningOnlyMessageFromAnswer(message)); + } + if (hasFinalAnswer && index === finalAnswerIndex) { + answers.push(stripInlineReasoning(message)); + } else { + activity.push(modelActivitySnippet(message)); + } + } else { + activity.push(message); } + }); - if (assistantHasInlineReasoning(message)) { - activityMessages.push(reasoningOnlyMessageFromAnswer(message)); - flushActivityMessages(); - turnUnits.push({ type: "message", message: stripInlineReasoning(message) }); - visibleIndex += 1; - continue; - } - - flushActivityMessages(); - turnUnits.push({ type: "message", message }); - visibleIndex += 1; + if (activity.length) { + units.push({ + type: "activity", + messages: activity, + turnLatencyMs: activityTurnLatencyMs(activity, ordered), + startedAtMs: activeTurnStartedAtMs, + }); + } + if (answers.length) { + units.push({ type: "message", message: mergeAssistantAnswers(answers) }); } - flushActivityMessages(); - units.push(...normalizeCompletedTurnUnits(turnUnits, flushOptions)); turnMessages = []; activeTurnId = undefined; activeTurnStartedAtMs = undefined; @@ -122,131 +122,142 @@ export function normalizeActivityTimeline( activeTurnStartedAtMs = validCreatedAtMs(message.createdAt); continue; } - - if (message.turnId && activeTurnId && message.turnId !== activeTurnId) { - flushTurn(); - } - if (message.turnId) { - activeTurnId = message.turnId; - } + if (message.turnId && activeTurnId && message.turnId !== activeTurnId) flushTurn(); + if (message.turnId) activeTurnId = message.turnId; turnMessages.push(message); } - flushTurn(options); + flushTurn(); return units; } +/** + * Keep an in-flight turn in arrival order. Until ``turn_end`` there is no + * reliable way to know whether an assistant text segment is the final answer + * or commentary before another tool call. Reclassifying it when that tool + * arrives makes an already-visible Markdown tree jump between containers. + * + * Completed turns still use ``normalizeActivityTimeline`` and collapse into + * one audit surface plus the final answer. While the turn is active, text and + * contiguous activity phases stay in arrival order. A later tool therefore + * appends a Working surface after existing Markdown instead of reparenting it. + */ +export function projectActivityTimeline( + messages: UIMessage[], + liveTurnId?: string | null, +): TurnUnit[] { + if (liveTurnId === undefined) return normalizeActivityTimeline(messages); + + const liveStart = findLiveTurnStart(messages, liveTurnId); + if (liveStart < 0) return normalizeActivityTimeline(messages); + const nextPrompt = messages.findIndex( + (message, index) => index > liveStart && message.role === "user", + ); + const liveEnd = nextPrompt < 0 ? messages.length : nextPrompt; + + return [ + ...normalizeActivityTimeline(messages.slice(0, liveStart)), + ...projectLiveTurn(messages.slice(liveStart, liveEnd)), + ...normalizeActivityTimeline(messages.slice(liveEnd)), + ]; +} + +function findLiveTurnStart(messages: UIMessage[], liveTurnId: string | null): number { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message.role !== "user") continue; + if (liveTurnId === null || message.turnId === liveTurnId) return index; + } + return -1; +} + +function projectLiveTurn(messages: UIMessage[]): TurnUnit[] { + const units: TurnUnit[] = []; + const prompt = messages[0]; + const startedAtMs = prompt?.role === "user" ? validCreatedAtMs(prompt.createdAt) : undefined; + let activity: UIMessage[] = []; + + if (prompt?.role === "user") units.push({ type: "message", message: prompt }); + + const flushActivity = () => { + if (!activity.length) return; + units.push({ + type: "activity", + messages: activity, + turnLatencyMs: activityTurnLatencyMs(activity, activity), + startedAtMs, + }); + activity = []; + }; + + for (const message of messages.slice(prompt?.role === "user" ? 1 : 0)) { + if (isRawActivity(message)) { + activity.push(message); + continue; + } + if (isAssistantAnswer(message)) { + if (message.reasoning?.trim() || message.reasoningStreaming) { + activity.push(reasoningOnlyMessageFromAnswer(message)); + } + flushActivity(); + units.push({ type: "message", message: stripInlineReasoning(message) }); + continue; + } + activity.push(message); + } + + flushActivity(); + return units; +} + +function isRawActivity(message: UIMessage): boolean { + return isAgentActivityMember(message); +} + +function isAssistantAnswer(message: UIMessage): boolean { + return message.role === "assistant" && message.kind !== "trace" && message.content.trim().length > 0; +} + function orderMessagesByTurnSeq(messages: UIMessage[]): UIMessage[] { - if ( - messages.length < 2 - || !messages.every((message) => Number.isFinite(message.turnSeq)) - ) { + if (messages.length < 2 || !messages.every((message) => Number.isFinite(message.turnSeq))) { return messages; } return messages .map((message, index) => ({ message, index })) - .sort((left, right) => { - const bySeq = (left.message.turnSeq ?? 0) - (right.message.turnSeq ?? 0); - return bySeq || left.index - right.index; - }) + .sort((left, right) => (left.message.turnSeq! - right.message.turnSeq!) || (left.index - right.index)) .map(({ message }) => message); } -function normalizeCompletedTurnUnits( - turnUnits: TurnUnit[], - options: NormalizeActivityTimelineOptions, -): TurnUnit[] { - if (options.preserveTrailingActivity || turnUnits.length < 2) return turnUnits; - if (turnUnits[turnUnits.length - 1]?.type !== "activity") return turnUnits; - - let trailingStart = turnUnits.length - 1; - while (trailingStart > 0 && turnUnits[trailingStart - 1]?.type === "activity") { - trailingStart -= 1; - } - - const previous = turnUnits[trailingStart - 1]; - if ( - !previous - || previous.type !== "message" - || previous.message.role !== "assistant" - ) { - return turnUnits; - } - - return [ - ...turnUnits.slice(0, trailingStart - 1), - ...turnUnits.slice(trailingStart), - previous, - ]; -} - -function visibleMessagesForTurn(messages: UIMessage[]): UIMessage[] { - const visibleMessages: UIMessage[] = []; - for (const message of messages) { - if (isAgentActivityMember(message)) continue; - visibleMessages.push(assistantHasInlineReasoning(message) ? stripInlineReasoning(message) : message); - } - return visibleMessages; -} - -function validCreatedAtMs(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function pushActivityUnits( - units: TurnUnit[], - activityMessages: UIMessage[], - visibleMessages: UIMessage[], - startedAtMs?: number, -) { - let runMessages: UIMessage[] = []; - let runBucket: "file" | "other" | undefined; - let runSegmentId: string | undefined; - - const flushRun = () => { - if (!runMessages.length) return; - units.push({ - type: "activity", - messages: runMessages, - turnLatencyMs: activityTurnLatencyMs(runMessages, visibleMessages), - startedAtMs, - }); - runMessages = []; - runBucket = undefined; - runSegmentId = undefined; +function mergeAssistantAnswers(answers: UIMessage[]): UIMessage { + const first = answers[0]; + const last = answers.at(-1)!; + const media = answers.flatMap((message) => message.media ?? []); + const images = answers.flatMap((message) => message.images ?? []); + const merged: UIMessage = { + ...first, + ...last, + id: first.id, + content: answers.map((message) => message.content.trim()).filter(Boolean).join("\n\n"), + createdAt: first.createdAt, + isStreaming: answers.some((message) => message.isStreaming), }; - - for (const message of activityMessages) { - const bucket = isFileEditActivityMessage(message) ? "file" : "other"; - const segmentId = message.activitySegmentId; - const segmentChanged = - bucket === "file" - && runBucket === "file" - && !!runSegmentId - && !!segmentId - && runSegmentId !== segmentId; - if ((runBucket && bucket !== runBucket) || segmentChanged) { - flushRun(); - } - runBucket = bucket; - if (segmentId) runSegmentId = segmentId; - runMessages.push(message); - } - - flushRun(); + if (media.length) merged.media = media; + else delete merged.media; + if (images.length) merged.images = images; + else delete merged.images; + return merged; } -function isFileEditActivityMessage(message: UIMessage): boolean { - return message.kind === "trace" && !!message.fileEdits?.length; -} - -function assistantHasInlineReasoning(message: UIMessage): boolean { - return ( - message.role === "assistant" - && message.kind !== "trace" - && message.content.trim().length > 0 - && (!!message.reasoning?.trim() || !!message.reasoningStreaming) - ); +function modelActivitySnippet(message: UIMessage): UIMessage { + return { + ...stripInlineReasoning(message), + id: `${message.id}-activity`, + activityKind: "model", + turnPhase: "activity", + // Keep the source stream state so the activity surface can render this + // segment with the same Markdown streaming semantics as a normal answer. + isStreaming: message.isStreaming, + }; } function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage { @@ -273,13 +284,17 @@ function stripInlineReasoning(message: UIMessage): UIMessage { return next; } -function activityTurnLatencyMs(activityMessages: UIMessage[], visibleMessages: UIMessage[]): number | undefined { - for (let i = visibleMessages.length - 1; i >= 0; i -= 1) { - const latency = visibleMessages[i].latencyMs; +function validCreatedAtMs(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function activityTurnLatencyMs(activityMessages: UIMessage[], allMessages: UIMessage[]): number | undefined { + for (let index = allMessages.length - 1; index >= 0; index -= 1) { + const latency = allMessages[index].latencyMs; if (isValidLatency(latency)) return latency; } - for (let i = activityMessages.length - 1; i >= 0; i -= 1) { - const latency = activityMessages[i].latencyMs; + for (let index = activityMessages.length - 1; index >= 0; index -= 1) { + const latency = activityMessages[index].latencyMs; if (isValidLatency(latency)) return latency; } return undefined; diff --git a/webui/src/lib/format.ts b/webui/src/lib/format.ts index 279e318e1..6e2abedaa 100644 --- a/webui/src/lib/format.ts +++ b/webui/src/lib/format.ts @@ -1,5 +1,16 @@ import i18n, { currentLocale } from "@/i18n"; +/** Compact token counts for dense runtime metadata (for example, 74.9K). */ +export function formatCompactTokenCount(value: number): string { + if (value < 1_000) return Math.round(value).toLocaleString(); + if (value < 1_000_000) { + const digits = value < 100_000 ? 1 : 0; + return `${Number((value / 1_000).toFixed(digits))}K`; + } + const digits = value < 100_000_000 ? 1 : 0; + return `${Number((value / 1_000_000).toFixed(digits))}M`; +} + const LOW_INFORMATION_TITLE_PREVIEWS = new Set([ "hi", "hello", diff --git a/webui/src/lib/thread-event-projection.ts b/webui/src/lib/thread-event-projection.ts index d577c70b9..2890b9269 100644 --- a/webui/src/lib/thread-event-projection.ts +++ b/webui/src/lib/thread-event-projection.ts @@ -136,7 +136,7 @@ export function pruneReasoningOnlyPlaceholders(prev: UIMessage[]): UIMessage[] { export function stampLastAssistantCompletion( prev: UIMessage[], - completion: Pick, + completion: Pick, turnId?: string, ): UIMessage[] { for (let i = prev.length - 1; i >= 0; i -= 1) { diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 0ca9ce4af..eeac73bdc 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -39,6 +39,16 @@ export interface UIMediaAttachment { export interface UIMessageSource { kind: "cron" | "local_trigger" | "trigger" | string; label?: string; } +export interface TurnUsage { + prompt_tokens?: number; + completion_tokens?: number; + cached_tokens?: number; + context_tokens?: number; + request_count?: number; + estimated_tokens?: number; + [key: string]: number | undefined; +} + export interface UIMessage { id: string; role: Role; @@ -56,6 +66,9 @@ export interface UIMessage { fileEdits?: UIFileEdit[]; /** Activity rows created during the same agent phase share one collapsible block. */ activitySegmentId?: string; + /** Internal projection marker for assistant text emitted before a later tool. + * It is not a wire message and is rendered as a compact activity row. */ + activityKind?: "model"; /** User turn: optimistic blob URLs for preview. Replay: placeholder chips. */ images?: UIImage[]; /** Signed or local UI-renderable media attachments. */ @@ -77,6 +90,10 @@ export interface UIMessage { latencyMs?: number; /** Client epoch milliseconds when the definitive ``turn_end`` was received. */ completedAt?: number; + /** Additive model usage for this turn; context_tokens is the final request only. */ + usage?: TurnUsage; + /** Configured context-window capacity for the model used by this turn. */ + contextWindowTokens?: number; /** Lightweight provenance for proactive assistant messages. */ source?: UIMessageSource; /** Structured provenance for a message delivered by another session. */ @@ -1225,7 +1242,12 @@ export interface InboundTurnMetadata { export type InboundEvent = | { event: "ready"; chat_id: string; client_id: string } - | { event: "attached"; chat_id: string; temporary?: boolean } + | { + event: "attached"; + chat_id: string; + temporary?: boolean; + usage?: TurnUsage; + } | { event: "message_accepted"; chat_id: string; @@ -1313,11 +1335,14 @@ export type InboundEvent = chat_id: string; model_name: string; model_preset?: string | null; + fallback?: boolean; } | ({ event: "turn_end"; chat_id: string; latency_ms?: number; + usage?: TurnUsage; + context_window_tokens?: number; /** Authoritative sustained-goal snapshot for this chat (same shape as ``goal_state`` events). */ goal_state?: GoalStateWsPayload; } & InboundTurnMetadata) diff --git a/webui/src/tests/agent-activity-cluster.test.tsx b/webui/src/tests/agent-activity-cluster.test.tsx index 08ad98331..54319f4d0 100644 --- a/webui/src/tests/agent-activity-cluster.test.tsx +++ b/webui/src/tests/agent-activity-cluster.test.tsx @@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" import { describe, expect, it, vi } from "vitest"; import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster"; +import { preloadMarkdownText } from "@/components/MarkdownText"; import { DEFAULT_LOCAL_PREFS, writeLocalPreferences } from "@/lib/local-preferences"; import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types"; @@ -139,6 +140,34 @@ function installReducedMotion() { } describe("AgentActivityCluster", () => { + it("keeps intermediate assistant output as normal Markdown inside live activity", async () => { + await act(async () => { + await preloadMarkdownText(); + }); + + render( + , + ); + + const block = screen.getByTestId("activity-model-message"); + await waitFor(() => expect(block.querySelector("strong")).not.toBeNull()); + expect(block.querySelector("strong")).toHaveTextContent("partial answer"); + expect(screen.queryByTestId("activity-step")).not.toBeInTheDocument(); + }); + it("jumps to the latest activity when opened", () => { const raf = installAnimationFrameQueue(); try { @@ -398,7 +427,7 @@ describe("AgentActivityCluster", () => { vi.advanceTimersByTime(301); }); expect(screen.queryByTestId("agent-activity-scroll")).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Thought" })).toHaveAttribute( + expect(screen.getByRole("button", { name: "Worked" })).toHaveAttribute( "aria-expanded", "false", ); @@ -422,7 +451,7 @@ describe("AgentActivityCluster", () => { />, ); - const button = screen.getByRole("button", { name: "Thought" }); + const button = screen.getByRole("button", { name: "Worked" }); expect(button).toHaveAttribute("data-thread-disclosure"); const chevron = button.querySelector("svg"); expect(chevron).toBeInTheDocument(); @@ -449,7 +478,7 @@ describe("AgentActivityCluster", () => { />, ); - expect(screen.getByText("Thought for 12s")).toBeInTheDocument(); + expect(screen.getByText("Worked for 12s")).toBeInTheDocument(); }); it("labels mixed tool activity as work instead of thought", () => { @@ -481,8 +510,8 @@ describe("AgentActivityCluster", () => { />, ); - expect(screen.getByText("Thought")).toBeInTheDocument(); - expect(screen.queryByText("Thought for 0s")).not.toBeInTheDocument(); + expect(screen.getByText("Worked")).toBeInTheDocument(); + expect(screen.queryByText("Worked for 0s")).not.toBeInTheDocument(); }); it("renders file edits as one-line activity rows", async () => { diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx index ac03c000d..369abde2f 100644 --- a/webui/src/tests/message-bubble.test.tsx +++ b/webui/src/tests/message-bubble.test.tsx @@ -245,6 +245,7 @@ describe("MessageBubble", () => { const quote = screen.getByLabelText("Quoted context"); expect(quote).toHaveTextContent("selected assistant excerpt"); + expect(quote).not.toHaveAttribute("title"); expect(screen.queryByText("Quoted context")).not.toBeInTheDocument(); expect(screen.getByText("What about this?")).toBeInTheDocument(); @@ -1011,4 +1012,50 @@ describe("MessageBubble", () => { expect(container.querySelector('img[src="/api/media/sig/svg"]')).toBeInTheDocument(); expect(screen.queryByLabelText("File attachment")).not.toBeInTheDocument(); }); + + it("keeps turn usage focused on the completed reply", () => { + const message: UIMessage = { + id: "a-usage", + role: "assistant", + content: "done", + createdAt: Date.now(), + latencyMs: 18_200, + contextWindowTokens: 128_000, + usage: { + prompt_tokens: 12_400, + completion_tokens: 823, + cached_tokens: 9_672, + context_tokens: 8_100, + request_count: 3, + }, + }; + + render(); + + const usage = screen.getByText("12.4K in · 823 out · 78% cached · 18s"); + expect(usage).toHaveAttribute("data-turn-usage"); + expect(usage).not.toHaveAttribute("tabindex"); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + it("marks estimated usage and omits cache when the provider did not report it", () => { + const message: UIMessage = { + id: "a-estimated-usage", + role: "assistant", + content: "done", + createdAt: Date.now(), + usage: { + prompt_tokens: 1_250, + completion_tokens: 90, + estimated_tokens: 1_340, + }, + }; + + render(); + + const usage = screen.getByText("~1.3K in · ~90 out"); + expect(usage).not.toHaveTextContent("cached"); + fireEvent.focus(usage); + expect(screen.getByRole("tooltip")).toHaveTextContent("Includes estimated usage"); + }); }); diff --git a/webui/src/tests/nanobot-client.test.ts b/webui/src/tests/nanobot-client.test.ts index 0112d73fb..b9f6852ea 100644 --- a/webui/src/tests/nanobot-client.test.ts +++ b/webui/src/tests/nanobot-client.test.ts @@ -1724,6 +1724,7 @@ describe("NanobotClient", () => { chat_id: "chat-a", model_name: "deepseek/deepseek-chat", model_preset: "Deep Research", + fallback: true, }); expect(chatHandler).toHaveBeenCalledWith({ @@ -1731,6 +1732,7 @@ describe("NanobotClient", () => { chat_id: "chat-a", model_name: "deepseek/deepseek-chat", model_preset: "Deep Research", + fallback: true, }); }); diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index 0b701c3f8..e8c26d843 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -317,9 +317,9 @@ function ascii(bytes: Uint8Array, offset: number, length: number): string { } const MODEL_PRESETS = [ - { name: "kimi", provider: "moonshot" }, - { name: "dflash", provider: "deepseek" }, - { name: "dspro", provider: "deepseek" }, + { name: "kimi", model: "moonshot/kimi-k2.5", provider: "moonshot" }, + { name: "dflash", model: "deepseek/deepseek-v4-flash", provider: "deepseek" }, + { name: "dspro", model: "deepseek/deepseek-v4-pro", provider: "deepseek" }, ]; function renderPresetComposer(variant: "thread" | "hero" = "thread") { @@ -337,28 +337,11 @@ function renderPresetComposer(variant: "thread" | "hero" = "thread") { />, ); return { - badge: screen.getByRole("spinbutton", { name: "kimi" }), + badge: screen.getByRole("button", { name: "kimi" }), onPresetChange, }; } -function pointerDown(badge: HTMLElement, pointerId = 7, clientY = 100, button = 0) { - fireEvent.pointerDown(badge, { - button, - clientY, - isPrimary: true, - pointerId, - pointerType: "mouse", - }); -} - -function longPress(badge: HTMLElement, pointerId = 7) { - pointerDown(badge, pointerId); - act(() => { - vi.advanceTimersByTime(400); - }); -} - describe("ThreadComposer", () => { it("locks an async send and keeps the draft when it is rejected", async () => { let resolveSend!: (accepted: boolean) => void; @@ -538,12 +521,42 @@ describe("ThreadComposer", () => { />, ); - const badge = screen.getByRole("spinbutton", { name: "gpt-5.6-sol" }); + const badge = screen.getByRole("button", { name: "gpt-5.6-sol" }); expect(badge).toHaveClass("w-fit", "max-w-[min(18rem,44vw)]"); expect(badge).not.toHaveClass("w-[5.75rem]"); expect(screen.getByText("gpt-5.6-sol")).toBeInTheDocument(); }); + it("shows a compact context meter beside the model selector", async () => { + render( + , + ); + + const context = screen.getByTestId("composer-context-usage"); + expect(context).toHaveClass("size-5", "rounded-full"); + expect(context).not.toHaveTextContent("Context 74.9K / 1M"); + expect(screen.getByTestId("composer-context-meter")).toBeInTheDocument(); + expect(context).toHaveAccessibleName( + "Context · 74.9K / 1M. 7% used.", + ); + + fireEvent.focus(context); + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip).toHaveTextContent("Context · 74.9K / 1M"); + expect(tooltip.parentElement).toHaveClass("rounded-full", "px-2.5", "py-1"); + expect(tooltip.parentElement).not.toHaveTextContent("Available"); + }); + it("keeps the thread composer compact while matching the hero style", () => { render( { const modelPill = screen.getByText("gpt-4o").closest(".composer-model-pill"); expect(modelPill).toHaveClass("font-medium", "text-foreground/70"); expect(modelPill).not.toHaveClass("font-semibold"); - expect(screen.getByTestId("composer-model-logo-openai")).toBeInTheDocument(); + const providerLogo = screen.getByTestId("composer-model-logo-openai"); + expect(providerLogo).toBeInTheDocument(); + expect(providerLogo).not.toHaveClass("border", "bg-background"); const input = screen.getByPlaceholderText("Type your message..."); expect(input.className).toContain("min-h-[50px]"); expect(input.className).toContain("text-[16px]"); @@ -571,141 +586,53 @@ describe("ThreadComposer", () => { expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument(); }); - it("shows model details in the shared tooltip without a native title", async () => { - render( - , - ); - - const badge = screen.getByLabelText("gpt-4o"); - expect(badge).not.toHaveAttribute("title"); - fireEvent.focus(badge); - - expect(await screen.findByRole("tooltip")).toHaveTextContent("gpt-4o · OpenAI"); - }); - - it("smoothly cycles to the next preset on click", () => { - vi.useFakeTimers(); - let runFrame: FrameRequestCallback | null = null; - vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { - runFrame = callback; - return 1; - }); - vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined); - const { badge, onPresetChange } = renderPresetComposer(); - - fireEvent.click(badge); - - expect(badge).toHaveAttribute("data-switching", "true"); - const track = screen.getByTestId("composer-model-pill-track"); - expect(track).not.toHaveAttribute("data-settling"); - expect(track).toHaveStyle({ transform: "translate3d(0, -40px, 0)" }); - - act(() => runFrame?.(16)); - - expect(onPresetChange).toHaveBeenCalledWith("dflash"); - expect(badge).toHaveAttribute("data-settling", "true"); - expect(track).toHaveAttribute("data-settling", "true"); - expect(track).toHaveStyle({ transform: "translate3d(0, -80px, 0)" }); - - act(() => vi.advanceTimersByTime(260)); - expect(badge).not.toHaveAttribute("data-switching"); - }); - - it("scrolls complete preset pills after a left-button long press and wraps", () => { - vi.useFakeTimers(); + it("opens a model picker and switches presets with one click", async () => { const { badge, onPresetChange } = renderPresetComposer(); expect(badge).toHaveClass("h-9"); - expect(badge).toHaveStyle({ touchAction: "manipulation" }); - const idleTouchMove = new Event("touchmove", { - bubbles: true, - cancelable: true, - }); - badge.dispatchEvent(idleTouchMove); - expect(idleTouchMove.defaultPrevented).toBe(false); - pointerDown(badge); - fireEvent.pointerMove(badge, { clientY: 80, pointerId: 7, pointerType: "mouse" }); - act(() => vi.advanceTimersByTime(500)); - fireEvent.pointerUp(badge, { clientY: 80, pointerId: 7, pointerType: "mouse" }); - expect(onPresetChange).not.toHaveBeenCalled(); - - longPress(badge); - expect(badge).toHaveAttribute("data-switching", "true"); - const viewport = screen.getByTestId("composer-model-pill-viewport"); - expect(viewport).toHaveClass( - "right-0", - "w-max", - "max-w-[calc(44vw+0.5rem)]", - "overflow-hidden", - "-top-3", - "-bottom-3", - ); - const track = screen.getByTestId("composer-model-pill-track"); - expect(track).toHaveClass("w-max", "max-w-full", "items-end", "gap-1"); - const activeTouchMove = new Event("touchmove", { - bubbles: true, - cancelable: true, - }); - badge.dispatchEvent(activeTouchMove); - expect(activeTouchMove.defaultPrevented).toBe(true); - const pills = track.querySelectorAll(".composer-model-pill"); - expect(pills).toHaveLength(5); - expect(Array.from(pills).every((pill) => pill.classList.contains("w-fit"))).toBe(true); - expect(Array.from(pills).every((pill) => pill.querySelector("img"))).toBe(true); - expect(Array.from(badge.querySelectorAll("img")).every((image) => !image.draggable)).toBe(true); - const centeredPill = track.querySelector("[data-preset-offset='0']"); - expect(centeredPill).toHaveTextContent("kimi"); - expect(centeredPill).toHaveStyle({ transform: "scale(1.0800)" }); - expect( - track.querySelector("[data-preset-offset='1']"), - ).toHaveStyle({ transform: "scale(1.0200)" }); - - fireEvent.pointerMove(badge, { - clientY: 122, - pointerId: 7, - pointerType: "mouse", - }); - expect(track.querySelector("[data-preset-offset='0']")).toHaveTextContent("kimi"); - fireEvent.pointerMove(badge, { - clientY: 123, - pointerId: 7, - pointerType: "mouse", - }); - expect(track.querySelector("[data-preset-offset='0']")).toHaveTextContent("dspro"); - fireEvent.pointerUp(badge, { - clientY: 123, - pointerId: 7, - pointerType: "mouse", - }); - - expect(onPresetChange).toHaveBeenCalledWith("dspro"); + expect(badge).toHaveClass("w-fit"); fireEvent.click(badge); - expect(onPresetChange).toHaveBeenCalledTimes(1); - expect(badge).toHaveAttribute("data-settling", "true"); - expect(track).toHaveAttribute("data-settling", "true"); - act(() => { - vi.advanceTimersByTime(260); - }); - expect(badge).not.toHaveAttribute("data-switching"); - expect(badge).not.toHaveAttribute("data-settling"); + const picker = screen.getByRole("dialog", { name: "Switch model for this chat" }); + expect(picker).toHaveClass("w-[min(18rem,calc(100vw-2rem))]"); + expect(badge).toHaveClass("w-fit"); + expect(badge.querySelector(".composer-model-pill")).not.toHaveClass("w-full"); + expect(within(picker).getAllByRole("option")).toHaveLength(3); + expect(within(picker).getByRole("option", { name: "dflash" })).toHaveTextContent( + /dflash\s*deepseek-v4-flash/, + ); + expect(within(picker).getByRole("option", { name: "kimi" })).toHaveAttribute( + "aria-selected", + "true", + ); + expect(document.activeElement).toBe(within(picker).getByRole("option", { name: "kimi" })); + fireEvent.click(within(picker).getByRole("option", { name: "dspro" })); + expect(onPresetChange).toHaveBeenCalledWith("dspro"); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(badge).toHaveClass("w-fit"); }); - it("supports the same long-press switcher in hero mode and cancels pointercancel", () => { + it("keeps long-press drag switching alongside the click picker", () => { vi.useFakeTimers(); + const { badge, onPresetChange } = renderPresetComposer(); + + fireEvent.pointerDown(badge, { pointerId: 1, pointerType: "touch", clientY: 100 }); + act(() => vi.advanceTimersByTime(400)); + expect(screen.getByTestId("composer-model-pill-viewport")).toBeInTheDocument(); + expect(screen.getByTestId("composer-model-pill-layout")).toHaveClass("invisible"); + expect(screen.getByTestId("composer-model-pill-track")).not.toHaveClass("transition-transform"); + + fireEvent.pointerMove(badge, { pointerId: 1, pointerType: "touch", clientY: 56 }); + fireEvent.pointerUp(badge, { pointerId: 1, pointerType: "touch", clientY: 56 }); + expect(onPresetChange).toHaveBeenCalledWith("dflash"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + vi.useRealTimers(); + }); + + it("uses the same click picker in hero mode", () => { const { badge, onPresetChange } = renderPresetComposer("hero"); expect(badge).toHaveClass("h-8"); - longPress(badge, 9); - expect(badge).toHaveAttribute("data-switching", "true"); - fireEvent.pointerMove(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" }); - fireEvent.pointerCancel(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" }); - expect(badge).not.toHaveAttribute("data-switching"); - expect(onPresetChange).not.toHaveBeenCalled(); + fireEvent.click(badge); + fireEvent.click(screen.getByRole("option", { name: "dflash" })); + expect(onPresetChange).toHaveBeenCalledWith("dflash"); }); it("transcribes voice input into the composer without sending", async () => { diff --git a/webui/src/tests/thread-messages.test.tsx b/webui/src/tests/thread-messages.test.tsx index e9198d072..3ac60b028 100644 --- a/webui/src/tests/thread-messages.test.tsx +++ b/webui/src/tests/thread-messages.test.tsx @@ -38,7 +38,7 @@ describe("ThreadMessages", () => { />, ); - expect(screen.getByRole("status", { name: "Thinking for 5s" })).toBeInTheDocument(); + expect(screen.getByRole("status", { name: "Working for 5s" })).toBeInTheDocument(); rerender( { expect(screen.getByText("stable final answer").closest("p")).toBe(paragraph); }); + it("keeps live Markdown mounted when a later tool activity arrives", async () => { + await act(async () => { + await preloadMarkdownText(); + }); + const turnId = "turn-live-order"; + const prompt: UIMessage = { + id: "u-live", + role: "user", + content: "research this", + createdAt: 1, + turnId, + turnPhase: "prompt", + turnSeq: 0, + }; + const commentary: UIMessage = { + id: "a-commentary", + role: "assistant", + content: "**I will check that.**", + createdAt: 2, + isStreaming: false, + turnId, + turnPhase: "answer", + turnSeq: 1, + }; + const { rerender } = render( + , + ); + const paragraph = await screen.findByText("I will check that."); + expect(paragraph.closest("[data-testid='activity-model-message']")).toBeNull(); + + rerender( + , + ); + + expect(screen.getByText("I will check that.")).toBe(paragraph); + expect(screen.getByText(/working/i)).toBeInTheDocument(); + }); + it("offers a follow-up action for text selected within one completed answer", async () => { const onQuoteSelection = vi.fn(); render( @@ -319,12 +379,12 @@ describe("ThreadMessages", () => { expect(unitKeysForDisplay(liveUnits)).toEqual(unitKeysForDisplay(replayUnits)); expect(unitKeysForDisplay(liveUnits)).toEqual([ "turn-turn-1-user", + "turn-turn-1-activity-1", "turn-turn-1-answer-1", - "turn-turn-1-answer-2", ]); }); - it("keeps file edits as their own activity row inside a turn", () => { + it("keeps file edits inside the single activity surface for a turn", () => { const messages: UIMessage[] = [ { id: "r1", @@ -364,14 +424,16 @@ describe("ThreadMessages", () => { const units = buildDisplayUnits(messages); - expect(units).toHaveLength(3); - expect(units.map((unit) => unit.type)).toEqual(["activity", "activity", "activity"]); - expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]); - expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]); - expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["r2"]); + expect(units).toHaveLength(1); + expect(units[0].type).toBe("activity"); + expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([ + "r1", + "t1", + "r2", + ]); }); - it("keeps ordinary tool activity in one Thought block across segment ids", () => { + it("keeps ordinary tool activity in one activity block across segment ids", () => { const messages: UIMessage[] = [ { id: "r1", @@ -449,10 +511,12 @@ describe("ThreadMessages", () => { const units = buildDisplayUnits(messages); - expect(units).toHaveLength(3); - expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]); - expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]); - expect(units[2]).toMatchObject({ + expect(units).toHaveLength(2); + expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([ + "r1", + "t1", + ]); + expect(units[1]).toMatchObject({ type: "message", message: { id: "a1", @@ -504,8 +568,8 @@ describe("ThreadMessages", () => { render(); - expect(screen.getByLabelText(/edited foo\.txt/i)).toBeInTheDocument(); - expect(screen.queryByLabelText(/editing foo\.txt/i)).not.toBeInTheDocument(); + expect(screen.getByLabelText(/editing foo\.txt/i)).toBeInTheDocument(); + expect(screen.queryByLabelText(/edited foo\.txt/i)).not.toBeInTheDocument(); }); it("times live activity from the user turn start", () => { @@ -731,22 +795,18 @@ describe("ThreadMessages", () => { const units = buildDisplayUnits(messages, true); - expect(units).toHaveLength(3); - expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["t0"]); - expect(units[1]).toMatchObject({ - type: "message", - message: { - id: "a1", - content: "partial answer", - }, - }); - expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["t1"]); + expect(units).toHaveLength(1); + expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([ + "t0", + "a1-activity", + "t1", + ]); render(); const answer = screen.getByText("partial answer"); const liveActivity = screen.getByRole("button", { name: /working/i }); - expect(answer.compareDocumentPosition(liveActivity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(liveActivity.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); it("moves late activity before a completed assistant answer", () => { @@ -779,10 +839,9 @@ describe("ThreadMessages", () => { const units = buildDisplayUnits(messages); - expect(units).toHaveLength(3); - expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]); - expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]); - expect(units[2]).toMatchObject({ + expect(units).toHaveLength(2); + expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1", "t1"]); + expect(units[1]).toMatchObject({ type: "message", message: { id: "a1", @@ -793,7 +852,7 @@ describe("ThreadMessages", () => { render(); const answer = screen.getByText("Hong Kong is hot today."); - const laterActivity = screen.getAllByText(/thought/i).at(-1); + const laterActivity = screen.getByRole("button", { name: /worked/i }); expect(laterActivity).toBeTruthy(); expect(laterActivity!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); @@ -834,7 +893,7 @@ describe("ThreadMessages", () => { render(); - const thought = screen.getAllByText(/thought/i).at(-1); + const thought = screen.getByRole("button", { name: /worked/i }); const answer = screen.getByText("知道,IEM Cologne Major 2026 今天开打了。"); expect(thought).toBeTruthy(); expect(thought!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); @@ -876,18 +935,16 @@ describe("ThreadMessages", () => { const units = buildDisplayUnits(messages, true); - expect(units).toHaveLength(4); + expect(units).toHaveLength(3); expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([ "thought", - ]); - expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual([ "web", ]); - expect(units[2]).toMatchObject({ + expect(units[1]).toMatchObject({ type: "message", message: { id: "answer" }, }); - expect(units[3]).toMatchObject({ + expect(units[2]).toMatchObject({ type: "message", message: { id: "next-user" }, }); @@ -1018,7 +1075,7 @@ describe("ThreadMessages", () => { expect(screen.queryByText("Worked for 0s")).not.toBeInTheDocument(); }); - it("shows copy on every assistant slice while keeping fork on the last slice", () => { + it("projects assistant slices into one answer with one action set", () => { const messages: UIMessage[] = [ { id: "early", @@ -1050,8 +1107,9 @@ describe("ThreadMessages", () => { />, ); - expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(2); + expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(1); expect(screen.getAllByRole("button", { name: "Fork" })).toHaveLength(1); + expect(screen.getByText("starting…")).toBeInTheDocument(); expect(screen.getByText("final reply")).toBeInTheDocument(); }); @@ -1095,7 +1153,7 @@ describe("ThreadMessages", () => { rerender(); - expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Copy"]')).toHaveLength(3); + expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Copy"]')).toHaveLength(2); expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Fork"]')).toHaveLength(2); }); @@ -1192,13 +1250,15 @@ describe("ThreadMessages", () => { .toHaveLength(1); }); - it("shows copy on adjacent assistant text slices", () => { + it("projects adjacent assistant text slices into one answer", () => { const messages: UIMessage[] = [ { id: "a1", role: "assistant", content: "part one", createdAt: 1 }, { id: "a2", role: "assistant", content: "part two", createdAt: 2 }, ]; render(); - expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(2); + expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(1); + expect(screen.getByText("part one")).toBeInTheDocument(); + expect(screen.getByText("part two")).toBeInTheDocument(); }); it("does not count failed optimistic messages in assistant fork indices", () => { @@ -1280,7 +1340,6 @@ describe("ThreadMessages", () => { .filter(Boolean); expect(assistantFlags).toEqual([ - ["a1", false], ["a2", true], ["a3", true], ]); diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index fb3b9dd06..b2e5f5856 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -609,12 +609,33 @@ describe("ThreadShell", () => { ), ); - const badge = await screen.findByLabelText("fast"); - expect(badge).not.toHaveAttribute("title"); - fireEvent.focus(badge); - expect(await screen.findByRole("tooltip")).toHaveTextContent( - "fast · gpt-5.5 · OpenAI Codex", + expect(await screen.findByTitle("fast · gpt-5.5 · OpenAI Codex")).toBeInTheDocument(); + expect(screen.queryByTitle("Default · deepseek-v4-pro · DeepSeek")).not.toBeInTheDocument(); + }); + + it("falls back to the current preset while a renamed session reference is stale", async () => { + const client = makeClient(); + const settings = settingsWithFastPreset(); + settings.agent.model_preset = "fast"; + settings.model_presets = settings.model_presets.map((preset) => ({ + ...preset, + active: preset.name === "fast", + })); + render( + wrap( + client, + {}} + settingsSnapshot={settings} + />, + "openai-codex/gpt-5.5", + ), ); + + expect(await screen.findByTitle("fast · gpt-5.5 · OpenAI Codex")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Model not configured" })).not.toBeInTheDocument(); }); it("switches through every named preset while preserving call-order priority", async () => { @@ -641,19 +662,18 @@ describe("ThreadShell", () => { )); const { rerender } = render(view("default")); - const badge = await screen.findByRole("spinbutton", { name: "Default" }); + const badge = await screen.findByRole("button", { name: "Default" }); expect(badge).toHaveTextContent("Default"); - fireEvent.keyDown(badge, { key: "ArrowDown" }); + fireEvent.click(badge); + fireEvent.click(await screen.findByRole("option", { name: /^fast\b/i })); expect(client.sendSystemCommand).toHaveBeenCalledWith( "preset-order", "/model fast", ); expect(await screen.findByText("fast")).toBeInTheDocument(); - fireEvent.keyDown( - screen.getByRole("spinbutton", { name: "fast" }), - { key: "End" }, - ); + fireEvent.click(screen.getByRole("button", { name: "fast" })); + fireEvent.click(await screen.findByRole("option", { name: /^extra\b/i })); expect(client.sendSystemCommand).toHaveBeenLastCalledWith( "preset-order", "/model extra", @@ -696,15 +716,11 @@ describe("ThreadShell", () => { ), ); - const badge = await screen.findByLabelText("fast"); - fireEvent.focus(badge); - expect(await screen.findByRole("tooltip")).toHaveTextContent( - "fast · gpt-4 · Company Proxy", - ); + expect(await screen.findByTitle("fast · gpt-4 · Company Proxy")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Model not configured" })).not.toBeInTheDocument(); }); - it("only highlights fallback model updates without replacing the preset label", async () => { + it("shows the effective fallback model in the composer badge", async () => { const client = makeClient(); render(wrap( client, @@ -718,7 +734,8 @@ describe("ThreadShell", () => { )); expect(await screen.findByText("Default")).toBeInTheDocument(); - const configuredBadge = screen.getByTestId("composer-model-logo-openai_codex").parentElement; + const configuredLogo = await screen.findByTestId("composer-model-logo-openai_codex"); + const configuredBadge = configuredLogo.parentElement; expect(configuredBadge).not.toBeNull(); expect(configuredBadge).toHaveClass("composer-model-badge"); expect(configuredBadge).not.toHaveAttribute("data-fallback"); @@ -728,31 +745,34 @@ describe("ThreadShell", () => { event: "turn_model_updated", chat_id: "fallback-model", model_name: "openai-codex/gpt-5.5", + model_preset: "Default", }); }); expect(configuredBadge).not.toHaveAttribute("data-fallback"); + expect(screen.getByText("Default")).toBeInTheDocument(); act(() => { client._emitChat("fallback-model", { event: "turn_model_updated", chat_id: "fallback-model", model_name: "deepseek/deepseek-chat", + fallback: true, }); }); - const logo = screen.getByTestId("composer-model-logo-openai_codex"); + const logo = await screen.findByTestId("composer-model-logo-deepseek"); const badge = logo.parentElement; expect(badge).not.toBeNull(); expect(badge).toBe(configuredBadge); - expect(screen.getByText("Default")).toBeInTheDocument(); - expect(screen.queryByText("deepseek-chat")).not.toBeInTheDocument(); + expect(screen.queryByText("Default")).not.toBeInTheDocument(); + expect(screen.getByText("deepseek-chat")).toBeInTheDocument(); expect(badge).toHaveAttribute("data-fallback", "true"); - expect(badge).not.toHaveAttribute("title"); - expect(logo).not.toHaveAttribute("data-fallback"); - const trigger = screen.getByLabelText("Default"); - fireEvent.focus(trigger); - expect(await screen.findByRole("tooltip")).toHaveTextContent("deepseek/deepseek-chat"); + expect(badge).toHaveAttribute( + "title", + "Default · using deepseek/deepseek-chat", + ); + expect(logo).toBeInTheDocument(); act(() => { client._emitChat("fallback-model", { @@ -766,12 +786,7 @@ describe("ThreadShell", () => { screen.getByTestId("composer-model-logo-openai_codex").parentElement, ).not.toHaveAttribute("data-fallback"); }); - expect(screen.getByRole("tooltip")).toHaveTextContent( - "Default · gpt-5.5 · OpenAI Codex", - ); - expect( - screen.getByTestId("composer-model-logo-openai_codex").parentElement, - ).toBe(badge); + expect(screen.getByText("Default")).toBeInTheDocument(); }); it("opens model settings from the unconfigured model badge", async () => { @@ -1084,10 +1099,8 @@ describe("ThreadShell", () => { )); const { rerender } = render(view(null)); - fireEvent.keyDown( - await screen.findByRole("spinbutton", { name: "Default" }), - { key: "ArrowDown" }, - ); + fireEvent.click(await screen.findByRole("button", { name: "Default" })); + fireEvent.click(await screen.findByRole("option", { name: /^fast\b/i })); expect(await screen.findByText("fast")).toBeInTheDocument(); expect(client.sendSystemCommand).not.toHaveBeenCalled(); diff --git a/webui/src/tests/thread-viewport.test.tsx b/webui/src/tests/thread-viewport.test.tsx index e5bdbe05c..494b38068 100644 --- a/webui/src/tests/thread-viewport.test.tsx +++ b/webui/src/tests/thread-viewport.test.tsx @@ -234,7 +234,7 @@ describe("ThreadViewport", () => { />, ); - const disclosure = screen.getByRole("button", { name: "Thought" }); + const disclosure = screen.getByRole("button", { name: "Worked" }); fireEvent.pointerDown(disclosure, { button: 0 }); expect(takeUserControl).toHaveBeenCalledTimes(1); diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index 26d71768b..f609b8ca3 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -350,6 +350,51 @@ describe("useNanobotStream", () => { expect(result.current.isStreaming).toBe(false); }); + it("stamps provider usage and latest context on the completed answer", () => { + const fake = fakeClient(); + const { result } = renderHook(() => useNanobotStream("chat-usage", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), + }); + + act(() => { + fake.emit("chat-usage", { + event: "delta", + chat_id: "chat-usage", + text: "done", + turn_id: "turn-usage", + }); + fake.emit("chat-usage", { + event: "turn_end", + chat_id: "chat-usage", + turn_id: "turn-usage", + latency_ms: 18_200, + context_window_tokens: 128_000, + usage: { + prompt_tokens: 12_400, + completion_tokens: 823, + cached_tokens: 9_672, + context_tokens: 8_100, + request_count: 3, + }, + }); + }); + + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages[0]).toMatchObject({ + content: "done", + isStreaming: false, + latencyMs: 18_200, + contextWindowTokens: 128_000, + usage: { + prompt_tokens: 12_400, + completion_tokens: 823, + cached_tokens: 9_672, + context_tokens: 8_100, + request_count: 3, + }, + }); + }); + it("preserves proactive automation source metadata on complete assistant messages", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-cron", EMPTY_MESSAGES), { @@ -2488,7 +2533,7 @@ describe("useNanobotStream", () => { ]); }); - it("lets stream_end finish streaming while side-channel status replies arrive", () => { + it("keeps the turn active after stream_end while side-channel replies arrive", () => { vi.useFakeTimers(); try { const fake = fakeClient(); @@ -2530,6 +2575,19 @@ describe("useNanobotStream", () => { vi.advanceTimersByTime(1000); }); + expect(result.current.isStreaming).toBe(true); + expect(result.current.messages.find((message) => message.content === "done")).toMatchObject({ + isStreaming: true, + }); + + act(() => { + fake.emit("chat-status-loop", { + event: "turn_end", + chat_id: "chat-status-loop", + turn_id: promptTurnId, + }); + }); + expect(result.current.isStreaming).toBe(false); expect(result.current.messages.find((message) => message.content === "done")).toMatchObject({ isStreaming: false, @@ -2589,7 +2647,7 @@ describe("useNanobotStream", () => { expect(result.current.messages).toHaveLength(3); expect(result.current.messages[1]).toMatchObject({ content: "Initial findings", - isStreaming: false, + isStreaming: true, }); act(() => {