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
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import (
ProgressEvent,
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
outbound_event_from_message,
outbound_message_for_event,
replace_outbound_event,
)
def test_progress_event_lives_on_outbound_message_event_field() -> None:
tool_events = [{"phase": "start", "name": "read_file"}]
file_edit_events = [{"phase": "end", "path": "app.py"}]
msg = outbound_message_for_event(
channel="websocket",
chat_id="chat-1",
event=ProgressEvent(
content="working",
tool_hint=True,
reasoning_delta=True,
stream_id="r1",
tool_events=tool_events,
file_edit_events=file_edit_events,
),
metadata={"origin_message_id": "m1"},
)
assert msg.content == "working"
assert msg.metadata == {"origin_message_id": "m1"}
event = outbound_event_from_message(msg)
assert isinstance(event, ProgressEvent)
assert event.content == "working"
assert event.tool_hint is True
assert event.reasoning_delta is True
assert event.stream_id == "r1"
assert event.tool_events == tool_events
assert event.file_edit_events == file_edit_events
def test_normal_outbound_message_has_no_runtime_event() -> None:
msg = OutboundMessage(channel="websocket", chat_id="chat-1", content="hello")
assert outbound_event_from_message(msg) is None
def test_metadata_flags_do_not_create_runtime_events() -> None:
msg = OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="legacy progress",
metadata={
"_progress": True,
"_stream_delta": True,
"_goal_status": True,
"message_id": "platform-routing-context",
},
)
assert outbound_event_from_message(msg) is None
def test_replace_outbound_event_keeps_routing_metadata() -> None:
msg = outbound_message_for_event(
channel="websocket",
chat_id="chat-1",
event=StreamDeltaEvent(content="hello", stream_id="s1"),
metadata={"message_id": "m1"},
)
updated = replace_outbound_event(
msg,
StreamEndEvent(stream_id="s1", resuming=True),
content="hello world",
)
assert updated.content == "hello world"
assert updated.metadata == {"message_id": "m1"}
assert isinstance(updated.event, StreamEndEvent)
assert updated.event.stream_id == "s1"
assert updated.event.resuming is True
def test_streamed_response_event_keeps_final_content_outside_event_payload() -> None:
msg = outbound_message_for_event(
channel="cli",
chat_id="direct",
event=StreamedResponseEvent(),
content="final answer",
)
assert msg.content == "final answer"
assert isinstance(outbound_event_from_message(msg), StreamedResponseEvent)
@@ -1,10 +1,19 @@
"""Tests for ChannelManager delta coalescing to reduce streaming latency."""
import asyncio
from unittest.mock import AsyncMock
import pytest
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import (
ProgressEvent,
RetryWaitEvent,
StreamDeltaEvent,
StreamEndEvent,
outbound_event_from_message,
outbound_message_for_event,
)
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.channels.manager import ChannelManager
@@ -29,221 +38,187 @@ class MockChannel(BaseChannel):
pass
async def send(self, msg):
"""Implement abstract method."""
return await self._send_mock(msg)
async def send_delta(self, chat_id, delta, metadata=None):
"""Override send_delta for testing."""
return await self._send_delta_mock(chat_id, delta, metadata)
async def send_delta(
self,
chat_id,
delta,
metadata=None,
*,
stream_id=None,
stream_end=False,
resuming=False,
):
return await self._send_delta_mock(
chat_id,
delta,
metadata,
stream_id=stream_id,
stream_end=stream_end,
resuming=resuming,
)
@pytest.fixture
def config():
"""Create a minimal config for testing."""
return Config()
@pytest.fixture
def bus():
"""Create a message bus for testing."""
return MessageBus()
@pytest.fixture
def manager(config, bus):
"""Create a channel manager with a mock channel."""
manager = ChannelManager(config, bus)
manager.channels["mock"] = MockChannel({}, bus)
return manager
def _delta(content: str, *, chat_id: str = "chat1", stream_id: str | None = None):
return outbound_message_for_event(
channel="mock",
chat_id=chat_id,
event=StreamDeltaEvent(content=content, stream_id=stream_id),
)
def _end(
content: str = "",
*,
chat_id: str = "chat1",
stream_id: str | None = None,
resuming: bool = False,
):
return outbound_message_for_event(
channel="mock",
chat_id=chat_id,
event=StreamEndEvent(content=content, stream_id=stream_id, resuming=resuming),
)
class TestDeltaCoalescing:
"""Tests for _stream_delta message coalescing."""
"""Tests for stream delta message coalescing."""
@pytest.mark.asyncio
async def test_single_delta_not_coalesced(self, manager, bus):
"""A single delta should be sent as-is."""
msg = OutboundMessage(
channel="mock",
chat_id="chat1",
content="Hello",
metadata={"_stream_delta": True},
)
msg = _delta("Hello")
await bus.publish_outbound(msg)
# Process one message
async def process_one():
try:
m = await asyncio.wait_for(bus.consume_outbound(), timeout=0.1)
if m.metadata.get("_stream_delta"):
event = outbound_event_from_message(m)
if isinstance(event, StreamDeltaEvent):
m, pending = manager._coalesce_stream_deltas(m)
# Put pending back (none expected)
for p in pending:
await bus.publish_outbound(p)
channel = manager.channels.get(m.channel)
if channel:
await channel.send_delta(m.chat_id, m.content, m.metadata)
event = outbound_event_from_message(m)
if channel and isinstance(event, StreamDeltaEvent):
await channel.send_delta(
m.chat_id,
m.content,
m.metadata,
stream_id=event.stream_id,
)
except asyncio.TimeoutError:
pass
await process_one()
manager.channels["mock"]._send_delta_mock.assert_called_once_with(
"chat1", "Hello", {"_stream_delta": True}
"chat1",
"Hello",
{},
stream_id=None,
stream_end=False,
resuming=False,
)
@pytest.mark.asyncio
async def test_multiple_deltas_coalesced(self, manager, bus):
"""Multiple consecutive deltas for same chat should be merged."""
# Put multiple deltas in queue
for text in ["Hello", " ", "world", "!"]:
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content=text,
metadata={"_stream_delta": True},
))
await bus.publish_outbound(_delta(text))
# Process using coalescing logic
first_msg = await bus.consume_outbound()
merged, pending = manager._coalesce_stream_deltas(first_msg)
# Should have merged all deltas
assert merged.content == "Hello world!"
assert merged.metadata.get("_stream_delta") is True
# No pending messages (all were coalesced)
assert isinstance(merged.event, StreamDeltaEvent)
assert len(pending) == 0
@pytest.mark.asyncio
async def test_deltas_different_chats_not_coalesced(self, manager, bus):
"""Deltas for different chats should not be merged."""
# Put deltas for different chats
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="Hello",
metadata={"_stream_delta": True},
))
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat2",
content="World",
metadata={"_stream_delta": True},
))
await bus.publish_outbound(_delta("Hello", chat_id="chat1"))
await bus.publish_outbound(_delta("World", chat_id="chat2"))
first_msg = await bus.consume_outbound()
merged, pending = manager._coalesce_stream_deltas(first_msg)
# First chat should not include second chat's content
assert merged.content == "Hello"
assert merged.chat_id == "chat1"
# Second chat should be in pending
assert len(pending) == 1
assert pending[0].chat_id == "chat2"
assert pending[0].content == "World"
@pytest.mark.asyncio
async def test_deltas_different_stream_ids_not_coalesced(self, manager, bus):
"""Deltas for the same chat but different streams should not be merged."""
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="A1",
metadata={"_stream_delta": True, "_stream_id": "stream-a"},
))
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="B1",
metadata={"_stream_delta": True, "_stream_id": "stream-b"},
))
await bus.publish_outbound(_delta("A1", stream_id="stream-a"))
await bus.publish_outbound(_delta("B1", stream_id="stream-b"))
first_msg = await bus.consume_outbound()
merged, pending = manager._coalesce_stream_deltas(first_msg)
assert merged.content == "A1"
assert merged.metadata.get("_stream_id") == "stream-a"
assert isinstance(merged.event, StreamDeltaEvent)
assert merged.event.stream_id == "stream-a"
assert len(pending) == 1
assert pending[0].content == "B1"
assert pending[0].metadata.get("_stream_id") == "stream-b"
assert isinstance(pending[0].event, StreamDeltaEvent)
assert pending[0].event.stream_id == "stream-b"
@pytest.mark.asyncio
async def test_stream_end_terminates_coalescing(self, manager, bus):
"""_stream_end should stop coalescing and be included in final message."""
# Put deltas with stream_end at the end
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="Hello",
metadata={"_stream_delta": True},
))
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content=" world",
metadata={"_stream_delta": True, "_stream_end": True},
))
await bus.publish_outbound(_delta("Hello"))
await bus.publish_outbound(_end(" world"))
first_msg = await bus.consume_outbound()
merged, pending = manager._coalesce_stream_deltas(first_msg)
# Should have merged content
assert merged.content == "Hello world"
# Should have stream_end flag
assert merged.metadata.get("_stream_end") is True
# No pending
assert isinstance(merged.event, StreamEndEvent)
assert len(pending) == 0
@pytest.mark.asyncio
async def test_coalescing_stops_at_first_non_matching_boundary(self, manager, bus):
"""Only consecutive deltas should be merged; later deltas stay queued."""
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="Hello",
metadata={"_stream_delta": True, "_stream_id": "seg-1"},
))
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="",
metadata={"_stream_end": True, "_stream_id": "seg-1"},
))
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="world",
metadata={"_stream_delta": True, "_stream_id": "seg-2"},
))
await bus.publish_outbound(_delta("Hello", stream_id="seg-1"))
await bus.publish_outbound(_end(stream_id="seg-1"))
await bus.publish_outbound(_delta("world", stream_id="seg-2"))
first_msg = await bus.consume_outbound()
merged, pending = manager._coalesce_stream_deltas(first_msg)
assert merged.content == "Hello"
assert merged.metadata.get("_stream_end") is None
assert isinstance(merged.event, StreamDeltaEvent)
assert len(pending) == 1
assert pending[0].metadata.get("_stream_end") is True
assert pending[0].metadata.get("_stream_id") == "seg-1"
assert isinstance(pending[0].event, StreamEndEvent)
assert pending[0].event.stream_id == "seg-1"
# The next stream segment must remain in queue order for later dispatch.
remaining = await bus.consume_outbound()
assert remaining.content == "world"
assert remaining.metadata.get("_stream_id") == "seg-2"
assert isinstance(remaining.event, StreamDeltaEvent)
assert remaining.event.stream_id == "seg-2"
@pytest.mark.asyncio
async def test_non_delta_message_preserved(self, manager, bus):
"""Non-delta messages should be preserved in pending list."""
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="Delta",
metadata={"_stream_delta": True},
))
await bus.publish_outbound(_delta("Delta"))
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="Final message",
metadata={}, # Not a delta
))
first_msg = await bus.consume_outbound()
@@ -252,17 +227,11 @@ class TestDeltaCoalescing:
assert merged.content == "Delta"
assert len(pending) == 1
assert pending[0].content == "Final message"
assert pending[0].metadata.get("_stream_delta") is None
assert pending[0].event is None
@pytest.mark.asyncio
async def test_empty_queue_stops_coalescing(self, manager, bus):
"""Coalescing should stop when queue is empty."""
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="Only message",
metadata={"_stream_delta": True},
))
await bus.publish_outbound(_delta("Only message"))
first_msg = await bus.consume_outbound()
merged, pending = manager._coalesce_stream_deltas(first_msg)
@@ -276,49 +245,35 @@ class TestDispatchOutboundWithCoalescing:
@pytest.mark.asyncio
async def test_dispatch_coalesces_and_processes_pending(self, manager, bus):
"""_dispatch_outbound should coalesce deltas and process pending messages."""
# Put multiple deltas followed by a regular message
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="A",
metadata={"_stream_delta": True},
))
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="B",
metadata={"_stream_delta": True},
))
await bus.publish_outbound(_delta("A"))
await bus.publish_outbound(_delta("B"))
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="Final",
metadata={}, # Regular message
))
# Run one iteration of dispatch logic manually
pending = []
processed = []
# First iteration: should coalesce A+B
if pending:
msg = pending.pop(0)
else:
msg = await bus.consume_outbound()
if msg.metadata.get("_stream_delta") and not msg.metadata.get("_stream_end"):
msg = pending.pop(0) if pending else await bus.consume_outbound()
event = outbound_event_from_message(msg)
if isinstance(event, StreamDeltaEvent):
msg, extra_pending = manager._coalesce_stream_deltas(msg)
pending.extend(extra_pending)
channel = manager.channels.get(msg.channel)
if channel:
await channel.send_delta(msg.chat_id, msg.content, msg.metadata)
event = outbound_event_from_message(msg)
if channel and isinstance(event, StreamDeltaEvent):
await channel.send_delta(
msg.chat_id,
msg.content,
msg.metadata,
stream_id=event.stream_id,
)
processed.append(("delta", msg.content))
# Should have sent coalesced delta
assert processed == [("delta", "AB")]
# Should have pending regular message
assert len(pending) == 1
assert pending[0].content == "Final"
@@ -354,23 +309,20 @@ class TestProgressFiltering:
assert manager._resolve_bool_override(FakeSection(), "send_progress", True) is False
assert manager._resolve_bool_override(FakeSection(), "send_tool_hints", False) is True
# Missing attribute falls back to default
assert manager._resolve_bool_override(FakeSection(), "unknown_key", True) is True
@pytest.mark.asyncio
async def test_channel_override_can_drop_progress_message(self, manager, bus):
manager.channels["mock"].send_progress = False
await bus.publish_outbound(OutboundMessage(
await bus.publish_outbound(outbound_message_for_event(
channel="mock",
chat_id="chat1",
content="thinking",
metadata={"_progress": True},
event=ProgressEvent(content="thinking"),
))
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="final answer",
metadata={},
))
task = asyncio.create_task(manager._dispatch_outbound())
@@ -391,13 +343,41 @@ class TestProgressFiltering:
assert send_mock.await_args_list[0].args[0].content == "final answer"
@pytest.mark.asyncio
async def test_channel_override_can_enable_tool_hints(self, manager, bus):
manager.channels["mock"].send_tool_hints = True
async def test_metadata_only_progress_flag_is_not_runtime_progress(self, manager, bus):
manager.channels["mock"].send_progress = False
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="read_file(foo.py)",
metadata={"_progress": True, "_tool_hint": True},
content="legacy progress-shaped message",
metadata={"_progress": True},
))
task = asyncio.create_task(manager._dispatch_outbound())
try:
for _ in range(30):
if manager.channels["mock"]._send_mock.await_count >= 1:
break
await asyncio.sleep(0.05)
finally:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
send_mock = manager.channels["mock"]._send_mock
assert send_mock.await_count == 1
sent = send_mock.await_args_list[0].args[0]
assert sent.content == "legacy progress-shaped message"
assert sent.event is None
@pytest.mark.asyncio
async def test_channel_override_can_enable_tool_hints(self, manager, bus):
manager.channels["mock"].send_tool_hints = True
await bus.publish_outbound(outbound_message_for_event(
channel="mock",
chat_id="chat1",
event=ProgressEvent(content="read_file(foo.py)", tool_hint=True),
))
task = asyncio.create_task(manager._dispatch_outbound())
@@ -423,24 +403,15 @@ class TestRetryWaitFiltering:
@pytest.mark.asyncio
async def test_retry_wait_message_dropped(self, manager, bus):
"""A ``_retry_wait`` message must be filtered before channel dispatch.
Regression: provider retry diagnostics like
``Model request failed, retry in 1s (attempt 1).`` were being
delivered to end-user channels because the runner bound
``on_retry_wait`` to the progress callback.
"""
retry_msg = OutboundMessage(
retry_msg = outbound_message_for_event(
channel="mock",
chat_id="chat1",
content="Model request failed, retry in 1s (attempt 1).",
metadata={"_retry_wait": True},
event=RetryWaitEvent(content="Model request failed, retry in 1s (attempt 1)."),
)
real_msg = OutboundMessage(
channel="mock",
chat_id="chat1",
content="final answer",
metadata={},
)
await bus.publish_outbound(retry_msg)
await bus.publish_outbound(real_msg)
@@ -462,4 +433,4 @@ class TestRetryWaitFiltering:
assert send_mock.await_count == 1
sent = send_mock.await_args_list[0].args[0]
assert sent.content == "final answer"
assert not sent.metadata.get("_retry_wait")
assert sent.event is None
@@ -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)
+36 -11
View File
@@ -9,6 +9,11 @@ from unittest.mock import AsyncMock, patch
import pytest
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import (
StreamDeltaEvent,
StreamedResponseEvent,
outbound_message_for_event,
)
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.channels.manager import ChannelManager
@@ -718,7 +723,7 @@ async def test_send_with_retry_no_retry_when_max_is_zero():
@pytest.mark.asyncio
async def test_send_with_retry_calls_send_delta():
"""_send_with_retry should call send_delta when metadata has _stream_delta."""
"""_send_with_retry should call send_delta for stream delta events."""
send_delta_called = False
class _StreamingChannel(BaseChannel):
@@ -734,7 +739,16 @@ async def test_send_with_retry_calls_send_delta():
async def send(self, msg: OutboundMessage) -> None:
pass # Should not be called
async def send_delta(self, chat_id: str, delta: str, metadata: dict | None = None) -> None:
async def send_delta(
self,
chat_id: str,
delta: str,
metadata: dict | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None:
nonlocal send_delta_called
send_delta_called = True
@@ -749,9 +763,10 @@ async def test_send_with_retry_calls_send_delta():
mgr.channels = {"streaming": _StreamingChannel(fake_config, mgr.bus)}
mgr._dispatch_task = None
msg = OutboundMessage(
channel="streaming", chat_id="123", content="test delta",
metadata={"_stream_delta": True}
msg = outbound_message_for_event(
channel="streaming",
chat_id="123",
event=StreamDeltaEvent(content="test delta"),
)
await mgr._send_with_retry(mgr.channels["streaming"], msg)
@@ -760,7 +775,7 @@ async def test_send_with_retry_calls_send_delta():
@pytest.mark.asyncio
async def test_send_with_retry_skips_send_when_streamed():
"""_send_with_retry should not call send when metadata has _streamed flag."""
"""_send_with_retry should not call send for streamed response events."""
send_called = False
send_delta_called = False
@@ -778,7 +793,16 @@ async def test_send_with_retry_skips_send_when_streamed():
nonlocal send_called
send_called = True
async def send_delta(self, chat_id: str, delta: str, metadata: dict | None = None) -> None:
async def send_delta(
self,
chat_id: str,
delta: str,
metadata: dict | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None:
nonlocal send_delta_called
send_delta_called = True
@@ -793,10 +817,11 @@ async def test_send_with_retry_skips_send_when_streamed():
mgr.channels = {"streamed": _StreamedChannel(fake_config, mgr.bus)}
mgr._dispatch_task = None
# _streamed means message was already sent via send_delta, so skip send
msg = OutboundMessage(
channel="streamed", chat_id="123", content="test",
metadata={"_streamed": True}
msg = outbound_message_for_event(
channel="streamed",
chat_id="123",
event=StreamedResponseEvent(),
content="test",
)
await mgr._send_with_retry(mgr.channels["streamed"], msg)
+8 -7
View File
@@ -10,6 +10,7 @@ pytest.importorskip("discord")
import discord
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.discord import (
MAX_MESSAGE_LEN,
@@ -718,9 +719,9 @@ async def test_send_delta_streams_by_editing_message(monkeypatch) -> None:
times = iter([1.0, 3.0, 5.0])
monkeypatch.setattr("nanobot.channels.discord.time.monotonic", lambda: next(times, 5.0))
await owner.send_delta("123", "hel", {"_stream_delta": True, "_stream_id": "s1"})
await owner.send_delta("123", "lo", {"_stream_delta": True, "_stream_id": "s1"})
await owner.send_delta("123", "", {"_stream_end": True, "_stream_id": "s1"})
await owner.send_delta("123", "hel", stream_id="s1")
await owner.send_delta("123", "lo", stream_id="s1")
await owner.send_delta("123", "", stream_id="s1", stream_end=True)
assert target.sent_payloads[0] == {"content": "hel"}
assert target.sent_messages[0].edits == [{"content": "hello"}, {"content": "hello"}]
@@ -745,9 +746,9 @@ async def test_send_delta_stream_end_splits_oversized_reply(monkeypatch) -> None
times = iter([1.0, 3.0])
monkeypatch.setattr("nanobot.channels.discord.time.monotonic", lambda: next(times, 3.0))
await owner.send_delta("123", prefix, {"_stream_delta": True, "_stream_id": "s1"})
await owner.send_delta("123", suffix, {"_stream_delta": True, "_stream_id": "s1"})
await owner.send_delta("123", "", {"_stream_end": True, "_stream_id": "s1"})
await owner.send_delta("123", prefix, stream_id="s1")
await owner.send_delta("123", suffix, stream_id="s1")
await owner.send_delta("123", "", stream_id="s1", stream_end=True)
assert target.sent_payloads == [{"content": prefix}, {"content": chunks[1]}]
assert target.sent_messages[0].edits == [{"content": chunks[0]}, {"content": chunks[0]}]
@@ -1073,7 +1074,7 @@ async def test_send_stops_typing_after_send() -> None:
channel="discord",
chat_id="123",
content="progress",
metadata={"_progress": True},
event=ProgressEvent(content="progress"),
)
)
+2 -4
View File
@@ -6,6 +6,7 @@ from pathlib import Path
import pytest
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.email import EmailChannel, EmailConfig
@@ -868,10 +869,7 @@ async def test_send_skips_progress_messages_before_smtp(monkeypatch) -> None:
channel="email",
chat_id="alice@example.com",
content="",
metadata={
"_progress": True,
"_tool_events": [{"phase": "end", "name": "exec"}],
},
event=ProgressEvent(tool_events=[{"phase": "end", "name": "exec"}]),
)
)
+17 -9
View File
@@ -193,7 +193,8 @@ class TestStreamEndReactionCleanup:
await ch.send_delta(
"oc_chat1", "",
metadata={"_stream_end": True, "message_id": "om_001"},
metadata={"message_id": "om_001"},
stream_end=True,
)
ch._remove_reaction.assert_called_once_with("om_001", "rx_42")
@@ -210,7 +211,7 @@ class TestStreamEndReactionCleanup:
await ch.send_delta(
"oc_chat1", "",
metadata={"_stream_end": True},
stream_end=True,
)
ch._remove_reaction.assert_not_called()
@@ -227,7 +228,8 @@ class TestStreamEndReactionCleanup:
await ch.send_delta(
"oc_chat1", "",
metadata={"_stream_end": True, "message_id": "om_001"},
metadata={"message_id": "om_001"},
stream_end=True,
)
ch._remove_reaction.assert_not_called()
@@ -242,7 +244,7 @@ class TestStreamEndReactionCleanup:
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
ch._remove_reaction = AsyncMock()
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True})
await ch.send_delta("oc_chat1", "", stream_end=True)
ch._remove_reaction.assert_not_called()
@@ -260,7 +262,7 @@ class TestStreamEndReactionCleanup:
@pytest.mark.asyncio
async def test_no_removal_when_resuming(self):
"""_resuming=True means more tool-call rounds follow; reaction must persist."""
"""resuming=True means more tool-call rounds follow; reaction must persist."""
ch = _make_channel()
ch.config.done_emoji = "DONE"
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
@@ -274,7 +276,9 @@ class TestStreamEndReactionCleanup:
await ch.send_delta(
"oc_chat1", "",
metadata={"_stream_end": True, "_resuming": True, "message_id": "om_001"},
metadata={"message_id": "om_001"},
stream_end=True,
resuming=True,
)
ch._remove_reaction.assert_not_called()
@@ -299,19 +303,23 @@ class TestStreamEndReactionCleanup:
# Intermediate stream end (more tool calls coming).
await ch.send_delta(
"oc_chat1", "",
metadata={"_stream_end": True, "_resuming": True, "message_id": "om_001"},
metadata={"message_id": "om_001"},
stream_end=True,
resuming=True,
)
ch._remove_reaction.assert_not_called()
ch._add_reaction.assert_not_called()
# Re-prime the stream buffer for the final round (the previous _stream_end popped it).
# Re-prime the stream buffer for the final round (the previous stream end popped it).
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="t", card_id="card_1", sequence=5, last_edit=0.0,
)
# Final stream end (resuming=False): OnIt removed, done_emoji added.
await ch.send_delta(
"oc_chat1", "",
metadata={"_stream_end": True, "_resuming": False, "message_id": "om_001"},
metadata={"message_id": "om_001"},
stream_end=True,
resuming=False,
)
ch._remove_reaction.assert_called_once_with("om_001", "rx_42")
ch._add_reaction.assert_called_once_with("om_001", "DONE")
+3 -1
View File
@@ -18,6 +18,7 @@ if not FEISHU_AVAILABLE:
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.feishu import FeishuChannel, FeishuConfig
@@ -332,7 +333,8 @@ async def test_send_skips_reply_for_progress_messages() -> None:
channel="feishu",
chat_id="oc_abc",
content="thinking...",
metadata={"message_id": "om_001", "_progress": True},
event=ProgressEvent(content="thinking..."),
metadata={"message_id": "om_001"},
))
channel._client.im.v1.message.create.assert_called_once()
+26 -18
View File
@@ -6,6 +6,7 @@ from unittest.mock import MagicMock
import pytest
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.feishu import FeishuChannel, FeishuConfig, _FeishuStreamBuf
@@ -272,7 +273,7 @@ class TestSendDelta:
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response()
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True})
await ch.send_delta("oc_chat1", "", stream_end=True)
assert "oc_chat1" not in ch._stream_bufs
ch._client.cardkit.v1.card_element.content.assert_called_once()
@@ -289,7 +290,7 @@ class TestSendDelta:
)
ch._client.im.v1.message.create.return_value = _mock_send_response("om_fb")
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True})
await ch.send_delta("oc_chat1", "", stream_end=True)
assert "oc_chat1" not in ch._stream_bufs
ch._client.cardkit.v1.card_element.content.assert_not_called()
@@ -306,7 +307,8 @@ class TestSendDelta:
await ch.send_delta(
"oc_chat1",
"",
metadata={"_stream_end": True, "message_id": "om_001", "chat_type": "group"},
metadata={"message_id": "om_001", "chat_type": "group"},
stream_end=True,
)
ch._client.im.v1.message.create.assert_called_once()
@@ -326,11 +328,11 @@ class TestSendDelta:
"oc_chat1",
"",
metadata={
"_stream_end": True,
"message_id": "om_001",
"chat_type": "group",
"thread_id": "ot_001",
},
stream_end=True,
)
ch._client.im.v1.message.reply.assert_called_once()
@@ -351,7 +353,8 @@ class TestSendDelta:
await ch.send_delta(
"oc_chat1",
"",
metadata={"_stream_end": True, "message_id": "om_001", "chat_type": "group"},
metadata={"message_id": "om_001", "chat_type": "group"},
stream_end=True,
)
ch._client.im.v1.message.reply.assert_called_once()
@@ -369,7 +372,7 @@ class TestSendDelta:
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response(success=False)
ch._client.im.v1.message.create.return_value = _mock_send_response("om_fb")
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True})
await ch.send_delta("oc_chat1", "", stream_end=True)
assert "oc_chat1" not in ch._stream_bufs
assert ch._client.cardkit.v1.card.settings.call_count == 2
@@ -388,7 +391,7 @@ class TestSendDelta:
]
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response(True)
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True})
await ch.send_delta("oc_chat1", "", stream_end=True)
assert "oc_chat1" not in ch._stream_bufs
assert ch._client.cardkit.v1.card_element.content.call_count == 2
@@ -398,7 +401,7 @@ class TestSendDelta:
@pytest.mark.asyncio
async def test_stream_end_without_buf_is_noop(self):
ch = _make_channel()
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True})
await ch.send_delta("oc_chat1", "", stream_end=True)
ch._client.cardkit.v1.card_element.content.assert_not_called()
@pytest.mark.asyncio
@@ -446,7 +449,7 @@ class TestToolHintInlineStreaming:
msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content='web_fetch("https://example.com")',
metadata={"_tool_hint": True},
event=ProgressEvent(content='web_fetch("https://example.com")', tool_hint=True),
)
await ch.send(msg)
@@ -482,7 +485,7 @@ class TestToolHintInlineStreaming:
msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content='read_file("path")',
metadata={"_tool_hint": True},
event=ProgressEvent(content='read_file("path")', tool_hint=True),
)
await ch.send(msg)
@@ -497,7 +500,8 @@ class TestToolHintInlineStreaming:
msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content='read_file("path")',
metadata={"_tool_hint": True, "message_id": "om_001", "chat_type": "group"},
event=ProgressEvent(content='read_file("path")', tool_hint=True),
metadata={"message_id": "om_001", "chat_type": "group"},
)
await ch.send(msg)
@@ -514,8 +518,8 @@ class TestToolHintInlineStreaming:
msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content='read_file("path")',
event=ProgressEvent(content='read_file("path")', tool_hint=True),
metadata={
"_tool_hint": True,
"message_id": "om_001",
"chat_type": "group",
"thread_id": "ot_001",
@@ -538,7 +542,8 @@ class TestToolHintInlineStreaming:
msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content='read_file("path")',
metadata={"_tool_hint": True, "message_id": "om_001", "chat_type": "group"},
event=ProgressEvent(content='read_file("path")', tool_hint=True),
metadata={"message_id": "om_001", "chat_type": "group"},
)
await ch.send(msg)
@@ -558,13 +563,15 @@ class TestToolHintInlineStreaming:
msg1 = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content='$ cd /project', metadata={"_tool_hint": True},
content='$ cd /project',
event=ProgressEvent(content='$ cd /project', tool_hint=True),
)
await ch.send(msg1)
msg2 = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content='$ git status', metadata={"_tool_hint": True},
content='$ git status',
event=ProgressEvent(content='$ git status', tool_hint=True),
)
await ch.send(msg2)
@@ -577,7 +584,7 @@ class TestToolHintInlineStreaming:
@pytest.mark.asyncio
async def test_tool_hint_preserved_on_final_stream_end(self):
"""When final _stream_end closes the card, tool hint is kept in the final text."""
"""When stream end closes the card, tool hint is kept in the final text."""
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Final content\n\n🔧 web_fetch(\"url\")\n\n",
@@ -586,7 +593,7 @@ class TestToolHintInlineStreaming:
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response()
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True})
await ch.send_delta("oc_chat1", "", stream_end=True)
assert "oc_chat1" not in ch._stream_bufs
update_call = ch._client.cardkit.v1.card_element.content.call_args[0][0]
@@ -603,7 +610,8 @@ class TestToolHintInlineStreaming:
for content in ("", " ", "\t\n"):
msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content=content, metadata={"_tool_hint": True},
content=content,
event=ProgressEvent(content=content, tool_hint=True),
)
await ch.send(msg)
@@ -1,7 +1,6 @@
"""Tests for FeishuChannel tool hint formatting."""
import json
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
@@ -18,6 +17,7 @@ if not FEISHU_AVAILABLE:
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.channels.feishu import FeishuChannel
@@ -51,7 +51,7 @@ async def test_tool_hint_sends_interactive_card(mock_feishu_channel):
channel="feishu",
chat_id="oc_123456",
content='web_search("test query")',
metadata={"_tool_hint": True}
event=ProgressEvent(tool_hint=True),
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
@@ -72,7 +72,7 @@ async def test_tool_hint_empty_content_does_not_send(mock_feishu_channel):
channel="feishu",
chat_id="oc_123456",
content=" ", # whitespace only
metadata={"_tool_hint": True}
event=ProgressEvent(tool_hint=True),
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
@@ -107,7 +107,7 @@ async def test_tool_hint_multiple_tools_in_one_message(mock_feishu_channel):
channel="feishu",
chat_id="oc_123456",
content='web_search("query"), read_file("/path/to/file")',
metadata={"_tool_hint": True}
event=ProgressEvent(tool_hint=True),
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
@@ -127,7 +127,7 @@ async def test_tool_hint_new_format_basic(mock_feishu_channel):
channel="feishu",
chat_id="oc_123456",
content='read src/main.py, grep "TODO"',
metadata={"_tool_hint": True}
event=ProgressEvent(tool_hint=True),
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
@@ -146,7 +146,7 @@ async def test_tool_hint_new_format_with_comma_in_quotes(mock_feishu_channel):
channel="feishu",
chat_id="oc_123456",
content='grep "hello, world", $ echo test',
metadata={"_tool_hint": True}
event=ProgressEvent(tool_hint=True),
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
@@ -165,7 +165,7 @@ async def test_tool_hint_new_format_with_folding(mock_feishu_channel):
channel="feishu",
chat_id="oc_123456",
content='read path × 3, grep "pattern"',
metadata={"_tool_hint": True}
event=ProgressEvent(tool_hint=True),
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
@@ -184,7 +184,7 @@ async def test_tool_hint_new_format_mcp(mock_feishu_channel):
channel="feishu",
chat_id="oc_123456",
content='4_5v::analyze_image("photo.jpg")',
metadata={"_tool_hint": True}
event=ProgressEvent(tool_hint=True),
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
@@ -202,7 +202,7 @@ async def test_tool_hint_keeps_commas_inside_arguments(mock_feishu_channel):
channel="feishu",
chat_id="oc_123456",
content='web_search("foo, bar"), read_file("/path/to/file")',
metadata={"_tool_hint": True}
event=ProgressEvent(tool_hint=True),
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
+7 -6
View File
@@ -11,6 +11,7 @@ from nio import RoomSendResponse, SyncError
import nanobot.channels.matrix as matrix_module
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.matrix import (
MATRIX_HTML_FORMAT,
@@ -1522,7 +1523,7 @@ async def test_send_progress_keeps_typing_keepalive_running() -> None:
channel="matrix",
chat_id="!room:matrix.org",
content="working...",
metadata={"_progress": True, "_progress_kind": "reasoning"},
event=ProgressEvent(content="working..."),
)
)
@@ -1544,7 +1545,7 @@ async def test_send_empty_content_does_not_call_room_send() -> None:
channel="matrix",
chat_id="!room:matrix.org",
content="",
metadata={"_progress": True},
event=ProgressEvent(),
)
)
@@ -1563,7 +1564,7 @@ async def test_send_whitespace_only_content_does_not_call_room_send() -> None:
channel="matrix",
chat_id="!room:matrix.org",
content=" \n\n ",
metadata={"_progress": True},
event=ProgressEvent(content=" \n\n "),
)
)
@@ -1883,7 +1884,7 @@ async def test_send_delta_stream_end_replaces_existing_message() -> None:
last_edit=100.0,
)
await channel.send_delta("!room:matrix.org", "", {"_stream_end": True})
await channel.send_delta("!room:matrix.org", "", stream_end=True)
assert "!room:matrix.org" not in channel._stream_bufs
assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS)
@@ -1933,7 +1934,7 @@ async def test_send_delta_threaded_edit_keeps_replace_and_thread_relation(monkey
}
await channel.send_delta("!room:matrix.org", "Hello", metadata)
await channel.send_delta("!room:matrix.org", " world", metadata)
await channel.send_delta("!room:matrix.org", "", {"_stream_end": True, **metadata})
await channel.send_delta("!room:matrix.org", "", metadata, stream_end=True)
edit_content = client.room_send_calls[1]["content"]
final_content = client.room_send_calls[2]["content"]
@@ -1966,7 +1967,7 @@ async def test_send_delta_stream_end_noop_when_buffer_missing() -> None:
client = _FakeAsyncClient("", "", "", None)
channel.client = client
await channel.send_delta("!room:matrix.org", "", {"_stream_end": True})
await channel.send_delta("!room:matrix.org", "", stream_end=True)
assert client.room_send_calls == []
assert client.typing_calls == []
+2 -1
View File
@@ -10,6 +10,7 @@ from unittest.mock import MagicMock
import pytest
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.signal import (
SignalChannel,
@@ -1341,7 +1342,7 @@ class TestSend:
channel="signal",
chat_id="+19995550001",
content="working...",
metadata={"_progress": True},
event=ProgressEvent(content="working..."),
)
await ch.send(msg)
# Progress messages should NOT stop the typing indicator
+16 -13
View File
@@ -12,6 +12,7 @@ except ImportError:
pytest.skip("Telegram dependencies not installed (python-telegram-bot)", allow_module_level=True)
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.telegram import (
TELEGRAM_REPLY_CONTEXT_MAX_LEN,
@@ -604,7 +605,7 @@ async def test_send_delta_stream_end_raises_and_keeps_buffer_on_failure() -> Non
channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0)
with pytest.raises(RuntimeError, match="boom"):
await channel.send_delta("123", "", {"_stream_end": True})
await channel.send_delta("123", "", stream_end=True)
assert "123" in channel._stream_bufs
@@ -621,7 +622,7 @@ async def test_send_delta_stream_end_treats_not_modified_as_success() -> None:
channel._app.bot.edit_message_text = AsyncMock(side_effect=BadRequest("Message is not modified"))
channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0, stream_id="s:0")
await channel.send_delta("123", "", {"_stream_end": True, "_stream_id": "s:0"})
await channel.send_delta("123", "", stream_id="s:0", stream_end=True)
assert "123" not in channel._stream_bufs
@@ -642,7 +643,7 @@ async def test_send_delta_stream_end_does_not_fallback_on_network_timeout() -> N
channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0)
with pytest.raises(TimedOut, match="network timeout"):
await channel.send_delta("123", "", {"_stream_end": True})
await channel.send_delta("123", "", stream_end=True)
# Every call to edit_message_text must have used parse_mode="HTML" —
# no plain-text fallback call should have been made.
@@ -666,7 +667,7 @@ async def test_send_delta_stream_end_does_not_fallback_on_network_error() -> Non
channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0)
with pytest.raises(NetworkError, match="connection reset"):
await channel.send_delta("123", "", {"_stream_end": True})
await channel.send_delta("123", "", stream_end=True)
# Every call to edit_message_text must have used parse_mode="HTML" —
# no plain-text fallback call should have been made.
@@ -693,7 +694,7 @@ async def test_send_delta_stream_end_falls_back_on_bad_request() -> None:
)
channel._stream_bufs["123"] = _StreamBuf(text="hello <bad>", message_id=7, last_edit=0.0)
await channel.send_delta("123", "", {"_stream_end": True})
await channel.send_delta("123", "", stream_end=True)
# edit_message_text should have been called twice: once for HTML, once for plain fallback
assert channel._app.bot.edit_message_text.call_count == 2
@@ -724,7 +725,7 @@ async def test_send_delta_stream_end_splits_oversized_reply() -> None:
oversized = "x" * (4000 + 500)
channel._stream_bufs["123"] = _StreamBuf(text=oversized, message_id=7, last_edit=0.0)
await channel.send_delta("123", "", {"_stream_end": True})
await channel.send_delta("123", "", stream_end=True)
channel._app.bot.edit_message_text.assert_called_once()
edit_text = channel._app.bot.edit_message_text.call_args.kwargs.get("text", "")
@@ -762,7 +763,7 @@ async def test_send_delta_stream_end_html_expansion_does_not_overflow() -> None:
channel._stream_bufs["123"] = _StreamBuf(text=markdown_text, message_id=7, last_edit=0.0)
await channel.send_delta("123", "", {"_stream_end": True})
await channel.send_delta("123", "", stream_end=True)
channel._app.bot.edit_message_text.assert_called_once()
edit_text = channel._app.bot.edit_message_text.call_args.kwargs.get("text", "")
@@ -789,7 +790,7 @@ async def test_send_delta_stream_end_splits_long_code_block_before_html_renderin
raw_text = "```python\n" + ("print(\"line\")\n" * 450) + "```\nDone"
channel._stream_bufs["123"] = _StreamBuf(text=raw_text, message_id=7, last_edit=0.0)
await channel.send_delta("123", "", {"_stream_end": True})
await channel.send_delta("123", "", stream_end=True)
html_chunks = [
channel._app.bot.edit_message_text.call_args.kwargs.get("text", ""),
@@ -819,7 +820,7 @@ async def test_send_delta_new_stream_id_replaces_stale_buffer() -> None:
stream_id="old:0",
)
await channel.send_delta("123", "world", {"_stream_delta": True, "_stream_id": "new:0"})
await channel.send_delta("123", "world", stream_id="new:0")
buf = channel._stream_bufs["123"]
assert buf.text == "world"
@@ -839,7 +840,7 @@ async def test_send_delta_incremental_edit_treats_not_modified_as_success() -> N
channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0, stream_id="s:0")
channel._app.bot.edit_message_text = AsyncMock(side_effect=BadRequest("Message is not modified"))
await channel.send_delta("123", "", {"_stream_delta": True, "_stream_id": "s:0"})
await channel.send_delta("123", "", stream_id="s:0")
assert channel._stream_bufs["123"].last_edit > 0.0
@@ -864,7 +865,7 @@ async def test_send_delta_incremental_edit_splits_oversized_buffer() -> None:
text=oversized, message_id=7, last_edit=0.0, stream_id="s:0"
)
await channel.send_delta("123", "y", {"_stream_delta": True, "_stream_id": "s:0"})
await channel.send_delta("123", "y", stream_id="s:0")
channel._app.bot.edit_message_text.assert_called_once()
edit_text = channel._app.bot.edit_message_text.call_args.kwargs.get("text", "")
@@ -888,7 +889,8 @@ async def test_send_delta_initial_send_keeps_message_in_thread() -> None:
await channel.send_delta(
"123",
"hello",
{"_stream_delta": True, "_stream_id": "s:0", "message_thread_id": 42},
{"message_thread_id": 42},
stream_id="s:0",
)
assert channel._app.bot.sent_messages[0]["message_thread_id"] == 42
@@ -962,7 +964,8 @@ async def test_send_progress_keeps_message_in_topic() -> None:
channel="telegram",
chat_id="123",
content="hello",
metadata={"_progress": True, "message_thread_id": 42},
event=ProgressEvent(content="hello"),
metadata={"message_thread_id": 42},
)
)
+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())
+12 -12
View File
@@ -16,6 +16,7 @@ import websockets
from ws_test_client import WsTestClient, issue_token, issue_token_ok
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
from nanobot.webui.gateway_services import build_gateway_services
@@ -213,8 +214,7 @@ async def test_server_send_message(bus: MagicMock) -> None:
@pytest.mark.asyncio
async def test_server_send_tags_tool_hint_with_kind(bus: MagicMock) -> None:
"""``_tool_hint`` metadata must surface as ``kind: "tool_hint"`` so WS
clients render breadcrumbs separately from conversational replies."""
"""Tool-hint progress events surface as ``kind: "tool_hint"``."""
ch = _ch(bus, 29919)
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
@@ -232,7 +232,7 @@ async def test_server_send_tags_tool_hint_with_kind(bus: MagicMock) -> None:
await ch.send(OutboundMessage(
channel="websocket", chat_id=ready.chat_id,
content='weather("get")',
metadata={"_progress": True, "_tool_hint": True},
event=ProgressEvent(content='weather("get")', tool_hint=True),
))
hint = await c.recv_message()
assert hint.raw.get("kind") == "tool_hint"
@@ -242,7 +242,7 @@ async def test_server_send_tags_tool_hint_with_kind(bus: MagicMock) -> None:
await ch.send(OutboundMessage(
channel="websocket", chat_id=ready.chat_id,
content="thinking…",
metadata={"_progress": True},
event=ProgressEvent(content="thinking…"),
))
prog = await c.recv_message()
assert prog.raw.get("kind") == "progress"
@@ -284,8 +284,8 @@ async def test_streaming_deltas_and_end(bus: MagicMock) -> None:
async with WsTestClient("ws://127.0.0.1:29911/", client_id="s") as c:
cid = (await c.recv_ready()).chat_id
for part in ("Hello", " ", "world", "!"):
await ch.send_delta(cid, part, {"_stream_delta": True, "_stream_id": "s1"})
await ch.send_delta(cid, "", {"_stream_end": True, "_stream_id": "s1"})
await ch.send_delta(cid, part, stream_id="s1")
await ch.send_delta(cid, "", stream_id="s1", stream_end=True)
msgs = await c.collect_stream()
deltas = [m for m in msgs if m.event == "delta"]
@@ -305,12 +305,12 @@ async def test_interleaved_streams(bus: MagicMock) -> None:
try:
async with WsTestClient("ws://127.0.0.1:29912/", client_id="i") as c:
cid = (await c.recv_ready()).chat_id
await ch.send_delta(cid, "A1", {"_stream_delta": True, "_stream_id": "sa"})
await ch.send_delta(cid, "B1", {"_stream_delta": True, "_stream_id": "sb"})
await ch.send_delta(cid, "A2", {"_stream_delta": True, "_stream_id": "sa"})
await ch.send_delta(cid, "", {"_stream_end": True, "_stream_id": "sa"})
await ch.send_delta(cid, "B2", {"_stream_delta": True, "_stream_id": "sb"})
await ch.send_delta(cid, "", {"_stream_end": True, "_stream_id": "sb"})
await ch.send_delta(cid, "A1", stream_id="sa")
await ch.send_delta(cid, "B1", stream_id="sb")
await ch.send_delta(cid, "A2", stream_id="sa")
await ch.send_delta(cid, "", stream_id="sa", stream_end=True)
await ch.send_delta(cid, "B2", stream_id="sb")
await ch.send_delta(cid, "", stream_id="sb", stream_end=True)
msgs = await c.recv_n(6)
sa = "".join(m.text for m in msgs if m.event == "delta" and m.stream_id == "sa")
+8 -2
View File
@@ -18,6 +18,7 @@ if not WECOM_AVAILABLE:
pytest.skip("WeCom dependencies not installed (wecom_aibot_sdk)", allow_module_level=True)
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.wecom import (
WecomChannel,
@@ -316,7 +317,7 @@ async def test_send_text_with_frame() -> None:
@pytest.mark.asyncio
async def test_send_progress_with_frame() -> None:
"""When metadata has _progress, send uses reply_stream with finish=False."""
"""Progress events use reply_stream with finish=False."""
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["*"]), MessageBus())
client = _FakeWeComClient()
channel._client = client
@@ -324,7 +325,12 @@ async def test_send_progress_with_frame() -> None:
channel._chat_frames["chat1"] = _FakeFrame()
await channel.send(
OutboundMessage(channel="wecom", chat_id="chat1", content="thinking...", metadata={"_progress": True})
OutboundMessage(
channel="wecom",
chat_id="chat1",
content="thinking...",
event=ProgressEvent(content="thinking..."),
)
)
client.reply_stream.assert_called_once()
+28 -14
View File
@@ -10,6 +10,7 @@ import httpx
import pytest
import nanobot.channels.weixin as weixin_mod
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.weixin import (
ITEM_IMAGE,
@@ -686,7 +687,8 @@ async def test_send_progress_message_keeps_typing_indicator() -> None:
"chat_id": "wx-user",
"content": "thinking",
"media": [],
"metadata": {"_progress": True},
"event": ProgressEvent(content="thinking"),
"metadata": {},
},
)()
)
@@ -1409,7 +1411,8 @@ async def test_buffer_single_tool_hint_not_sent_immediately() -> None:
"chat_id": "wx-user",
"content": "Using tool",
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
"event": ProgressEvent(content="Using tool", tool_hint=True),
"metadata": {},
},
)()
)
@@ -1437,7 +1440,8 @@ async def test_buffer_multiple_tool_hints_flushed_on_final_answer() -> None:
"chat_id": "wx-user",
"content": hint,
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
"event": ProgressEvent(content=hint, tool_hint=True),
"metadata": {},
},
)()
)
@@ -1482,7 +1486,8 @@ async def test_thought_progress_flushes_tool_hints() -> None:
"chat_id": "wx-user",
"content": "search 'foo'",
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
"event": ProgressEvent(content="search 'foo'", tool_hint=True),
"metadata": {},
},
)()
)
@@ -1497,7 +1502,8 @@ async def test_thought_progress_flushes_tool_hints() -> None:
"chat_id": "wx-user",
"content": "Let me think...",
"media": [],
"metadata": {"_progress": True},
"event": ProgressEvent(content="Let me think..."),
"metadata": {},
},
)()
)
@@ -1547,7 +1553,8 @@ async def test_reasoning_delta_does_not_flush_tool_hints() -> None:
"chat_id": "wx-user",
"content": "search 'foo'",
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
"event": ProgressEvent(content="search 'foo'", tool_hint=True),
"metadata": {},
},
)()
)
@@ -1561,7 +1568,8 @@ async def test_reasoning_delta_does_not_flush_tool_hints() -> None:
"chat_id": "wx-user",
"content": "Thinking step 1...",
"media": [],
"metadata": {"_progress": True, "_reasoning_delta": True},
"event": ProgressEvent(content="Thinking step 1...", reasoning_delta=True),
"metadata": {},
},
)()
)
@@ -1610,7 +1618,8 @@ async def test_empty_progress_message_does_not_flush_tool_hints() -> None:
"chat_id": "wx-user",
"content": "search 'foo'",
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
"event": ProgressEvent(content="search 'foo'", tool_hint=True),
"metadata": {},
},
)()
)
@@ -1624,7 +1633,8 @@ async def test_empty_progress_message_does_not_flush_tool_hints() -> None:
"chat_id": "wx-user",
"content": "",
"media": [],
"metadata": {"_progress": True, "_tool_events": [{"phase": "end"}]},
"event": ProgressEvent(tool_events=[{"phase": "end"}]),
"metadata": {},
},
)()
)
@@ -1671,7 +1681,8 @@ async def test_buffer_flush_refreshes_context_token() -> None:
"chat_id": "wx-user",
"content": "hint",
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
"event": ProgressEvent(content="hint", tool_hint=True),
"metadata": {},
},
)()
)
@@ -1712,7 +1723,8 @@ async def test_buffer_flush_failure_does_not_block_final_answer() -> None:
"chat_id": "wx-user",
"content": "hint",
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
"event": ProgressEvent(content="hint", tool_hint=True),
"metadata": {},
},
)()
)
@@ -1753,12 +1765,13 @@ async def test_buffer_flushed_on_stream_end() -> None:
"chat_id": "wx-user",
"content": "hint",
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
"event": ProgressEvent(content="hint", tool_hint=True),
"metadata": {},
},
)()
)
await channel.send_delta("wx-user", "", {"_stream_end": True})
await channel.send_delta("wx-user", "", stream_end=True)
channel._send_text.assert_awaited_once_with("wx-user", "hint", "ctx-1")
assert "wx-user" not in channel._pending_tool_hints
@@ -1826,7 +1839,8 @@ async def test_send_tool_hints_false_drops_tool_hints() -> None:
"chat_id": "wx-user",
"content": "hint",
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
"event": ProgressEvent(content="hint", tool_hint=True),
"metadata": {},
},
)()
)
+21 -10
View File
@@ -3,6 +3,7 @@ from unittest.mock import patch
import pytest
from nanobot.bus.outbound_events import ProgressEvent, RetryWaitEvent
from nanobot.cli import commands
@@ -14,7 +15,8 @@ async def test_interactive_retry_wait_is_rendered_as_progress_even_when_progress
channels_config = SimpleNamespace(send_progress=False, send_tool_hints=False)
msg = SimpleNamespace(
content="Model request failed, retry in 2s (attempt 1).",
metadata={"_retry_wait": True},
event=RetryWaitEvent(content="Model request failed, retry in 2s (attempt 1)."),
metadata={},
)
async def fake_print(text: str, active_thinking: object | None, renderer=None) -> None:
@@ -40,7 +42,8 @@ async def test_reasoning_displayed_when_show_reasoning_enabled():
)
msg = SimpleNamespace(
content="Let me think about this...",
metadata={"_progress": True, "_reasoning": True},
event=ProgressEvent(content="Let me think about this...", reasoning=True),
metadata={},
)
with patch("nanobot.cli.commands._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)):
@@ -59,7 +62,8 @@ async def test_reasoning_delta_displayed_when_show_reasoning_enabled():
)
msg = SimpleNamespace(
content="I should search first.",
metadata={"_progress": True, "_reasoning_delta": True},
event=ProgressEvent(content="I should search first.", reasoning_delta=True),
metadata={},
)
with patch("nanobot.cli.commands._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)):
@@ -81,7 +85,8 @@ async def test_reasoning_delta_buffers_until_sentence_boundary():
first = await commands._maybe_print_interactive_progress(
SimpleNamespace(
content="The",
metadata={"_progress": True, "_reasoning_delta": True},
event=ProgressEvent(content="The", reasoning_delta=True),
metadata={},
),
None,
channels_config,
@@ -90,7 +95,8 @@ async def test_reasoning_delta_buffers_until_sentence_boundary():
second = await commands._maybe_print_interactive_progress(
SimpleNamespace(
content=" user asked.",
metadata={"_progress": True, "_reasoning_delta": True},
event=ProgressEvent(content=" user asked.", reasoning_delta=True),
metadata={},
),
None,
channels_config,
@@ -114,7 +120,8 @@ async def test_reasoning_end_flushes_buffered_delta():
delta = await commands._maybe_print_interactive_progress(
SimpleNamespace(
content="The user asked",
metadata={"_progress": True, "_reasoning_delta": True},
event=ProgressEvent(content="The user asked", reasoning_delta=True),
metadata={},
),
None,
channels_config,
@@ -123,7 +130,8 @@ async def test_reasoning_end_flushes_buffered_delta():
end = await commands._maybe_print_interactive_progress(
SimpleNamespace(
content="",
metadata={"_progress": True, "_reasoning_end": True},
event=ProgressEvent(reasoning_end=True),
metadata={},
),
None,
channels_config,
@@ -143,7 +151,8 @@ async def test_reasoning_hidden_when_show_reasoning_disabled():
)
msg = SimpleNamespace(
content="Let me think about this...",
metadata={"_progress": True, "_reasoning": True},
event=ProgressEvent(content="Let me think about this...", reasoning=True),
metadata={},
)
with patch("nanobot.cli.commands._print_cli_reasoning") as mock_reasoning:
@@ -162,7 +171,8 @@ async def test_non_reasoning_progress_not_affected_by_show_reasoning():
)
msg = SimpleNamespace(
content="working on it...",
metadata={"_progress": True},
event=ProgressEvent(content="working on it..."),
metadata={},
)
async def fake_print(text: str, thinking=None, renderer=None):
@@ -185,7 +195,8 @@ async def test_reasoning_shown_when_send_progress_disabled():
)
msg = SimpleNamespace(
content="Let me think about this...",
metadata={"_progress": True, "_reasoning": True},
event=ProgressEvent(content="Let me think about this...", reasoning=True),
metadata={},
)
with patch(
-8
View File
@@ -49,10 +49,6 @@ async def test_maybe_continue_turn_queues_internal_message():
"message_id": "msg-1",
"origin_message_id": "msg-0",
"_wants_stream": True,
"_stream_id": "stream-1",
"_stream_delta": True,
"_stream_end": True,
"_resuming": True,
"webui": True,
},
),
@@ -78,10 +74,6 @@ async def test_maybe_continue_turn_queues_internal_message():
assert queued.metadata["message_id"] == "msg-1"
assert queued.metadata["origin_message_id"] == "msg-0"
assert queued.metadata["_wants_stream"] is True
assert "_stream_id" not in queued.metadata
assert "_stream_delta" not in queued.metadata
assert "_stream_end" not in queued.metadata
assert "_resuming" not in queued.metadata
assert "Finish the migration." in queued.content
assert ctx.all_messages == messages[:-1]
assert ctx.final_content == ""
+5 -2
View File
@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import GoalStatusEvent
from nanobot.session import webui_turns as wth
@@ -28,7 +29,8 @@ async def test_publish_turn_run_status_running_records_wall_clock() -> None:
assert isinstance(t0, float)
call = bus.publish_outbound.await_args[0][0]
assert call.chat_id == "chat-a"
assert call.metadata.get("started_at") == t0
assert isinstance(call.event, GoalStatusEvent)
assert call.event.started_at == t0
@pytest.mark.asyncio
@@ -41,7 +43,8 @@ async def test_publish_turn_run_status_reuses_explicit_wall_clock() -> None:
assert wth.websocket_turn_wall_started_at("chat-a") == 1234.5
call = bus.publish_outbound.await_args[0][0]
assert call.metadata.get("started_at") == 1234.5
assert isinstance(call.event, GoalStatusEvent)
assert call.event.started_at == 1234.5
@pytest.mark.asyncio