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:
Xubin Ren
2026-05-13 06:27:53 +00:00
co-authored by Cursor
parent 01fa362c03
commit a6b059d379
15 changed files with 504 additions and 13 deletions
+56 -4
View File
@@ -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>
);
}
+34 -1
View File
@@ -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.
+1
View File
@@ -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"
+1
View File
@@ -320,6 +320,7 @@
"assistantTyping": "助手正在输入",
"toolSingle": "正在使用工具",
"toolMany": "已使用 {{count}} 个工具",
"reasoning": "思考中",
"imageAttachment": "图片附件",
"copyReply": "复制回复",
"copiedReply": "已复制回复"
+5 -1
View File
@@ -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";
+33
View File
@@ -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",
+72
View File
@@ -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), {