nanobot/tests/channels/test_channel_manager_reasoning.py
Xubin Ren a6b059d379 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>
2026-05-13 06:27:53 +00:00

184 lines
5.4 KiB
Python

"""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