From 2f0e638bd1a5e774c68ed4ea9c327327b45ae76a Mon Sep 17 00:00:00 2001 From: chengyongru Date: Mon, 1 Jun 2026 14:42:11 +0800 Subject: [PATCH] refactor: route file edit progress via channel capability --- nanobot/bus/progress.py | 23 +--------- nanobot/channels/base.py | 13 ++++++ nanobot/channels/manager.py | 7 +++ nanobot/channels/websocket.py | 46 +++++++++++++------ tests/agent/test_loop_progress.py | 21 +++------ .../test_channel_manager_reasoning.py | 42 +++++++++++++++++ 6 files changed, 101 insertions(+), 51 deletions(-) diff --git a/nanobot/bus/progress.py b/nanobot/bus/progress.py index 6cdb416d9..db9c4a653 100644 --- a/nanobot/bus/progress.py +++ b/nanobot/bus/progress.py @@ -44,32 +44,12 @@ def build_bus_progress_callback( ) ) - if msg.channel == "websocket": - async def _websocket_progress( - content: str, - *, - tool_hint: bool = False, - tool_events: list[dict[str, Any]] | None = None, - file_edit_events: list[dict[str, Any]] | None = None, - reasoning: bool = False, - reasoning_end: bool = False, - ) -> None: - await _publish_progress( - content, - tool_hint=tool_hint, - tool_events=tool_events, - file_edit_events=file_edit_events, - reasoning=reasoning, - reasoning_end=reasoning_end, - ) - - return _websocket_progress - async def _bus_progress( content: str, *, tool_hint: bool = False, tool_events: list[dict[str, Any]] | None = None, + file_edit_events: list[dict[str, Any]] | None = None, reasoning: bool = False, reasoning_end: bool = False, ) -> None: @@ -77,6 +57,7 @@ def build_bus_progress_callback( content, tool_hint=tool_hint, tool_events=tool_events, + file_edit_events=file_edit_events, reasoning=reasoning, reasoning_end=reasoning_end, ) diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index aac3147e8..f9d7bdd19 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -155,6 +155,19 @@ class BaseChannel(ABC): """ return + async def send_file_edit_events( + self, + chat_id: str, + edits: list[dict[str, Any]], + metadata: dict[str, Any] | None = None, + ) -> None: + """Deliver structured live file-edit events. + + Default is no-op. Channels with a rich activity surface can override + this to render editing progress without receiving empty text messages. + """ + return + async def send_reasoning(self, msg: OutboundMessage) -> None: """Deliver a complete reasoning block. diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 2ccb31089..03980d3c3 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -389,6 +389,13 @@ class ChannelManager: # to a single delta + end pair so plugins only implement the # streaming primitives. await channel.send_reasoning(msg) + elif msg.metadata.get("_file_edit_events"): + edits = msg.metadata.get("_file_edit_events") + await channel.send_file_edit_events( + msg.chat_id, + edits if isinstance(edits, list) else [], + msg.metadata, + ) elif msg.metadata.get("_stream_delta") or msg.metadata.get("_stream_end"): await channel.send_delta(msg.chat_id, msg.content, msg.metadata) elif not msg.metadata.get("_streamed"): diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 3cebcc7d8..efb7e0b32 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -27,16 +27,16 @@ from websockets.exceptions import ConnectionClosed from websockets.http11 import Request as WsRequest from websockets.http11 import Response -from nanobot.security.workspace_access import ( - WORKSPACE_SCOPE_METADATA_KEY, - WorkspaceScopeError, -) from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.command.builtin import builtin_command_palette from nanobot.config.paths import get_media_dir, get_workspace_path from nanobot.config.schema import Base +from nanobot.security.workspace_access import ( + WORKSPACE_SCOPE_METADATA_KEY, + WorkspaceScopeError, +) from nanobot.session.goal_state import goal_state_ws_blob from nanobot.session.webui_turns import websocket_turn_wall_started_at from nanobot.utils.media_decode import ( @@ -44,14 +44,14 @@ from nanobot.utils.media_decode import ( save_base64_data_url, ) from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel -from nanobot.webui.settings_api import runtime_capabilities from nanobot.webui.cli_apps_api import normalize_cli_app_mentions +from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions from nanobot.webui.media_api import ( serve_signed_media, sign_media_path, sign_or_stage_media_path, ) -from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions +from nanobot.webui.settings_api import runtime_capabilities from nanobot.webui.settings_routes import WebUISettingsRouter from nanobot.webui.sidebar_state import ( read_webui_sidebar_state, @@ -1687,15 +1687,12 @@ class WebSocketChannel(BaseChannel): ) return if msg.metadata.get("_file_edit_events"): - payload: dict[str, Any] = { - "event": "file_edit", - "chat_id": msg.chat_id, - "edits": msg.metadata["_file_edit_events"], - } - self._try_append_webui_transcript(msg.chat_id, payload) - raw = json.dumps(payload, ensure_ascii=False) - for connection in conns: - await self._safe_send_to(connection, raw, label=" ") + edits = msg.metadata.get("_file_edit_events") + await self.send_file_edit_events( + msg.chat_id, + edits if isinstance(edits, list) else [], + msg.metadata, + ) return text = msg.content wire_text = self._rewrite_local_markdown_images(text) @@ -1787,6 +1784,25 @@ class WebSocketChannel(BaseChannel): for connection in conns: await self._safe_send_to(connection, raw, label=" reasoning_end ") + async def send_file_edit_events( + self, + chat_id: str, + edits: list[dict[str, Any]], + metadata: dict[str, Any] | None = None, + ) -> None: + conns = list(self._subs.get(chat_id, ())) + if not conns: + return + payload: dict[str, Any] = { + "event": "file_edit", + "chat_id": chat_id, + "edits": edits, + } + self._try_append_webui_transcript(chat_id, payload) + raw = json.dumps(payload, ensure_ascii=False) + for connection in conns: + await self._safe_send_to(connection, raw, label=" file_edit ") + async def send_delta( self, chat_id: str, diff --git a/tests/agent/test_loop_progress.py b/tests/agent/test_loop_progress.py index 2f5486f72..08cfecb1d 100644 --- a/tests/agent/test_loop_progress.py +++ b/tests/agent/test_loop_progress.py @@ -283,7 +283,7 @@ class TestToolEventProgress: assert finish["result"] == "file.txt" @pytest.mark.asyncio - async def test_bus_progress_forwards_file_edit_events_for_websocket_only(self, tmp_path: Path) -> None: + async def test_bus_progress_forwards_file_edit_events_without_channel_branch(self, tmp_path: Path) -> None: bus = MessageBus() provider = MagicMock() provider.get_default_model.return_value = "test-model" @@ -299,27 +299,18 @@ class TestToolEventProgress: "status": "editing", }] - websocket_progress = await loop._build_bus_progress_callback(InboundMessage( - channel="websocket", + progress = await loop._build_bus_progress_callback(InboundMessage( + channel="telegram", sender_id="u1", chat_id="chat1", content="edit", )) - assert on_progress_accepts_file_edit_events(websocket_progress) is True - await websocket_progress("", file_edit_events=edit_events) + assert on_progress_accepts_file_edit_events(progress) is True + await invoke_file_edit_progress(progress, edit_events) outbound = await bus.consume_outbound() + assert outbound.channel == "telegram" assert outbound.metadata["_file_edit_events"] == edit_events - telegram_progress = await loop._build_bus_progress_callback(InboundMessage( - channel="telegram", - sender_id="u1", - chat_id="chat2", - content="edit", - )) - assert on_progress_accepts_file_edit_events(telegram_progress) is False - await invoke_file_edit_progress(telegram_progress, edit_events) - assert bus.outbound_size == 0 - @pytest.mark.asyncio async def test_goal_turn_keeps_live_file_edit_progress_for_webui(self, tmp_path: Path) -> None: """The /goal command rewrites the prompt but must not bypass WebUI file-edit progress.""" diff --git a/tests/channels/test_channel_manager_reasoning.py b/tests/channels/test_channel_manager_reasoning.py index bc2a640c6..b02262751 100644 --- a/tests/channels/test_channel_manager_reasoning.py +++ b/tests/channels/test_channel_manager_reasoning.py @@ -37,6 +37,7 @@ class _MockChannel(BaseChannel): self._send_mock = AsyncMock() self._delta_mock = AsyncMock() self._end_mock = AsyncMock() + self._file_edit_mock = AsyncMock() async def start(self): # pragma: no cover - not exercised pass @@ -53,6 +54,9 @@ class _MockChannel(BaseChannel): async def send_reasoning_end(self, chat_id, metadata=None): return await self._end_mock(chat_id, metadata) + async def send_file_edit_events(self, chat_id, edits, metadata=None): + return await self._file_edit_mock(chat_id, edits, metadata) + @pytest.fixture def manager() -> ChannelManager: @@ -195,6 +199,44 @@ async def test_base_channel_reasoning_primitives_are_noop_safe(): ) is None +@pytest.mark.asyncio +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( + channel="mock", + chat_id="c1", + content="", + metadata={"_progress": True, "_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} + ) + channel._send_mock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_base_channel_file_edit_events_are_noop_safe(): + 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 + raise AssertionError("file edit events should not call send") + + channel = _Plain({}, MessageBus()) + assert await channel.send_file_edit_events("c", [{"path": "a.py"}]) 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