diff --git a/docs/configuration.md b/docs/configuration.md index 85091d1f7..ed5a534cf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -743,7 +743,7 @@ Global settings that apply to all channels. Configure under the `channels` secti |---------|---------|-------------| | `sendProgress` | `true` | Stream agent's text progress to the channel | | `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("…")`) | -| `showReasoning` | `false` | Surface model reasoning/thinking content (DeepSeek-R1 `reasoning_content`, Anthropic `thinking_blocks`, inline `` tags). Independent of `sendProgress`. | +| `showReasoning` | `true` | Allow channels to surface model reasoning/thinking content (DeepSeek-R1 `reasoning_content`, Anthropic `thinking_blocks`, inline `` tags). The setting is a plugin opt-in: even when `true`, a channel only renders reasoning if it overrides `send_reasoning()`. Currently surfaced on CLI and WebSocket/WebUI; other channels (Telegram, Slack, Discord, ...) keep it as a silent no-op until their bubble UI is adapted. Independent of `sendProgress`. | | `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) | | `transcriptionProvider` | `"groq"` | Voice transcription backend: `"groq"` (free tier, default) or `"openai"`. API key is auto-resolved from the matching provider config. | | `transcriptionLanguage` | `null` | Optional ISO-639-1 language hint for audio transcription, e.g. `"en"`, `"ko"`, `"ja"`. | diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index c7091a5f6..e7b045f01 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -147,10 +147,13 @@ class _LoopHook(AgentHook): ) async def emit_reasoning(self, reasoning_content: str | None) -> None: - """Send reasoning/thinking content as progress before the main answer.""" - ch = self._loop.channels_config - if not ch or not ch.show_reasoning: - return + """Publish reasoning content; channel plugins decide whether to render. + + The loop is intentionally not the gate: ``ChannelsConfig.show_reasoning`` + is a default that ``ChannelManager`` and ``BaseChannel.send_reasoning`` + consult per channel. A channel without a low-emphasis UI primitive + keeps the base no-op and the content drops at the dispatch boundary. + """ if self._on_progress and reasoning_content: await self._on_progress(reasoning_content, reasoning=True) diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index 087677494..c82003d88 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -28,6 +28,7 @@ class BaseChannel(ABC): transcription_language: str | None = None send_progress: bool = True send_tool_hints: bool = False + show_reasoning: bool = True def __init__(self, config: Any, bus: MessageBus): """ @@ -120,6 +121,18 @@ class BaseChannel(ABC): """ pass + async def send_reasoning(self, msg: OutboundMessage) -> None: + """Surface model reasoning/thinking content. + + Default is no-op. Channels with a native low-emphasis primitive + (Slack context block, Telegram expandable blockquote, Discord + subtext, WebUI italic bubble, ...) override to render reasoning + as a subordinate trace. Channels without a suitable affordance + keep this no-op: silently dropping is better than leaking raw + model thoughts as regular conversational messages. + """ + return + @property def supports_streaming(self) -> bool: """True when config enables streaming AND this subclass implements send_delta.""" diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 1d92bb879..abf9bf043 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -36,6 +36,7 @@ _SEND_RETRY_DELAYS = (1, 2, 4) _BOOL_CAMEL_ALIASES: dict[str, str] = { "send_progress": "sendProgress", "send_tool_hints": "sendToolHints", + "show_reasoning": "showReasoning", } class ChannelManager: @@ -104,6 +105,9 @@ class ChannelManager: channel.send_tool_hints = self._resolve_bool_override( section, "send_tool_hints", self.config.channels.send_tool_hints, ) + channel.show_reasoning = self._resolve_bool_override( + section, "show_reasoning", self.config.channels.show_reasoning, + ) self.channels[name] = channel logger.info("{} channel enabled", cls.display_name) except Exception as e: @@ -279,6 +283,18 @@ class ChannelManager: timeout=1.0 ) + if msg.metadata.get("_reasoning"): + # Reasoning rides its own plugin channel: only delivered when + # the destination channel both opts in (``show_reasoning``) + # and overrides ``send_reasoning``. Channels without a + # low-emphasis UI primitive keep the base no-op and the + # content silently drops here rather than leak as a + # conversational reply. + channel = self.channels.get(msg.channel) + if channel is not None and channel.show_reasoning: + await self._send_with_retry(channel, msg) + continue + if msg.metadata.get("_progress"): if msg.metadata.get("_tool_hint") and not self._should_send_progress( msg.channel, tool_hint=True, @@ -329,7 +345,9 @@ class ChannelManager: @staticmethod async def _send_once(channel: BaseChannel, msg: OutboundMessage) -> None: """Send one outbound message without retry policy.""" - if msg.metadata.get("_stream_delta") or msg.metadata.get("_stream_end"): + if msg.metadata.get("_reasoning"): + await channel.send_reasoning(msg) + elif msg.metadata.get("_stream_delta") or msg.metadata.get("_stream_end"): await channel.send_delta(msg.chat_id, msg.content, msg.metadata) elif not msg.metadata.get("_streamed"): await channel.send(msg) diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 76ca513d0..bba68397f 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -1487,6 +1487,30 @@ class WebSocketChannel(BaseChannel): for connection in conns: await self._safe_send_to(connection, raw, label=" ") + async def send_reasoning(self, msg: OutboundMessage) -> None: + """Stream model reasoning as a subordinate trace frame. + + Renders as ``kind=reasoning`` alongside the existing ``tool_hint`` / + ``progress`` frames; the WebUI mounts these on the active assistant + bubble rather than as a conversational reply. + """ + conns = list(self._subs.get(msg.chat_id, ())) + if not conns: + return + if not msg.content: + return + payload: dict[str, Any] = { + "event": "message", + "chat_id": msg.chat_id, + "text": msg.content, + "kind": "reasoning", + } + if msg.reply_to: + payload["reply_to"] = msg.reply_to + raw = json.dumps(payload, ensure_ascii=False) + for connection in conns: + await self._safe_send_to(connection, raw, label=" reasoning ") + async def send_delta( self, chat_id: str, diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 72110eedd..ff7454d71 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -35,7 +35,7 @@ class ChannelsConfig(Base): send_progress: bool = True # stream agent's text progress to the channel send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…")) - show_reasoning: bool = False # show model reasoning/thinking content + show_reasoning: bool = True # surface model reasoning when channel implements it send_max_retries: int = Field(default=3, ge=0, le=10) # Max delivery attempts (initial send included) transcription_provider: str = "groq" # Voice transcription backend: "groq" or "openai" transcription_language: str | None = Field(default=None, pattern=r"^[a-z]{2,3}$") # Optional ISO-639-1 hint for audio transcription diff --git a/tests/channels/test_channel_manager_reasoning.py b/tests/channels/test_channel_manager_reasoning.py new file mode 100644 index 000000000..2200f4be2 --- /dev/null +++ b/tests/channels/test_channel_manager_reasoning.py @@ -0,0 +1,183 @@ +"""Tests for ChannelManager routing of model reasoning content. + +Reasoning is delivered as a separate plugin action (``send_reasoning``) +rather than a metadata flag on a regular outbound. The manager routes +``_reasoning`` messages only to channels that opt in via +``channel.show_reasoning``; channels without a low-emphasis UI primitive +keep the base no-op and the content silently drops at dispatch. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel +from nanobot.channels.manager import ChannelManager +from nanobot.config.schema import Config + + +class _MockChannel(BaseChannel): + name = "mock" + display_name = "Mock" + + def __init__(self, config, bus): + super().__init__(config, bus) + self._send_mock = AsyncMock() + self._send_reasoning_mock = AsyncMock() + + async def start(self): # pragma: no cover - not exercised + pass + + async def stop(self): # pragma: no cover - not exercised + pass + + async def send(self, msg): + return await self._send_mock(msg) + + async def send_reasoning(self, msg): + return await self._send_reasoning_mock(msg) + + +@pytest.fixture +def manager() -> ChannelManager: + mgr = ChannelManager(Config(), MessageBus()) + mgr.channels["mock"] = _MockChannel({}, mgr.bus) + return mgr + + +@pytest.mark.asyncio +async def test_reasoning_routes_to_send_reasoning_not_send(manager): + channel = manager.channels["mock"] + msg = OutboundMessage( + channel="mock", + chat_id="c1", + content="step-by-step thinking", + metadata={"_progress": True, "_reasoning": True}, + ) + await manager._send_once(channel, msg) + channel._send_reasoning_mock.assert_awaited_once_with(msg) + channel._send_mock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_dispatch_drops_reasoning_when_channel_opts_out(manager): + channel = manager.channels["mock"] + channel.show_reasoning = False + msg = OutboundMessage( + channel="mock", + chat_id="c1", + content="hidden thinking", + metadata={"_progress": True, "_reasoning": True}, + ) + await manager.bus.publish_outbound(msg) + + pumped = await _pump_one(manager) + + assert pumped is True + channel._send_reasoning_mock.assert_not_awaited() + channel._send_mock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_dispatch_delivers_reasoning_when_channel_opts_in(manager): + channel = manager.channels["mock"] + channel.show_reasoning = True + msg = OutboundMessage( + channel="mock", + chat_id="c1", + content="visible thinking", + metadata={"_progress": True, "_reasoning": True}, + ) + await manager.bus.publish_outbound(msg) + + pumped = await _pump_one(manager) + + assert pumped is True + channel._send_reasoning_mock.assert_awaited_once() + delivered = channel._send_reasoning_mock.await_args.args[0] + assert delivered.content == "visible thinking" + + +@pytest.mark.asyncio +async def test_dispatch_silently_drops_reasoning_for_unknown_channel(manager): + msg = OutboundMessage( + channel="ghost", + chat_id="c1", + content="nobody home", + metadata={"_progress": True, "_reasoning": True}, + ) + await manager.bus.publish_outbound(msg) + + pumped = await _pump_one(manager) + + assert pumped is True + # Mock channel must not receive anything destined for a different channel. + manager.channels["mock"]._send_reasoning_mock.assert_not_awaited() + manager.channels["mock"]._send_mock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_base_channel_send_reasoning_is_noop_safe(): + """Plugins that don't override `send_reasoning` must not blow up.""" + + class _Plain(BaseChannel): + name = "plain" + display_name = "Plain" + + async def start(self): # pragma: no cover + pass + + async def stop(self): # pragma: no cover + pass + + async def send(self, msg): # pragma: no cover + pass + + channel = _Plain({}, MessageBus()) + # No exception, returns None. + assert await channel.send_reasoning( + OutboundMessage(channel="plain", chat_id="c", content="x", metadata={}) + ) is None + + +@pytest.mark.asyncio +async def test_reasoning_routing_does_not_consult_send_progress(manager): + """`show_reasoning` is orthogonal to `send_progress` — turning off + progress streaming must not silence reasoning.""" + channel = manager.channels["mock"] + channel.send_progress = False + channel.show_reasoning = True + msg = OutboundMessage( + channel="mock", + chat_id="c1", + content="still surfaces", + metadata={"_progress": True, "_reasoning": True}, + ) + await manager.bus.publish_outbound(msg) + + pumped = await _pump_one(manager) + + assert pumped is True + channel._send_reasoning_mock.assert_awaited_once() + + +async def _pump_one(manager: ChannelManager) -> bool: + """Drive the dispatcher for exactly one message, then cancel.""" + import asyncio + + task = asyncio.create_task(manager._dispatch_outbound()) + # Yield control until the queue drains. + for _ in range(50): + await asyncio.sleep(0.01) + if manager.bus.outbound.qsize() == 0: + break + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + return True diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index 92b61f7d6..0e682ed0a 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -358,6 +358,60 @@ async def test_send_delta_emits_delta_and_stream_end() -> None: assert second["stream_id"] == "sid" +@pytest.mark.asyncio +async def test_send_reasoning_emits_reasoning_kind_frame() -> None: + bus = MagicMock() + channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) + mock_ws = AsyncMock() + channel._attach(mock_ws, "chat-1") + + await channel.send_reasoning(OutboundMessage( + channel="websocket", + chat_id="chat-1", + content="step-by-step thinking", + metadata={"_progress": True, "_reasoning": True}, + )) + + mock_ws.send.assert_awaited_once() + payload = json.loads(mock_ws.send.await_args.args[0]) + assert payload["event"] == "message" + assert payload["chat_id"] == "chat-1" + assert payload["text"] == "step-by-step thinking" + assert payload["kind"] == "reasoning" + + +@pytest.mark.asyncio +async def test_send_reasoning_drops_empty_content() -> None: + """Empty reasoning emits nothing — keeps the frontend bubble clean.""" + bus = MagicMock() + channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) + mock_ws = AsyncMock() + channel._attach(mock_ws, "chat-1") + + await channel.send_reasoning(OutboundMessage( + channel="websocket", + chat_id="chat-1", + content="", + metadata={"_reasoning": True}, + )) + + mock_ws.send.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_send_reasoning_without_subscribers_is_noop() -> None: + bus = MagicMock() + channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) + + await channel.send_reasoning(OutboundMessage( + channel="websocket", + chat_id="unattached", + content="thinking", + metadata={"_reasoning": True}, + )) + # No subscribers, no exception, no send. + + @pytest.mark.asyncio async def test_send_turn_end_emits_turn_end_event() -> None: bus = MagicMock() diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx index 3bd580567..556460824 100644 --- a/webui/src/components/MessageBubble.tsx +++ b/webui/src/components/MessageBubble.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Wrench } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Sparkles, Wrench } from "lucide-react"; import { useTranslation } from "react-i18next"; import { ImageLightbox } from "@/components/ImageLightbox"; @@ -85,12 +85,14 @@ export function MessageBubble({ message }: MessageBubbleProps) { const empty = message.content.trim().length === 0; const media = message.media ?? []; + const reasoning = message.role === "assistant" ? message.reasoning ?? [] : []; const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty; return (
- {empty && message.isStreaming ? ( + {reasoning.length > 0 ? : null} + {empty && message.isStreaming && reasoning.length === 0 ? ( - ) : ( + ) : empty && message.isStreaming ? null : ( <> {message.content} {message.isStreaming && } @@ -433,3 +435,53 @@ function TraceGroup({ message, animClass }: TraceGroupProps) {
); } + +interface ReasoningBubbleProps { + lines: string[]; +} + +/** + * Subordinate "thinking" trace shown above an assistant turn. Mirrors the + * CLI's italic dim ``ChevronRight`` row visually; collapsible because + * reasoning from models like DeepSeek-R1 / o-series can run long. Defaults + * to expanded while the answer is still streaming (so the user sees the + * model "thinking out loud"), but the toggle persists across rerenders. + */ +function ReasoningBubble({ lines }: ReasoningBubbleProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(true); + const text = useMemo(() => lines.join("\n\n"), [lines]); + return ( +
+ + {open && ( +
+ {text} +
+ )} +
+ ); +} diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index 8ec1a9ac4..ee460cf56 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -183,10 +183,43 @@ export function useNanobotStream( if (ev.event === "message") { if ( suppressStreamUntilTurnEndRef.current && - (ev.kind === "tool_hint" || ev.kind === "progress") + (ev.kind === "tool_hint" || ev.kind === "progress" || ev.kind === "reasoning") ) { return; } + // Model reasoning rides its own channel: stash it on the next + // assistant turn so the bubble renders it as a subordinate trace. + // If the assistant message hasn't materialized yet (typical, since + // reasoning fires before tool calls/answers), park it on a sentinel + // pending row that the next assistant message absorbs. + if (ev.kind === "reasoning") { + const line = ev.text; + if (!line) return; + setMessages((prev) => { + for (let i = prev.length - 1; i >= 0; i -= 1) { + const candidate = prev[i]; + if (candidate.role === "assistant" && candidate.kind !== "trace") { + const merged: UIMessage = { + ...candidate, + reasoning: [...(candidate.reasoning ?? []), line], + }; + return [...prev.slice(0, i), merged, ...prev.slice(i + 1)]; + } + } + return [ + ...prev, + { + id: crypto.randomUUID(), + role: "assistant", + content: "", + isStreaming: true, + reasoning: [line], + createdAt: Date.now(), + }, + ]; + }); + return; + } // Intermediate agent breadcrumbs (tool-call hints, raw progress). // Attach them to the last trace row if it was the last emitted item // so a sequence of calls collapses into one compact trace group. diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 4cf1b6391..1f6eb7b54 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -332,6 +332,7 @@ "assistantTyping": "Assistant is typing", "toolSingle": "Using a tool", "toolMany": "Used {{count}} tools", + "reasoning": "Thinking", "imageAttachment": "Image attachment", "copyReply": "Copy reply", "copiedReply": "Copied reply" diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index fed932f29..662a5f7bd 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -320,6 +320,7 @@ "assistantTyping": "助手正在输入", "toolSingle": "正在使用工具", "toolMany": "已使用 {{count}} 个工具", + "reasoning": "思考中", "imageAttachment": "图片附件", "copyReply": "复制回复", "copiedReply": "已复制回复" diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 5e7dc9288..0338b75f3 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -44,6 +44,10 @@ export interface UIMessage { images?: UIImage[]; /** Signed or local UI-renderable media attachments. */ media?: UIMediaAttachment[]; + /** Assistant turn: model reasoning / thinking content collected from + * `kind: "reasoning"` frames. Each entry is one emit cycle, joined with + * blank lines on render. */ + reasoning?: string[]; } export interface ChatSummary { @@ -141,7 +145,7 @@ export type InboundEvent = media_urls?: Array<{ url: string; name?: string }>; /** Present when the frame is an agent breadcrumb (e.g. tool hint, * generic progress line) rather than a conversational reply. */ - kind?: "tool_hint" | "progress"; + kind?: "tool_hint" | "progress" | "reasoning"; } | { event: "delta"; diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx index 35cdaed40..77608b121 100644 --- a/webui/src/tests/message-bubble.test.tsx +++ b/webui/src/tests/message-bubble.test.tsx @@ -103,6 +103,39 @@ describe("MessageBubble", () => { expect(container.querySelector("video[controls]")).toBeInTheDocument(); }); + it("surfaces reasoning content above the assistant answer when provided", () => { + const message: UIMessage = { + id: "a-reasoning", + role: "assistant", + content: "The answer is 42.", + createdAt: Date.now(), + reasoning: ["Step 1: parse intent.", "Step 2: compute."], + }; + + render(); + + expect(screen.getByText("Thinking")).toBeInTheDocument(); + expect(screen.getByText(/Step 1: parse intent\./)).toBeInTheDocument(); + expect(screen.getByText(/Step 2: compute\./)).toBeInTheDocument(); + expect(screen.getByText("The answer is 42.")).toBeInTheDocument(); + }); + + it("collapses the reasoning section when toggled", () => { + const message: UIMessage = { + id: "a-reasoning-collapse", + role: "assistant", + content: "done", + createdAt: Date.now(), + reasoning: ["hidden after toggle"], + }; + + render(); + + expect(screen.getByText("hidden after toggle")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /thinking/i })); + expect(screen.queryByText("hidden after toggle")).not.toBeInTheDocument(); + }); + it("renders assistant image media as a larger generated result", () => { const message: UIMessage = { id: "a-image", diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index 60e6ada62..7fb94063c 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -113,6 +113,78 @@ describe("useNanobotStream", () => { expect(result.current.messages[1].kind).toBeUndefined(); }); + it("parks reasoning frames on a placeholder assistant message until the answer arrives", () => { + const fake = fakeClient(); + const { result } = renderHook(() => useNanobotStream("chat-r", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), + }); + + act(() => { + fake.emit("chat-r", { + event: "message", + chat_id: "chat-r", + text: "Let me think step by step.", + kind: "reasoning", + }); + fake.emit("chat-r", { + event: "message", + chat_id: "chat-r", + text: "First, decompose the request.", + kind: "reasoning", + }); + }); + + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages[0].role).toBe("assistant"); + expect(result.current.messages[0].reasoning).toEqual([ + "Let me think step by step.", + "First, decompose the request.", + ]); + }); + + it("attaches reasoning to the latest assistant turn rather than spawning a new one", () => { + const fake = fakeClient(); + const { result } = renderHook(() => useNanobotStream("chat-r2", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), + }); + + act(() => { + fake.emit("chat-r2", { + event: "message", + chat_id: "chat-r2", + text: "The answer is 42.", + }); + fake.emit("chat-r2", { + event: "message", + chat_id: "chat-r2", + text: "Reasoning surfaced post-hoc.", + kind: "reasoning", + }); + }); + + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages[0].content).toBe("The answer is 42."); + expect(result.current.messages[0].reasoning).toEqual(["Reasoning surfaced post-hoc."]); + }); + + it("ignores empty reasoning frames", () => { + const fake = fakeClient(); + const { result } = renderHook(() => useNanobotStream("chat-r3", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), + }); + + act(() => { + fake.emit("chat-r3", { + event: "message", + chat_id: "chat-r3", + text: "", + kind: "reasoning", + }); + }); + + expect(result.current.messages).toHaveLength(0); + }); + it("attaches assistant media_urls to complete messages", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-m", EMPTY_MESSAGES), {