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
@@ -5,6 +5,7 @@ import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import GoalStatusEvent
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import GenerationSettings, LLMResponse
from nanobot.session.webui_turns import WebuiTurnCoordinator
@@ -54,13 +55,13 @@ async def test_process_direct_websocket_clears_run_status(tmp_path) -> None:
events.append(await loop.bus.consume_outbound())
statuses = [
event.metadata
event.event
for event in events
if event.metadata.get("_goal_status") is True
if isinstance(event.event, GoalStatusEvent)
]
assert [status["goal_status"] for status in statuses] == ["running", "idle"]
assert isinstance(statuses[0].get("started_at"), float)
assert "started_at" not in statuses[1]
assert [status.status for status in statuses] == ["running", "idle"]
assert isinstance(statuses[0].started_at, float)
assert statuses[1].started_at is None
@pytest.mark.asyncio
+77 -43
View File
@@ -9,6 +9,15 @@ import pytest
import nanobot.agent.runner as runner_module
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import (
GoalStatusEvent,
ProgressEvent,
SessionUpdatedEvent,
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
TurnEndEvent,
)
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse, ToolCallRequest
from nanobot.session.webui_turns import WebuiTurnCoordinator
@@ -260,25 +269,45 @@ class TestToolEventProgress:
)
await loop._dispatch(msg)
# Drain all outbound messages and find the one carrying _tool_events
# Drain all outbound messages and find the one carrying tool events.
outbound = []
while bus.outbound_size > 0:
outbound.append(await bus.consume_outbound())
tool_event_msgs = [m for m in outbound if m.metadata and m.metadata.get("_tool_events")]
assert tool_event_msgs, "expected at least one outbound message with _tool_events"
tool_event_msgs = [
m
for m in outbound
if isinstance(m.event, ProgressEvent) and m.event.tool_events
]
assert tool_event_msgs, "expected at least one outbound message with tool events"
start_msgs = [m for m in tool_event_msgs if m.metadata["_tool_events"][0]["phase"] == "start"]
finish_msgs = [m for m in tool_event_msgs if m.metadata["_tool_events"][0]["phase"] in ("end", "error")]
start_msgs = [
m
for m in tool_event_msgs
if isinstance(m.event, ProgressEvent)
and m.event.tool_events
and m.event.tool_events[0]["phase"] == "start"
]
finish_msgs = [
m
for m in tool_event_msgs
if isinstance(m.event, ProgressEvent)
and m.event.tool_events
and m.event.tool_events[0]["phase"] in ("end", "error")
]
assert start_msgs, "expected a start-phase tool event"
assert finish_msgs, "expected a finish-phase tool event"
start = start_msgs[0].metadata["_tool_events"][0]
assert isinstance(start_msgs[0].event, ProgressEvent)
assert start_msgs[0].event.tool_events is not None
start = start_msgs[0].event.tool_events[0]
assert start["name"] == "exec"
assert start["call_id"] == "tc1"
assert start["result"] is None
finish = finish_msgs[0].metadata["_tool_events"][0]
assert isinstance(finish_msgs[0].event, ProgressEvent)
assert finish_msgs[0].event.tool_events is not None
finish = finish_msgs[0].event.tool_events[0]
assert finish["phase"] == "end"
assert finish["result"] == "file.txt"
@@ -309,7 +338,8 @@ class TestToolEventProgress:
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
assert isinstance(outbound.event, ProgressEvent)
assert outbound.event.file_edit_events == edit_events
@pytest.mark.asyncio
async def test_goal_turn_keeps_live_file_edit_progress_for_webui(self, tmp_path: Path) -> None:
@@ -389,7 +419,8 @@ class TestToolEventProgress:
edit_events = [
event
for msg in outbound
for event in msg.metadata.get("_file_edit_events", [])
if isinstance(msg.event, ProgressEvent)
for event in msg.event.file_edit_events or []
]
assert any(
event["status"] == "editing"
@@ -433,8 +464,8 @@ class TestToolEventProgress:
outbound.append(await bus.consume_outbound())
assert [m.content for m in outbound] == ["Hello"]
assert not any(m.metadata.get("_progress") for m in outbound)
assert not any(m.metadata.get("_streamed") for m in outbound)
assert not any(isinstance(m.event, ProgressEvent) for m in outbound)
assert not any(isinstance(m.event, StreamedResponseEvent) for m in outbound)
provider.chat_stream_with_retry.assert_not_awaited()
provider.chat_with_retry.assert_awaited_once()
@@ -443,7 +474,7 @@ class TestToolEventProgress:
self,
tmp_path: Path,
) -> None:
"""Streaming channels still receive provider deltas through _stream_delta messages."""
"""Streaming channels still receive provider deltas through stream events."""
bus = MessageBus()
provider = MagicMock()
provider.supports_progress_deltas = True
@@ -473,21 +504,19 @@ class TestToolEventProgress:
while bus.outbound_size > 0:
outbound.append(await bus.consume_outbound())
deltas = [m for m in outbound if m.metadata.get("_stream_delta")]
stream_end = [m for m in outbound if m.metadata.get("_stream_end")]
deltas = [m for m in outbound if isinstance(m.event, StreamDeltaEvent)]
stream_end = [m for m in outbound if isinstance(m.event, StreamEndEvent)]
final = [
m for m in outbound
if not m.metadata.get("_stream_delta")
and not m.metadata.get("_stream_end")
and not m.metadata.get("_turn_end")
and not m.metadata.get("_goal_status")
if not isinstance(m.event, StreamDeltaEvent | StreamEndEvent)
and not isinstance(m.event, TurnEndEvent | GoalStatusEvent)
]
assert [m.content for m in deltas] == ["Hel", "lo"]
assert len(stream_end) == 1
assert final[-1].content == "Hello"
assert final[-1].metadata.get("_streamed") is True
turn_end_msgs = [m for m in outbound if m.metadata.get("_turn_end")]
assert isinstance(final[-1].event, StreamedResponseEvent)
turn_end_msgs = [m for m in outbound if isinstance(m.event, TurnEndEvent)]
assert len(turn_end_msgs) == 1
assert turn_end_msgs[0].content == ""
provider.chat_with_retry.assert_not_awaited()
@@ -528,23 +557,28 @@ class TestToolEventProgress:
while bus.outbound_size > 0:
outbound.append(await bus.consume_outbound())
deltas = [m for m in outbound if m.metadata.get("_stream_delta")]
stream_end = [m for m in outbound if m.metadata.get("_stream_end")]
deltas = [m for m in outbound if isinstance(m.event, StreamDeltaEvent)]
stream_end = [m for m in outbound if isinstance(m.event, StreamEndEvent)]
final = [
m for m in outbound
if not m.metadata.get("_stream_delta")
and not m.metadata.get("_stream_end")
and not m.metadata.get("_turn_end")
and not m.metadata.get("_goal_status")
if not isinstance(m.event, StreamDeltaEvent | StreamEndEvent)
and not isinstance(m.event, TurnEndEvent | GoalStatusEvent)
]
assert [m.content for m in deltas] == ["partial", "full retry response"]
assert [m.metadata.get("_resuming") for m in stream_end] == [True, False]
assert deltas[0].metadata.get("_stream_id") == stream_end[0].metadata.get("_stream_id")
assert deltas[1].metadata.get("_stream_id") == stream_end[1].metadata.get("_stream_id")
assert deltas[0].metadata.get("_stream_id") != deltas[1].metadata.get("_stream_id")
assert [m.event.resuming for m in stream_end if isinstance(m.event, StreamEndEvent)] == [
True,
False,
]
assert isinstance(deltas[0].event, StreamDeltaEvent)
assert isinstance(deltas[1].event, StreamDeltaEvent)
assert isinstance(stream_end[0].event, StreamEndEvent)
assert isinstance(stream_end[1].event, StreamEndEvent)
assert deltas[0].event.stream_id == stream_end[0].event.stream_id
assert deltas[1].event.stream_id == stream_end[1].event.stream_id
assert deltas[0].event.stream_id != deltas[1].event.stream_id
assert final[-1].content == "full retry response"
assert final[-1].metadata.get("_streamed") is True
assert isinstance(final[-1].event, StreamedResponseEvent)
provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio
@@ -623,9 +657,9 @@ class TestToolEventProgress:
done_msgs = [m for m in outbound if m.content == "Done"]
assert len(done_msgs) == 1
assert not done_msgs[0].metadata.get("_turn_end")
assert not isinstance(done_msgs[0].event, TurnEndEvent)
turn_end_msgs = [m for m in outbound if m.metadata.get("_turn_end")]
turn_end_msgs = [m for m in outbound if isinstance(m.event, TurnEndEvent)]
assert len(turn_end_msgs) == 1
assert turn_end_msgs[0].content == ""
assert turn_end_msgs[0].chat_id == "chat1"
@@ -659,14 +693,14 @@ class TestToolEventProgress:
outbound.append(await bus.consume_outbound())
error_msgs = [m for m in outbound if m.content == "Sorry, I encountered an error."]
turn_end_msgs = [m for m in outbound if m.metadata.get("_turn_end")]
statuses = [m for m in outbound if m.metadata.get("_goal_status")]
turn_end_msgs = [m for m in outbound if isinstance(m.event, TurnEndEvent)]
statuses = [m for m in outbound if isinstance(m.event, GoalStatusEvent)]
assert len(error_msgs) == 1
assert len(turn_end_msgs) == 1
assert turn_end_msgs[0].content == ""
assert turn_end_msgs[0].chat_id == "chat1"
assert [m.metadata["goal_status"] for m in statuses] == ["idle"]
assert [m.event.status for m in statuses if isinstance(m.event, GoalStatusEvent)] == ["idle"]
assert outbound.index(error_msgs[0]) < outbound.index(turn_end_msgs[0])
assert outbound.index(turn_end_msgs[0]) < outbound.index(statuses[-1])
@@ -705,27 +739,27 @@ class TestToolEventProgress:
outbound: list = []
for _ in range(12):
outbound.append(await asyncio.wait_for(bus.consume_outbound(), timeout=0.5))
if outbound[-1].metadata.get("_turn_end"):
if isinstance(outbound[-1].event, TurnEndEvent):
break
else:
raise AssertionError("_turn_end message not found")
raise AssertionError("turn-end event not found")
done_with_body = [m for m in outbound if m.content == "Done"]
assert len(done_with_body) == 1
assert outbound[-1].metadata.get("_turn_end") is True
assert isinstance(outbound[-1].event, TurnEndEvent)
await asyncio.wait_for(title_started.wait(), timeout=0.5)
release_title.set()
session_updated = None
for _ in range(10):
candidate = await asyncio.wait_for(bus.consume_outbound(), timeout=0.5)
if (candidate.metadata or {}).get("_session_updated"):
if isinstance(candidate.event, SessionUpdatedEvent):
session_updated = candidate
break
assert session_updated is not None
assert (session_updated.metadata or {}).get("_session_updated") is True
assert (session_updated.metadata or {}).get("_session_update_scope") == "metadata"
assert isinstance(session_updated.event, SessionUpdatedEvent)
assert session_updated.event.scope == "metadata"
assert provider.chat_with_retry.await_count == 2
@pytest.mark.asyncio
@@ -837,4 +871,4 @@ class TestToolEventProgress:
assert len(outbound) == 1
assert outbound[0].content == "Done"
assert (outbound[0].metadata or {}).get("_turn_end") is not True
assert not isinstance(outbound[0].event, TurnEndEvent)
+7 -5
View File
@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from nanobot.bus.outbound_events import StreamedResponseEvent
from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, ToolCallRequest
@@ -23,8 +24,8 @@ def _make_loop(tmp_path):
with patch("nanobot.agent.loop.ContextBuilder"), \
patch("nanobot.agent.loop.SessionManager"), \
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr:
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0)
patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr:
mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path)
return loop
@@ -193,8 +194,9 @@ async def test_streamed_flag_not_set_on_llm_error(tmp_path):
assert result is not None
assert "503" in result.content
assert not result.metadata.get("_streamed"), \
"_streamed must not be set when stop_reason is error"
assert not isinstance(result.event, StreamedResponseEvent), (
"streamed response event must not be set when stop_reason is error"
)
@pytest.mark.asyncio
@@ -239,7 +241,7 @@ async def test_ssrf_soft_block_can_finalize_after_streamed_tool_call(tmp_path):
assert result is not None
assert result.content == "I cannot access private URLs. Please share the local file."
assert result.metadata.get("_streamed") is True
assert isinstance(result.event, StreamedResponseEvent)
@pytest.mark.asyncio
+23 -16
View File
@@ -8,6 +8,13 @@ import pytest
from nanobot.agent.context import ContextBuilder
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import (
GoalStatusEvent,
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
TurnEndEvent,
)
from nanobot.bus.queue import MessageBus
from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META
from nanobot.providers.base import LLMResponse
@@ -765,7 +772,6 @@ async def test_internal_continuation_preserves_streaming_route_metadata(
"_wants_stream": True,
"message_id": "om_001",
"origin_message_id": "root_001",
"_stream_id": "old-stream",
},
))
@@ -775,23 +781,23 @@ async def test_internal_continuation_preserves_streaming_route_metadata(
assert queued.metadata["_wants_stream"] is True
assert queued.metadata["message_id"] == "om_001"
assert queued.metadata["origin_message_id"] == "root_001"
assert "_stream_id" not in queued.metadata
await loop._dispatch(queued)
outbound = []
while loop.bus.outbound_size:
outbound.append(await loop.bus.consume_outbound())
deltas = [m for m in outbound if m.metadata.get("_stream_delta")]
ends = [m for m in outbound if m.metadata.get("_stream_end")]
streamed_markers = [m for m in outbound if m.metadata.get("_streamed")]
deltas = [m for m in outbound if isinstance(m.event, StreamDeltaEvent)]
ends = [m for m in outbound if isinstance(m.event, StreamEndEvent)]
streamed_markers = [m for m in outbound if isinstance(m.event, StreamedResponseEvent)]
assert [m.content for m in deltas] == ["done"]
assert len(ends) == 1
assert ends[0].metadata["_resuming"] is False
assert isinstance(ends[0].event, StreamEndEvent)
assert ends[0].event.resuming is False
assert ends[0].metadata["message_id"] == "om_001"
assert ends[0].metadata["origin_message_id"] == "root_001"
assert isinstance(ends[0].metadata.get("_stream_id"), str)
assert isinstance(ends[0].event.stream_id, str)
assert streamed_markers and streamed_markers[-1].content == "done"
@@ -842,10 +848,10 @@ async def test_websocket_internal_continuation_keeps_single_visible_run(
first_outbound = []
while loop.bus.outbound_size:
first_outbound.append(await loop.bus.consume_outbound())
first_statuses = [m.metadata for m in first_outbound if m.metadata.get("_goal_status")]
assert [m["goal_status"] for m in first_statuses] == ["running"]
assert not [m for m in first_outbound if m.metadata.get("_turn_end")]
started_at = first_statuses[0]["started_at"]
first_statuses = [m.event for m in first_outbound if isinstance(m.event, GoalStatusEvent)]
assert [m.status for m in first_statuses] == ["running"]
assert not [m for m in first_outbound if isinstance(m.event, TurnEndEvent)]
started_at = first_statuses[0].started_at
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
assert queued.metadata[INTERNAL_CONTINUATION_META] is True
@@ -856,12 +862,13 @@ async def test_websocket_internal_continuation_keeps_single_visible_run(
second_outbound = []
while loop.bus.outbound_size:
second_outbound.append(await loop.bus.consume_outbound())
second_statuses = [m.metadata for m in second_outbound if m.metadata.get("_goal_status")]
assert [m["goal_status"] for m in second_statuses] == ["running", "idle"]
assert second_statuses[0]["started_at"] == started_at
turn_end = [m for m in second_outbound if m.metadata.get("_turn_end")]
second_statuses = [m.event for m in second_outbound if isinstance(m.event, GoalStatusEvent)]
assert [m.status for m in second_statuses] == ["running", "idle"]
assert second_statuses[0].started_at == started_at
turn_end = [m for m in second_outbound if isinstance(m.event, TurnEndEvent)]
assert len(turn_end) == 1
assert isinstance(turn_end[0].metadata.get("latency_ms"), int)
assert isinstance(turn_end[0].event, TurnEndEvent)
assert isinstance(turn_end[0].event.latency_ms, int)
@pytest.mark.asyncio
+3 -2
View File
@@ -127,6 +127,7 @@ class TestDispatch:
@pytest.mark.asyncio
async def test_dispatch_streaming_preserves_message_metadata(self):
from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import StreamDeltaEvent, StreamEndEvent
loop, bus = _make_loop()
msg = InboundMessage(
@@ -156,10 +157,10 @@ class TestDispatch:
assert first.metadata["thread_root_event_id"] == "$root1"
assert first.metadata["thread_reply_to_event_id"] == "$reply1"
assert first.metadata["_stream_delta"] is True
assert isinstance(first.event, StreamDeltaEvent)
assert second.metadata["thread_root_event_id"] == "$root1"
assert second.metadata["thread_reply_to_event_id"] == "$reply1"
assert second.metadata["_stream_end"] is True
assert isinstance(second.event, StreamEndEvent)
@pytest.mark.asyncio
async def test_processing_lock_serializes(self):
+5 -3
View File
@@ -13,6 +13,7 @@ from nanobot.agent.tools.long_task import (
CompleteGoalTool,
LongTaskTool,
)
from nanobot.bus.outbound_events import GoalStateSyncEvent
from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeEventBus
from nanobot.session.goal_state import GOAL_STATE_KEY
@@ -144,8 +145,8 @@ async def test_long_task_publishes_goal_state_ws_after_save(tmp_path):
call = bus.publish_outbound.await_args.args[0]
assert call.channel == "websocket"
assert call.chat_id == "chat-99"
assert call.metadata.get("_goal_state_sync") is True
assert call.metadata["goal_state"] == {
assert isinstance(call.event, GoalStateSyncEvent)
assert call.event.goal_state == {
"active": True,
"ui_summary": "alpha",
"objective": "Objective alpha",
@@ -180,7 +181,8 @@ async def test_complete_goal_publishes_inactive_goal_state_ws(tmp_path):
bus.publish_outbound.assert_awaited_once()
call = bus.publish_outbound.await_args.args[0]
assert call.metadata["goal_state"] == {"active": False}
assert isinstance(call.event, GoalStateSyncEvent)
assert call.event.goal_state == {"active": False}
@pytest.mark.asyncio