mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-15 07:14:08 +00:00
refactor: route file edit progress via channel capability
This commit is contained in:
parent
8129c16b7d
commit
2f0e638bd1
@ -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(
|
async def _bus_progress(
|
||||||
content: str,
|
content: str,
|
||||||
*,
|
*,
|
||||||
tool_hint: bool = False,
|
tool_hint: bool = False,
|
||||||
tool_events: list[dict[str, Any]] | None = None,
|
tool_events: list[dict[str, Any]] | None = None,
|
||||||
|
file_edit_events: list[dict[str, Any]] | None = None,
|
||||||
reasoning: bool = False,
|
reasoning: bool = False,
|
||||||
reasoning_end: bool = False,
|
reasoning_end: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -77,6 +57,7 @@ def build_bus_progress_callback(
|
|||||||
content,
|
content,
|
||||||
tool_hint=tool_hint,
|
tool_hint=tool_hint,
|
||||||
tool_events=tool_events,
|
tool_events=tool_events,
|
||||||
|
file_edit_events=file_edit_events,
|
||||||
reasoning=reasoning,
|
reasoning=reasoning,
|
||||||
reasoning_end=reasoning_end,
|
reasoning_end=reasoning_end,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -155,6 +155,19 @@ class BaseChannel(ABC):
|
|||||||
"""
|
"""
|
||||||
return
|
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:
|
async def send_reasoning(self, msg: OutboundMessage) -> None:
|
||||||
"""Deliver a complete reasoning block.
|
"""Deliver a complete reasoning block.
|
||||||
|
|
||||||
|
|||||||
@ -389,6 +389,13 @@ class ChannelManager:
|
|||||||
# to a single delta + end pair so plugins only implement the
|
# to a single delta + end pair so plugins only implement the
|
||||||
# streaming primitives.
|
# streaming primitives.
|
||||||
await channel.send_reasoning(msg)
|
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"):
|
elif msg.metadata.get("_stream_delta") or msg.metadata.get("_stream_end"):
|
||||||
await channel.send_delta(msg.chat_id, msg.content, msg.metadata)
|
await channel.send_delta(msg.chat_id, msg.content, msg.metadata)
|
||||||
elif not msg.metadata.get("_streamed"):
|
elif not msg.metadata.get("_streamed"):
|
||||||
|
|||||||
@ -27,16 +27,16 @@ from websockets.exceptions import ConnectionClosed
|
|||||||
from websockets.http11 import Request as WsRequest
|
from websockets.http11 import Request as WsRequest
|
||||||
from websockets.http11 import Response
|
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.events import OUTBOUND_META_AGENT_UI, OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.command.builtin import builtin_command_palette
|
from nanobot.command.builtin import builtin_command_palette
|
||||||
from nanobot.config.paths import get_media_dir, get_workspace_path
|
from nanobot.config.paths import get_media_dir, get_workspace_path
|
||||||
from nanobot.config.schema import Base
|
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.goal_state import goal_state_ws_blob
|
||||||
from nanobot.session.webui_turns import websocket_turn_wall_started_at
|
from nanobot.session.webui_turns import websocket_turn_wall_started_at
|
||||||
from nanobot.utils.media_decode import (
|
from nanobot.utils.media_decode import (
|
||||||
@ -44,14 +44,14 @@ from nanobot.utils.media_decode import (
|
|||||||
save_base64_data_url,
|
save_base64_data_url,
|
||||||
)
|
)
|
||||||
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
|
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.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 (
|
from nanobot.webui.media_api import (
|
||||||
serve_signed_media,
|
serve_signed_media,
|
||||||
sign_media_path,
|
sign_media_path,
|
||||||
sign_or_stage_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.settings_routes import WebUISettingsRouter
|
||||||
from nanobot.webui.sidebar_state import (
|
from nanobot.webui.sidebar_state import (
|
||||||
read_webui_sidebar_state,
|
read_webui_sidebar_state,
|
||||||
@ -1687,15 +1687,12 @@ class WebSocketChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
if msg.metadata.get("_file_edit_events"):
|
if msg.metadata.get("_file_edit_events"):
|
||||||
payload: dict[str, Any] = {
|
edits = msg.metadata.get("_file_edit_events")
|
||||||
"event": "file_edit",
|
await self.send_file_edit_events(
|
||||||
"chat_id": msg.chat_id,
|
msg.chat_id,
|
||||||
"edits": msg.metadata["_file_edit_events"],
|
edits if isinstance(edits, list) else [],
|
||||||
}
|
msg.metadata,
|
||||||
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=" ")
|
|
||||||
return
|
return
|
||||||
text = msg.content
|
text = msg.content
|
||||||
wire_text = self._rewrite_local_markdown_images(text)
|
wire_text = self._rewrite_local_markdown_images(text)
|
||||||
@ -1787,6 +1784,25 @@ class WebSocketChannel(BaseChannel):
|
|||||||
for connection in conns:
|
for connection in conns:
|
||||||
await self._safe_send_to(connection, raw, label=" reasoning_end ")
|
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(
|
async def send_delta(
|
||||||
self,
|
self,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
|
|||||||
@ -283,7 +283,7 @@ class TestToolEventProgress:
|
|||||||
assert finish["result"] == "file.txt"
|
assert finish["result"] == "file.txt"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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()
|
bus = MessageBus()
|
||||||
provider = MagicMock()
|
provider = MagicMock()
|
||||||
provider.get_default_model.return_value = "test-model"
|
provider.get_default_model.return_value = "test-model"
|
||||||
@ -299,27 +299,18 @@ class TestToolEventProgress:
|
|||||||
"status": "editing",
|
"status": "editing",
|
||||||
}]
|
}]
|
||||||
|
|
||||||
websocket_progress = await loop._build_bus_progress_callback(InboundMessage(
|
progress = await loop._build_bus_progress_callback(InboundMessage(
|
||||||
channel="websocket",
|
channel="telegram",
|
||||||
sender_id="u1",
|
sender_id="u1",
|
||||||
chat_id="chat1",
|
chat_id="chat1",
|
||||||
content="edit",
|
content="edit",
|
||||||
))
|
))
|
||||||
assert on_progress_accepts_file_edit_events(websocket_progress) is True
|
assert on_progress_accepts_file_edit_events(progress) is True
|
||||||
await websocket_progress("", file_edit_events=edit_events)
|
await invoke_file_edit_progress(progress, edit_events)
|
||||||
outbound = await bus.consume_outbound()
|
outbound = await bus.consume_outbound()
|
||||||
|
assert outbound.channel == "telegram"
|
||||||
assert outbound.metadata["_file_edit_events"] == edit_events
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_goal_turn_keeps_live_file_edit_progress_for_webui(self, tmp_path: Path) -> None:
|
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."""
|
"""The /goal command rewrites the prompt but must not bypass WebUI file-edit progress."""
|
||||||
|
|||||||
@ -37,6 +37,7 @@ class _MockChannel(BaseChannel):
|
|||||||
self._send_mock = AsyncMock()
|
self._send_mock = AsyncMock()
|
||||||
self._delta_mock = AsyncMock()
|
self._delta_mock = AsyncMock()
|
||||||
self._end_mock = AsyncMock()
|
self._end_mock = AsyncMock()
|
||||||
|
self._file_edit_mock = AsyncMock()
|
||||||
|
|
||||||
async def start(self): # pragma: no cover - not exercised
|
async def start(self): # pragma: no cover - not exercised
|
||||||
pass
|
pass
|
||||||
@ -53,6 +54,9 @@ class _MockChannel(BaseChannel):
|
|||||||
async def send_reasoning_end(self, chat_id, metadata=None):
|
async def send_reasoning_end(self, chat_id, metadata=None):
|
||||||
return await self._end_mock(chat_id, metadata)
|
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
|
@pytest.fixture
|
||||||
def manager() -> ChannelManager:
|
def manager() -> ChannelManager:
|
||||||
@ -195,6 +199,44 @@ async def test_base_channel_reasoning_primitives_are_noop_safe():
|
|||||||
) is None
|
) 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
|
@pytest.mark.asyncio
|
||||||
async def test_reasoning_routing_does_not_consult_send_progress(manager):
|
async def test_reasoning_routing_does_not_consult_send_progress(manager):
|
||||||
"""`show_reasoning` is orthogonal to `send_progress` — turning off
|
"""`show_reasoning` is orthogonal to `send_progress` — turning off
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user