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
+56 -58
View File
@@ -15,6 +15,14 @@ from websockets.exceptions import ConnectionClosed
from websockets.frames import Close
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
from nanobot.bus.outbound_events import (
GoalStateSyncEvent,
GoalStatusEvent,
ProgressEvent,
RuntimeModelUpdatedEvent,
SessionUpdatedEvent,
TurnEndEvent,
)
from nanobot.bus.queue import MessageBus
from nanobot.channels.websocket import (
WebSocketChannel,
@@ -853,11 +861,10 @@ async def test_runtime_model_update_publisher_uses_websocket_outbound_event() ->
assert event.channel == "websocket"
assert event.chat_id == "*"
assert event.content == ""
assert event.metadata == {
"_runtime_model_updated": True,
"model": "openai/gpt-4.1",
"model_preset": "fast",
}
assert event.metadata == {}
assert isinstance(event.event, RuntimeModelUpdatedEvent)
assert event.event.model == "openai/gpt-4.1"
assert event.event.model_preset == "fast"
@pytest.mark.asyncio
@@ -929,11 +936,10 @@ async def test_send_progress_includes_structured_tool_events() -> None:
channel="websocket",
chat_id="chat-1",
content='search "hermes"',
metadata={
"_progress": True,
"_tool_hint": True,
"webui_turn_id": "turn-1",
"_tool_events": [
event=ProgressEvent(
content='search "hermes"',
tool_hint=True,
tool_events=[
{
"version": 1,
"phase": "start",
@@ -946,6 +952,9 @@ async def test_send_progress_includes_structured_tool_events() -> None:
"embeds": [],
}
],
),
metadata={
"webui_turn_id": "turn-1",
},
))
@@ -981,9 +990,8 @@ async def test_send_file_edit_progress_uses_file_edit_event() -> None:
channel="websocket",
chat_id="chat-1",
content="",
metadata={
"_progress": True,
"_file_edit_events": [
event=ProgressEvent(
file_edit_events=[
{
"version": 1,
"phase": "start",
@@ -996,7 +1004,7 @@ async def test_send_file_edit_progress_uses_file_edit_event() -> None:
"status": "editing",
}
],
},
),
))
payload = json.loads(mock_ws.send.await_args.args[0])
@@ -1034,7 +1042,8 @@ async def test_send_progress_includes_agent_ui_blob() -> None:
channel="websocket",
chat_id="chat-1",
content="progress · panel",
metadata={"_progress": True, OUTBOUND_META_AGENT_UI: blob},
event=ProgressEvent(content="progress · panel"),
metadata={OUTBOUND_META_AGENT_UI: blob},
))
payload = json.loads(mock_ws.send.await_args.args[0])
@@ -1051,7 +1060,7 @@ async def test_send_delta_removes_connection_on_connection_closed() -> None:
mock_ws.send.side_effect = ConnectionClosed(Close(1006, ""), Close(1006, ""), True)
channel._attach(mock_ws, "chat-1")
await channel.send_delta("chat-1", "chunk", {"_stream_delta": True, "_stream_id": "s1"})
await channel.send_delta("chat-1", "chunk", stream_id="s1")
assert "chat-1" not in channel._subs
assert mock_ws not in channel._conn_chats
@@ -1064,8 +1073,8 @@ async def test_send_delta_emits_delta_and_stream_end() -> None:
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel.send_delta("chat-1", "part", {"_stream_delta": True, "_stream_id": "sid"})
await channel.send_delta("chat-1", "", {"_stream_end": True, "_stream_id": "sid"})
await channel.send_delta("chat-1", "part", stream_id="sid")
await channel.send_delta("chat-1", "", stream_id="sid", stream_end=True)
assert mock_ws.send.await_count == 2
first = json.loads(mock_ws.send.call_args_list[0][0][0])
@@ -1090,7 +1099,8 @@ async def test_send_delta_stream_end_includes_inline_final_text() -> None:
await channel.send_delta(
"chat-1",
"merged plain text",
{"_stream_delta": True, "_stream_end": True, "_stream_id": "sid"},
stream_id="sid",
stream_end=True,
)
mock_ws.send.assert_awaited_once()
@@ -1124,9 +1134,9 @@ async def test_send_delta_stream_end_rewrites_local_markdown_image(monkeypatch,
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel.send_delta("chat-1", "![Diagram](", {"_stream_delta": True, "_stream_id": "sid"})
await channel.send_delta("chat-1", "diagram.png)", {"_stream_delta": True, "_stream_id": "sid"})
await channel.send_delta("chat-1", "", {"_stream_end": True, "_stream_id": "sid"})
await channel.send_delta("chat-1", "![Diagram](", stream_id="sid")
await channel.send_delta("chat-1", "diagram.png)", stream_id="sid")
await channel.send_delta("chat-1", "", stream_id="sid", stream_end=True)
assert mock_ws.send.await_count == 3
final = json.loads(mock_ws.send.call_args_list[2][0][0])
@@ -1160,7 +1170,8 @@ async def test_send_delta_stream_end_rewrites_inline_final_text(monkeypatch, tmp
await channel.send_delta(
"chat-1",
"![Diagram](diagram.png)",
{"_stream_delta": True, "_stream_end": True, "_stream_id": "sid"},
stream_id="sid",
stream_end=True,
)
mock_ws.send.assert_awaited_once()
@@ -1179,7 +1190,7 @@ async def test_send_reasoning_delta_emits_streaming_frame() -> None:
await channel.send_reasoning_delta(
"chat-1",
"step-by-step thinking",
{"_reasoning_delta": True, "_stream_id": "r1"},
stream_id="r1",
)
mock_ws.send.assert_awaited_once()
@@ -1197,7 +1208,7 @@ async def test_send_reasoning_end_emits_close_frame() -> None:
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel.send_reasoning_end("chat-1", {"_reasoning_end": True, "_stream_id": "r1"})
await channel.send_reasoning_end("chat-1", stream_id="r1")
payload = json.loads(mock_ws.send.await_args.args[0])
assert payload == {"event": "reasoning_end", "chat_id": "chat-1", "stream_id": "r1"}
@@ -1205,9 +1216,7 @@ async def test_send_reasoning_end_emits_close_frame() -> None:
@pytest.mark.asyncio
async def test_send_reasoning_one_shot_expands_to_delta_plus_end() -> None:
"""``send_reasoning`` is back-compat for hooks that haven't migrated:
the base implementation must produce one delta and one end so the
WebUI sees the same shape either way."""
"""``send_reasoning`` produces one delta and one end."""
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
@@ -1217,7 +1226,7 @@ async def test_send_reasoning_one_shot_expands_to_delta_plus_end() -> None:
channel="websocket",
chat_id="chat-1",
content="thinking",
metadata={"_reasoning": True},
event=ProgressEvent(content="thinking", reasoning=True),
))
assert mock_ws.send.await_count == 2
@@ -1235,7 +1244,7 @@ async def test_send_reasoning_delta_drops_empty_chunks() -> None:
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel.send_reasoning_delta("chat-1", "", {"_reasoning_delta": True})
await channel.send_reasoning_delta("chat-1", "")
mock_ws.send.assert_not_awaited()
@@ -1261,14 +1270,14 @@ async def test_stream_transcript_persists_without_subscribers() -> None:
gateway=_basic_handler(bus),
)
await channel.send_delta("chat-1", "hello", {"_stream_delta": True, "_stream_id": "s1"})
await channel.send_delta("chat-1", " world", {"_stream_delta": True, "_stream_id": "s1"})
await channel.send_delta("chat-1", "", {"_stream_end": True, "_stream_id": "s1"})
await channel.send_delta("chat-1", "hello", stream_id="s1")
await channel.send_delta("chat-1", " world", stream_id="s1")
await channel.send_delta("chat-1", "", stream_id="s1", stream_end=True)
await channel.send(OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
metadata={"_turn_end": True, "latency_ms": 42},
event=TurnEndEvent(latency_ms=42),
))
assert channel._subs == {}
@@ -1292,7 +1301,7 @@ async def test_send_turn_end_emits_turn_end_event() -> None:
channel="websocket",
chat_id="chat-1",
content="",
metadata={"_turn_end": True},
event=TurnEndEvent(),
))
assert _sent_ws_payloads(mock_ws) == [
@@ -1312,7 +1321,7 @@ async def test_send_turn_end_includes_latency_ms_when_present() -> None:
channel="websocket",
chat_id="chat-1",
content="",
metadata={"_turn_end": True, "latency_ms": 1500},
event=TurnEndEvent(latency_ms=1500),
))
assert _sent_ws_payloads(mock_ws) == [
@@ -1333,7 +1342,7 @@ async def test_send_turn_end_includes_goal_state_when_present() -> None:
channel="websocket",
chat_id="chat-1",
content="",
metadata={"_turn_end": True, "goal_state": blob},
event=TurnEndEvent(goal_state=blob),
))
assert _sent_ws_payloads(mock_ws) == [
@@ -1353,11 +1362,7 @@ async def test_send_goal_status_running_emits_event_with_started_at() -> None:
channel="websocket",
chat_id="chat-1",
content="",
metadata={
"_goal_status": True,
"goal_status": "running",
"started_at": 1_700_000_000.5,
},
event=GoalStatusEvent(status="running", started_at=1_700_000_000.5),
))
mock_ws.send.assert_awaited_once()
@@ -1381,11 +1386,7 @@ async def test_send_goal_status_idle_omits_started_at() -> None:
channel="websocket",
chat_id="chat-1",
content="",
metadata={
"_goal_status": True,
"goal_status": "idle",
"goal_started_at": 99.0,
},
event=GoalStatusEvent(status="idle", started_at=99.0),
))
mock_ws.send.assert_awaited_once()
@@ -1406,10 +1407,7 @@ async def test_send_goal_state_emits_blob_per_chat() -> None:
channel="websocket",
chat_id="chat-a",
content="",
metadata={
"_goal_state_sync": True,
"goal_state": {"active": True, "ui_summary": "A"},
},
event=GoalStateSyncEvent(goal_state={"active": True, "ui_summary": "A"}),
))
mock_a.send.assert_awaited_once()
@@ -1528,7 +1526,7 @@ async def test_send_session_updated_emits_session_updated_event() -> None:
channel="websocket",
chat_id="chat-1",
content="",
metadata={"_session_updated": True},
event=SessionUpdatedEvent(),
))
mock_ws.send.assert_awaited_once()
@@ -1547,7 +1545,7 @@ async def test_send_session_updated_includes_scope_when_present() -> None:
channel="websocket",
chat_id="chat-1",
content="",
metadata={"_session_updated": True, "_session_update_scope": "metadata"},
event=SessionUpdatedEvent(scope="metadata"),
))
mock_ws.send.assert_awaited_once()
@@ -1573,7 +1571,7 @@ async def test_send_delta_missing_connection_is_noop() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus, gateway=_basic_handler(bus))
# No exception, no error — just a no-op
await channel.send_delta("nonexistent", "chunk", {"_stream_delta": True, "_stream_id": "s1"})
await channel.send_delta("nonexistent", "chunk", stream_id="s1")
assert channel._subs == {}
@@ -2191,13 +2189,13 @@ async def test_end_to_end_server_pushes_streaming_deltas_to_client(bus: MagicMoc
# Server pushes deltas directly
await channel.send_delta(
chat_id, "Hello ", {"_stream_delta": True, "_stream_id": "s1"}
chat_id, "Hello ", stream_id="s1"
)
await channel.send_delta(
chat_id, "world", {"_stream_delta": True, "_stream_id": "s1"}
chat_id, "world", stream_id="s1"
)
await channel.send_delta(
chat_id, "", {"_stream_end": True, "_stream_id": "s1"}
chat_id, "", stream_id="s1", stream_end=True
)
delta1 = json.loads(await client.recv())
@@ -2218,7 +2216,7 @@ async def test_end_to_end_server_pushes_streaming_deltas_to_client(bus: MagicMoc
channel="websocket",
chat_id=chat_id,
content="",
metadata={"_turn_end": True},
event=TurnEndEvent(),
))
turn_end = json.loads(await client.recv())