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
+54
View File
@@ -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()