mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-19 16:12:30 +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 |
|
||||
| `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) |
|
||||
| `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"`. |
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
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"
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
@ -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 (
|
||||
<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 />
|
||||
) : (
|
||||
) : empty && message.isStreaming ? null : (
|
||||
<>
|
||||
<MarkdownText>{message.content}</MarkdownText>
|
||||
{message.isStreaming && <StreamCursor />}
|
||||
@ -433,3 +435,53 @@ function TraceGroup({ message, animClass }: TraceGroupProps) {
|
||||
</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 (
|
||||
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.
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -320,6 +320,7 @@
|
||||
"assistantTyping": "助手正在输入",
|
||||
"toolSingle": "正在使用工具",
|
||||
"toolMany": "已使用 {{count}} 个工具",
|
||||
"reasoning": "思考中",
|
||||
"imageAttachment": "图片附件",
|
||||
"copyReply": "复制回复",
|
||||
"copiedReply": "已复制回复"
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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(<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", () => {
|
||||
const message: UIMessage = {
|
||||
id: "a-image",
|
||||
|
||||
@ -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), {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user