refactor(bus): type outbound runtime events

This commit is contained in:
chengyongru
2026-07-01 20:17:00 +08:00
committed by Xubin Ren
parent f6d1dba32a
commit 5f4cfbcb16
45 changed files with 1206 additions and 741 deletions
@@ -8,10 +8,9 @@ channels that opt in via ``channel.show_reasoning``; plugins without a
low-emphasis UI primitive keep the base no-op and the content silently
drops at dispatch.
One-shot ``_reasoning`` frames are accepted for back-compat with hooks
that haven't migrated yet — ``BaseChannel.send_reasoning`` expands them
to a single delta + end pair so plugins only implement the streaming
primitives.
One-shot reasoning frames are represented as typed progress events and
``BaseChannel.send_reasoning`` expands them to a single delta + end pair so
plugins only implement the streaming primitives.
"""
from __future__ import annotations
@@ -22,6 +21,7 @@ from unittest.mock import AsyncMock
import pytest
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent, outbound_message_for_event
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.channels.manager import ChannelManager
@@ -48,11 +48,11 @@ class _MockChannel(BaseChannel):
async def send(self, msg):
return await self._send_mock(msg)
async def send_reasoning_delta(self, chat_id, delta, metadata=None):
return await self._delta_mock(chat_id, delta, metadata)
async def send_reasoning_delta(self, chat_id, delta, metadata=None, *, stream_id=None):
return await self._delta_mock(chat_id, delta, metadata, stream_id=stream_id)
async def send_reasoning_end(self, chat_id, metadata=None):
return await self._end_mock(chat_id, metadata)
async def send_reasoning_end(self, chat_id, metadata=None, *, stream_id=None):
return await self._end_mock(chat_id, metadata, stream_id=stream_id)
async def send_file_edit_events(self, chat_id, edits, metadata=None):
return await self._file_edit_mock(chat_id, edits, metadata)
@@ -94,17 +94,17 @@ def test_websocket_gateway_uses_configured_workspace_restriction(tmp_path, monke
@pytest.mark.asyncio
async def test_reasoning_delta_routes_to_send_reasoning_delta(manager):
channel = manager.channels["mock"]
msg = OutboundMessage(
msg = outbound_message_for_event(
channel="mock",
chat_id="c1",
content="step-by-step",
metadata={"_progress": True, "_reasoning_delta": True, "_stream_id": "r1"},
event=ProgressEvent(content="step-by-step", reasoning_delta=True, stream_id="r1"),
)
await manager._send_once(channel, msg)
channel._delta_mock.assert_awaited_once()
args = channel._delta_mock.await_args.args
assert args[0] == "c1"
assert args[1] == "step-by-step"
assert channel._delta_mock.await_args.kwargs["stream_id"] == "r1"
channel._send_mock.assert_not_awaited()
channel._end_mock.assert_not_awaited()
@@ -112,11 +112,10 @@ async def test_reasoning_delta_routes_to_send_reasoning_delta(manager):
@pytest.mark.asyncio
async def test_reasoning_end_routes_to_send_reasoning_end(manager):
channel = manager.channels["mock"]
msg = OutboundMessage(
msg = outbound_message_for_event(
channel="mock",
chat_id="c1",
content="",
metadata={"_progress": True, "_reasoning_end": True, "_stream_id": "r1"},
event=ProgressEvent(reasoning_end=True, stream_id="r1"),
)
await manager._send_once(channel, msg)
channel._end_mock.assert_awaited_once()
@@ -124,16 +123,13 @@ async def test_reasoning_end_routes_to_send_reasoning_end(manager):
@pytest.mark.asyncio
async def test_legacy_one_shot_reasoning_expands_to_delta_plus_end(manager):
"""`_reasoning` (no delta/end pair) falls back through `send_reasoning`
which the base class expands to a single delta + end. Hooks that haven't
migrated still surface in WebUI as a complete stream segment."""
async def test_one_shot_reasoning_expands_to_delta_plus_end(manager):
"""One-shot reasoning expands to a single delta + end."""
channel = manager.channels["mock"]
msg = OutboundMessage(
msg = outbound_message_for_event(
channel="mock",
chat_id="c1",
content="one-shot reasoning",
metadata={"_progress": True, "_reasoning": True},
event=ProgressEvent(content="one-shot reasoning", reasoning=True),
)
await manager._send_once(channel, msg)
channel._delta_mock.assert_awaited_once()
@@ -144,11 +140,10 @@ async def test_legacy_one_shot_reasoning_expands_to_delta_plus_end(manager):
async def test_dispatch_drops_reasoning_when_channel_opts_out(manager):
channel = manager.channels["mock"]
channel.show_reasoning = False
msg = OutboundMessage(
msg = outbound_message_for_event(
channel="mock",
chat_id="c1",
content="hidden thinking",
metadata={"_progress": True, "_reasoning_delta": True},
event=ProgressEvent(content="hidden thinking", reasoning_delta=True),
)
await manager.bus.publish_outbound(msg)
@@ -164,17 +159,15 @@ async def test_dispatch_delivers_reasoning_when_channel_opts_in(manager):
channel = manager.channels["mock"]
channel.show_reasoning = True
for chunk in ("first ", "second"):
await manager.bus.publish_outbound(OutboundMessage(
await manager.bus.publish_outbound(outbound_message_for_event(
channel="mock",
chat_id="c1",
content=chunk,
metadata={"_progress": True, "_reasoning_delta": True, "_stream_id": "r1"},
event=ProgressEvent(content=chunk, reasoning_delta=True, stream_id="r1"),
))
await manager.bus.publish_outbound(OutboundMessage(
await manager.bus.publish_outbound(outbound_message_for_event(
channel="mock",
chat_id="c1",
content="",
metadata={"_progress": True, "_reasoning_end": True, "_stream_id": "r1"},
event=ProgressEvent(reasoning_end=True, stream_id="r1"),
))
await _pump_one(manager)
@@ -185,11 +178,10 @@ async def test_dispatch_delivers_reasoning_when_channel_opts_in(manager):
@pytest.mark.asyncio
async def test_dispatch_silently_drops_reasoning_for_unknown_channel(manager):
msg = OutboundMessage(
msg = outbound_message_for_event(
channel="ghost",
chat_id="c1",
content="nobody home",
metadata={"_progress": True, "_reasoning_delta": True},
event=ProgressEvent(content="nobody home", reasoning_delta=True),
)
await manager.bus.publish_outbound(msg)
@@ -229,17 +221,34 @@ async def test_base_channel_reasoning_primitives_are_noop_safe():
async def test_file_edit_events_route_to_channel_capability(manager):
channel = manager.channels["mock"]
edits = [{"version": 1, "phase": "start", "path": "src/app.py"}]
msg = OutboundMessage(
msg = outbound_message_for_event(
channel="mock",
chat_id="c1",
content="",
metadata={"_progress": True, "_file_edit_events": edits},
event=ProgressEvent(file_edit_events=edits),
)
await manager._send_once(channel, msg)
channel._file_edit_mock.assert_awaited_once_with(
"c1", edits, {"_progress": True, "_file_edit_events": edits}
"c1", edits, msg.metadata
)
channel._send_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_typed_file_edit_event_routes_to_channel_capability(manager):
channel = manager.channels["mock"]
edits = [{"version": 1, "phase": "start", "path": "src/app.py"}]
msg = outbound_message_for_event(
channel="mock",
chat_id="c1",
event=ProgressEvent(file_edit_events=edits),
)
await manager._send_once(channel, msg)
channel._file_edit_mock.assert_awaited_once_with(
"c1", edits, msg.metadata
)
channel._send_mock.assert_not_awaited()
@@ -270,11 +279,10 @@ async def test_reasoning_routing_does_not_consult_send_progress(manager):
channel = manager.channels["mock"]
channel.send_progress = False
channel.show_reasoning = True
await manager.bus.publish_outbound(OutboundMessage(
await manager.bus.publish_outbound(outbound_message_for_event(
channel="mock",
chat_id="c1",
content="still surfaces",
metadata={"_progress": True, "_reasoning_delta": True},
event=ProgressEvent(content="still surfaces", reasoning_delta=True),
))
await _pump_one(manager)