From 5f4cfbcb1604648d846d274fcec226d3d5925b36 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Tue, 30 Jun 2026 00:03:07 +0800 Subject: [PATCH] refactor(bus): type outbound runtime events --- nanobot/agent/loop.py | 58 ++-- nanobot/bus/events.py | 12 +- nanobot/bus/outbound_events.py | 127 +++++++ nanobot/bus/progress.py | 27 +- nanobot/channels/base.py | 54 ++- nanobot/channels/discord.py | 17 +- nanobot/channels/email.py | 3 +- nanobot/channels/feishu.py | 27 +- nanobot/channels/manager.py | 142 +++++--- nanobot/channels/matrix.py | 17 +- nanobot/channels/signal.py | 3 +- nanobot/channels/slack.py | 5 +- nanobot/channels/telegram.py | 23 +- nanobot/channels/websocket.py | 110 ++++--- nanobot/channels/wecom.py | 3 +- nanobot/channels/weixin.py | 37 ++- nanobot/cli/commands.py | 39 ++- nanobot/session/turn_continuation.py | 4 - nanobot/session/webui_turns.py | 106 +++--- .../test_loop_direct_websocket_status.py | 11 +- tests/agent/test_loop_progress.py | 120 ++++--- tests/agent/test_loop_runner_integration.py | 12 +- tests/agent/test_loop_save_turn.py | 39 ++- tests/agent/test_task_cancel.py | 5 +- tests/agent/tools/test_long_task.py | 8 +- tests/bus/test_outbound_events.py | 98 ++++++ .../test_channel_manager_delta_coalescing.py | 311 ++++++++---------- .../test_channel_manager_reasoning.py | 88 ++--- tests/channels/test_channel_plugins.py | 47 ++- tests/channels/test_discord_channel.py | 15 +- tests/channels/test_email_channel.py | 6 +- tests/channels/test_feishu_reaction.py | 26 +- tests/channels/test_feishu_reply.py | 4 +- tests/channels/test_feishu_streaming.py | 44 ++- .../test_feishu_tool_hint_code_block.py | 18 +- tests/channels/test_matrix_channel.py | 13 +- tests/channels/test_signal_channel.py | 3 +- tests/channels/test_telegram_channel.py | 29 +- tests/channels/test_websocket_channel.py | 114 ++++--- tests/channels/test_websocket_integration.py | 24 +- tests/channels/test_wecom_channel.py | 10 +- tests/channels/test_weixin_channel.py | 42 ++- tests/cli/test_interactive_retry_wait.py | 31 +- tests/session/test_turn_continuation.py | 8 - tests/utils/test_webui_turn_helpers.py | 7 +- 45 files changed, 1206 insertions(+), 741 deletions(-) create mode 100644 nanobot/bus/outbound_events.py create mode 100644 tests/bus/test_outbound_events.py diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index d2b44699..7e28d1e9 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -31,6 +31,13 @@ from nanobot.agent.tools.message import MessageTool from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.self import MyTool from nanobot.bus.events import InboundMessage, OutboundMessage +from nanobot.bus.outbound_events import ( + RetryWaitEvent, + StreamDeltaEvent, + StreamedResponseEvent, + StreamEndEvent, + outbound_message_for_event, +) from nanobot.bus.progress import build_bus_progress_callback from nanobot.bus.queue import MessageBus from nanobot.bus.runtime_events import ( @@ -567,14 +574,12 @@ class AgentLoop: """Build a retry-wait callback that publishes to the message bus.""" async def _on_retry_wait(content: str) -> None: - meta = dict(msg.metadata or {}) - meta["_retry_wait"] = True await self.bus.publish_outbound( - OutboundMessage( + outbound_message_for_event( channel=msg.channel, chat_id=msg.chat_id, - content=content, - metadata=meta, + event=RetryWaitEvent(content=content), + metadata=msg.metadata, ) ) @@ -999,26 +1004,31 @@ class AgentLoop: return f"{stream_base_id}:{stream_segment}" async def on_stream(delta: str) -> None: - meta = dict(msg.metadata or {}) - meta["_stream_delta"] = True - meta["_stream_id"] = _current_stream_id() - await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, chat_id=msg.chat_id, - content=delta, - metadata=meta, - )) + await self.bus.publish_outbound( + outbound_message_for_event( + channel=msg.channel, + chat_id=msg.chat_id, + event=StreamDeltaEvent( + content=delta, + stream_id=_current_stream_id(), + ), + metadata=msg.metadata, + ) + ) async def on_stream_end(*, resuming: bool = False) -> None: nonlocal stream_segment - meta = dict(msg.metadata or {}) - meta["_stream_end"] = True - meta["_resuming"] = resuming - meta["_stream_id"] = _current_stream_id() - await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, chat_id=msg.chat_id, - content="", - metadata=meta, - )) + await self.bus.publish_outbound( + outbound_message_for_event( + channel=msg.channel, + chat_id=msg.chat_id, + event=StreamEndEvent( + stream_id=_current_stream_id(), + resuming=resuming, + ), + metadata=msg.metadata, + ) + ) stream_segment += 1 response = await self._process_message( @@ -1371,9 +1381,10 @@ class AgentLoop: preview = final_content[:120] + "..." if len(final_content) > 120 else final_content logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview) + event = None meta = dict(msg.metadata or {}) if on_stream is not None and stop_reason not in {"error", "tool_error"}: - meta["_streamed"] = True + event = StreamedResponseEvent() if turn_latency_ms is not None: meta["latency_ms"] = int(turn_latency_ms) @@ -1381,6 +1392,7 @@ class AgentLoop: channel=msg.channel, chat_id=msg.chat_id, content=final_content, + event=event, metadata=meta, ) diff --git a/nanobot/bus/events.py b/nanobot/bus/events.py index 713fe01d..5bfdd6db 100644 --- a/nanobot/bus/events.py +++ b/nanobot/bus/events.py @@ -2,7 +2,10 @@ from dataclasses import dataclass, field from datetime import datetime -from typing import Any +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from nanobot.bus.outbound_events import OutboundEvent # Optional ``OutboundMessage.metadata`` key for structured, channel-agnostic UI # payloads. Value is JSON-serializable with at least ``kind``; rich clients may @@ -39,9 +42,9 @@ class InboundMessage: class OutboundMessage: """Message to send to a chat channel. - ``metadata`` can carry routing (``message_id``, …), trace flags (``_progress``), - and optional ``OUTBOUND_META_AGENT_UI`` blobs for rich clients; non-WebUI - channels may ignore unknown keys. + ``event`` carries internal runtime/UI semantics. ``metadata`` is reserved + for channel routing context (``message_id``, thread ids, etc.) and optional + ``OUTBOUND_META_AGENT_UI`` blobs for rich clients. """ channel: str @@ -51,3 +54,4 @@ class OutboundMessage: media: list[str] = field(default_factory=list) metadata: dict[str, Any] = field(default_factory=dict) buttons: list[list[str]] = field(default_factory=list) + event: "OutboundEvent | None" = None diff --git a/nanobot/bus/outbound_events.py b/nanobot/bus/outbound_events.py new file mode 100644 index 00000000..2a5a5ba0 --- /dev/null +++ b/nanobot/bus/outbound_events.py @@ -0,0 +1,127 @@ +"""Typed outbound events carried by :class:`OutboundMessage`. + +The message bus still transports :class:`nanobot.bus.events.OutboundMessage` +because channels need chat routing fields. Runtime/UI semantics live on the +message's explicit ``event`` field rather than in reserved metadata flags. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, replace +from typing import Any + +from nanobot.bus.events import OutboundMessage + + +class OutboundEvent: + """Marker base for internal outbound runtime events.""" + + +@dataclass(frozen=True) +class ProgressEvent(OutboundEvent): + content: str = "" + tool_hint: bool = False + reasoning: bool = False + reasoning_delta: bool = False + reasoning_end: bool = False + stream_id: str | None = None + tool_events: list[dict[str, Any]] | None = None + file_edit_events: list[dict[str, Any]] | None = None + + +@dataclass(frozen=True) +class RetryWaitEvent(OutboundEvent): + content: str = "" + + +@dataclass(frozen=True) +class StreamDeltaEvent(OutboundEvent): + content: str = "" + stream_id: str | None = None + + +@dataclass(frozen=True) +class StreamEndEvent(OutboundEvent): + content: str = "" + stream_id: str | None = None + resuming: bool = False + + +@dataclass(frozen=True) +class StreamedResponseEvent(OutboundEvent): + pass + + +@dataclass(frozen=True) +class TurnEndEvent(OutboundEvent): + latency_ms: int | None = None + goal_state: dict[str, Any] | None = None + + +@dataclass(frozen=True) +class GoalStatusEvent(OutboundEvent): + status: str + started_at: float | None = None + + +@dataclass(frozen=True) +class GoalStateSyncEvent(OutboundEvent): + goal_state: dict[str, Any] + + +@dataclass(frozen=True) +class SessionUpdatedEvent(OutboundEvent): + scope: str | None = None + + +@dataclass(frozen=True) +class RuntimeModelUpdatedEvent(OutboundEvent): + model: str | None + model_preset: str | None = None + + +def outbound_message_for_event( + *, + channel: str, + chat_id: str, + event: OutboundEvent, + content: str | None = None, + metadata: Mapping[str, Any] | None = None, +) -> OutboundMessage: + """Build an :class:`OutboundMessage` for a typed event.""" + + return OutboundMessage( + channel=channel, + chat_id=chat_id, + content=_event_content(event) if content is None else content, + event=event, + metadata=dict(metadata or {}), + ) + + +def outbound_event_from_message(msg: OutboundMessage) -> OutboundEvent | None: + """Return the typed outbound event carried by *msg*, if any.""" + + return msg.event + + +def replace_outbound_event( + msg: OutboundMessage, + event: OutboundEvent, + *, + content: str | None = None, +) -> OutboundMessage: + """Return *msg* with a new event and optional content.""" + + return replace( + msg, + content=_event_content(event) if content is None else content, + event=event, + ) + + +def _event_content(event: OutboundEvent) -> str: + if isinstance(event, ProgressEvent | RetryWaitEvent | StreamDeltaEvent | StreamEndEvent): + return event.content + return "" diff --git a/nanobot/bus/progress.py b/nanobot/bus/progress.py index d30b7ed7..4dbb4849 100644 --- a/nanobot/bus/progress.py +++ b/nanobot/bus/progress.py @@ -10,7 +10,8 @@ from __future__ import annotations from collections.abc import Awaitable, Callable from typing import Any -from nanobot.bus.events import InboundMessage, OutboundMessage +from nanobot.bus.events import InboundMessage +from nanobot.bus.outbound_events import ProgressEvent, outbound_message_for_event from nanobot.bus.queue import MessageBus @@ -29,23 +30,19 @@ def build_bus_progress_callback( reasoning: bool = False, reasoning_end: bool = False, ) -> None: - meta = dict(msg.metadata or {}) - meta["_progress"] = True - meta["_tool_hint"] = tool_hint - if reasoning: - meta["_reasoning_delta"] = True - if reasoning_end: - meta["_reasoning_end"] = True - if tool_events: - meta["_tool_events"] = tool_events - if file_edit_events: - meta["_file_edit_events"] = file_edit_events await bus.publish_outbound( - OutboundMessage( + outbound_message_for_event( channel=msg.channel, chat_id=msg.chat_id, - content=content, - metadata=meta, + event=ProgressEvent( + content=content, + tool_hint=tool_hint, + reasoning_delta=reasoning, + reasoning_end=reasoning_end, + tool_events=tool_events, + file_edit_events=file_edit_events, + ), + metadata=msg.metadata, ) ) diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index 37fff8a4..ce0fe570 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -101,20 +101,33 @@ class BaseChannel(ABC): """ pass - async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None: + async def send_delta( + self, + chat_id: str, + delta: str, + metadata: dict[str, Any] | None = None, + *, + stream_id: str | None = None, + stream_end: bool = False, + resuming: bool = False, + ) -> None: """Deliver a streaming text chunk. Override in subclasses to enable streaming. Implementations should raise on delivery failure so the channel manager can retry. - Streaming contract: ``_stream_delta`` is a chunk, ``_stream_end`` ends - the current segment, and stateful implementations must key buffers by - ``_stream_id`` rather than only by ``chat_id``. + Stateful implementations should key buffers by ``stream_id`` rather + than only by ``chat_id`` when it is provided. """ pass async def send_reasoning_delta( - self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None + self, + chat_id: str, + delta: str, + metadata: dict[str, Any] | None = None, + *, + stream_id: str | None = None, ) -> None: """Stream a chunk of model reasoning/thinking content. @@ -123,15 +136,17 @@ class BaseChannel(ABC): subtext, WebUI italic bubble, ...) override to render reasoning as a subordinate trace that updates in place as the model thinks. - Streaming contract mirrors :meth:`send_delta`: ``_reasoning_delta`` - is a chunk, ``_reasoning_end`` ends the current reasoning segment, - and stateful implementations should key buffers by ``_stream_id`` - rather than only by ``chat_id``. + Streaming contract mirrors :meth:`send_delta`: stateful implementations + should key buffers by ``stream_id`` rather than only by ``chat_id``. """ return async def send_reasoning_end( - self, chat_id: str, metadata: dict[str, Any] | None = None + self, + chat_id: str, + metadata: dict[str, Any] | None = None, + *, + stream_id: str | None = None, ) -> None: """Mark the end of a reasoning stream segment. @@ -165,13 +180,18 @@ class BaseChannel(ABC): """ if not msg.content: return - meta = dict(msg.metadata or {}) - meta.setdefault("_reasoning_delta", True) - await self.send_reasoning_delta(msg.chat_id, msg.content, meta) - end_meta = dict(meta) - end_meta.pop("_reasoning_delta", None) - end_meta["_reasoning_end"] = True - await self.send_reasoning_end(msg.chat_id, end_meta) + stream_id = getattr(msg.event, "stream_id", None) + await self.send_reasoning_delta( + msg.chat_id, + msg.content, + msg.metadata, + stream_id=stream_id, + ) + await self.send_reasoning_end( + msg.chat_id, + msg.metadata, + stream_id=stream_id, + ) @property def supports_streaming(self) -> bool: diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index aaa8584f..6252d555 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, Any, Literal from pydantic import Field from nanobot.bus.events import OutboundMessage +from nanobot.bus.outbound_events import ProgressEvent from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.command.builtin import build_help_text @@ -458,7 +459,7 @@ class DiscordChannel(BaseChannel): self.logger.warning("client not ready; dropping outbound message") return - is_progress = bool((msg.metadata or {}).get("_progress")) + is_progress = isinstance(msg.event, ProgressEvent) try: await client.send_outbound(msg) @@ -471,7 +472,14 @@ class DiscordChannel(BaseChannel): await self._clear_reactions(msg.chat_id) async def send_delta( - self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None + self, + chat_id: str, + delta: str, + metadata: dict[str, Any] | None = None, + *, + stream_id: str | None = None, + stream_end: bool = False, + resuming: bool = False, ) -> None: """Progressive Discord delivery: send once, then edit until the stream ends.""" client = self._client @@ -479,10 +487,7 @@ class DiscordChannel(BaseChannel): self.logger.warning("client not ready; dropping stream delta") return - meta = metadata or {} - stream_id = meta.get("_stream_id") - - if meta.get("_stream_end"): + if stream_end: buf = self._stream_bufs.get(chat_id) if not buf or buf.message is None or not buf.text: return diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py index a7476451..5f025e0f 100644 --- a/nanobot/channels/email.py +++ b/nanobot/channels/email.py @@ -23,6 +23,7 @@ from loguru import logger from pydantic import Field from nanobot.bus.events import OutboundMessage +from nanobot.bus.outbound_events import ProgressEvent from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.paths import get_media_dir @@ -218,7 +219,7 @@ class EmailChannel(BaseChannel): return # Skip progress messages to prevent sending an empty email after each tool call - if (msg.metadata or {}).get("_progress"): + if isinstance(msg.event, ProgressEvent): self.logger.debug("Skip progress message to {}", msg.chat_id) return diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index e1a03c72..4f1d3602 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -22,6 +22,7 @@ from rich.panel import Panel from rich.text import Text from nanobot.bus.events import OutboundMessage +from nanobot.bus.outbound_events import ProgressEvent from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.paths import get_media_dir @@ -1797,14 +1798,19 @@ class FeishuChannel(BaseChannel): return self._stream_update_text_sync(card_id, content, sequence), sequence async def send_delta( - self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None + self, + chat_id: str, + delta: str, + metadata: dict[str, Any] | None = None, + *, + stream_id: str | None = None, + stream_end: bool = False, + resuming: bool = False, ) -> None: """Progressive streaming via CardKit: create card on first delta, stream-update on subsequent. Supported metadata keys: - _stream_end: Finalize the streaming card. - _tool_hint: Delta is a formatted tool hint (for display only). - message_id: Original message id (used with _stream_end for reaction cleanup). + message_id: Original message id (used with stream end for reaction cleanup). chat_type: "group" or "p2p" — controls reply-in-thread for streaming cards. """ if not self._client: @@ -1815,14 +1821,14 @@ class FeishuChannel(BaseChannel): rid_type = "chat_id" if chat_id.startswith("oc_") else "open_id" # --- stream end: final update or fallback --- - if meta.get("_stream_end"): + if stream_end: message_id = meta.get("message_id") # Only finalize the OnIt -> DONE reaction transition on the truly - # final stream end. _resuming=True means the agent will keep + # final stream end. resuming=True means the agent will keep # working (more tool-call rounds), so leave the reaction state # in place — otherwise the OnIt indicator disappears prematurely # and the DONE reaction fires after every tool call. - if message_id and not meta.get("_resuming"): + if message_id and not resuming: reaction_id = self._reaction_ids.pop(message_id, None) if reaction_id: await self._remove_reaction(message_id, reaction_id) @@ -1965,7 +1971,9 @@ class FeishuChannel(BaseChannel): # Handle tool hint messages. When a streaming card is active for # this chat, inline the hint into the card instead of sending a # separate message so the user experience stays cohesive. - if msg.metadata.get("_tool_hint"): + progress_event = msg.event if isinstance(msg.event, ProgressEvent) else None + + if progress_event and progress_event.tool_hint: hint = (msg.content or "").strip() if not hint: return @@ -1976,6 +1984,7 @@ class FeishuChannel(BaseChannel): await self.send_delta( msg.chat_id, "\n\n" + self._format_tool_hint_delta(hint) + "\n\n", + metadata=msg.metadata, ) return # No active streaming card — send as a regular interactive card @@ -2009,7 +2018,7 @@ class FeishuChannel(BaseChannel): reply_message_id: str | None = None _msg_id = msg.metadata.get("message_id") has_thread_id = msg.metadata.get("thread_id") - if self.config.reply_to_message and not msg.metadata.get("_progress", False): + if self.config.reply_to_message and progress_event is None: reply_message_id = _msg_id # For topic group messages, always reply to keep context in thread elif has_thread_id: diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 23a342f1..5a2d0634 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -12,6 +12,16 @@ from typing import TYPE_CHECKING, Any from loguru import logger from nanobot.bus.events import OutboundMessage +from nanobot.bus.outbound_events import ( + ProgressEvent, + RetryWaitEvent, + RuntimeModelUpdatedEvent, + StreamDeltaEvent, + StreamedResponseEvent, + StreamEndEvent, + outbound_event_from_message, + replace_outbound_event, +) from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import Config @@ -266,7 +276,7 @@ class ChannelManager: def _should_suppress_outbound(self, msg: OutboundMessage) -> bool: metadata = msg.metadata or {} - if metadata.get("_progress"): + if isinstance(outbound_event_from_message(msg), ProgressEvent): return False fingerprint = self._fingerprint_content(msg.content) if not fingerprint: @@ -305,57 +315,59 @@ class ChannelManager: timeout=1.0 ) - if ( - msg.metadata.get("_reasoning_delta") - or msg.metadata.get("_reasoning_end") - or msg.metadata.get("_reasoning") + event = outbound_event_from_message(msg) + progress_event = event if isinstance(event, ProgressEvent) else None + if progress_event and ( + progress_event.reasoning_delta + or progress_event.reasoning_end + or progress_event.reasoning ): # Reasoning rides its own plugin channel: only delivered # when the destination channel opts in via ``show_reasoning`` # and overrides the streaming primitives. Channels without # a low-emphasis UI affordance keep the base no-op and the - # content silently drops here. ``_reasoning`` (one-shot) - # is accepted for backward compatibility with hooks that - # haven't migrated to delta/end yet. + # content silently drops here. channel = self.channels.get(msg.channel) if channel is not None and channel.show_reasoning: await self._send_with_retry(channel, msg) continue - if msg.metadata.get("_progress"): - if msg.metadata.get("_tool_hint") and not self._should_send_progress( + if progress_event: + if progress_event.tool_hint and not self._should_send_progress( msg.channel, tool_hint=True, ): continue - if not msg.metadata.get("_tool_hint") and not self._should_send_progress( + if not progress_event.tool_hint and not self._should_send_progress( msg.channel, tool_hint=False, ): continue - if msg.metadata.get("_retry_wait"): + if isinstance(event, RetryWaitEvent): continue if ( - msg.metadata.get("_runtime_model_updated") + isinstance(event, RuntimeModelUpdatedEvent) and msg.channel == "websocket" and "websocket" not in self.channels ): continue - # Coalesce consecutive _stream_delta messages for the same (channel, chat_id) + # Coalesce consecutive stream delta messages for the same (channel, chat_id) # to reduce API calls and improve streaming latency - if msg.metadata.get("_stream_delta") and not msg.metadata.get("_stream_end"): + if isinstance(event, StreamDeltaEvent): msg, extra_pending = self._coalesce_stream_deltas(msg) pending.extend(extra_pending) + event = outbound_event_from_message(msg) channel = self.channels.get(msg.channel) if channel: # Duplicate suppression is scoped to a known source message # so repeated content from separate turns is still delivered. if ( - not msg.metadata.get("_stream_delta") - and not msg.metadata.get("_stream_end") - and not msg.metadata.get("_streamed") + not isinstance( + event, + StreamDeltaEvent | StreamEndEvent | StreamedResponseEvent, + ) ): if self._should_suppress_outbound(msg): logger.info("Suppressing duplicate outbound message to {}:{}", msg.channel, msg.chat_id) @@ -372,31 +384,53 @@ class ChannelManager: @staticmethod async def _send_once(channel: BaseChannel, msg: OutboundMessage) -> None: """Send one outbound message without retry policy.""" - if msg.metadata.get("_reasoning_end"): - await channel.send_reasoning_end(msg.chat_id, msg.metadata) - elif msg.metadata.get("_reasoning_delta"): - await channel.send_reasoning_delta(msg.chat_id, msg.content, msg.metadata) - elif msg.metadata.get("_reasoning"): - # Back-compat: one-shot reasoning. BaseChannel translates this - # to a single delta + end pair so plugins only implement the - # streaming primitives. + event = outbound_event_from_message(msg) + if isinstance(event, ProgressEvent) and event.reasoning_end: + await channel.send_reasoning_end( + msg.chat_id, + msg.metadata, + stream_id=event.stream_id, + ) + elif isinstance(event, ProgressEvent) and event.reasoning_delta: + await channel.send_reasoning_delta( + msg.chat_id, + msg.content, + msg.metadata, + stream_id=event.stream_id, + ) + elif isinstance(event, ProgressEvent) and event.reasoning: + # BaseChannel translates one-shot reasoning to a single delta + + # end pair so plugins only implement the streaming primitives. await channel.send_reasoning(msg) - elif msg.metadata.get("_file_edit_events"): - edits = msg.metadata.get("_file_edit_events") + elif isinstance(event, ProgressEvent) and event.file_edit_events: await channel.send_file_edit_events( msg.chat_id, - edits if isinstance(edits, list) else [], + event.file_edit_events, msg.metadata, ) - elif msg.metadata.get("_stream_delta") or msg.metadata.get("_stream_end"): - await channel.send_delta(msg.chat_id, msg.content, msg.metadata) - elif not msg.metadata.get("_streamed"): + elif isinstance(event, StreamDeltaEvent): + await channel.send_delta( + msg.chat_id, + msg.content, + msg.metadata, + stream_id=event.stream_id, + ) + elif isinstance(event, StreamEndEvent): + await channel.send_delta( + msg.chat_id, + msg.content, + msg.metadata, + stream_id=event.stream_id, + stream_end=True, + resuming=event.resuming, + ) + elif not isinstance(event, StreamedResponseEvent): await channel.send(msg) def _coalesce_stream_deltas( self, first_msg: OutboundMessage ) -> tuple[OutboundMessage, list[OutboundMessage]]: - """Merge consecutive _stream_delta messages for the same (channel, chat_id, _stream_id). + """Merge consecutive stream deltas for the same (channel, chat_id, stream_id). This reduces the number of API calls when the queue has accumulated multiple deltas, which happens when LLM generates faster than the channel can process. @@ -404,10 +438,15 @@ class ChannelManager: Returns: tuple of (merged_message, list_of_non_matching_messages) """ - first_metadata = first_msg.metadata or {} - target_key = (first_msg.channel, first_msg.chat_id, first_metadata.get("_stream_id")) + first_event = outbound_event_from_message(first_msg) + first_stream_id = first_event.stream_id if isinstance(first_event, StreamDeltaEvent) else None + target_key = (first_msg.channel, first_msg.chat_id, first_stream_id) combined_content = first_msg.content - final_metadata = dict(first_msg.metadata or {}) + final_event: StreamDeltaEvent | StreamEndEvent = ( + first_event + if isinstance(first_event, StreamDeltaEvent) + else StreamDeltaEvent(stream_id=first_stream_id) + ) non_matching: list[OutboundMessage] = [] # Only merge consecutive deltas. As soon as we hit any other message, @@ -419,21 +458,29 @@ class ChannelManager: break # Check if this message belongs to the same stream - next_metadata = next_msg.metadata or {} + next_event = outbound_event_from_message(next_msg) + next_stream_id = ( + next_event.stream_id + if isinstance(next_event, StreamDeltaEvent | StreamEndEvent) + else None + ) same_target = ( next_msg.channel, next_msg.chat_id, - next_metadata.get("_stream_id"), + next_stream_id, ) == target_key - is_delta = next_metadata.get("_stream_delta") - is_end = next_metadata.get("_stream_end") + is_delta = isinstance(next_event, StreamDeltaEvent) + is_end = isinstance(next_event, StreamEndEvent) - if same_target and is_delta and not final_metadata.get("_stream_end"): + if same_target and (is_delta or (is_end and next_msg.content)): # Accumulate content combined_content += next_msg.content - # If we see _stream_end, remember it and stop coalescing this stream - if is_end: - final_metadata["_stream_end"] = True + # If we see stream_end, remember it and stop coalescing this stream + if isinstance(next_event, StreamEndEvent): + final_event = StreamEndEvent( + stream_id=next_stream_id, + resuming=next_event.resuming, + ) # Stream ended - stop coalescing this stream break else: @@ -441,12 +488,7 @@ class ChannelManager: non_matching.append(next_msg) break - merged = OutboundMessage( - channel=first_msg.channel, - chat_id=first_msg.chat_id, - content=combined_content, - metadata=final_metadata, - ) + merged = replace_outbound_event(first_msg, final_event, content=combined_content) return merged, non_matching async def _send_with_retry(self, channel: BaseChannel, msg: OutboundMessage) -> None: diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index abfa2b13..73e34d58 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -49,6 +49,7 @@ except ImportError as e: ) from e from nanobot.bus.events import OutboundMessage +from nanobot.bus.outbound_events import ProgressEvent from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.paths import get_data_dir, get_media_dir @@ -504,7 +505,7 @@ class MatrixChannel(BaseChannel): text = msg.content or "" candidates = self._collect_outbound_media_candidates(msg.media) relates_to = self._build_thread_relates_to(msg.metadata) - is_progress = bool((msg.metadata or {}).get("_progress")) + is_progress = isinstance(msg.event, ProgressEvent) try: failures: list[str] = [] if candidates: @@ -528,11 +529,19 @@ class MatrixChannel(BaseChannel): if not is_progress: await self._stop_typing_keepalive(msg.chat_id, clear_typing=True) - async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None: - meta = metadata or {} + async def send_delta( + self, + chat_id: str, + delta: str, + metadata: dict[str, Any] | None = None, + *, + stream_id: str | None = None, + stream_end: bool = False, + resuming: bool = False, + ) -> None: relates_to = self._build_thread_relates_to(metadata) - if meta.get("_stream_end"): + if stream_end: buf = self._stream_bufs.pop(chat_id, None) if not buf or not buf.event_id or not buf.text: return diff --git a/nanobot/channels/signal.py b/nanobot/channels/signal.py index 2a38f60a..3a282fb7 100644 --- a/nanobot/channels/signal.py +++ b/nanobot/channels/signal.py @@ -18,6 +18,7 @@ import httpx from pydantic import Field, computed_field, field_validator from nanobot.bus.events import InboundMessage, OutboundMessage +from nanobot.bus.outbound_events import ProgressEvent from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.paths import get_media_dir @@ -539,7 +540,7 @@ class SignalChannel(BaseChannel): async def send(self, msg: OutboundMessage) -> None: """Send a message through Signal.""" - is_progress_message = bool(msg.metadata.get("_progress")) + is_progress_message = isinstance(msg.event, ProgressEvent) try: plain_text, text_styles = _markdown_to_signal(msg.content) if not plain_text and not msg.media: diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 45aa2117..a9a43c11 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -14,6 +14,7 @@ from slack_sdk.web.async_client import AsyncWebClient from slackify_markdown import slackify_markdown from nanobot.bus.events import OutboundMessage +from nanobot.bus.outbound_events import ProgressEvent from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.paths import get_media_dir @@ -164,7 +165,7 @@ class SlackChannel(BaseChannel): # only makes sense within the originating conversation. thread_ts_param = thread_ts if thread_ts and target_chat_id == origin_chat_id else None - is_progress = (msg.metadata or {}).get("_progress", False) + is_progress = isinstance(msg.event, ProgressEvent) if is_progress and not msg.content: pass # skip empty progress messages (e.g. tool-event-only updates) elif msg.content or not (msg.media or []): @@ -190,7 +191,7 @@ class SlackChannel(BaseChannel): self.logger.exception("Failed to upload file {}", media_path) # Update reaction emoji when the final (non-progress) response is sent - if not (msg.metadata or {}).get("_progress"): + if not is_progress: event = slack_meta.get("event", {}) await self._update_react_emoji(origin_chat_id, event.get("ts")) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 606f0480..af4a3fc2 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -26,6 +26,7 @@ from telegram.ext import Application, CallbackQueryHandler, ContextTypes, Messag from telegram.request import HTTPXRequest from nanobot.bus.events import OutboundMessage +from nanobot.bus.outbound_events import ProgressEvent from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.command.builtin import build_help_text @@ -36,7 +37,7 @@ from nanobot.utils.helpers import split_message TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit # Telegram's actual API limit is 4096; we split raw markdown at 4000 as a -# safety margin for mid-stream edits (plain text). For _stream_end, we split +# safety margin for mid-stream edits (plain text). On stream end, we split # raw markdown into chunks whose rendered HTML fits Telegram's true 4096-char # boundary so the final rendered message never overflows. TELEGRAM_HTML_MAX_LEN = 4096 @@ -706,8 +707,10 @@ class TelegramChannel(BaseChannel): self.logger.warning("bot not running") return + progress_event = msg.event if isinstance(msg.event, ProgressEvent) else None + # Only stop typing indicator and remove reaction for final responses - if not msg.metadata.get("_progress", False): + if progress_event is None: self._stop_typing(msg.chat_id) if reply_to_message_id := msg.metadata.get("message_id"): with suppress(ValueError): @@ -792,7 +795,7 @@ class TelegramChannel(BaseChannel): # Send text content if msg.content and msg.content != "[empty message]": - render_as_blockquote = bool(msg.metadata.get("_tool_hint")) + render_as_blockquote = bool(progress_event and progress_event.tool_hint) buttons = getattr(msg, "buttons", None) or [] reply_markup = self._build_keyboard(buttons) if buttons else None text = msg.content @@ -887,15 +890,23 @@ class TelegramChannel(BaseChannel): def _is_not_modified_error(exc: Exception) -> bool: return isinstance(exc, BadRequest) and "message is not modified" in str(exc).lower() - async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None: + async def send_delta( + self, + chat_id: str, + delta: str, + metadata: dict[str, Any] | None = None, + *, + stream_id: str | None = None, + stream_end: bool = False, + resuming: bool = False, + ) -> None: """Progressive message editing: send on first delta, edit on subsequent ones.""" if not self._app: return meta = metadata or {} int_chat_id = int(chat_id) - stream_id = meta.get("_stream_id") - if meta.get("_stream_end"): + if stream_end: buf = self._stream_bufs.get(chat_id) if not buf or not buf.message_id or not buf.text: return diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 1fcf7aa1..e5d237ef 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -19,6 +19,16 @@ from websockets.exceptions import ConnectionClosed from websockets.http11 import Request as WsRequest from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage +from nanobot.bus.outbound_events import ( + GoalStateSyncEvent, + GoalStatusEvent, + ProgressEvent, + RuntimeModelUpdatedEvent, + SessionUpdatedEvent, + TurnEndEvent, + outbound_event_from_message, + outbound_message_for_event, +) from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.paths import get_media_dir @@ -148,16 +158,13 @@ def publish_runtime_model_update( model_preset: str | None, ) -> None: """Enqueue a runtime model snapshot for websocket subscribers (fan-out in-channel).""" - bus.outbound.put_nowait(OutboundMessage( - channel="websocket", - chat_id="*", - content="", - metadata={ - "_runtime_model_updated": True, - "model": model, - "model_preset": model_preset, - }, - )) + bus.outbound.put_nowait( + outbound_message_for_event( + channel="websocket", + chat_id="*", + event=RuntimeModelUpdatedEvent(model=model, model_preset=model_preset), + ) + ) def _parse_inbound_payload(raw: str) -> str | None: @@ -851,70 +858,63 @@ class WebSocketChannel(BaseChannel): raise async def send(self, msg: OutboundMessage) -> None: - if msg.metadata.get("_runtime_model_updated"): + event = outbound_event_from_message(msg) + progress_event = event if isinstance(event, ProgressEvent) else None + if isinstance(event, RuntimeModelUpdatedEvent): await self.send_runtime_model_updated( - model_name=msg.metadata.get("model"), - model_preset=msg.metadata.get("model_preset"), + model_name=event.model, + model_preset=event.model_preset, ) return # Snapshot the subscriber set so ConnectionClosed cleanups mid-iteration are safe. conns = list(self._subs.get(msg.chat_id, ())) if not conns: - if ( - msg.metadata.get("_progress") - or msg.metadata.get("_file_edit_events") - or msg.metadata.get("_turn_end") - or msg.metadata.get("_session_updated") - or msg.metadata.get("_goal_status") - or msg.metadata.get("_goal_state_sync") + if isinstance( + event, + ProgressEvent + | TurnEndEvent + | SessionUpdatedEvent + | GoalStatusEvent + | GoalStateSyncEvent, ): self.logger.debug("no active subscribers for chat_id={}", msg.chat_id) else: self.logger.warning("no active subscribers for chat_id={}", msg.chat_id) - if msg.metadata.get("_goal_state_sync"): + if isinstance(event, GoalStateSyncEvent): if conns: - blob = msg.metadata.get("goal_state") - await self.send_goal_state(msg.chat_id, blob if isinstance(blob, dict) else {"active": False}) + await self.send_goal_state(msg.chat_id, event.goal_state or {"active": False}) return - if msg.metadata.get("_goal_status"): + if isinstance(event, GoalStatusEvent): if conns: - status = msg.metadata.get("goal_status") - if status in ("running", "idle"): - started_raw = msg.metadata.get("started_at", msg.metadata.get("goal_started_at")) + if event.status in ("running", "idle"): await self.send_goal_status( msg.chat_id, - status, - started_at=float(started_raw) if isinstance(started_raw, int | float) else None, + event.status, + started_at=event.started_at, ) return # Signal that the agent has fully finished processing the current turn. - if msg.metadata.get("_turn_end"): - lat = msg.metadata.get("latency_ms") - lat_i = int(lat) if isinstance(lat, (int, float)) else None - gs = msg.metadata.get("goal_state") - gs_blob = gs if isinstance(gs, dict) else None + if isinstance(event, TurnEndEvent): await self.send_turn_end( msg.chat_id, - latency_ms=lat_i, - goal_state=gs_blob, + latency_ms=event.latency_ms, + goal_state=event.goal_state, metadata=msg.metadata, ) await self.send_session_updated(msg.chat_id, scope="thread") return - if msg.metadata.get("_session_updated"): + if isinstance(event, SessionUpdatedEvent): if conns: - scope = msg.metadata.get("_session_update_scope") await self.send_session_updated( msg.chat_id, - scope=scope if isinstance(scope, str) else None, + scope=event.scope, ) return - if msg.metadata.get("_file_edit_events"): - edits = msg.metadata.get("_file_edit_events") + if progress_event and progress_event.file_edit_events: await self.send_file_edit_events( msg.chat_id, - edits if isinstance(edits, list) else [], + progress_event.file_edit_events, msg.metadata, ) return @@ -939,17 +939,17 @@ class WebSocketChannel(BaseChannel): lat = msg.metadata.get("latency_ms") if isinstance(lat, (int, float)): payload["latency_ms"] = int(lat) - if msg.metadata.get("_tool_events"): - payload["tool_events"] = msg.metadata["_tool_events"] + if progress_event and progress_event.tool_events: + payload["tool_events"] = progress_event.tool_events agent_ui = msg.metadata.get(OUTBOUND_META_AGENT_UI) if agent_ui is not None: payload["agent_ui"] = agent_ui # Mark intermediate agent breadcrumbs (tool-call hints, generic # progress strings) so WS clients can render them as subordinate # trace rows rather than conversational replies. - if msg.metadata.get("_tool_hint"): + if progress_event and progress_event.tool_hint: payload["kind"] = "tool_hint" - elif msg.metadata.get("_progress"): + elif progress_event: payload["kind"] = "progress" phase = "activity" if payload.get("kind") in ("tool_hint", "progress") else "answer" self._transcripts.prepare_and_append( @@ -971,6 +971,8 @@ class WebSocketChannel(BaseChannel): chat_id: str, delta: str, metadata: dict[str, Any] | None = None, + *, + stream_id: str | None = None, ) -> None: """Push one chunk of model reasoning. Mirrors ``send_delta`` shape so clients receive a stream that opens, updates in place, and closes — @@ -986,7 +988,6 @@ class WebSocketChannel(BaseChannel): "chat_id": chat_id, "text": delta, } - stream_id = meta.get("_stream_id") if stream_id is not None: body["stream_id"] = stream_id self._transcripts.prepare_and_append( @@ -1005,6 +1006,8 @@ class WebSocketChannel(BaseChannel): self, chat_id: str, metadata: dict[str, Any] | None = None, + *, + stream_id: str | None = None, ) -> None: """Close the current reasoning stream segment for in-place renderers.""" conns = list(self._subs.get(chat_id, ())) @@ -1013,7 +1016,6 @@ class WebSocketChannel(BaseChannel): "event": "reasoning_end", "chat_id": chat_id, } - stream_id = meta.get("_stream_id") if stream_id is not None: body["stream_id"] = stream_id self._transcripts.prepare_and_append( @@ -1057,11 +1059,15 @@ class WebSocketChannel(BaseChannel): chat_id: str, delta: str, metadata: dict[str, Any] | None = None, + *, + stream_id: str | None = None, + stream_end: bool = False, + resuming: bool = False, ) -> None: conns = list(self._subs.get(chat_id, ())) meta = metadata or {} - stream_key = (chat_id, str(meta.get("_stream_id") or "")) - if meta.get("_stream_end"): + stream_key = (chat_id, str(stream_id or "")) + if stream_end: body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id} buffered = self._stream_text_buffers.pop(stream_key, []) if delta: @@ -1077,8 +1083,8 @@ class WebSocketChannel(BaseChannel): "text": delta, } self._stream_text_buffers.setdefault(stream_key, []).append(delta) - if meta.get("_stream_id") is not None: - body["stream_id"] = meta["_stream_id"] + if stream_id is not None: + body["stream_id"] = stream_id self._transcripts.prepare_and_append( chat_id, body, diff --git a/nanobot/channels/wecom.py b/nanobot/channels/wecom.py index 8fd36052..ef185b1c 100644 --- a/nanobot/channels/wecom.py +++ b/nanobot/channels/wecom.py @@ -13,6 +13,7 @@ from typing import Any from pydantic import Field from nanobot.bus.events import OutboundMessage +from nanobot.bus.outbound_events import ProgressEvent from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.paths import get_media_dir @@ -497,7 +498,7 @@ class WecomChannel(BaseChannel): try: content = (msg.content or "").strip() - is_progress = bool(msg.metadata.get("_progress")) + is_progress = isinstance(msg.event, ProgressEvent) # Get the stored frame for this chat frame = self._chat_frames.get(msg.chat_id) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index fdb52d10..eab09c84 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -29,6 +29,7 @@ from loguru import logger from pydantic import Field from nanobot.bus.events import OutboundMessage +from nanobot.bus.outbound_events import ProgressEvent from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.paths import get_media_dir, get_runtime_subdir @@ -1101,11 +1102,13 @@ class WeixinChannel(BaseChannel): raise RuntimeError("WeChat client not initialized or not authenticated") self._assert_session_active() - is_progress = bool((msg.metadata or {}).get("_progress", False)) + event = getattr(msg, "event", None) + progress_event = event if isinstance(event, ProgressEvent) else None + is_progress = progress_event is not None # Buffer tool hints to coalesce consecutive ones and avoid burning # WeChat iLink rate-limit quota (~7 msgs / 5 min). - if is_progress and (msg.metadata or {}).get("_tool_hint"): + if progress_event and progress_event.tool_hint: if not self.send_tool_hints: return self._pending_tool_hints.setdefault(msg.chat_id, []).append(msg.content) @@ -1118,7 +1121,7 @@ class WeixinChannel(BaseChannel): # Reasoning deltas are invisible in WeChat (there is no reasoning # UI). Skip them entirely — do not send and do not flush buffer. - if is_progress and (msg.metadata or {}).get("_reasoning_delta"): + if progress_event and (progress_event.reasoning_delta or progress_event.reasoning): self.logger.debug( "Dropped invisible reasoning delta for {}", msg.chat_id ) @@ -1232,40 +1235,46 @@ class WeixinChannel(BaseChannel): await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL) async def send_delta( - self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None + self, + chat_id: str, + delta: str, + metadata: dict[str, Any] | None = None, + *, + stream_id: str | None = None, + stream_end: bool = False, + resuming: bool = False, ) -> None: """Deliver a streamed reply to WeChat. WeChat iLink has no native incremental delivery, and the manager bypasses :meth:`send` for the ``_streamed`` final answer. So we - accumulate the content deltas here and flush the full reply as a - single message at ``_stream_end`` — otherwise a streamed reply would - never reach the user. Reasoning deltas are invisible in WeChat and are - dropped. + accumulate content deltas and flush the full reply as a single message + at stream end. Reasoning deltas are invisible in WeChat and are dropped. """ meta = metadata or {} if meta.get("_reasoning_delta") or meta.get("_reasoning"): return - is_end = meta.get("_stream_end") - # Accumulate intermediate deltas. The _stream_end message's own content + is_end = stream_end or bool(meta.get("_stream_end")) + buffer_key = stream_id or chat_id + # Accumulate intermediate deltas. The stream_end message's own content # (present when the manager coalesces deltas into the end message) is # folded into `full` below instead of appended here, so a send retry # recomputes the same `full` from an unchanged buffer rather than # double-counting that delta. if delta and not is_end: - self._stream_buffers.setdefault(chat_id, []).append(delta) + self._stream_buffers.setdefault(buffer_key, []).append(delta) if not is_end: return - full = ("".join(self._stream_buffers.get(chat_id, [])) + (delta or "")).strip() + full = ("".join(self._stream_buffers.get(buffer_key, [])) + (delta or "")).strip() await self._flush_tool_hints(chat_id) if full: # Send before clearing the buffer: if the send raises, the buffer is # left intact so ChannelManager._send_with_retry can re-deliver the - # same _stream_end message instead of silently losing the reply. + # same stream_end message instead of silently losing the reply. await self.send( OutboundMessage(channel=self.name, chat_id=chat_id, content=full) ) - self._stream_buffers.pop(chat_id, None) + self._stream_buffers.pop(buffer_key, None) async def _start_typing(self, chat_id: str, context_token: str = "") -> None: """Start typing indicator immediately when a message is received.""" diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 89ffdfcd..636c3913 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -50,6 +50,14 @@ from rich.text import Text # noqa: E402 from nanobot import __logo__, __version__ # noqa: E402 from nanobot.agent.loop import AgentLoop # noqa: E402 +from nanobot.bus.outbound_events import ( # noqa: E402 + ProgressEvent, + RetryWaitEvent, + StreamDeltaEvent, + StreamedResponseEvent, + StreamEndEvent, + outbound_event_from_message, +) from nanobot.cli.gateway import create_gateway_app # noqa: E402 from nanobot.cli.stream import StreamRenderer, ThinkingSpinner # noqa: E402 from nanobot.config.paths import get_workspace_path, is_default_workspace # noqa: E402 @@ -461,25 +469,25 @@ async def _maybe_print_interactive_progress( renderer: StreamRenderer | None = None, reasoning_buffer: _ReasoningBuffer | None = None, ) -> bool: - metadata = msg.metadata or {} - if metadata.get("_retry_wait"): + event = outbound_event_from_message(msg) + if isinstance(event, RetryWaitEvent): await _print_interactive_progress_line(msg.content, thinking, renderer) return True - if not metadata.get("_progress"): + if not isinstance(event, ProgressEvent): return False reasoning_buffer = reasoning_buffer or _ReasoningBuffer() - if metadata.get("_reasoning_end"): + if event.reasoning_end: if channels_config and not channels_config.show_reasoning: reasoning_buffer.clear() else: _flush_cli_reasoning(reasoning_buffer, thinking, renderer) return True - is_tool_hint = metadata.get("_tool_hint", False) - is_reasoning = metadata.get("_reasoning", False) or metadata.get("_reasoning_delta", False) + is_tool_hint = event.tool_hint + is_reasoning = event.reasoning or event.reasoning_delta if is_reasoning: if channels_config and not channels_config.show_reasoning: reasoning_buffer.clear() @@ -1456,7 +1464,7 @@ def agent( bus_task = asyncio.create_task(agent_loop.run()) turn_done = asyncio.Event() turn_done.set() - turn_response: list[tuple[str, dict]] = [] + turn_response: list[Any] = [] renderer: StreamRenderer | None = None reasoning_buffer = _ReasoningBuffer() @@ -1464,18 +1472,19 @@ def agent( while True: try: msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) + event = outbound_event_from_message(msg) - if msg.metadata.get("_stream_delta"): + if isinstance(event, StreamDeltaEvent): if renderer: await renderer.on_delta(msg.content) continue - if msg.metadata.get("_stream_end"): + if isinstance(event, StreamEndEvent): if renderer: await renderer.on_end( - resuming=msg.metadata.get("_resuming", False), + resuming=event.resuming, ) continue - if msg.metadata.get("_streamed"): + if isinstance(event, StreamedResponseEvent): turn_done.set() continue @@ -1490,7 +1499,7 @@ def agent( if not turn_done.is_set(): if msg.content: - turn_response.append((msg.content, dict(msg.metadata or {}))) + turn_response.append(msg) turn_done.set() elif msg.content: await _print_interactive_response( @@ -1543,8 +1552,10 @@ def agent( await turn_done.wait() if turn_response: - content, meta = turn_response[0] - if content and not meta.get("_streamed"): + response_msg = turn_response[0] + content = response_msg.content + meta = response_msg.metadata + if content and not isinstance(response_msg.event, StreamedResponseEvent): if renderer: await renderer.close() print_kwargs: dict[str, Any] = {} diff --git a/nanobot/session/turn_continuation.py b/nanobot/session/turn_continuation.py index 2d63bdf4..b10d5acb 100644 --- a/nanobot/session/turn_continuation.py +++ b/nanobot/session/turn_continuation.py @@ -29,10 +29,6 @@ _GOAL_CONTINUATION_SENDER = "system:continuation" _GOAL_CONTINUATION_ROUNDS_KEY = "_sustained_goal_continuation_rounds" _MAX_GOAL_CONTINUATION_ROUNDS = 12 _STRIPPED_INBOUND_META_KEYS = { - "_stream_id", - "_stream_delta", - "_stream_end", - "_resuming", INTERNAL_CONTINUATION_PENDING_META, } diff --git a/nanobot/session/webui_turns.py b/nanobot/session/webui_turns.py index 8c6072bb..b97125e7 100644 --- a/nanobot/session/webui_turns.py +++ b/nanobot/session/webui_turns.py @@ -11,7 +11,15 @@ from typing import Any from loguru import logger from nanobot.bus import progress as bus_progress -from nanobot.bus.events import InboundMessage, OutboundMessage +from nanobot.bus.events import InboundMessage +from nanobot.bus.outbound_events import ( + GoalStateSyncEvent, + GoalStatusEvent, + RuntimeModelUpdatedEvent, + SessionUpdatedEvent, + TurnEndEvent, + outbound_message_for_event, +) from nanobot.bus.queue import MessageBus from nanobot.bus.runtime_events import ( GoalStateChanged, @@ -206,26 +214,22 @@ async def publish_turn_run_status( if msg.channel != "websocket": return cid = str(msg.chat_id) - meta: dict[str, Any] = { - **dict(msg.metadata or {}), - "_goal_status": True, - "goal_status": status, - } + started_at_event: float | None = None if status == "running": if isinstance(started_at, int | float) and started_at > 0: t0 = float(started_at) else: t0 = time.time() - meta["started_at"] = t0 + started_at_event = t0 _WEBSOCKET_TURN_WALL_STARTED_AT[cid] = t0 else: _WEBSOCKET_TURN_WALL_STARTED_AT.pop(cid, None) await bus.publish_outbound( - OutboundMessage( + outbound_message_for_event( channel=msg.channel, chat_id=cid, - content="", - metadata=meta, + event=GoalStatusEvent(status=status, started_at=started_at_event), + metadata=msg.metadata, ), ) @@ -318,28 +322,25 @@ class WebuiTurnCoordinator: if not cid: return await self.bus.publish_outbound( - OutboundMessage( + outbound_message_for_event( channel=event.context.channel, chat_id=cid, - content="", - metadata={ - "_goal_state_sync": True, - "goal_state": goal_state_ws_blob(event.session_metadata), - }, + event=GoalStateSyncEvent( + goal_state=goal_state_ws_blob(event.session_metadata), + ), + metadata=event.context.metadata, ), ) async def _handle_runtime_model_changed(self, event: RuntimeModelChanged) -> None: await self.bus.publish_outbound( - OutboundMessage( + outbound_message_for_event( channel="websocket", chat_id="*", - content="", - metadata={ - "_runtime_model_updated": True, - "model": event.model, - "model_preset": event.model_preset, - }, + event=RuntimeModelUpdatedEvent( + model=event.model, + model_preset=event.model_preset, + ), ) ) @@ -374,17 +375,18 @@ class WebuiTurnCoordinator: if msg.channel != "websocket": return - turn_metadata: dict[str, Any] = {**msg.metadata, "_turn_end": True} - if latency_ms is not None: - turn_metadata["latency_ms"] = int(latency_ms) session = self.sessions.get_or_create(session_key) - turn_metadata["goal_state"] = goal_state_ws_blob(session.metadata) - await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content="", - metadata=turn_metadata, - )) + await self.bus.publish_outbound( + outbound_message_for_event( + channel=msg.channel, + chat_id=msg.chat_id, + event=TurnEndEvent( + latency_ms=latency_ms, + goal_state=goal_state_ws_blob(session.metadata), + ), + metadata=msg.metadata, + ) + ) self._schedule_title_update(msg, session_key=session_key) def _schedule_title_update(self, msg: InboundMessage, *, session_key: str) -> None: @@ -404,16 +406,11 @@ class WebuiTurnCoordinator: model=title_llm.model, ) if generated: - await self.bus.publish_outbound(OutboundMessage( + await self._publish_session_metadata_updated( channel=msg.channel, chat_id=msg.chat_id, - content="", - metadata={ - **msg.metadata, - "_session_updated": True, - "_session_update_scope": "metadata", - }, - )) + metadata=msg.metadata, + ) self.schedule_background(_generate_title_and_notify()) @@ -438,15 +435,26 @@ class WebuiTurnCoordinator: model=title_llm.model, ) if generated: - await self.bus.publish_outbound(OutboundMessage( + await self._publish_session_metadata_updated( channel=event.context.channel, chat_id=event.context.chat_id, - content="", - metadata={ - **event.context.metadata, - "_session_updated": True, - "_session_update_scope": "metadata", - }, - )) + metadata=event.context.metadata, + ) self.schedule_background(_generate_title_and_notify()) + + async def _publish_session_metadata_updated( + self, + *, + channel: str, + chat_id: str, + metadata: dict[str, Any], + ) -> None: + await self.bus.publish_outbound( + outbound_message_for_event( + channel=channel, + chat_id=chat_id, + event=SessionUpdatedEvent(scope="metadata"), + metadata=metadata, + ) + ) diff --git a/tests/agent/test_loop_direct_websocket_status.py b/tests/agent/test_loop_direct_websocket_status.py index ef3d34c4..1c18a25d 100644 --- a/tests/agent/test_loop_direct_websocket_status.py +++ b/tests/agent/test_loop_direct_websocket_status.py @@ -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 diff --git a/tests/agent/test_loop_progress.py b/tests/agent/test_loop_progress.py index 19473cc7..22b62318 100644 --- a/tests/agent/test_loop_progress.py +++ b/tests/agent/test_loop_progress.py @@ -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) diff --git a/tests/agent/test_loop_runner_integration.py b/tests/agent/test_loop_runner_integration.py index dbd21318..bcd727a6 100644 --- a/tests/agent/test_loop_runner_integration.py +++ b/tests/agent/test_loop_runner_integration.py @@ -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 diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index a6984ad4..daac07a4 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -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 diff --git a/tests/agent/test_task_cancel.py b/tests/agent/test_task_cancel.py index e6a59d5e..0d9e9d2d 100644 --- a/tests/agent/test_task_cancel.py +++ b/tests/agent/test_task_cancel.py @@ -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): diff --git a/tests/agent/tools/test_long_task.py b/tests/agent/tools/test_long_task.py index 03bd91d8..7edda8c3 100644 --- a/tests/agent/tools/test_long_task.py +++ b/tests/agent/tools/test_long_task.py @@ -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 diff --git a/tests/bus/test_outbound_events.py b/tests/bus/test_outbound_events.py new file mode 100644 index 00000000..d7658b17 --- /dev/null +++ b/tests/bus/test_outbound_events.py @@ -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) diff --git a/tests/channels/test_channel_manager_delta_coalescing.py b/tests/channels/test_channel_manager_delta_coalescing.py index e3ed8347..5224c803 100644 --- a/tests/channels/test_channel_manager_delta_coalescing.py +++ b/tests/channels/test_channel_manager_delta_coalescing.py @@ -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 diff --git a/tests/channels/test_channel_manager_reasoning.py b/tests/channels/test_channel_manager_reasoning.py index 5df1b3fb..1ad480dd 100644 --- a/tests/channels/test_channel_manager_reasoning.py +++ b/tests/channels/test_channel_manager_reasoning.py @@ -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) diff --git a/tests/channels/test_channel_plugins.py b/tests/channels/test_channel_plugins.py index 7fb4fad1..ab8b3989 100644 --- a/tests/channels/test_channel_plugins.py +++ b/tests/channels/test_channel_plugins.py @@ -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) diff --git a/tests/channels/test_discord_channel.py b/tests/channels/test_discord_channel.py index d4027644..223d7fa4 100644 --- a/tests/channels/test_discord_channel.py +++ b/tests/channels/test_discord_channel.py @@ -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"), ) ) diff --git a/tests/channels/test_email_channel.py b/tests/channels/test_email_channel.py index 22f46682..a8c53c08 100644 --- a/tests/channels/test_email_channel.py +++ b/tests/channels/test_email_channel.py @@ -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"}]), ) ) diff --git a/tests/channels/test_feishu_reaction.py b/tests/channels/test_feishu_reaction.py index 6cc1925e..6d21c0a3 100644 --- a/tests/channels/test_feishu_reaction.py +++ b/tests/channels/test_feishu_reaction.py @@ -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") diff --git a/tests/channels/test_feishu_reply.py b/tests/channels/test_feishu_reply.py index 0a6408da..71e09826 100644 --- a/tests/channels/test_feishu_reply.py +++ b/tests/channels/test_feishu_reply.py @@ -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() diff --git a/tests/channels/test_feishu_streaming.py b/tests/channels/test_feishu_streaming.py index 6810998b..da3537b0 100644 --- a/tests/channels/test_feishu_streaming.py +++ b/tests/channels/test_feishu_streaming.py @@ -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) diff --git a/tests/channels/test_feishu_tool_hint_code_block.py b/tests/channels/test_feishu_tool_hint_code_block.py index 4f9d214c..4fe31f52 100644 --- a/tests/channels/test_feishu_tool_hint_code_block.py +++ b/tests/channels/test_feishu_tool_hint_code_block.py @@ -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: diff --git a/tests/channels/test_matrix_channel.py b/tests/channels/test_matrix_channel.py index c8fc58c4..9b3349be 100644 --- a/tests/channels/test_matrix_channel.py +++ b/tests/channels/test_matrix_channel.py @@ -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 == [] diff --git a/tests/channels/test_signal_channel.py b/tests/channels/test_signal_channel.py index 277c85b8..7eefbcc4 100644 --- a/tests/channels/test_signal_channel.py +++ b/tests/channels/test_signal_channel.py @@ -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 diff --git a/tests/channels/test_telegram_channel.py b/tests/channels/test_telegram_channel.py index 91895fc6..462d4d0b 100644 --- a/tests/channels/test_telegram_channel.py +++ b/tests/channels/test_telegram_channel.py @@ -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 ", 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}, ) ) diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index 2d8e92a2..2a0562bc 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -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()) diff --git a/tests/channels/test_websocket_integration.py b/tests/channels/test_websocket_integration.py index 24bf9f4c..4059c435 100644 --- a/tests/channels/test_websocket_integration.py +++ b/tests/channels/test_websocket_integration.py @@ -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") diff --git a/tests/channels/test_wecom_channel.py b/tests/channels/test_wecom_channel.py index 5300ec5f..94d91c42 100644 --- a/tests/channels/test_wecom_channel.py +++ b/tests/channels/test_wecom_channel.py @@ -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() diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index 177c88d7..1e0ec7b1 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -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": {}, }, )() ) diff --git a/tests/cli/test_interactive_retry_wait.py b/tests/cli/test_interactive_retry_wait.py index 5eeb2c12..9d2d8977 100644 --- a/tests/cli/test_interactive_retry_wait.py +++ b/tests/cli/test_interactive_retry_wait.py @@ -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( diff --git a/tests/session/test_turn_continuation.py b/tests/session/test_turn_continuation.py index b27ebd93..f3e2c829 100644 --- a/tests/session/test_turn_continuation.py +++ b/tests/session/test_turn_continuation.py @@ -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 == "" diff --git a/tests/utils/test_webui_turn_helpers.py b/tests/utils/test_webui_turn_helpers.py index cb8cbf48..3f7a2b21 100644 --- a/tests/utils/test_webui_turn_helpers.py +++ b/tests/utils/test_webui_turn_helpers.py @@ -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