mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-20 00:22:31 +00:00
refactor(reasoning): make channel plugins own reasoning rendering
Reasoning was being shipped to every channel as a generic progress message with a `_reasoning: true` flag. Two problems with that: 1. Channels without a low-emphasis UI primitive (Telegram, Slack, Discord, Feishu...) would dump raw model thoughts as ordinary replies, polluting the conversation. 2. The agent loop double-gated by inspecting `channels_config`, which coupled the loop to display policy. Treat reasoning as its own plugin action — `BaseChannel.send_reasoning` defaults to a documented no-op; channels that have a fitting affordance override. ChannelManager routes `_reasoning` outbounds to that method only when the channel opts in via `show_reasoning` (camelCase alias `showReasoning` mirrors `sendProgress`). Plugins that don't override silently drop reasoning — "no fit, no leak" is the contract. Reference implementation lands for WebSocket / WebUI: a new `kind: "reasoning"` frame, parked on the active assistant bubble as a collapsible `Thinking` group above the answer. CLI keeps its existing direct path (it doesn't go through the bus). `ChannelsConfig.show_reasoning` flips to `true` by default — only adapted channels surface anything, others stay quiet. Loop net diff is -3 lines: the `channels_config.show_reasoning` check moves out, leaving emit_reasoning a one-liner that publishes and trusts the channel to decide. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
01fa362c03
commit
a6b059d379
@ -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 |
|
| `sendProgress` | `true` | Stream agent's text progress to the channel |
|
||||||
| `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("…")`) |
|
| `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 `<think>` tags). Independent of `sendProgress`. |
|
| `showReasoning` | `true` | Allow channels to surface model reasoning/thinking content (DeepSeek-R1 `reasoning_content`, Anthropic `thinking_blocks`, inline `<think>` 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) |
|
| `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. |
|
| `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"`. |
|
| `transcriptionLanguage` | `null` | Optional ISO-639-1 language hint for audio transcription, e.g. `"en"`, `"ko"`, `"ja"`. |
|
||||||
|
|||||||
@ -147,10 +147,13 @@ class _LoopHook(AgentHook):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
||||||
"""Send reasoning/thinking content as progress before the main answer."""
|
"""Publish reasoning content; channel plugins decide whether to render.
|
||||||
ch = self._loop.channels_config
|
|
||||||
if not ch or not ch.show_reasoning:
|
The loop is intentionally not the gate: ``ChannelsConfig.show_reasoning``
|
||||||
return
|
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:
|
if self._on_progress and reasoning_content:
|
||||||
await self._on_progress(reasoning_content, reasoning=True)
|
await self._on_progress(reasoning_content, reasoning=True)
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,7 @@ class BaseChannel(ABC):
|
|||||||
transcription_language: str | None = None
|
transcription_language: str | None = None
|
||||||
send_progress: bool = True
|
send_progress: bool = True
|
||||||
send_tool_hints: bool = False
|
send_tool_hints: bool = False
|
||||||
|
show_reasoning: bool = True
|
||||||
|
|
||||||
def __init__(self, config: Any, bus: MessageBus):
|
def __init__(self, config: Any, bus: MessageBus):
|
||||||
"""
|
"""
|
||||||
@ -120,6 +121,18 @@ class BaseChannel(ABC):
|
|||||||
"""
|
"""
|
||||||
pass
|
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
|
@property
|
||||||
def supports_streaming(self) -> bool:
|
def supports_streaming(self) -> bool:
|
||||||
"""True when config enables streaming AND this subclass implements send_delta."""
|
"""True when config enables streaming AND this subclass implements send_delta."""
|
||||||
|
|||||||
@ -36,6 +36,7 @@ _SEND_RETRY_DELAYS = (1, 2, 4)
|
|||||||
_BOOL_CAMEL_ALIASES: dict[str, str] = {
|
_BOOL_CAMEL_ALIASES: dict[str, str] = {
|
||||||
"send_progress": "sendProgress",
|
"send_progress": "sendProgress",
|
||||||
"send_tool_hints": "sendToolHints",
|
"send_tool_hints": "sendToolHints",
|
||||||
|
"show_reasoning": "showReasoning",
|
||||||
}
|
}
|
||||||
|
|
||||||
class ChannelManager:
|
class ChannelManager:
|
||||||
@ -104,6 +105,9 @@ class ChannelManager:
|
|||||||
channel.send_tool_hints = self._resolve_bool_override(
|
channel.send_tool_hints = self._resolve_bool_override(
|
||||||
section, "send_tool_hints", self.config.channels.send_tool_hints,
|
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
|
self.channels[name] = channel
|
||||||
logger.info("{} channel enabled", cls.display_name)
|
logger.info("{} channel enabled", cls.display_name)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -279,6 +283,18 @@ class ChannelManager:
|
|||||||
timeout=1.0
|
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("_progress"):
|
||||||
if msg.metadata.get("_tool_hint") and not self._should_send_progress(
|
if msg.metadata.get("_tool_hint") and not self._should_send_progress(
|
||||||
msg.channel, tool_hint=True,
|
msg.channel, tool_hint=True,
|
||||||
@ -329,7 +345,9 @@ class ChannelManager:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def _send_once(channel: BaseChannel, msg: OutboundMessage) -> None:
|
async def _send_once(channel: BaseChannel, msg: OutboundMessage) -> None:
|
||||||
"""Send one outbound message without retry policy."""
|
"""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)
|
await channel.send_delta(msg.chat_id, msg.content, msg.metadata)
|
||||||
elif not msg.metadata.get("_streamed"):
|
elif not msg.metadata.get("_streamed"):
|
||||||
await channel.send(msg)
|
await channel.send(msg)
|
||||||
|
|||||||
@ -1487,6 +1487,30 @@ class WebSocketChannel(BaseChannel):
|
|||||||
for connection in conns:
|
for connection in conns:
|
||||||
await self._safe_send_to(connection, raw, label=" ")
|
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(
|
async def send_delta(
|
||||||
self,
|
self,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
|
|||||||
@ -35,7 +35,7 @@ class ChannelsConfig(Base):
|
|||||||
|
|
||||||
send_progress: bool = True # stream agent's text progress to the channel
|
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("…"))
|
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)
|
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_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
|
transcription_language: str | None = Field(default=None, pattern=r"^[a-z]{2,3}$") # Optional ISO-639-1 hint for audio transcription
|
||||||
|
|||||||
183
tests/channels/test_channel_manager_reasoning.py
Normal file
183
tests/channels/test_channel_manager_reasoning.py
Normal file
@ -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
|
||||||
@ -358,6 +358,60 @@ async def test_send_delta_emits_delta_and_stream_end() -> None:
|
|||||||
assert second["stream_id"] == "sid"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_send_turn_end_emits_turn_end_event() -> None:
|
async def test_send_turn_end_emits_turn_end_event() -> None:
|
||||||
bus = MagicMock()
|
bus = MagicMock()
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Wrench } from "lucide-react";
|
import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Sparkles, Wrench } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { ImageLightbox } from "@/components/ImageLightbox";
|
import { ImageLightbox } from "@/components/ImageLightbox";
|
||||||
@ -85,12 +85,14 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
|||||||
|
|
||||||
const empty = message.content.trim().length === 0;
|
const empty = message.content.trim().length === 0;
|
||||||
const media = message.media ?? [];
|
const media = message.media ?? [];
|
||||||
|
const reasoning = message.role === "assistant" ? message.reasoning ?? [] : [];
|
||||||
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
|
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
|
||||||
return (
|
return (
|
||||||
<div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
|
<div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
|
||||||
{empty && message.isStreaming ? (
|
{reasoning.length > 0 ? <ReasoningBubble lines={reasoning} /> : null}
|
||||||
|
{empty && message.isStreaming && reasoning.length === 0 ? (
|
||||||
<TypingDots />
|
<TypingDots />
|
||||||
) : (
|
) : empty && message.isStreaming ? null : (
|
||||||
<>
|
<>
|
||||||
<MarkdownText>{message.content}</MarkdownText>
|
<MarkdownText>{message.content}</MarkdownText>
|
||||||
{message.isStreaming && <StreamCursor />}
|
{message.isStreaming && <StreamCursor />}
|
||||||
@ -433,3 +435,53 @@ function TraceGroup({ message, animClass }: TraceGroupProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="mb-2 w-full animate-in fade-in-0 slide-in-from-top-1 duration-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded-md px-2 py-1.5",
|
||||||
|
"text-xs text-muted-foreground transition-colors hover:bg-muted/45",
|
||||||
|
)}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<Sparkles className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
<span className="font-medium">{t("message.reasoning", { defaultValue: "Thinking" })}</span>
|
||||||
|
<ChevronRight
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"ml-auto h-3.5 w-3.5 transition-transform duration-200",
|
||||||
|
open && "rotate-90",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-1 whitespace-pre-wrap break-words border-l border-muted-foreground/20 pl-3",
|
||||||
|
"text-[12.5px] italic leading-relaxed text-muted-foreground/85",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -183,10 +183,43 @@ export function useNanobotStream(
|
|||||||
if (ev.event === "message") {
|
if (ev.event === "message") {
|
||||||
if (
|
if (
|
||||||
suppressStreamUntilTurnEndRef.current &&
|
suppressStreamUntilTurnEndRef.current &&
|
||||||
(ev.kind === "tool_hint" || ev.kind === "progress")
|
(ev.kind === "tool_hint" || ev.kind === "progress" || ev.kind === "reasoning")
|
||||||
) {
|
) {
|
||||||
return;
|
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).
|
// Intermediate agent breadcrumbs (tool-call hints, raw progress).
|
||||||
// Attach them to the last trace row if it was the last emitted item
|
// 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.
|
// so a sequence of calls collapses into one compact trace group.
|
||||||
|
|||||||
@ -332,6 +332,7 @@
|
|||||||
"assistantTyping": "Assistant is typing",
|
"assistantTyping": "Assistant is typing",
|
||||||
"toolSingle": "Using a tool",
|
"toolSingle": "Using a tool",
|
||||||
"toolMany": "Used {{count}} tools",
|
"toolMany": "Used {{count}} tools",
|
||||||
|
"reasoning": "Thinking",
|
||||||
"imageAttachment": "Image attachment",
|
"imageAttachment": "Image attachment",
|
||||||
"copyReply": "Copy reply",
|
"copyReply": "Copy reply",
|
||||||
"copiedReply": "Copied reply"
|
"copiedReply": "Copied reply"
|
||||||
|
|||||||
@ -320,6 +320,7 @@
|
|||||||
"assistantTyping": "助手正在输入",
|
"assistantTyping": "助手正在输入",
|
||||||
"toolSingle": "正在使用工具",
|
"toolSingle": "正在使用工具",
|
||||||
"toolMany": "已使用 {{count}} 个工具",
|
"toolMany": "已使用 {{count}} 个工具",
|
||||||
|
"reasoning": "思考中",
|
||||||
"imageAttachment": "图片附件",
|
"imageAttachment": "图片附件",
|
||||||
"copyReply": "复制回复",
|
"copyReply": "复制回复",
|
||||||
"copiedReply": "已复制回复"
|
"copiedReply": "已复制回复"
|
||||||
|
|||||||
@ -44,6 +44,10 @@ export interface UIMessage {
|
|||||||
images?: UIImage[];
|
images?: UIImage[];
|
||||||
/** Signed or local UI-renderable media attachments. */
|
/** Signed or local UI-renderable media attachments. */
|
||||||
media?: UIMediaAttachment[];
|
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 {
|
export interface ChatSummary {
|
||||||
@ -141,7 +145,7 @@ export type InboundEvent =
|
|||||||
media_urls?: Array<{ url: string; name?: string }>;
|
media_urls?: Array<{ url: string; name?: string }>;
|
||||||
/** Present when the frame is an agent breadcrumb (e.g. tool hint,
|
/** Present when the frame is an agent breadcrumb (e.g. tool hint,
|
||||||
* generic progress line) rather than a conversational reply. */
|
* generic progress line) rather than a conversational reply. */
|
||||||
kind?: "tool_hint" | "progress";
|
kind?: "tool_hint" | "progress" | "reasoning";
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
event: "delta";
|
event: "delta";
|
||||||
|
|||||||
@ -103,6 +103,39 @@ describe("MessageBubble", () => {
|
|||||||
expect(container.querySelector("video[controls]")).toBeInTheDocument();
|
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(<MessageBubble message={message} />);
|
||||||
|
|
||||||
|
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(<MessageBubble message={message} />);
|
||||||
|
|
||||||
|
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", () => {
|
it("renders assistant image media as a larger generated result", () => {
|
||||||
const message: UIMessage = {
|
const message: UIMessage = {
|
||||||
id: "a-image",
|
id: "a-image",
|
||||||
|
|||||||
@ -113,6 +113,78 @@ describe("useNanobotStream", () => {
|
|||||||
expect(result.current.messages[1].kind).toBeUndefined();
|
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", () => {
|
it("attaches assistant media_urls to complete messages", () => {
|
||||||
const fake = fakeClient();
|
const fake = fakeClient();
|
||||||
const { result } = renderHook(() => useNanobotStream("chat-m", EMPTY_MESSAGES), {
|
const { result } = renderHook(() => useNanobotStream("chat-m", EMPTY_MESSAGES), {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user