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
+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), {