refactor(bus): type outbound runtime events
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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"}]),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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", "
|
||||
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", "
|
||||
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",
|
||||
"",
|
||||
{"_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())
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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": {},
|
||||
},
|
||||
)()
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user