refactor(bus): type outbound runtime events

This commit is contained in:
chengyongru 2026-06-30 00:03:07 +08:00 committed by Xubin Ren
parent f6d1dba32a
commit 5f4cfbcb16
45 changed files with 1206 additions and 741 deletions

View File

@ -31,6 +31,13 @@ from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.self import MyTool from nanobot.agent.tools.self import MyTool
from nanobot.bus.events import InboundMessage, OutboundMessage 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.progress import build_bus_progress_callback
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import ( from nanobot.bus.runtime_events import (
@ -567,14 +574,12 @@ class AgentLoop:
"""Build a retry-wait callback that publishes to the message bus.""" """Build a retry-wait callback that publishes to the message bus."""
async def _on_retry_wait(content: str) -> None: async def _on_retry_wait(content: str) -> None:
meta = dict(msg.metadata or {})
meta["_retry_wait"] = True
await self.bus.publish_outbound( await self.bus.publish_outbound(
OutboundMessage( outbound_message_for_event(
channel=msg.channel, channel=msg.channel,
chat_id=msg.chat_id, chat_id=msg.chat_id,
content=content, event=RetryWaitEvent(content=content),
metadata=meta, metadata=msg.metadata,
) )
) )
@ -999,26 +1004,31 @@ class AgentLoop:
return f"{stream_base_id}:{stream_segment}" return f"{stream_base_id}:{stream_segment}"
async def on_stream(delta: str) -> None: async def on_stream(delta: str) -> None:
meta = dict(msg.metadata or {}) await self.bus.publish_outbound(
meta["_stream_delta"] = True outbound_message_for_event(
meta["_stream_id"] = _current_stream_id() channel=msg.channel,
await self.bus.publish_outbound(OutboundMessage( chat_id=msg.chat_id,
channel=msg.channel, chat_id=msg.chat_id, event=StreamDeltaEvent(
content=delta, content=delta,
metadata=meta, stream_id=_current_stream_id(),
)) ),
metadata=msg.metadata,
)
)
async def on_stream_end(*, resuming: bool = False) -> None: async def on_stream_end(*, resuming: bool = False) -> None:
nonlocal stream_segment nonlocal stream_segment
meta = dict(msg.metadata or {}) await self.bus.publish_outbound(
meta["_stream_end"] = True outbound_message_for_event(
meta["_resuming"] = resuming channel=msg.channel,
meta["_stream_id"] = _current_stream_id() chat_id=msg.chat_id,
await self.bus.publish_outbound(OutboundMessage( event=StreamEndEvent(
channel=msg.channel, chat_id=msg.chat_id, stream_id=_current_stream_id(),
content="", resuming=resuming,
metadata=meta, ),
)) metadata=msg.metadata,
)
)
stream_segment += 1 stream_segment += 1
response = await self._process_message( response = await self._process_message(
@ -1371,9 +1381,10 @@ class AgentLoop:
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview) logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
event = None
meta = dict(msg.metadata or {}) meta = dict(msg.metadata or {})
if on_stream is not None and stop_reason not in {"error", "tool_error"}: 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: if turn_latency_ms is not None:
meta["latency_ms"] = int(turn_latency_ms) meta["latency_ms"] = int(turn_latency_ms)
@ -1381,6 +1392,7 @@ class AgentLoop:
channel=msg.channel, channel=msg.channel,
chat_id=msg.chat_id, chat_id=msg.chat_id,
content=final_content, content=final_content,
event=event,
metadata=meta, metadata=meta,
) )

View File

@ -2,7 +2,10 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime 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 # Optional ``OutboundMessage.metadata`` key for structured, channel-agnostic UI
# payloads. Value is JSON-serializable with at least ``kind``; rich clients may # payloads. Value is JSON-serializable with at least ``kind``; rich clients may
@ -39,9 +42,9 @@ class InboundMessage:
class OutboundMessage: class OutboundMessage:
"""Message to send to a chat channel. """Message to send to a chat channel.
``metadata`` can carry routing (``message_id``, ), trace flags (``_progress``), ``event`` carries internal runtime/UI semantics. ``metadata`` is reserved
and optional ``OUTBOUND_META_AGENT_UI`` blobs for rich clients; non-WebUI for channel routing context (``message_id``, thread ids, etc.) and optional
channels may ignore unknown keys. ``OUTBOUND_META_AGENT_UI`` blobs for rich clients.
""" """
channel: str channel: str
@ -51,3 +54,4 @@ class OutboundMessage:
media: list[str] = field(default_factory=list) media: list[str] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict) metadata: dict[str, Any] = field(default_factory=dict)
buttons: list[list[str]] = field(default_factory=list) buttons: list[list[str]] = field(default_factory=list)
event: "OutboundEvent | None" = None

View File

@ -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 ""

View File

@ -10,7 +10,8 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any 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 from nanobot.bus.queue import MessageBus
@ -29,23 +30,19 @@ def build_bus_progress_callback(
reasoning: bool = False, reasoning: bool = False,
reasoning_end: bool = False, reasoning_end: bool = False,
) -> None: ) -> 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( await bus.publish_outbound(
OutboundMessage( outbound_message_for_event(
channel=msg.channel, channel=msg.channel,
chat_id=msg.chat_id, chat_id=msg.chat_id,
content=content, event=ProgressEvent(
metadata=meta, 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,
) )
) )

View File

@ -101,20 +101,33 @@ class BaseChannel(ABC):
""" """
pass 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. """Deliver a streaming text chunk.
Override in subclasses to enable streaming. Implementations should Override in subclasses to enable streaming. Implementations should
raise on delivery failure so the channel manager can retry. raise on delivery failure so the channel manager can retry.
Streaming contract: ``_stream_delta`` is a chunk, ``_stream_end`` ends Stateful implementations should key buffers by ``stream_id`` rather
the current segment, and stateful implementations must key buffers by than only by ``chat_id`` when it is provided.
``_stream_id`` rather than only by ``chat_id``.
""" """
pass pass
async def send_reasoning_delta( 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: ) -> None:
"""Stream a chunk of model reasoning/thinking content. """Stream a chunk of model reasoning/thinking content.
@ -123,15 +136,17 @@ class BaseChannel(ABC):
subtext, WebUI italic bubble, ...) override to render reasoning subtext, WebUI italic bubble, ...) override to render reasoning
as a subordinate trace that updates in place as the model thinks. as a subordinate trace that updates in place as the model thinks.
Streaming contract mirrors :meth:`send_delta`: ``_reasoning_delta`` Streaming contract mirrors :meth:`send_delta`: stateful implementations
is a chunk, ``_reasoning_end`` ends the current reasoning segment, should key buffers by ``stream_id`` rather than only by ``chat_id``.
and stateful implementations should key buffers by ``_stream_id``
rather than only by ``chat_id``.
""" """
return return
async def send_reasoning_end( 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: ) -> None:
"""Mark the end of a reasoning stream segment. """Mark the end of a reasoning stream segment.
@ -165,13 +180,18 @@ class BaseChannel(ABC):
""" """
if not msg.content: if not msg.content:
return return
meta = dict(msg.metadata or {}) stream_id = getattr(msg.event, "stream_id", None)
meta.setdefault("_reasoning_delta", True) await self.send_reasoning_delta(
await self.send_reasoning_delta(msg.chat_id, msg.content, meta) msg.chat_id,
end_meta = dict(meta) msg.content,
end_meta.pop("_reasoning_delta", None) msg.metadata,
end_meta["_reasoning_end"] = True stream_id=stream_id,
await self.send_reasoning_end(msg.chat_id, end_meta) )
await self.send_reasoning_end(
msg.chat_id,
msg.metadata,
stream_id=stream_id,
)
@property @property
def supports_streaming(self) -> bool: def supports_streaming(self) -> bool:

View File

@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, Any, Literal
from pydantic import Field from pydantic import Field
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.command.builtin import build_help_text from nanobot.command.builtin import build_help_text
@ -458,7 +459,7 @@ class DiscordChannel(BaseChannel):
self.logger.warning("client not ready; dropping outbound message") self.logger.warning("client not ready; dropping outbound message")
return return
is_progress = bool((msg.metadata or {}).get("_progress")) is_progress = isinstance(msg.event, ProgressEvent)
try: try:
await client.send_outbound(msg) await client.send_outbound(msg)
@ -471,7 +472,14 @@ class DiscordChannel(BaseChannel):
await self._clear_reactions(msg.chat_id) await self._clear_reactions(msg.chat_id)
async def send_delta( 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: ) -> None:
"""Progressive Discord delivery: send once, then edit until the stream ends.""" """Progressive Discord delivery: send once, then edit until the stream ends."""
client = self._client client = self._client
@ -479,10 +487,7 @@ class DiscordChannel(BaseChannel):
self.logger.warning("client not ready; dropping stream delta") self.logger.warning("client not ready; dropping stream delta")
return return
meta = metadata or {} if stream_end:
stream_id = meta.get("_stream_id")
if meta.get("_stream_end"):
buf = self._stream_bufs.get(chat_id) buf = self._stream_bufs.get(chat_id)
if not buf or buf.message is None or not buf.text: if not buf or buf.message is None or not buf.text:
return return

View File

@ -23,6 +23,7 @@ from loguru import logger
from pydantic import Field from pydantic import Field
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
@ -218,7 +219,7 @@ class EmailChannel(BaseChannel):
return return
# Skip progress messages to prevent sending an empty email after each tool call # 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) self.logger.debug("Skip progress message to {}", msg.chat_id)
return return

View File

@ -22,6 +22,7 @@ from rich.panel import Panel
from rich.text import Text from rich.text import Text
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir 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 return self._stream_update_text_sync(card_id, content, sequence), sequence
async def send_delta( 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: ) -> None:
"""Progressive streaming via CardKit: create card on first delta, stream-update on subsequent. """Progressive streaming via CardKit: create card on first delta, stream-update on subsequent.
Supported metadata keys: Supported metadata keys:
_stream_end: Finalize the streaming card. message_id: Original message id (used with stream end for reaction cleanup).
_tool_hint: Delta is a formatted tool hint (for display only).
message_id: Original message id (used with _stream_end for reaction cleanup).
chat_type: "group" or "p2p" controls reply-in-thread for streaming cards. chat_type: "group" or "p2p" controls reply-in-thread for streaming cards.
""" """
if not self._client: if not self._client:
@ -1815,14 +1821,14 @@ class FeishuChannel(BaseChannel):
rid_type = "chat_id" if chat_id.startswith("oc_") else "open_id" rid_type = "chat_id" if chat_id.startswith("oc_") else "open_id"
# --- stream end: final update or fallback --- # --- stream end: final update or fallback ---
if meta.get("_stream_end"): if stream_end:
message_id = meta.get("message_id") message_id = meta.get("message_id")
# Only finalize the OnIt -> DONE reaction transition on the truly # 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 # working (more tool-call rounds), so leave the reaction state
# in place — otherwise the OnIt indicator disappears prematurely # in place — otherwise the OnIt indicator disappears prematurely
# and the DONE reaction fires after every tool call. # 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) reaction_id = self._reaction_ids.pop(message_id, None)
if reaction_id: if reaction_id:
await self._remove_reaction(message_id, 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 # Handle tool hint messages. When a streaming card is active for
# this chat, inline the hint into the card instead of sending a # this chat, inline the hint into the card instead of sending a
# separate message so the user experience stays cohesive. # 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() hint = (msg.content or "").strip()
if not hint: if not hint:
return return
@ -1976,6 +1984,7 @@ class FeishuChannel(BaseChannel):
await self.send_delta( await self.send_delta(
msg.chat_id, msg.chat_id,
"\n\n" + self._format_tool_hint_delta(hint) + "\n\n", "\n\n" + self._format_tool_hint_delta(hint) + "\n\n",
metadata=msg.metadata,
) )
return return
# No active streaming card — send as a regular interactive card # No active streaming card — send as a regular interactive card
@ -2009,7 +2018,7 @@ class FeishuChannel(BaseChannel):
reply_message_id: str | None = None reply_message_id: str | None = None
_msg_id = msg.metadata.get("message_id") _msg_id = msg.metadata.get("message_id")
has_thread_id = msg.metadata.get("thread_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 reply_message_id = _msg_id
# For topic group messages, always reply to keep context in thread # For topic group messages, always reply to keep context in thread
elif has_thread_id: elif has_thread_id:

View File

@ -12,6 +12,16 @@ from typing import TYPE_CHECKING, Any
from loguru import logger from loguru import logger
from nanobot.bus.events import OutboundMessage 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.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.schema import Config from nanobot.config.schema import Config
@ -266,7 +276,7 @@ class ChannelManager:
def _should_suppress_outbound(self, msg: OutboundMessage) -> bool: def _should_suppress_outbound(self, msg: OutboundMessage) -> bool:
metadata = msg.metadata or {} metadata = msg.metadata or {}
if metadata.get("_progress"): if isinstance(outbound_event_from_message(msg), ProgressEvent):
return False return False
fingerprint = self._fingerprint_content(msg.content) fingerprint = self._fingerprint_content(msg.content)
if not fingerprint: if not fingerprint:
@ -305,57 +315,59 @@ class ChannelManager:
timeout=1.0 timeout=1.0
) )
if ( event = outbound_event_from_message(msg)
msg.metadata.get("_reasoning_delta") progress_event = event if isinstance(event, ProgressEvent) else None
or msg.metadata.get("_reasoning_end") if progress_event and (
or msg.metadata.get("_reasoning") progress_event.reasoning_delta
or progress_event.reasoning_end
or progress_event.reasoning
): ):
# Reasoning rides its own plugin channel: only delivered # Reasoning rides its own plugin channel: only delivered
# when the destination channel opts in via ``show_reasoning`` # when the destination channel opts in via ``show_reasoning``
# and overrides the streaming primitives. Channels without # and overrides the streaming primitives. Channels without
# a low-emphasis UI affordance keep the base no-op and the # a low-emphasis UI affordance keep the base no-op and the
# content silently drops here. ``_reasoning`` (one-shot) # content silently drops here.
# is accepted for backward compatibility with hooks that
# haven't migrated to delta/end yet.
channel = self.channels.get(msg.channel) channel = self.channels.get(msg.channel)
if channel is not None and channel.show_reasoning: if channel is not None and channel.show_reasoning:
await self._send_with_retry(channel, msg) await self._send_with_retry(channel, msg)
continue continue
if msg.metadata.get("_progress"): if progress_event:
if msg.metadata.get("_tool_hint") and not self._should_send_progress( if progress_event.tool_hint and not self._should_send_progress(
msg.channel, tool_hint=True, msg.channel, tool_hint=True,
): ):
continue 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, msg.channel, tool_hint=False,
): ):
continue continue
if msg.metadata.get("_retry_wait"): if isinstance(event, RetryWaitEvent):
continue continue
if ( if (
msg.metadata.get("_runtime_model_updated") isinstance(event, RuntimeModelUpdatedEvent)
and msg.channel == "websocket" and msg.channel == "websocket"
and "websocket" not in self.channels and "websocket" not in self.channels
): ):
continue 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 # 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) msg, extra_pending = self._coalesce_stream_deltas(msg)
pending.extend(extra_pending) pending.extend(extra_pending)
event = outbound_event_from_message(msg)
channel = self.channels.get(msg.channel) channel = self.channels.get(msg.channel)
if channel: if channel:
# Duplicate suppression is scoped to a known source message # Duplicate suppression is scoped to a known source message
# so repeated content from separate turns is still delivered. # so repeated content from separate turns is still delivered.
if ( if (
not msg.metadata.get("_stream_delta") not isinstance(
and not msg.metadata.get("_stream_end") event,
and not msg.metadata.get("_streamed") StreamDeltaEvent | StreamEndEvent | StreamedResponseEvent,
)
): ):
if self._should_suppress_outbound(msg): if self._should_suppress_outbound(msg):
logger.info("Suppressing duplicate outbound message to {}:{}", msg.channel, msg.chat_id) logger.info("Suppressing duplicate outbound message to {}:{}", msg.channel, msg.chat_id)
@ -372,31 +384,53 @@ class ChannelManager:
@staticmethod @staticmethod
async def _send_once(channel: BaseChannel, msg: OutboundMessage) -> None: async def _send_once(channel: BaseChannel, msg: OutboundMessage) -> None:
"""Send one outbound message without retry policy.""" """Send one outbound message without retry policy."""
if msg.metadata.get("_reasoning_end"): event = outbound_event_from_message(msg)
await channel.send_reasoning_end(msg.chat_id, msg.metadata) if isinstance(event, ProgressEvent) and event.reasoning_end:
elif msg.metadata.get("_reasoning_delta"): await channel.send_reasoning_end(
await channel.send_reasoning_delta(msg.chat_id, msg.content, msg.metadata) msg.chat_id,
elif msg.metadata.get("_reasoning"): msg.metadata,
# Back-compat: one-shot reasoning. BaseChannel translates this stream_id=event.stream_id,
# to a single delta + end pair so plugins only implement the )
# streaming primitives. 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) await channel.send_reasoning(msg)
elif msg.metadata.get("_file_edit_events"): elif isinstance(event, ProgressEvent) and event.file_edit_events:
edits = msg.metadata.get("_file_edit_events")
await channel.send_file_edit_events( await channel.send_file_edit_events(
msg.chat_id, msg.chat_id,
edits if isinstance(edits, list) else [], event.file_edit_events,
msg.metadata, msg.metadata,
) )
elif msg.metadata.get("_stream_delta") or msg.metadata.get("_stream_end"): elif isinstance(event, StreamDeltaEvent):
await channel.send_delta(msg.chat_id, msg.content, msg.metadata) await channel.send_delta(
elif not msg.metadata.get("_streamed"): 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) await channel.send(msg)
def _coalesce_stream_deltas( def _coalesce_stream_deltas(
self, first_msg: OutboundMessage self, first_msg: OutboundMessage
) -> tuple[OutboundMessage, list[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 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. deltas, which happens when LLM generates faster than the channel can process.
@ -404,10 +438,15 @@ class ChannelManager:
Returns: Returns:
tuple of (merged_message, list_of_non_matching_messages) tuple of (merged_message, list_of_non_matching_messages)
""" """
first_metadata = first_msg.metadata or {} first_event = outbound_event_from_message(first_msg)
target_key = (first_msg.channel, first_msg.chat_id, first_metadata.get("_stream_id")) 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 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] = [] non_matching: list[OutboundMessage] = []
# Only merge consecutive deltas. As soon as we hit any other message, # Only merge consecutive deltas. As soon as we hit any other message,
@ -419,21 +458,29 @@ class ChannelManager:
break break
# Check if this message belongs to the same stream # 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 = ( same_target = (
next_msg.channel, next_msg.channel,
next_msg.chat_id, next_msg.chat_id,
next_metadata.get("_stream_id"), next_stream_id,
) == target_key ) == target_key
is_delta = next_metadata.get("_stream_delta") is_delta = isinstance(next_event, StreamDeltaEvent)
is_end = next_metadata.get("_stream_end") 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 # Accumulate content
combined_content += next_msg.content combined_content += next_msg.content
# If we see _stream_end, remember it and stop coalescing this stream # If we see stream_end, remember it and stop coalescing this stream
if is_end: if isinstance(next_event, StreamEndEvent):
final_metadata["_stream_end"] = True final_event = StreamEndEvent(
stream_id=next_stream_id,
resuming=next_event.resuming,
)
# Stream ended - stop coalescing this stream # Stream ended - stop coalescing this stream
break break
else: else:
@ -441,12 +488,7 @@ class ChannelManager:
non_matching.append(next_msg) non_matching.append(next_msg)
break break
merged = OutboundMessage( merged = replace_outbound_event(first_msg, final_event, content=combined_content)
channel=first_msg.channel,
chat_id=first_msg.chat_id,
content=combined_content,
metadata=final_metadata,
)
return merged, non_matching return merged, non_matching
async def _send_with_retry(self, channel: BaseChannel, msg: OutboundMessage) -> None: async def _send_with_retry(self, channel: BaseChannel, msg: OutboundMessage) -> None:

View File

@ -49,6 +49,7 @@ except ImportError as e:
) from e ) from e
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_data_dir, get_media_dir from nanobot.config.paths import get_data_dir, get_media_dir
@ -504,7 +505,7 @@ class MatrixChannel(BaseChannel):
text = msg.content or "" text = msg.content or ""
candidates = self._collect_outbound_media_candidates(msg.media) candidates = self._collect_outbound_media_candidates(msg.media)
relates_to = self._build_thread_relates_to(msg.metadata) relates_to = self._build_thread_relates_to(msg.metadata)
is_progress = bool((msg.metadata or {}).get("_progress")) is_progress = isinstance(msg.event, ProgressEvent)
try: try:
failures: list[str] = [] failures: list[str] = []
if candidates: if candidates:
@ -528,11 +529,19 @@ class MatrixChannel(BaseChannel):
if not is_progress: if not is_progress:
await self._stop_typing_keepalive(msg.chat_id, clear_typing=True) 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: async def send_delta(
meta = metadata or {} 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) relates_to = self._build_thread_relates_to(metadata)
if meta.get("_stream_end"): if stream_end:
buf = self._stream_bufs.pop(chat_id, None) buf = self._stream_bufs.pop(chat_id, None)
if not buf or not buf.event_id or not buf.text: if not buf or not buf.event_id or not buf.text:
return return

View File

@ -18,6 +18,7 @@ import httpx
from pydantic import Field, computed_field, field_validator from pydantic import Field, computed_field, field_validator
from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
@ -539,7 +540,7 @@ class SignalChannel(BaseChannel):
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
"""Send a message through Signal.""" """Send a message through Signal."""
is_progress_message = bool(msg.metadata.get("_progress")) is_progress_message = isinstance(msg.event, ProgressEvent)
try: try:
plain_text, text_styles = _markdown_to_signal(msg.content) plain_text, text_styles = _markdown_to_signal(msg.content)
if not plain_text and not msg.media: if not plain_text and not msg.media:

View File

@ -14,6 +14,7 @@ from slack_sdk.web.async_client import AsyncWebClient
from slackify_markdown import slackify_markdown from slackify_markdown import slackify_markdown
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
@ -164,7 +165,7 @@ class SlackChannel(BaseChannel):
# only makes sense within the originating conversation. # only makes sense within the originating conversation.
thread_ts_param = thread_ts if thread_ts and target_chat_id == origin_chat_id else None 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: if is_progress and not msg.content:
pass # skip empty progress messages (e.g. tool-event-only updates) pass # skip empty progress messages (e.g. tool-event-only updates)
elif msg.content or not (msg.media or []): elif msg.content or not (msg.media or []):
@ -190,7 +191,7 @@ class SlackChannel(BaseChannel):
self.logger.exception("Failed to upload file {}", media_path) self.logger.exception("Failed to upload file {}", media_path)
# Update reaction emoji when the final (non-progress) response is sent # 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", {}) event = slack_meta.get("event", {})
await self._update_react_emoji(origin_chat_id, event.get("ts")) await self._update_react_emoji(origin_chat_id, event.get("ts"))

View File

@ -26,6 +26,7 @@ from telegram.ext import Application, CallbackQueryHandler, ContextTypes, Messag
from telegram.request import HTTPXRequest from telegram.request import HTTPXRequest
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.command.builtin import build_help_text 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_MAX_MESSAGE_LEN = 4000 # Telegram message character limit
# Telegram's actual API limit is 4096; we split raw markdown at 4000 as a # 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 # raw markdown into chunks whose rendered HTML fits Telegram's true 4096-char
# boundary so the final rendered message never overflows. # boundary so the final rendered message never overflows.
TELEGRAM_HTML_MAX_LEN = 4096 TELEGRAM_HTML_MAX_LEN = 4096
@ -706,8 +707,10 @@ class TelegramChannel(BaseChannel):
self.logger.warning("bot not running") self.logger.warning("bot not running")
return return
progress_event = msg.event if isinstance(msg.event, ProgressEvent) else None
# Only stop typing indicator and remove reaction for final responses # 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) self._stop_typing(msg.chat_id)
if reply_to_message_id := msg.metadata.get("message_id"): if reply_to_message_id := msg.metadata.get("message_id"):
with suppress(ValueError): with suppress(ValueError):
@ -792,7 +795,7 @@ class TelegramChannel(BaseChannel):
# Send text content # Send text content
if msg.content and msg.content != "[empty message]": 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 [] buttons = getattr(msg, "buttons", None) or []
reply_markup = self._build_keyboard(buttons) if buttons else None reply_markup = self._build_keyboard(buttons) if buttons else None
text = msg.content text = msg.content
@ -887,15 +890,23 @@ class TelegramChannel(BaseChannel):
def _is_not_modified_error(exc: Exception) -> bool: def _is_not_modified_error(exc: Exception) -> bool:
return isinstance(exc, BadRequest) and "message is not modified" in str(exc).lower() 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.""" """Progressive message editing: send on first delta, edit on subsequent ones."""
if not self._app: if not self._app:
return return
meta = metadata or {} meta = metadata or {}
int_chat_id = int(chat_id) 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) buf = self._stream_bufs.get(chat_id)
if not buf or not buf.message_id or not buf.text: if not buf or not buf.message_id or not buf.text:
return return

View File

@ -19,6 +19,16 @@ from websockets.exceptions import ConnectionClosed
from websockets.http11 import Request as WsRequest from websockets.http11 import Request as WsRequest
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage 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.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
@ -148,16 +158,13 @@ def publish_runtime_model_update(
model_preset: str | None, model_preset: str | None,
) -> None: ) -> None:
"""Enqueue a runtime model snapshot for websocket subscribers (fan-out in-channel).""" """Enqueue a runtime model snapshot for websocket subscribers (fan-out in-channel)."""
bus.outbound.put_nowait(OutboundMessage( bus.outbound.put_nowait(
channel="websocket", outbound_message_for_event(
chat_id="*", channel="websocket",
content="", chat_id="*",
metadata={ event=RuntimeModelUpdatedEvent(model=model, model_preset=model_preset),
"_runtime_model_updated": True, )
"model": model, )
"model_preset": model_preset,
},
))
def _parse_inbound_payload(raw: str) -> str | None: def _parse_inbound_payload(raw: str) -> str | None:
@ -851,70 +858,63 @@ class WebSocketChannel(BaseChannel):
raise raise
async def send(self, msg: OutboundMessage) -> None: 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( await self.send_runtime_model_updated(
model_name=msg.metadata.get("model"), model_name=event.model,
model_preset=msg.metadata.get("model_preset"), model_preset=event.model_preset,
) )
return return
# Snapshot the subscriber set so ConnectionClosed cleanups mid-iteration are safe. # Snapshot the subscriber set so ConnectionClosed cleanups mid-iteration are safe.
conns = list(self._subs.get(msg.chat_id, ())) conns = list(self._subs.get(msg.chat_id, ()))
if not conns: if not conns:
if ( if isinstance(
msg.metadata.get("_progress") event,
or msg.metadata.get("_file_edit_events") ProgressEvent
or msg.metadata.get("_turn_end") | TurnEndEvent
or msg.metadata.get("_session_updated") | SessionUpdatedEvent
or msg.metadata.get("_goal_status") | GoalStatusEvent
or msg.metadata.get("_goal_state_sync") | GoalStateSyncEvent,
): ):
self.logger.debug("no active subscribers for chat_id={}", msg.chat_id) self.logger.debug("no active subscribers for chat_id={}", msg.chat_id)
else: else:
self.logger.warning("no active subscribers for chat_id={}", msg.chat_id) 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: if conns:
blob = msg.metadata.get("goal_state") await self.send_goal_state(msg.chat_id, event.goal_state or {"active": False})
await self.send_goal_state(msg.chat_id, blob if isinstance(blob, dict) else {"active": False})
return return
if msg.metadata.get("_goal_status"): if isinstance(event, GoalStatusEvent):
if conns: if conns:
status = msg.metadata.get("goal_status") if event.status in ("running", "idle"):
if status in ("running", "idle"):
started_raw = msg.metadata.get("started_at", msg.metadata.get("goal_started_at"))
await self.send_goal_status( await self.send_goal_status(
msg.chat_id, msg.chat_id,
status, event.status,
started_at=float(started_raw) if isinstance(started_raw, int | float) else None, started_at=event.started_at,
) )
return return
# Signal that the agent has fully finished processing the current turn. # Signal that the agent has fully finished processing the current turn.
if msg.metadata.get("_turn_end"): if isinstance(event, TurnEndEvent):
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
await self.send_turn_end( await self.send_turn_end(
msg.chat_id, msg.chat_id,
latency_ms=lat_i, latency_ms=event.latency_ms,
goal_state=gs_blob, goal_state=event.goal_state,
metadata=msg.metadata, metadata=msg.metadata,
) )
await self.send_session_updated(msg.chat_id, scope="thread") await self.send_session_updated(msg.chat_id, scope="thread")
return return
if msg.metadata.get("_session_updated"): if isinstance(event, SessionUpdatedEvent):
if conns: if conns:
scope = msg.metadata.get("_session_update_scope")
await self.send_session_updated( await self.send_session_updated(
msg.chat_id, msg.chat_id,
scope=scope if isinstance(scope, str) else None, scope=event.scope,
) )
return return
if msg.metadata.get("_file_edit_events"): if progress_event and progress_event.file_edit_events:
edits = msg.metadata.get("_file_edit_events")
await self.send_file_edit_events( await self.send_file_edit_events(
msg.chat_id, msg.chat_id,
edits if isinstance(edits, list) else [], progress_event.file_edit_events,
msg.metadata, msg.metadata,
) )
return return
@ -939,17 +939,17 @@ class WebSocketChannel(BaseChannel):
lat = msg.metadata.get("latency_ms") lat = msg.metadata.get("latency_ms")
if isinstance(lat, (int, float)): if isinstance(lat, (int, float)):
payload["latency_ms"] = int(lat) payload["latency_ms"] = int(lat)
if msg.metadata.get("_tool_events"): if progress_event and progress_event.tool_events:
payload["tool_events"] = msg.metadata["_tool_events"] payload["tool_events"] = progress_event.tool_events
agent_ui = msg.metadata.get(OUTBOUND_META_AGENT_UI) agent_ui = msg.metadata.get(OUTBOUND_META_AGENT_UI)
if agent_ui is not None: if agent_ui is not None:
payload["agent_ui"] = agent_ui payload["agent_ui"] = agent_ui
# Mark intermediate agent breadcrumbs (tool-call hints, generic # Mark intermediate agent breadcrumbs (tool-call hints, generic
# progress strings) so WS clients can render them as subordinate # progress strings) so WS clients can render them as subordinate
# trace rows rather than conversational replies. # trace rows rather than conversational replies.
if msg.metadata.get("_tool_hint"): if progress_event and progress_event.tool_hint:
payload["kind"] = "tool_hint" payload["kind"] = "tool_hint"
elif msg.metadata.get("_progress"): elif progress_event:
payload["kind"] = "progress" payload["kind"] = "progress"
phase = "activity" if payload.get("kind") in ("tool_hint", "progress") else "answer" phase = "activity" if payload.get("kind") in ("tool_hint", "progress") else "answer"
self._transcripts.prepare_and_append( self._transcripts.prepare_and_append(
@ -971,6 +971,8 @@ class WebSocketChannel(BaseChannel):
chat_id: str, chat_id: str,
delta: str, delta: str,
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None: ) -> None:
"""Push one chunk of model reasoning. Mirrors ``send_delta`` shape so """Push one chunk of model reasoning. Mirrors ``send_delta`` shape so
clients receive a stream that opens, updates in place, and closes clients receive a stream that opens, updates in place, and closes
@ -986,7 +988,6 @@ class WebSocketChannel(BaseChannel):
"chat_id": chat_id, "chat_id": chat_id,
"text": delta, "text": delta,
} }
stream_id = meta.get("_stream_id")
if stream_id is not None: if stream_id is not None:
body["stream_id"] = stream_id body["stream_id"] = stream_id
self._transcripts.prepare_and_append( self._transcripts.prepare_and_append(
@ -1005,6 +1006,8 @@ class WebSocketChannel(BaseChannel):
self, self,
chat_id: str, chat_id: str,
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None: ) -> None:
"""Close the current reasoning stream segment for in-place renderers.""" """Close the current reasoning stream segment for in-place renderers."""
conns = list(self._subs.get(chat_id, ())) conns = list(self._subs.get(chat_id, ()))
@ -1013,7 +1016,6 @@ class WebSocketChannel(BaseChannel):
"event": "reasoning_end", "event": "reasoning_end",
"chat_id": chat_id, "chat_id": chat_id,
} }
stream_id = meta.get("_stream_id")
if stream_id is not None: if stream_id is not None:
body["stream_id"] = stream_id body["stream_id"] = stream_id
self._transcripts.prepare_and_append( self._transcripts.prepare_and_append(
@ -1057,11 +1059,15 @@ class WebSocketChannel(BaseChannel):
chat_id: str, chat_id: str,
delta: str, delta: str,
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None: ) -> None:
conns = list(self._subs.get(chat_id, ())) conns = list(self._subs.get(chat_id, ()))
meta = metadata or {} meta = metadata or {}
stream_key = (chat_id, str(meta.get("_stream_id") or "")) stream_key = (chat_id, str(stream_id or ""))
if meta.get("_stream_end"): if stream_end:
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id} body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
buffered = self._stream_text_buffers.pop(stream_key, []) buffered = self._stream_text_buffers.pop(stream_key, [])
if delta: if delta:
@ -1077,8 +1083,8 @@ class WebSocketChannel(BaseChannel):
"text": delta, "text": delta,
} }
self._stream_text_buffers.setdefault(stream_key, []).append(delta) self._stream_text_buffers.setdefault(stream_key, []).append(delta)
if meta.get("_stream_id") is not None: if stream_id is not None:
body["stream_id"] = meta["_stream_id"] body["stream_id"] = stream_id
self._transcripts.prepare_and_append( self._transcripts.prepare_and_append(
chat_id, chat_id,
body, body,

View File

@ -13,6 +13,7 @@ from typing import Any
from pydantic import Field from pydantic import Field
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
@ -497,7 +498,7 @@ class WecomChannel(BaseChannel):
try: try:
content = (msg.content or "").strip() 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 # Get the stored frame for this chat
frame = self._chat_frames.get(msg.chat_id) frame = self._chat_frames.get(msg.chat_id)

View File

@ -29,6 +29,7 @@ from loguru import logger
from pydantic import Field from pydantic import Field
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir, get_runtime_subdir 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") raise RuntimeError("WeChat client not initialized or not authenticated")
self._assert_session_active() 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 # Buffer tool hints to coalesce consecutive ones and avoid burning
# WeChat iLink rate-limit quota (~7 msgs / 5 min). # 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: if not self.send_tool_hints:
return return
self._pending_tool_hints.setdefault(msg.chat_id, []).append(msg.content) 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 # Reasoning deltas are invisible in WeChat (there is no reasoning
# UI). Skip them entirely — do not send and do not flush buffer. # 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( self.logger.debug(
"Dropped invisible reasoning delta for {}", msg.chat_id "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) await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL)
async def send_delta( 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: ) -> None:
"""Deliver a streamed reply to WeChat. """Deliver a streamed reply to WeChat.
WeChat iLink has no native incremental delivery, and the manager WeChat iLink has no native incremental delivery, and the manager
bypasses :meth:`send` for the ``_streamed`` final answer. So we bypasses :meth:`send` for the ``_streamed`` final answer. So we
accumulate the content deltas here and flush the full reply as a accumulate content deltas and flush the full reply as a single message
single message at ``_stream_end`` otherwise a streamed reply would at stream end. Reasoning deltas are invisible in WeChat and are dropped.
never reach the user. Reasoning deltas are invisible in WeChat and are
dropped.
""" """
meta = metadata or {} meta = metadata or {}
if meta.get("_reasoning_delta") or meta.get("_reasoning"): if meta.get("_reasoning_delta") or meta.get("_reasoning"):
return return
is_end = meta.get("_stream_end") is_end = stream_end or bool(meta.get("_stream_end"))
# Accumulate intermediate deltas. The _stream_end message's own content 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 # (present when the manager coalesces deltas into the end message) is
# folded into `full` below instead of appended here, so a send retry # folded into `full` below instead of appended here, so a send retry
# recomputes the same `full` from an unchanged buffer rather than # recomputes the same `full` from an unchanged buffer rather than
# double-counting that delta. # double-counting that delta.
if delta and not is_end: 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: if not is_end:
return 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) await self._flush_tool_hints(chat_id)
if full: if full:
# Send before clearing the buffer: if the send raises, the buffer is # Send before clearing the buffer: if the send raises, the buffer is
# left intact so ChannelManager._send_with_retry can re-deliver the # 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( await self.send(
OutboundMessage(channel=self.name, chat_id=chat_id, content=full) 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: async def _start_typing(self, chat_id: str, context_token: str = "") -> None:
"""Start typing indicator immediately when a message is received.""" """Start typing indicator immediately when a message is received."""

View File

@ -50,6 +50,14 @@ from rich.text import Text # noqa: E402
from nanobot import __logo__, __version__ # noqa: E402 from nanobot import __logo__, __version__ # noqa: E402
from nanobot.agent.loop import AgentLoop # 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.gateway import create_gateway_app # noqa: E402
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner # noqa: E402 from nanobot.cli.stream import StreamRenderer, ThinkingSpinner # noqa: E402
from nanobot.config.paths import get_workspace_path, is_default_workspace # 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, renderer: StreamRenderer | None = None,
reasoning_buffer: _ReasoningBuffer | None = None, reasoning_buffer: _ReasoningBuffer | None = None,
) -> bool: ) -> bool:
metadata = msg.metadata or {} event = outbound_event_from_message(msg)
if metadata.get("_retry_wait"): if isinstance(event, RetryWaitEvent):
await _print_interactive_progress_line(msg.content, thinking, renderer) await _print_interactive_progress_line(msg.content, thinking, renderer)
return True return True
if not metadata.get("_progress"): if not isinstance(event, ProgressEvent):
return False return False
reasoning_buffer = reasoning_buffer or _ReasoningBuffer() reasoning_buffer = reasoning_buffer or _ReasoningBuffer()
if metadata.get("_reasoning_end"): if event.reasoning_end:
if channels_config and not channels_config.show_reasoning: if channels_config and not channels_config.show_reasoning:
reasoning_buffer.clear() reasoning_buffer.clear()
else: else:
_flush_cli_reasoning(reasoning_buffer, thinking, renderer) _flush_cli_reasoning(reasoning_buffer, thinking, renderer)
return True return True
is_tool_hint = metadata.get("_tool_hint", False) is_tool_hint = event.tool_hint
is_reasoning = metadata.get("_reasoning", False) or metadata.get("_reasoning_delta", False) is_reasoning = event.reasoning or event.reasoning_delta
if is_reasoning: if is_reasoning:
if channels_config and not channels_config.show_reasoning: if channels_config and not channels_config.show_reasoning:
reasoning_buffer.clear() reasoning_buffer.clear()
@ -1456,7 +1464,7 @@ def agent(
bus_task = asyncio.create_task(agent_loop.run()) bus_task = asyncio.create_task(agent_loop.run())
turn_done = asyncio.Event() turn_done = asyncio.Event()
turn_done.set() turn_done.set()
turn_response: list[tuple[str, dict]] = [] turn_response: list[Any] = []
renderer: StreamRenderer | None = None renderer: StreamRenderer | None = None
reasoning_buffer = _ReasoningBuffer() reasoning_buffer = _ReasoningBuffer()
@ -1464,18 +1472,19 @@ def agent(
while True: while True:
try: try:
msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) 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: if renderer:
await renderer.on_delta(msg.content) await renderer.on_delta(msg.content)
continue continue
if msg.metadata.get("_stream_end"): if isinstance(event, StreamEndEvent):
if renderer: if renderer:
await renderer.on_end( await renderer.on_end(
resuming=msg.metadata.get("_resuming", False), resuming=event.resuming,
) )
continue continue
if msg.metadata.get("_streamed"): if isinstance(event, StreamedResponseEvent):
turn_done.set() turn_done.set()
continue continue
@ -1490,7 +1499,7 @@ def agent(
if not turn_done.is_set(): if not turn_done.is_set():
if msg.content: if msg.content:
turn_response.append((msg.content, dict(msg.metadata or {}))) turn_response.append(msg)
turn_done.set() turn_done.set()
elif msg.content: elif msg.content:
await _print_interactive_response( await _print_interactive_response(
@ -1543,8 +1552,10 @@ def agent(
await turn_done.wait() await turn_done.wait()
if turn_response: if turn_response:
content, meta = turn_response[0] response_msg = turn_response[0]
if content and not meta.get("_streamed"): content = response_msg.content
meta = response_msg.metadata
if content and not isinstance(response_msg.event, StreamedResponseEvent):
if renderer: if renderer:
await renderer.close() await renderer.close()
print_kwargs: dict[str, Any] = {} print_kwargs: dict[str, Any] = {}

View File

@ -29,10 +29,6 @@ _GOAL_CONTINUATION_SENDER = "system:continuation"
_GOAL_CONTINUATION_ROUNDS_KEY = "_sustained_goal_continuation_rounds" _GOAL_CONTINUATION_ROUNDS_KEY = "_sustained_goal_continuation_rounds"
_MAX_GOAL_CONTINUATION_ROUNDS = 12 _MAX_GOAL_CONTINUATION_ROUNDS = 12
_STRIPPED_INBOUND_META_KEYS = { _STRIPPED_INBOUND_META_KEYS = {
"_stream_id",
"_stream_delta",
"_stream_end",
"_resuming",
INTERNAL_CONTINUATION_PENDING_META, INTERNAL_CONTINUATION_PENDING_META,
} }

View File

@ -11,7 +11,15 @@ from typing import Any
from loguru import logger from loguru import logger
from nanobot.bus import progress as bus_progress 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.queue import MessageBus
from nanobot.bus.runtime_events import ( from nanobot.bus.runtime_events import (
GoalStateChanged, GoalStateChanged,
@ -206,26 +214,22 @@ async def publish_turn_run_status(
if msg.channel != "websocket": if msg.channel != "websocket":
return return
cid = str(msg.chat_id) cid = str(msg.chat_id)
meta: dict[str, Any] = { started_at_event: float | None = None
**dict(msg.metadata or {}),
"_goal_status": True,
"goal_status": status,
}
if status == "running": if status == "running":
if isinstance(started_at, int | float) and started_at > 0: if isinstance(started_at, int | float) and started_at > 0:
t0 = float(started_at) t0 = float(started_at)
else: else:
t0 = time.time() t0 = time.time()
meta["started_at"] = t0 started_at_event = t0
_WEBSOCKET_TURN_WALL_STARTED_AT[cid] = t0 _WEBSOCKET_TURN_WALL_STARTED_AT[cid] = t0
else: else:
_WEBSOCKET_TURN_WALL_STARTED_AT.pop(cid, None) _WEBSOCKET_TURN_WALL_STARTED_AT.pop(cid, None)
await bus.publish_outbound( await bus.publish_outbound(
OutboundMessage( outbound_message_for_event(
channel=msg.channel, channel=msg.channel,
chat_id=cid, chat_id=cid,
content="", event=GoalStatusEvent(status=status, started_at=started_at_event),
metadata=meta, metadata=msg.metadata,
), ),
) )
@ -318,28 +322,25 @@ class WebuiTurnCoordinator:
if not cid: if not cid:
return return
await self.bus.publish_outbound( await self.bus.publish_outbound(
OutboundMessage( outbound_message_for_event(
channel=event.context.channel, channel=event.context.channel,
chat_id=cid, chat_id=cid,
content="", event=GoalStateSyncEvent(
metadata={ goal_state=goal_state_ws_blob(event.session_metadata),
"_goal_state_sync": True, ),
"goal_state": goal_state_ws_blob(event.session_metadata), metadata=event.context.metadata,
},
), ),
) )
async def _handle_runtime_model_changed(self, event: RuntimeModelChanged) -> None: async def _handle_runtime_model_changed(self, event: RuntimeModelChanged) -> None:
await self.bus.publish_outbound( await self.bus.publish_outbound(
OutboundMessage( outbound_message_for_event(
channel="websocket", channel="websocket",
chat_id="*", chat_id="*",
content="", event=RuntimeModelUpdatedEvent(
metadata={ model=event.model,
"_runtime_model_updated": True, model_preset=event.model_preset,
"model": event.model, ),
"model_preset": event.model_preset,
},
) )
) )
@ -374,17 +375,18 @@ class WebuiTurnCoordinator:
if msg.channel != "websocket": if msg.channel != "websocket":
return 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) session = self.sessions.get_or_create(session_key)
turn_metadata["goal_state"] = goal_state_ws_blob(session.metadata) await self.bus.publish_outbound(
await self.bus.publish_outbound(OutboundMessage( outbound_message_for_event(
channel=msg.channel, channel=msg.channel,
chat_id=msg.chat_id, chat_id=msg.chat_id,
content="", event=TurnEndEvent(
metadata=turn_metadata, latency_ms=latency_ms,
)) goal_state=goal_state_ws_blob(session.metadata),
),
metadata=msg.metadata,
)
)
self._schedule_title_update(msg, session_key=session_key) self._schedule_title_update(msg, session_key=session_key)
def _schedule_title_update(self, msg: InboundMessage, *, session_key: str) -> None: def _schedule_title_update(self, msg: InboundMessage, *, session_key: str) -> None:
@ -404,16 +406,11 @@ class WebuiTurnCoordinator:
model=title_llm.model, model=title_llm.model,
) )
if generated: if generated:
await self.bus.publish_outbound(OutboundMessage( await self._publish_session_metadata_updated(
channel=msg.channel, channel=msg.channel,
chat_id=msg.chat_id, chat_id=msg.chat_id,
content="", metadata=msg.metadata,
metadata={ )
**msg.metadata,
"_session_updated": True,
"_session_update_scope": "metadata",
},
))
self.schedule_background(_generate_title_and_notify()) self.schedule_background(_generate_title_and_notify())
@ -438,15 +435,26 @@ class WebuiTurnCoordinator:
model=title_llm.model, model=title_llm.model,
) )
if generated: if generated:
await self.bus.publish_outbound(OutboundMessage( await self._publish_session_metadata_updated(
channel=event.context.channel, channel=event.context.channel,
chat_id=event.context.chat_id, chat_id=event.context.chat_id,
content="", metadata=event.context.metadata,
metadata={ )
**event.context.metadata,
"_session_updated": True,
"_session_update_scope": "metadata",
},
))
self.schedule_background(_generate_title_and_notify()) 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,
)
)

View File

@ -5,6 +5,7 @@ import pytest
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import GoalStatusEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.providers.base import GenerationSettings, LLMResponse from nanobot.providers.base import GenerationSettings, LLMResponse
from nanobot.session.webui_turns import WebuiTurnCoordinator 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()) events.append(await loop.bus.consume_outbound())
statuses = [ statuses = [
event.metadata event.event
for event in events 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 [status.status for status in statuses] == ["running", "idle"]
assert isinstance(statuses[0].get("started_at"), float) assert isinstance(statuses[0].started_at, float)
assert "started_at" not in statuses[1] assert statuses[1].started_at is None
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@ -9,6 +9,15 @@ import pytest
import nanobot.agent.runner as runner_module import nanobot.agent.runner as runner_module
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage 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.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse, ToolCallRequest from nanobot.providers.base import LLMResponse, ToolCallRequest
from nanobot.session.webui_turns import WebuiTurnCoordinator from nanobot.session.webui_turns import WebuiTurnCoordinator
@ -260,25 +269,45 @@ class TestToolEventProgress:
) )
await loop._dispatch(msg) 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 = [] outbound = []
while bus.outbound_size > 0: while bus.outbound_size > 0:
outbound.append(await bus.consume_outbound()) outbound.append(await bus.consume_outbound())
tool_event_msgs = [m for m in outbound if m.metadata and m.metadata.get("_tool_events")] tool_event_msgs = [
assert tool_event_msgs, "expected at least one outbound message with _tool_events" 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"] start_msgs = [
finish_msgs = [m for m in tool_event_msgs if m.metadata["_tool_events"][0]["phase"] in ("end", "error")] 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 start_msgs, "expected a start-phase tool event"
assert finish_msgs, "expected a finish-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["name"] == "exec"
assert start["call_id"] == "tc1" assert start["call_id"] == "tc1"
assert start["result"] is None 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["phase"] == "end"
assert finish["result"] == "file.txt" assert finish["result"] == "file.txt"
@ -309,7 +338,8 @@ class TestToolEventProgress:
await invoke_file_edit_progress(progress, edit_events) await invoke_file_edit_progress(progress, edit_events)
outbound = await bus.consume_outbound() outbound = await bus.consume_outbound()
assert outbound.channel == "telegram" 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 @pytest.mark.asyncio
async def test_goal_turn_keeps_live_file_edit_progress_for_webui(self, tmp_path: Path) -> None: 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 = [ edit_events = [
event event
for msg in outbound 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( assert any(
event["status"] == "editing" event["status"] == "editing"
@ -433,8 +464,8 @@ class TestToolEventProgress:
outbound.append(await bus.consume_outbound()) outbound.append(await bus.consume_outbound())
assert [m.content for m in outbound] == ["Hello"] assert [m.content for m in outbound] == ["Hello"]
assert not any(m.metadata.get("_progress") for m in outbound) assert not any(isinstance(m.event, ProgressEvent) for m in outbound)
assert not any(m.metadata.get("_streamed") 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_stream_with_retry.assert_not_awaited()
provider.chat_with_retry.assert_awaited_once() provider.chat_with_retry.assert_awaited_once()
@ -443,7 +474,7 @@ class TestToolEventProgress:
self, self,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
"""Streaming channels still receive provider deltas through _stream_delta messages.""" """Streaming channels still receive provider deltas through stream events."""
bus = MessageBus() bus = MessageBus()
provider = MagicMock() provider = MagicMock()
provider.supports_progress_deltas = True provider.supports_progress_deltas = True
@ -473,21 +504,19 @@ class TestToolEventProgress:
while bus.outbound_size > 0: while bus.outbound_size > 0:
outbound.append(await bus.consume_outbound()) outbound.append(await bus.consume_outbound())
deltas = [m for m in outbound if m.metadata.get("_stream_delta")] deltas = [m for m in outbound if isinstance(m.event, StreamDeltaEvent)]
stream_end = [m for m in outbound if m.metadata.get("_stream_end")] stream_end = [m for m in outbound if isinstance(m.event, StreamEndEvent)]
final = [ final = [
m for m in outbound m for m in outbound
if not m.metadata.get("_stream_delta") if not isinstance(m.event, StreamDeltaEvent | StreamEndEvent)
and not m.metadata.get("_stream_end") and not isinstance(m.event, TurnEndEvent | GoalStatusEvent)
and not m.metadata.get("_turn_end")
and not m.metadata.get("_goal_status")
] ]
assert [m.content for m in deltas] == ["Hel", "lo"] assert [m.content for m in deltas] == ["Hel", "lo"]
assert len(stream_end) == 1 assert len(stream_end) == 1
assert final[-1].content == "Hello" assert final[-1].content == "Hello"
assert final[-1].metadata.get("_streamed") is True assert isinstance(final[-1].event, StreamedResponseEvent)
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 len(turn_end_msgs) == 1
assert turn_end_msgs[0].content == "" assert turn_end_msgs[0].content == ""
provider.chat_with_retry.assert_not_awaited() provider.chat_with_retry.assert_not_awaited()
@ -528,23 +557,28 @@ class TestToolEventProgress:
while bus.outbound_size > 0: while bus.outbound_size > 0:
outbound.append(await bus.consume_outbound()) outbound.append(await bus.consume_outbound())
deltas = [m for m in outbound if m.metadata.get("_stream_delta")] deltas = [m for m in outbound if isinstance(m.event, StreamDeltaEvent)]
stream_end = [m for m in outbound if m.metadata.get("_stream_end")] stream_end = [m for m in outbound if isinstance(m.event, StreamEndEvent)]
final = [ final = [
m for m in outbound m for m in outbound
if not m.metadata.get("_stream_delta") if not isinstance(m.event, StreamDeltaEvent | StreamEndEvent)
and not m.metadata.get("_stream_end") and not isinstance(m.event, TurnEndEvent | GoalStatusEvent)
and not m.metadata.get("_turn_end")
and not m.metadata.get("_goal_status")
] ]
assert [m.content for m in deltas] == ["partial", "full retry response"] assert [m.content for m in deltas] == ["partial", "full retry response"]
assert [m.metadata.get("_resuming") for m in stream_end] == [True, False] assert [m.event.resuming for m in stream_end if isinstance(m.event, StreamEndEvent)] == [
assert deltas[0].metadata.get("_stream_id") == stream_end[0].metadata.get("_stream_id") True,
assert deltas[1].metadata.get("_stream_id") == stream_end[1].metadata.get("_stream_id") False,
assert deltas[0].metadata.get("_stream_id") != deltas[1].metadata.get("_stream_id") ]
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].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() provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
@ -623,9 +657,9 @@ class TestToolEventProgress:
done_msgs = [m for m in outbound if m.content == "Done"] done_msgs = [m for m in outbound if m.content == "Done"]
assert len(done_msgs) == 1 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 len(turn_end_msgs) == 1
assert turn_end_msgs[0].content == "" assert turn_end_msgs[0].content == ""
assert turn_end_msgs[0].chat_id == "chat1" assert turn_end_msgs[0].chat_id == "chat1"
@ -659,14 +693,14 @@ class TestToolEventProgress:
outbound.append(await bus.consume_outbound()) outbound.append(await bus.consume_outbound())
error_msgs = [m for m in outbound if m.content == "Sorry, I encountered an error."] 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")] turn_end_msgs = [m for m in outbound if isinstance(m.event, TurnEndEvent)]
statuses = [m for m in outbound if m.metadata.get("_goal_status")] statuses = [m for m in outbound if isinstance(m.event, GoalStatusEvent)]
assert len(error_msgs) == 1 assert len(error_msgs) == 1
assert len(turn_end_msgs) == 1 assert len(turn_end_msgs) == 1
assert turn_end_msgs[0].content == "" assert turn_end_msgs[0].content == ""
assert turn_end_msgs[0].chat_id == "chat1" 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(error_msgs[0]) < outbound.index(turn_end_msgs[0])
assert outbound.index(turn_end_msgs[0]) < outbound.index(statuses[-1]) assert outbound.index(turn_end_msgs[0]) < outbound.index(statuses[-1])
@ -705,27 +739,27 @@ class TestToolEventProgress:
outbound: list = [] outbound: list = []
for _ in range(12): for _ in range(12):
outbound.append(await asyncio.wait_for(bus.consume_outbound(), timeout=0.5)) 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 break
else: 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"] done_with_body = [m for m in outbound if m.content == "Done"]
assert len(done_with_body) == 1 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) await asyncio.wait_for(title_started.wait(), timeout=0.5)
release_title.set() release_title.set()
session_updated = None session_updated = None
for _ in range(10): for _ in range(10):
candidate = await asyncio.wait_for(bus.consume_outbound(), timeout=0.5) 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 session_updated = candidate
break break
assert session_updated is not None assert session_updated is not None
assert (session_updated.metadata or {}).get("_session_updated") is True assert isinstance(session_updated.event, SessionUpdatedEvent)
assert (session_updated.metadata or {}).get("_session_update_scope") == "metadata" assert session_updated.event.scope == "metadata"
assert provider.chat_with_retry.await_count == 2 assert provider.chat_with_retry.await_count == 2
@pytest.mark.asyncio @pytest.mark.asyncio
@ -837,4 +871,4 @@ class TestToolEventProgress:
assert len(outbound) == 1 assert len(outbound) == 1
assert outbound[0].content == "Done" assert outbound[0].content == "Done"
assert (outbound[0].metadata or {}).get("_turn_end") is not True assert not isinstance(outbound[0].event, TurnEndEvent)

View File

@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from nanobot.bus.outbound_events import StreamedResponseEvent
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, ToolCallRequest from nanobot.providers.base import LLMResponse, ToolCallRequest
@ -23,8 +24,8 @@ def _make_loop(tmp_path):
with patch("nanobot.agent.loop.ContextBuilder"), \ with patch("nanobot.agent.loop.ContextBuilder"), \
patch("nanobot.agent.loop.SessionManager"), \ patch("nanobot.agent.loop.SessionManager"), \
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr: patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr:
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0) mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path) loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path)
return loop return loop
@ -193,8 +194,9 @@ async def test_streamed_flag_not_set_on_llm_error(tmp_path):
assert result is not None assert result is not None
assert "503" in result.content assert "503" in result.content
assert not result.metadata.get("_streamed"), \ assert not isinstance(result.event, StreamedResponseEvent), (
"_streamed must not be set when stop_reason is error" "streamed response event must not be set when stop_reason is error"
)
@pytest.mark.asyncio @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 is not None
assert result.content == "I cannot access private URLs. Please share the local file." 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 @pytest.mark.asyncio

View File

@ -8,6 +8,13 @@ import pytest
from nanobot.agent.context import ContextBuilder from nanobot.agent.context import ContextBuilder
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage 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.bus.queue import MessageBus
from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META
from nanobot.providers.base import LLMResponse from nanobot.providers.base import LLMResponse
@ -765,7 +772,6 @@ async def test_internal_continuation_preserves_streaming_route_metadata(
"_wants_stream": True, "_wants_stream": True,
"message_id": "om_001", "message_id": "om_001",
"origin_message_id": "root_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["_wants_stream"] is True
assert queued.metadata["message_id"] == "om_001" assert queued.metadata["message_id"] == "om_001"
assert queued.metadata["origin_message_id"] == "root_001" assert queued.metadata["origin_message_id"] == "root_001"
assert "_stream_id" not in queued.metadata
await loop._dispatch(queued) await loop._dispatch(queued)
outbound = [] outbound = []
while loop.bus.outbound_size: while loop.bus.outbound_size:
outbound.append(await loop.bus.consume_outbound()) outbound.append(await loop.bus.consume_outbound())
deltas = [m for m in outbound if m.metadata.get("_stream_delta")] deltas = [m for m in outbound if isinstance(m.event, StreamDeltaEvent)]
ends = [m for m in outbound if m.metadata.get("_stream_end")] ends = [m for m in outbound if isinstance(m.event, StreamEndEvent)]
streamed_markers = [m for m in outbound if m.metadata.get("_streamed")] streamed_markers = [m for m in outbound if isinstance(m.event, StreamedResponseEvent)]
assert [m.content for m in deltas] == ["done"] assert [m.content for m in deltas] == ["done"]
assert len(ends) == 1 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["message_id"] == "om_001"
assert ends[0].metadata["origin_message_id"] == "root_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" 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 = [] first_outbound = []
while loop.bus.outbound_size: while loop.bus.outbound_size:
first_outbound.append(await loop.bus.consume_outbound()) first_outbound.append(await loop.bus.consume_outbound())
first_statuses = [m.metadata for m in first_outbound if m.metadata.get("_goal_status")] first_statuses = [m.event for m in first_outbound if isinstance(m.event, GoalStatusEvent)]
assert [m["goal_status"] for m in first_statuses] == ["running"] assert [m.status for m in first_statuses] == ["running"]
assert not [m for m in first_outbound if m.metadata.get("_turn_end")] assert not [m for m in first_outbound if isinstance(m.event, TurnEndEvent)]
started_at = first_statuses[0]["started_at"] started_at = first_statuses[0].started_at
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5) queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
assert queued.metadata[INTERNAL_CONTINUATION_META] is True assert queued.metadata[INTERNAL_CONTINUATION_META] is True
@ -856,12 +862,13 @@ async def test_websocket_internal_continuation_keeps_single_visible_run(
second_outbound = [] second_outbound = []
while loop.bus.outbound_size: while loop.bus.outbound_size:
second_outbound.append(await loop.bus.consume_outbound()) second_outbound.append(await loop.bus.consume_outbound())
second_statuses = [m.metadata for m in second_outbound if m.metadata.get("_goal_status")] second_statuses = [m.event for m in second_outbound if isinstance(m.event, GoalStatusEvent)]
assert [m["goal_status"] for m in second_statuses] == ["running", "idle"] assert [m.status for m in second_statuses] == ["running", "idle"]
assert second_statuses[0]["started_at"] == started_at assert second_statuses[0].started_at == started_at
turn_end = [m for m in second_outbound if m.metadata.get("_turn_end")] turn_end = [m for m in second_outbound if isinstance(m.event, TurnEndEvent)]
assert len(turn_end) == 1 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 @pytest.mark.asyncio

View File

@ -127,6 +127,7 @@ class TestDispatch:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_dispatch_streaming_preserves_message_metadata(self): async def test_dispatch_streaming_preserves_message_metadata(self):
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import StreamDeltaEvent, StreamEndEvent
loop, bus = _make_loop() loop, bus = _make_loop()
msg = InboundMessage( msg = InboundMessage(
@ -156,10 +157,10 @@ class TestDispatch:
assert first.metadata["thread_root_event_id"] == "$root1" assert first.metadata["thread_root_event_id"] == "$root1"
assert first.metadata["thread_reply_to_event_id"] == "$reply1" 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_root_event_id"] == "$root1"
assert second.metadata["thread_reply_to_event_id"] == "$reply1" assert second.metadata["thread_reply_to_event_id"] == "$reply1"
assert second.metadata["_stream_end"] is True assert isinstance(second.event, StreamEndEvent)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_processing_lock_serializes(self): async def test_processing_lock_serializes(self):

View File

@ -13,6 +13,7 @@ from nanobot.agent.tools.long_task import (
CompleteGoalTool, CompleteGoalTool,
LongTaskTool, LongTaskTool,
) )
from nanobot.bus.outbound_events import GoalStateSyncEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeEventBus from nanobot.bus.runtime_events import RuntimeEventBus
from nanobot.session.goal_state import GOAL_STATE_KEY 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] call = bus.publish_outbound.await_args.args[0]
assert call.channel == "websocket" assert call.channel == "websocket"
assert call.chat_id == "chat-99" assert call.chat_id == "chat-99"
assert call.metadata.get("_goal_state_sync") is True assert isinstance(call.event, GoalStateSyncEvent)
assert call.metadata["goal_state"] == { assert call.event.goal_state == {
"active": True, "active": True,
"ui_summary": "alpha", "ui_summary": "alpha",
"objective": "Objective 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() bus.publish_outbound.assert_awaited_once()
call = bus.publish_outbound.await_args.args[0] 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 @pytest.mark.asyncio

View File

@ -0,0 +1,98 @@
from __future__ import annotations
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import (
ProgressEvent,
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
outbound_event_from_message,
outbound_message_for_event,
replace_outbound_event,
)
def test_progress_event_lives_on_outbound_message_event_field() -> None:
tool_events = [{"phase": "start", "name": "read_file"}]
file_edit_events = [{"phase": "end", "path": "app.py"}]
msg = outbound_message_for_event(
channel="websocket",
chat_id="chat-1",
event=ProgressEvent(
content="working",
tool_hint=True,
reasoning_delta=True,
stream_id="r1",
tool_events=tool_events,
file_edit_events=file_edit_events,
),
metadata={"origin_message_id": "m1"},
)
assert msg.content == "working"
assert msg.metadata == {"origin_message_id": "m1"}
event = outbound_event_from_message(msg)
assert isinstance(event, ProgressEvent)
assert event.content == "working"
assert event.tool_hint is True
assert event.reasoning_delta is True
assert event.stream_id == "r1"
assert event.tool_events == tool_events
assert event.file_edit_events == file_edit_events
def test_normal_outbound_message_has_no_runtime_event() -> None:
msg = OutboundMessage(channel="websocket", chat_id="chat-1", content="hello")
assert outbound_event_from_message(msg) is None
def test_metadata_flags_do_not_create_runtime_events() -> None:
msg = OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="legacy progress",
metadata={
"_progress": True,
"_stream_delta": True,
"_goal_status": True,
"message_id": "platform-routing-context",
},
)
assert outbound_event_from_message(msg) is None
def test_replace_outbound_event_keeps_routing_metadata() -> None:
msg = outbound_message_for_event(
channel="websocket",
chat_id="chat-1",
event=StreamDeltaEvent(content="hello", stream_id="s1"),
metadata={"message_id": "m1"},
)
updated = replace_outbound_event(
msg,
StreamEndEvent(stream_id="s1", resuming=True),
content="hello world",
)
assert updated.content == "hello world"
assert updated.metadata == {"message_id": "m1"}
assert isinstance(updated.event, StreamEndEvent)
assert updated.event.stream_id == "s1"
assert updated.event.resuming is True
def test_streamed_response_event_keeps_final_content_outside_event_payload() -> None:
msg = outbound_message_for_event(
channel="cli",
chat_id="direct",
event=StreamedResponseEvent(),
content="final answer",
)
assert msg.content == "final answer"
assert isinstance(outbound_event_from_message(msg), StreamedResponseEvent)

View File

@ -1,10 +1,19 @@
"""Tests for ChannelManager delta coalescing to reduce streaming latency.""" """Tests for ChannelManager delta coalescing to reduce streaming latency."""
import asyncio import asyncio
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
import pytest import pytest
from nanobot.bus.events import OutboundMessage 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.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.channels.manager import ChannelManager from nanobot.channels.manager import ChannelManager
@ -29,221 +38,187 @@ class MockChannel(BaseChannel):
pass pass
async def send(self, msg): async def send(self, msg):
"""Implement abstract method."""
return await self._send_mock(msg) return await self._send_mock(msg)
async def send_delta(self, chat_id, delta, metadata=None): async def send_delta(
"""Override send_delta for testing.""" self,
return await self._send_delta_mock(chat_id, delta, metadata) 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 @pytest.fixture
def config(): def config():
"""Create a minimal config for testing."""
return Config() return Config()
@pytest.fixture @pytest.fixture
def bus(): def bus():
"""Create a message bus for testing."""
return MessageBus() return MessageBus()
@pytest.fixture @pytest.fixture
def manager(config, bus): def manager(config, bus):
"""Create a channel manager with a mock channel."""
manager = ChannelManager(config, bus) manager = ChannelManager(config, bus)
manager.channels["mock"] = MockChannel({}, bus) manager.channels["mock"] = MockChannel({}, bus)
return manager 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: class TestDeltaCoalescing:
"""Tests for _stream_delta message coalescing.""" """Tests for stream delta message coalescing."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_single_delta_not_coalesced(self, manager, bus): async def test_single_delta_not_coalesced(self, manager, bus):
"""A single delta should be sent as-is.""" msg = _delta("Hello")
msg = OutboundMessage(
channel="mock",
chat_id="chat1",
content="Hello",
metadata={"_stream_delta": True},
)
await bus.publish_outbound(msg) await bus.publish_outbound(msg)
# Process one message
async def process_one(): async def process_one():
try: try:
m = await asyncio.wait_for(bus.consume_outbound(), timeout=0.1) 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) m, pending = manager._coalesce_stream_deltas(m)
# Put pending back (none expected)
for p in pending: for p in pending:
await bus.publish_outbound(p) await bus.publish_outbound(p)
channel = manager.channels.get(m.channel) channel = manager.channels.get(m.channel)
if channel: event = outbound_event_from_message(m)
await channel.send_delta(m.chat_id, m.content, m.metadata) 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: except asyncio.TimeoutError:
pass pass
await process_one() await process_one()
manager.channels["mock"]._send_delta_mock.assert_called_once_with( 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 @pytest.mark.asyncio
async def test_multiple_deltas_coalesced(self, manager, bus): 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", "!"]: for text in ["Hello", " ", "world", "!"]:
await bus.publish_outbound(OutboundMessage( await bus.publish_outbound(_delta(text))
channel="mock",
chat_id="chat1",
content=text,
metadata={"_stream_delta": True},
))
# Process using coalescing logic
first_msg = await bus.consume_outbound() first_msg = await bus.consume_outbound()
merged, pending = manager._coalesce_stream_deltas(first_msg) merged, pending = manager._coalesce_stream_deltas(first_msg)
# Should have merged all deltas
assert merged.content == "Hello world!" assert merged.content == "Hello world!"
assert merged.metadata.get("_stream_delta") is True assert isinstance(merged.event, StreamDeltaEvent)
# No pending messages (all were coalesced)
assert len(pending) == 0 assert len(pending) == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_deltas_different_chats_not_coalesced(self, manager, bus): async def test_deltas_different_chats_not_coalesced(self, manager, bus):
"""Deltas for different chats should not be merged.""" await bus.publish_outbound(_delta("Hello", chat_id="chat1"))
# Put deltas for different chats await bus.publish_outbound(_delta("World", chat_id="chat2"))
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},
))
first_msg = await bus.consume_outbound() first_msg = await bus.consume_outbound()
merged, pending = manager._coalesce_stream_deltas(first_msg) merged, pending = manager._coalesce_stream_deltas(first_msg)
# First chat should not include second chat's content
assert merged.content == "Hello" assert merged.content == "Hello"
assert merged.chat_id == "chat1" assert merged.chat_id == "chat1"
# Second chat should be in pending
assert len(pending) == 1 assert len(pending) == 1
assert pending[0].chat_id == "chat2" assert pending[0].chat_id == "chat2"
assert pending[0].content == "World" assert pending[0].content == "World"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_deltas_different_stream_ids_not_coalesced(self, manager, bus): 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(_delta("A1", stream_id="stream-a"))
await bus.publish_outbound(OutboundMessage( await bus.publish_outbound(_delta("B1", stream_id="stream-b"))
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"},
))
first_msg = await bus.consume_outbound() first_msg = await bus.consume_outbound()
merged, pending = manager._coalesce_stream_deltas(first_msg) merged, pending = manager._coalesce_stream_deltas(first_msg)
assert merged.content == "A1" 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 len(pending) == 1
assert pending[0].content == "B1" 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 @pytest.mark.asyncio
async def test_stream_end_terminates_coalescing(self, manager, bus): async def test_stream_end_terminates_coalescing(self, manager, bus):
"""_stream_end should stop coalescing and be included in final message.""" await bus.publish_outbound(_delta("Hello"))
# Put deltas with stream_end at the end await bus.publish_outbound(_end(" world"))
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},
))
first_msg = await bus.consume_outbound() first_msg = await bus.consume_outbound()
merged, pending = manager._coalesce_stream_deltas(first_msg) merged, pending = manager._coalesce_stream_deltas(first_msg)
# Should have merged content
assert merged.content == "Hello world" assert merged.content == "Hello world"
# Should have stream_end flag assert isinstance(merged.event, StreamEndEvent)
assert merged.metadata.get("_stream_end") is True
# No pending
assert len(pending) == 0 assert len(pending) == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_coalescing_stops_at_first_non_matching_boundary(self, manager, bus): 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(_delta("Hello", stream_id="seg-1"))
await bus.publish_outbound(OutboundMessage( await bus.publish_outbound(_end(stream_id="seg-1"))
channel="mock", await bus.publish_outbound(_delta("world", stream_id="seg-2"))
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"},
))
first_msg = await bus.consume_outbound() first_msg = await bus.consume_outbound()
merged, pending = manager._coalesce_stream_deltas(first_msg) merged, pending = manager._coalesce_stream_deltas(first_msg)
assert merged.content == "Hello" assert merged.content == "Hello"
assert merged.metadata.get("_stream_end") is None assert isinstance(merged.event, StreamDeltaEvent)
assert len(pending) == 1 assert len(pending) == 1
assert pending[0].metadata.get("_stream_end") is True assert isinstance(pending[0].event, StreamEndEvent)
assert pending[0].metadata.get("_stream_id") == "seg-1" 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() remaining = await bus.consume_outbound()
assert remaining.content == "world" 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 @pytest.mark.asyncio
async def test_non_delta_message_preserved(self, manager, bus): async def test_non_delta_message_preserved(self, manager, bus):
"""Non-delta messages should be preserved in pending list.""" await bus.publish_outbound(_delta("Delta"))
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="Delta",
metadata={"_stream_delta": True},
))
await bus.publish_outbound(OutboundMessage( await bus.publish_outbound(OutboundMessage(
channel="mock", channel="mock",
chat_id="chat1", chat_id="chat1",
content="Final message", content="Final message",
metadata={}, # Not a delta
)) ))
first_msg = await bus.consume_outbound() first_msg = await bus.consume_outbound()
@ -252,17 +227,11 @@ class TestDeltaCoalescing:
assert merged.content == "Delta" assert merged.content == "Delta"
assert len(pending) == 1 assert len(pending) == 1
assert pending[0].content == "Final message" assert pending[0].content == "Final message"
assert pending[0].metadata.get("_stream_delta") is None assert pending[0].event is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_empty_queue_stops_coalescing(self, manager, bus): async def test_empty_queue_stops_coalescing(self, manager, bus):
"""Coalescing should stop when queue is empty.""" await bus.publish_outbound(_delta("Only message"))
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="Only message",
metadata={"_stream_delta": True},
))
first_msg = await bus.consume_outbound() first_msg = await bus.consume_outbound()
merged, pending = manager._coalesce_stream_deltas(first_msg) merged, pending = manager._coalesce_stream_deltas(first_msg)
@ -276,49 +245,35 @@ class TestDispatchOutboundWithCoalescing:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_dispatch_coalesces_and_processes_pending(self, manager, bus): async def test_dispatch_coalesces_and_processes_pending(self, manager, bus):
"""_dispatch_outbound should coalesce deltas and process pending messages.""" await bus.publish_outbound(_delta("A"))
# Put multiple deltas followed by a regular message await bus.publish_outbound(_delta("B"))
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(OutboundMessage( await bus.publish_outbound(OutboundMessage(
channel="mock", channel="mock",
chat_id="chat1", chat_id="chat1",
content="Final", content="Final",
metadata={}, # Regular message
)) ))
# Run one iteration of dispatch logic manually
pending = [] pending = []
processed = [] processed = []
# First iteration: should coalesce A+B msg = pending.pop(0) if pending else await bus.consume_outbound()
if pending: event = outbound_event_from_message(msg)
msg = pending.pop(0) if isinstance(event, StreamDeltaEvent):
else:
msg = await bus.consume_outbound()
if msg.metadata.get("_stream_delta") and not msg.metadata.get("_stream_end"):
msg, extra_pending = manager._coalesce_stream_deltas(msg) msg, extra_pending = manager._coalesce_stream_deltas(msg)
pending.extend(extra_pending) pending.extend(extra_pending)
channel = manager.channels.get(msg.channel) channel = manager.channels.get(msg.channel)
if channel: event = outbound_event_from_message(msg)
await channel.send_delta(msg.chat_id, msg.content, msg.metadata) 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)) processed.append(("delta", msg.content))
# Should have sent coalesced delta
assert processed == [("delta", "AB")] assert processed == [("delta", "AB")]
# Should have pending regular message
assert len(pending) == 1 assert len(pending) == 1
assert pending[0].content == "Final" 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_progress", True) is False
assert manager._resolve_bool_override(FakeSection(), "send_tool_hints", False) is True 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 assert manager._resolve_bool_override(FakeSection(), "unknown_key", True) is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_channel_override_can_drop_progress_message(self, manager, bus): async def test_channel_override_can_drop_progress_message(self, manager, bus):
manager.channels["mock"].send_progress = False manager.channels["mock"].send_progress = False
await bus.publish_outbound(OutboundMessage( await bus.publish_outbound(outbound_message_for_event(
channel="mock", channel="mock",
chat_id="chat1", chat_id="chat1",
content="thinking", event=ProgressEvent(content="thinking"),
metadata={"_progress": True},
)) ))
await bus.publish_outbound(OutboundMessage( await bus.publish_outbound(OutboundMessage(
channel="mock", channel="mock",
chat_id="chat1", chat_id="chat1",
content="final answer", content="final answer",
metadata={},
)) ))
task = asyncio.create_task(manager._dispatch_outbound()) 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" assert send_mock.await_args_list[0].args[0].content == "final answer"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_channel_override_can_enable_tool_hints(self, manager, bus): async def test_metadata_only_progress_flag_is_not_runtime_progress(self, manager, bus):
manager.channels["mock"].send_tool_hints = True manager.channels["mock"].send_progress = False
await bus.publish_outbound(OutboundMessage( await bus.publish_outbound(OutboundMessage(
channel="mock", channel="mock",
chat_id="chat1", chat_id="chat1",
content="read_file(foo.py)", content="legacy progress-shaped message",
metadata={"_progress": True, "_tool_hint": True}, 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()) task = asyncio.create_task(manager._dispatch_outbound())
@ -423,24 +403,15 @@ class TestRetryWaitFiltering:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_retry_wait_message_dropped(self, manager, bus): async def test_retry_wait_message_dropped(self, manager, bus):
"""A ``_retry_wait`` message must be filtered before channel dispatch. retry_msg = outbound_message_for_event(
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(
channel="mock", channel="mock",
chat_id="chat1", chat_id="chat1",
content="Model request failed, retry in 1s (attempt 1).", event=RetryWaitEvent(content="Model request failed, retry in 1s (attempt 1)."),
metadata={"_retry_wait": True},
) )
real_msg = OutboundMessage( real_msg = OutboundMessage(
channel="mock", channel="mock",
chat_id="chat1", chat_id="chat1",
content="final answer", content="final answer",
metadata={},
) )
await bus.publish_outbound(retry_msg) await bus.publish_outbound(retry_msg)
await bus.publish_outbound(real_msg) await bus.publish_outbound(real_msg)
@ -462,4 +433,4 @@ class TestRetryWaitFiltering:
assert send_mock.await_count == 1 assert send_mock.await_count == 1
sent = send_mock.await_args_list[0].args[0] sent = send_mock.await_args_list[0].args[0]
assert sent.content == "final answer" assert sent.content == "final answer"
assert not sent.metadata.get("_retry_wait") assert sent.event is None

View File

@ -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 low-emphasis UI primitive keep the base no-op and the content silently
drops at dispatch. drops at dispatch.
One-shot ``_reasoning`` frames are accepted for back-compat with hooks One-shot reasoning frames are represented as typed progress events and
that haven't migrated yet — ``BaseChannel.send_reasoning`` expands them ``BaseChannel.send_reasoning`` expands them to a single delta + end pair so
to a single delta + end pair so plugins only implement the streaming plugins only implement the streaming primitives.
primitives.
""" """
from __future__ import annotations from __future__ import annotations
@ -22,6 +21,7 @@ from unittest.mock import AsyncMock
import pytest import pytest
from nanobot.bus.events import OutboundMessage 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.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.channels.manager import ChannelManager from nanobot.channels.manager import ChannelManager
@ -48,11 +48,11 @@ class _MockChannel(BaseChannel):
async def send(self, msg): async def send(self, msg):
return await self._send_mock(msg) return await self._send_mock(msg)
async def send_reasoning_delta(self, chat_id, delta, metadata=None): async def send_reasoning_delta(self, chat_id, delta, metadata=None, *, stream_id=None):
return await self._delta_mock(chat_id, delta, metadata) return await self._delta_mock(chat_id, delta, metadata, stream_id=stream_id)
async def send_reasoning_end(self, chat_id, metadata=None): async def send_reasoning_end(self, chat_id, metadata=None, *, stream_id=None):
return await self._end_mock(chat_id, metadata) return await self._end_mock(chat_id, metadata, stream_id=stream_id)
async def send_file_edit_events(self, chat_id, edits, metadata=None): async def send_file_edit_events(self, chat_id, edits, metadata=None):
return await self._file_edit_mock(chat_id, edits, metadata) 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 @pytest.mark.asyncio
async def test_reasoning_delta_routes_to_send_reasoning_delta(manager): async def test_reasoning_delta_routes_to_send_reasoning_delta(manager):
channel = manager.channels["mock"] channel = manager.channels["mock"]
msg = OutboundMessage( msg = outbound_message_for_event(
channel="mock", channel="mock",
chat_id="c1", chat_id="c1",
content="step-by-step", event=ProgressEvent(content="step-by-step", reasoning_delta=True, stream_id="r1"),
metadata={"_progress": True, "_reasoning_delta": True, "_stream_id": "r1"},
) )
await manager._send_once(channel, msg) await manager._send_once(channel, msg)
channel._delta_mock.assert_awaited_once() channel._delta_mock.assert_awaited_once()
args = channel._delta_mock.await_args.args args = channel._delta_mock.await_args.args
assert args[0] == "c1" assert args[0] == "c1"
assert args[1] == "step-by-step" assert args[1] == "step-by-step"
assert channel._delta_mock.await_args.kwargs["stream_id"] == "r1"
channel._send_mock.assert_not_awaited() channel._send_mock.assert_not_awaited()
channel._end_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 @pytest.mark.asyncio
async def test_reasoning_end_routes_to_send_reasoning_end(manager): async def test_reasoning_end_routes_to_send_reasoning_end(manager):
channel = manager.channels["mock"] channel = manager.channels["mock"]
msg = OutboundMessage( msg = outbound_message_for_event(
channel="mock", channel="mock",
chat_id="c1", chat_id="c1",
content="", event=ProgressEvent(reasoning_end=True, stream_id="r1"),
metadata={"_progress": True, "_reasoning_end": True, "_stream_id": "r1"},
) )
await manager._send_once(channel, msg) await manager._send_once(channel, msg)
channel._end_mock.assert_awaited_once() channel._end_mock.assert_awaited_once()
@ -124,16 +123,13 @@ async def test_reasoning_end_routes_to_send_reasoning_end(manager):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_legacy_one_shot_reasoning_expands_to_delta_plus_end(manager): async def test_one_shot_reasoning_expands_to_delta_plus_end(manager):
"""`_reasoning` (no delta/end pair) falls back through `send_reasoning` """One-shot reasoning expands to a single delta + end."""
which the base class expands to a single delta + end. Hooks that haven't
migrated still surface in WebUI as a complete stream segment."""
channel = manager.channels["mock"] channel = manager.channels["mock"]
msg = OutboundMessage( msg = outbound_message_for_event(
channel="mock", channel="mock",
chat_id="c1", chat_id="c1",
content="one-shot reasoning", event=ProgressEvent(content="one-shot reasoning", reasoning=True),
metadata={"_progress": True, "_reasoning": True},
) )
await manager._send_once(channel, msg) await manager._send_once(channel, msg)
channel._delta_mock.assert_awaited_once() 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): async def test_dispatch_drops_reasoning_when_channel_opts_out(manager):
channel = manager.channels["mock"] channel = manager.channels["mock"]
channel.show_reasoning = False channel.show_reasoning = False
msg = OutboundMessage( msg = outbound_message_for_event(
channel="mock", channel="mock",
chat_id="c1", chat_id="c1",
content="hidden thinking", event=ProgressEvent(content="hidden thinking", reasoning_delta=True),
metadata={"_progress": True, "_reasoning_delta": True},
) )
await manager.bus.publish_outbound(msg) 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 = manager.channels["mock"]
channel.show_reasoning = True channel.show_reasoning = True
for chunk in ("first ", "second"): for chunk in ("first ", "second"):
await manager.bus.publish_outbound(OutboundMessage( await manager.bus.publish_outbound(outbound_message_for_event(
channel="mock", channel="mock",
chat_id="c1", chat_id="c1",
content=chunk, event=ProgressEvent(content=chunk, reasoning_delta=True, stream_id="r1"),
metadata={"_progress": True, "_reasoning_delta": True, "_stream_id": "r1"},
)) ))
await manager.bus.publish_outbound(OutboundMessage( await manager.bus.publish_outbound(outbound_message_for_event(
channel="mock", channel="mock",
chat_id="c1", chat_id="c1",
content="", event=ProgressEvent(reasoning_end=True, stream_id="r1"),
metadata={"_progress": True, "_reasoning_end": True, "_stream_id": "r1"},
)) ))
await _pump_one(manager) await _pump_one(manager)
@ -185,11 +178,10 @@ async def test_dispatch_delivers_reasoning_when_channel_opts_in(manager):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_dispatch_silently_drops_reasoning_for_unknown_channel(manager): async def test_dispatch_silently_drops_reasoning_for_unknown_channel(manager):
msg = OutboundMessage( msg = outbound_message_for_event(
channel="ghost", channel="ghost",
chat_id="c1", chat_id="c1",
content="nobody home", event=ProgressEvent(content="nobody home", reasoning_delta=True),
metadata={"_progress": True, "_reasoning_delta": True},
) )
await manager.bus.publish_outbound(msg) 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): async def test_file_edit_events_route_to_channel_capability(manager):
channel = manager.channels["mock"] channel = manager.channels["mock"]
edits = [{"version": 1, "phase": "start", "path": "src/app.py"}] edits = [{"version": 1, "phase": "start", "path": "src/app.py"}]
msg = OutboundMessage( msg = outbound_message_for_event(
channel="mock", channel="mock",
chat_id="c1", chat_id="c1",
content="", event=ProgressEvent(file_edit_events=edits),
metadata={"_progress": True, "_file_edit_events": edits},
) )
await manager._send_once(channel, msg) await manager._send_once(channel, msg)
channel._file_edit_mock.assert_awaited_once_with( 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() 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 = manager.channels["mock"]
channel.send_progress = False channel.send_progress = False
channel.show_reasoning = True channel.show_reasoning = True
await manager.bus.publish_outbound(OutboundMessage( await manager.bus.publish_outbound(outbound_message_for_event(
channel="mock", channel="mock",
chat_id="c1", chat_id="c1",
content="still surfaces", event=ProgressEvent(content="still surfaces", reasoning_delta=True),
metadata={"_progress": True, "_reasoning_delta": True},
)) ))
await _pump_one(manager) await _pump_one(manager)

View File

@ -9,6 +9,11 @@ from unittest.mock import AsyncMock, patch
import pytest import pytest
from nanobot.bus.events import OutboundMessage 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.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.channels.manager import ChannelManager 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 @pytest.mark.asyncio
async def test_send_with_retry_calls_send_delta(): 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 send_delta_called = False
class _StreamingChannel(BaseChannel): class _StreamingChannel(BaseChannel):
@ -734,7 +739,16 @@ async def test_send_with_retry_calls_send_delta():
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
pass # Should not be called 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 nonlocal send_delta_called
send_delta_called = True 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.channels = {"streaming": _StreamingChannel(fake_config, mgr.bus)}
mgr._dispatch_task = None mgr._dispatch_task = None
msg = OutboundMessage( msg = outbound_message_for_event(
channel="streaming", chat_id="123", content="test delta", channel="streaming",
metadata={"_stream_delta": True} chat_id="123",
event=StreamDeltaEvent(content="test delta"),
) )
await mgr._send_with_retry(mgr.channels["streaming"], msg) 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 @pytest.mark.asyncio
async def test_send_with_retry_skips_send_when_streamed(): 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_called = False
send_delta_called = False send_delta_called = False
@ -778,7 +793,16 @@ async def test_send_with_retry_skips_send_when_streamed():
nonlocal send_called nonlocal send_called
send_called = True 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 nonlocal send_delta_called
send_delta_called = True 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.channels = {"streamed": _StreamedChannel(fake_config, mgr.bus)}
mgr._dispatch_task = None mgr._dispatch_task = None
# _streamed means message was already sent via send_delta, so skip send msg = outbound_message_for_event(
msg = OutboundMessage( channel="streamed",
channel="streamed", chat_id="123", content="test", chat_id="123",
metadata={"_streamed": True} event=StreamedResponseEvent(),
content="test",
) )
await mgr._send_with_retry(mgr.channels["streamed"], msg) await mgr._send_with_retry(mgr.channels["streamed"], msg)

View File

@ -10,6 +10,7 @@ pytest.importorskip("discord")
import discord import discord
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.discord import ( from nanobot.channels.discord import (
MAX_MESSAGE_LEN, 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]) times = iter([1.0, 3.0, 5.0])
monkeypatch.setattr("nanobot.channels.discord.time.monotonic", lambda: next(times, 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", "hel", stream_id="s1")
await owner.send_delta("123", "lo", {"_stream_delta": True, "_stream_id": "s1"}) await owner.send_delta("123", "lo", stream_id="s1")
await owner.send_delta("123", "", {"_stream_end": True, "_stream_id": "s1"}) await owner.send_delta("123", "", stream_id="s1", stream_end=True)
assert target.sent_payloads[0] == {"content": "hel"} assert target.sent_payloads[0] == {"content": "hel"}
assert target.sent_messages[0].edits == [{"content": "hello"}, {"content": "hello"}] 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]) times = iter([1.0, 3.0])
monkeypatch.setattr("nanobot.channels.discord.time.monotonic", lambda: next(times, 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", prefix, stream_id="s1")
await owner.send_delta("123", suffix, {"_stream_delta": True, "_stream_id": "s1"}) await owner.send_delta("123", suffix, stream_id="s1")
await owner.send_delta("123", "", {"_stream_end": True, "_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_payloads == [{"content": prefix}, {"content": chunks[1]}]
assert target.sent_messages[0].edits == [{"content": chunks[0]}, {"content": chunks[0]}] 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", channel="discord",
chat_id="123", chat_id="123",
content="progress", content="progress",
metadata={"_progress": True}, event=ProgressEvent(content="progress"),
) )
) )

View File

@ -6,6 +6,7 @@ from pathlib import Path
import pytest import pytest
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.email import EmailChannel, EmailConfig from nanobot.channels.email import EmailChannel, EmailConfig
@ -868,10 +869,7 @@ async def test_send_skips_progress_messages_before_smtp(monkeypatch) -> None:
channel="email", channel="email",
chat_id="alice@example.com", chat_id="alice@example.com",
content="", content="",
metadata={ event=ProgressEvent(tool_events=[{"phase": "end", "name": "exec"}]),
"_progress": True,
"_tool_events": [{"phase": "end", "name": "exec"}],
},
) )
) )

View File

@ -193,7 +193,8 @@ class TestStreamEndReactionCleanup:
await ch.send_delta( await ch.send_delta(
"oc_chat1", "", "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") ch._remove_reaction.assert_called_once_with("om_001", "rx_42")
@ -210,7 +211,7 @@ class TestStreamEndReactionCleanup:
await ch.send_delta( await ch.send_delta(
"oc_chat1", "", "oc_chat1", "",
metadata={"_stream_end": True}, stream_end=True,
) )
ch._remove_reaction.assert_not_called() ch._remove_reaction.assert_not_called()
@ -227,7 +228,8 @@ class TestStreamEndReactionCleanup:
await ch.send_delta( await ch.send_delta(
"oc_chat1", "", "oc_chat1", "",
metadata={"_stream_end": True, "message_id": "om_001"}, metadata={"message_id": "om_001"},
stream_end=True,
) )
ch._remove_reaction.assert_not_called() 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._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
ch._remove_reaction = AsyncMock() 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() ch._remove_reaction.assert_not_called()
@ -260,7 +262,7 @@ class TestStreamEndReactionCleanup:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_no_removal_when_resuming(self): 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 = _make_channel()
ch.config.done_emoji = "DONE" ch.config.done_emoji = "DONE"
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
@ -274,7 +276,9 @@ class TestStreamEndReactionCleanup:
await ch.send_delta( await ch.send_delta(
"oc_chat1", "", "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._remove_reaction.assert_not_called()
@ -299,19 +303,23 @@ class TestStreamEndReactionCleanup:
# Intermediate stream end (more tool calls coming). # Intermediate stream end (more tool calls coming).
await ch.send_delta( await ch.send_delta(
"oc_chat1", "", "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._remove_reaction.assert_not_called()
ch._add_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( ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="t", card_id="card_1", sequence=5, last_edit=0.0, text="t", card_id="card_1", sequence=5, last_edit=0.0,
) )
# Final stream end (resuming=False): OnIt removed, done_emoji added. # Final stream end (resuming=False): OnIt removed, done_emoji added.
await ch.send_delta( await ch.send_delta(
"oc_chat1", "", "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._remove_reaction.assert_called_once_with("om_001", "rx_42")
ch._add_reaction.assert_called_once_with("om_001", "DONE") ch._add_reaction.assert_called_once_with("om_001", "DONE")

View File

@ -18,6 +18,7 @@ if not FEISHU_AVAILABLE:
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True) pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.feishu import FeishuChannel, FeishuConfig from nanobot.channels.feishu import FeishuChannel, FeishuConfig
@ -332,7 +333,8 @@ async def test_send_skips_reply_for_progress_messages() -> None:
channel="feishu", channel="feishu",
chat_id="oc_abc", chat_id="oc_abc",
content="thinking...", 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() channel._client.im.v1.message.create.assert_called_once()

View File

@ -6,6 +6,7 @@ from unittest.mock import MagicMock
import pytest import pytest
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.feishu import FeishuChannel, FeishuConfig, _FeishuStreamBuf 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_element.content.return_value = _mock_content_response()
ch._client.cardkit.v1.card.settings.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 assert "oc_chat1" not in ch._stream_bufs
ch._client.cardkit.v1.card_element.content.assert_called_once() 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") 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 "oc_chat1" not in ch._stream_bufs
ch._client.cardkit.v1.card_element.content.assert_not_called() ch._client.cardkit.v1.card_element.content.assert_not_called()
@ -306,7 +307,8 @@ class TestSendDelta:
await ch.send_delta( await ch.send_delta(
"oc_chat1", "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() ch._client.im.v1.message.create.assert_called_once()
@ -326,11 +328,11 @@ class TestSendDelta:
"oc_chat1", "oc_chat1",
"", "",
metadata={ metadata={
"_stream_end": True,
"message_id": "om_001", "message_id": "om_001",
"chat_type": "group", "chat_type": "group",
"thread_id": "ot_001", "thread_id": "ot_001",
}, },
stream_end=True,
) )
ch._client.im.v1.message.reply.assert_called_once() ch._client.im.v1.message.reply.assert_called_once()
@ -351,7 +353,8 @@ class TestSendDelta:
await ch.send_delta( await ch.send_delta(
"oc_chat1", "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() 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.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") 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 "oc_chat1" not in ch._stream_bufs
assert ch._client.cardkit.v1.card.settings.call_count == 2 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) 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 "oc_chat1" not in ch._stream_bufs
assert ch._client.cardkit.v1.card_element.content.call_count == 2 assert ch._client.cardkit.v1.card_element.content.call_count == 2
@ -398,7 +401,7 @@ class TestSendDelta:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stream_end_without_buf_is_noop(self): async def test_stream_end_without_buf_is_noop(self):
ch = _make_channel() 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() ch._client.cardkit.v1.card_element.content.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
@ -446,7 +449,7 @@ class TestToolHintInlineStreaming:
msg = OutboundMessage( msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1", channel="feishu", chat_id="oc_chat1",
content='web_fetch("https://example.com")', 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) await ch.send(msg)
@ -482,7 +485,7 @@ class TestToolHintInlineStreaming:
msg = OutboundMessage( msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1", channel="feishu", chat_id="oc_chat1",
content='read_file("path")', content='read_file("path")',
metadata={"_tool_hint": True}, event=ProgressEvent(content='read_file("path")', tool_hint=True),
) )
await ch.send(msg) await ch.send(msg)
@ -497,7 +500,8 @@ class TestToolHintInlineStreaming:
msg = OutboundMessage( msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1", channel="feishu", chat_id="oc_chat1",
content='read_file("path")', 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) await ch.send(msg)
@ -514,8 +518,8 @@ class TestToolHintInlineStreaming:
msg = OutboundMessage( msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1", channel="feishu", chat_id="oc_chat1",
content='read_file("path")', content='read_file("path")',
event=ProgressEvent(content='read_file("path")', tool_hint=True),
metadata={ metadata={
"_tool_hint": True,
"message_id": "om_001", "message_id": "om_001",
"chat_type": "group", "chat_type": "group",
"thread_id": "ot_001", "thread_id": "ot_001",
@ -538,7 +542,8 @@ class TestToolHintInlineStreaming:
msg = OutboundMessage( msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1", channel="feishu", chat_id="oc_chat1",
content='read_file("path")', 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) await ch.send(msg)
@ -558,13 +563,15 @@ class TestToolHintInlineStreaming:
msg1 = OutboundMessage( msg1 = OutboundMessage(
channel="feishu", chat_id="oc_chat1", 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) await ch.send(msg1)
msg2 = OutboundMessage( msg2 = OutboundMessage(
channel="feishu", chat_id="oc_chat1", 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) await ch.send(msg2)
@ -577,7 +584,7 @@ class TestToolHintInlineStreaming:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_tool_hint_preserved_on_final_stream_end(self): 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 = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Final content\n\n🔧 web_fetch(\"url\")\n\n", 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_element.content.return_value = _mock_content_response()
ch._client.cardkit.v1.card.settings.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 assert "oc_chat1" not in ch._stream_bufs
update_call = ch._client.cardkit.v1.card_element.content.call_args[0][0] update_call = ch._client.cardkit.v1.card_element.content.call_args[0][0]
@ -603,7 +610,8 @@ class TestToolHintInlineStreaming:
for content in ("", " ", "\t\n"): for content in ("", " ", "\t\n"):
msg = OutboundMessage( msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1", 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) await ch.send(msg)

View File

@ -1,7 +1,6 @@
"""Tests for FeishuChannel tool hint formatting.""" """Tests for FeishuChannel tool hint formatting."""
import json import json
from types import SimpleNamespace
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
@ -18,6 +17,7 @@ if not FEISHU_AVAILABLE:
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True) pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.channels.feishu import FeishuChannel from nanobot.channels.feishu import FeishuChannel
@ -51,7 +51,7 @@ async def test_tool_hint_sends_interactive_card(mock_feishu_channel):
channel="feishu", channel="feishu",
chat_id="oc_123456", chat_id="oc_123456",
content='web_search("test query")', 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: 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", channel="feishu",
chat_id="oc_123456", chat_id="oc_123456",
content=" ", # whitespace only content=" ", # whitespace only
metadata={"_tool_hint": True} event=ProgressEvent(tool_hint=True),
) )
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: 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", channel="feishu",
chat_id="oc_123456", chat_id="oc_123456",
content='web_search("query"), read_file("/path/to/file")', 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: 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", channel="feishu",
chat_id="oc_123456", chat_id="oc_123456",
content='read src/main.py, grep "TODO"', 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: 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", channel="feishu",
chat_id="oc_123456", chat_id="oc_123456",
content='grep "hello, world", $ echo test', 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: 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", channel="feishu",
chat_id="oc_123456", chat_id="oc_123456",
content='read path × 3, grep "pattern"', 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: 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", channel="feishu",
chat_id="oc_123456", chat_id="oc_123456",
content='4_5v::analyze_image("photo.jpg")', 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: 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", channel="feishu",
chat_id="oc_123456", chat_id="oc_123456",
content='web_search("foo, bar"), read_file("/path/to/file")', 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: with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:

View File

@ -11,6 +11,7 @@ from nio import RoomSendResponse, SyncError
import nanobot.channels.matrix as matrix_module import nanobot.channels.matrix as matrix_module
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.matrix import ( from nanobot.channels.matrix import (
MATRIX_HTML_FORMAT, MATRIX_HTML_FORMAT,
@ -1522,7 +1523,7 @@ async def test_send_progress_keeps_typing_keepalive_running() -> None:
channel="matrix", channel="matrix",
chat_id="!room:matrix.org", chat_id="!room:matrix.org",
content="working...", 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", channel="matrix",
chat_id="!room:matrix.org", chat_id="!room:matrix.org",
content="", 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", channel="matrix",
chat_id="!room:matrix.org", chat_id="!room:matrix.org",
content=" \n\n ", 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, 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 "!room:matrix.org" not in channel._stream_bufs
assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS) 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", "Hello", metadata)
await channel.send_delta("!room:matrix.org", " world", 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"] edit_content = client.room_send_calls[1]["content"]
final_content = client.room_send_calls[2]["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) client = _FakeAsyncClient("", "", "", None)
channel.client = client 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.room_send_calls == []
assert client.typing_calls == [] assert client.typing_calls == []

View File

@ -10,6 +10,7 @@ from unittest.mock import MagicMock
import pytest import pytest
from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.signal import ( from nanobot.channels.signal import (
SignalChannel, SignalChannel,
@ -1341,7 +1342,7 @@ class TestSend:
channel="signal", channel="signal",
chat_id="+19995550001", chat_id="+19995550001",
content="working...", content="working...",
metadata={"_progress": True}, event=ProgressEvent(content="working..."),
) )
await ch.send(msg) await ch.send(msg)
# Progress messages should NOT stop the typing indicator # Progress messages should NOT stop the typing indicator

View File

@ -12,6 +12,7 @@ except ImportError:
pytest.skip("Telegram dependencies not installed (python-telegram-bot)", allow_module_level=True) pytest.skip("Telegram dependencies not installed (python-telegram-bot)", allow_module_level=True)
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.telegram import ( from nanobot.channels.telegram import (
TELEGRAM_REPLY_CONTEXT_MAX_LEN, 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) channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0)
with pytest.raises(RuntimeError, match="boom"): 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 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._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") 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 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) channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0)
with pytest.raises(TimedOut, match="network timeout"): 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" — # Every call to edit_message_text must have used parse_mode="HTML" —
# no plain-text fallback call should have been made. # 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) channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0)
with pytest.raises(NetworkError, match="connection reset"): 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" — # Every call to edit_message_text must have used parse_mode="HTML" —
# no plain-text fallback call should have been made. # no plain-text fallback call should have been made.
@ -693,7 +694,7 @@ async def test_send_delta_stream_end_falls_back_on_bad_request() -> None:
) )
channel._stream_bufs["123"] = _StreamBuf(text="hello <bad>", message_id=7, last_edit=0.0) channel._stream_bufs["123"] = _StreamBuf(text="hello <bad>", message_id=7, last_edit=0.0)
await channel.send_delta("123", "", {"_stream_end": True}) await channel.send_delta("123", "", stream_end=True)
# edit_message_text should have been called twice: once for HTML, once for plain fallback # 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 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) oversized = "x" * (4000 + 500)
channel._stream_bufs["123"] = _StreamBuf(text=oversized, message_id=7, last_edit=0.0) 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() channel._app.bot.edit_message_text.assert_called_once()
edit_text = channel._app.bot.edit_message_text.call_args.kwargs.get("text", "") 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) 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() channel._app.bot.edit_message_text.assert_called_once()
edit_text = channel._app.bot.edit_message_text.call_args.kwargs.get("text", "") 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" raw_text = "```python\n" + ("print(\"line\")\n" * 450) + "```\nDone"
channel._stream_bufs["123"] = _StreamBuf(text=raw_text, message_id=7, last_edit=0.0) 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 = [ html_chunks = [
channel._app.bot.edit_message_text.call_args.kwargs.get("text", ""), 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", 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"] buf = channel._stream_bufs["123"]
assert buf.text == "world" 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._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")) 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 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" 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() channel._app.bot.edit_message_text.assert_called_once()
edit_text = channel._app.bot.edit_message_text.call_args.kwargs.get("text", "") 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( await channel.send_delta(
"123", "123",
"hello", "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 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", channel="telegram",
chat_id="123", chat_id="123",
content="hello", content="hello",
metadata={"_progress": True, "message_thread_id": 42}, event=ProgressEvent(content="hello"),
metadata={"message_thread_id": 42},
) )
) )

View File

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

View File

@ -16,6 +16,7 @@ import websockets
from ws_test_client import WsTestClient, issue_token, issue_token_ok from ws_test_client import WsTestClient, issue_token, issue_token_ok
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
from nanobot.webui.gateway_services import build_gateway_services 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 @pytest.mark.asyncio
async def test_server_send_tags_tool_hint_with_kind(bus: MagicMock) -> None: async def test_server_send_tags_tool_hint_with_kind(bus: MagicMock) -> None:
"""``_tool_hint`` metadata must surface as ``kind: "tool_hint"`` so WS """Tool-hint progress events surface as ``kind: "tool_hint"``."""
clients render breadcrumbs separately from conversational replies."""
ch = _ch(bus, 29919) ch = _ch(bus, 29919)
t = asyncio.create_task(ch.start()) t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3) 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( await ch.send(OutboundMessage(
channel="websocket", chat_id=ready.chat_id, channel="websocket", chat_id=ready.chat_id,
content='weather("get")', content='weather("get")',
metadata={"_progress": True, "_tool_hint": True}, event=ProgressEvent(content='weather("get")', tool_hint=True),
)) ))
hint = await c.recv_message() hint = await c.recv_message()
assert hint.raw.get("kind") == "tool_hint" 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( await ch.send(OutboundMessage(
channel="websocket", chat_id=ready.chat_id, channel="websocket", chat_id=ready.chat_id,
content="thinking…", content="thinking…",
metadata={"_progress": True}, event=ProgressEvent(content="thinking…"),
)) ))
prog = await c.recv_message() prog = await c.recv_message()
assert prog.raw.get("kind") == "progress" 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: async with WsTestClient("ws://127.0.0.1:29911/", client_id="s") as c:
cid = (await c.recv_ready()).chat_id cid = (await c.recv_ready()).chat_id
for part in ("Hello", " ", "world", "!"): for part in ("Hello", " ", "world", "!"):
await ch.send_delta(cid, part, {"_stream_delta": True, "_stream_id": "s1"}) await ch.send_delta(cid, part, stream_id="s1")
await ch.send_delta(cid, "", {"_stream_end": True, "_stream_id": "s1"}) await ch.send_delta(cid, "", stream_id="s1", stream_end=True)
msgs = await c.collect_stream() msgs = await c.collect_stream()
deltas = [m for m in msgs if m.event == "delta"] deltas = [m for m in msgs if m.event == "delta"]
@ -305,12 +305,12 @@ async def test_interleaved_streams(bus: MagicMock) -> None:
try: try:
async with WsTestClient("ws://127.0.0.1:29912/", client_id="i") as c: async with WsTestClient("ws://127.0.0.1:29912/", client_id="i") as c:
cid = (await c.recv_ready()).chat_id cid = (await c.recv_ready()).chat_id
await ch.send_delta(cid, "A1", {"_stream_delta": True, "_stream_id": "sa"}) await ch.send_delta(cid, "A1", stream_id="sa")
await ch.send_delta(cid, "B1", {"_stream_delta": True, "_stream_id": "sb"}) await ch.send_delta(cid, "B1", stream_id="sb")
await ch.send_delta(cid, "A2", {"_stream_delta": True, "_stream_id": "sa"}) await ch.send_delta(cid, "A2", stream_id="sa")
await ch.send_delta(cid, "", {"_stream_end": True, "_stream_id": "sa"}) await ch.send_delta(cid, "", stream_id="sa", stream_end=True)
await ch.send_delta(cid, "B2", {"_stream_delta": True, "_stream_id": "sb"}) await ch.send_delta(cid, "B2", stream_id="sb")
await ch.send_delta(cid, "", {"_stream_end": True, "_stream_id": "sb"}) await ch.send_delta(cid, "", stream_id="sb", stream_end=True)
msgs = await c.recv_n(6) msgs = await c.recv_n(6)
sa = "".join(m.text for m in msgs if m.event == "delta" and m.stream_id == "sa") sa = "".join(m.text for m in msgs if m.event == "delta" and m.stream_id == "sa")

View File

@ -18,6 +18,7 @@ if not WECOM_AVAILABLE:
pytest.skip("WeCom dependencies not installed (wecom_aibot_sdk)", allow_module_level=True) pytest.skip("WeCom dependencies not installed (wecom_aibot_sdk)", allow_module_level=True)
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.wecom import ( from nanobot.channels.wecom import (
WecomChannel, WecomChannel,
@ -316,7 +317,7 @@ async def test_send_text_with_frame() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_progress_with_frame() -> None: 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()) channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["*"]), MessageBus())
client = _FakeWeComClient() client = _FakeWeComClient()
channel._client = client channel._client = client
@ -324,7 +325,12 @@ async def test_send_progress_with_frame() -> None:
channel._chat_frames["chat1"] = _FakeFrame() channel._chat_frames["chat1"] = _FakeFrame()
await channel.send( 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() client.reply_stream.assert_called_once()

View File

@ -10,6 +10,7 @@ import httpx
import pytest import pytest
import nanobot.channels.weixin as weixin_mod import nanobot.channels.weixin as weixin_mod
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.weixin import ( from nanobot.channels.weixin import (
ITEM_IMAGE, ITEM_IMAGE,
@ -686,7 +687,8 @@ async def test_send_progress_message_keeps_typing_indicator() -> None:
"chat_id": "wx-user", "chat_id": "wx-user",
"content": "thinking", "content": "thinking",
"media": [], "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", "chat_id": "wx-user",
"content": "Using tool", "content": "Using tool",
"media": [], "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", "chat_id": "wx-user",
"content": hint, "content": hint,
"media": [], "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", "chat_id": "wx-user",
"content": "search 'foo'", "content": "search 'foo'",
"media": [], "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", "chat_id": "wx-user",
"content": "Let me think...", "content": "Let me think...",
"media": [], "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", "chat_id": "wx-user",
"content": "search 'foo'", "content": "search 'foo'",
"media": [], "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", "chat_id": "wx-user",
"content": "Thinking step 1...", "content": "Thinking step 1...",
"media": [], "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", "chat_id": "wx-user",
"content": "search 'foo'", "content": "search 'foo'",
"media": [], "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", "chat_id": "wx-user",
"content": "", "content": "",
"media": [], "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", "chat_id": "wx-user",
"content": "hint", "content": "hint",
"media": [], "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", "chat_id": "wx-user",
"content": "hint", "content": "hint",
"media": [], "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", "chat_id": "wx-user",
"content": "hint", "content": "hint",
"media": [], "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") channel._send_text.assert_awaited_once_with("wx-user", "hint", "ctx-1")
assert "wx-user" not in channel._pending_tool_hints 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", "chat_id": "wx-user",
"content": "hint", "content": "hint",
"media": [], "media": [],
"metadata": {"_progress": True, "_tool_hint": True}, "event": ProgressEvent(content="hint", tool_hint=True),
"metadata": {},
}, },
)() )()
) )

View File

@ -3,6 +3,7 @@ from unittest.mock import patch
import pytest import pytest
from nanobot.bus.outbound_events import ProgressEvent, RetryWaitEvent
from nanobot.cli import commands 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) channels_config = SimpleNamespace(send_progress=False, send_tool_hints=False)
msg = SimpleNamespace( msg = SimpleNamespace(
content="Model request failed, retry in 2s (attempt 1).", 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: 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( msg = SimpleNamespace(
content="Let me think about this...", 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)): 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( msg = SimpleNamespace(
content="I should search first.", 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)): 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( first = await commands._maybe_print_interactive_progress(
SimpleNamespace( SimpleNamespace(
content="The", content="The",
metadata={"_progress": True, "_reasoning_delta": True}, event=ProgressEvent(content="The", reasoning_delta=True),
metadata={},
), ),
None, None,
channels_config, channels_config,
@ -90,7 +95,8 @@ async def test_reasoning_delta_buffers_until_sentence_boundary():
second = await commands._maybe_print_interactive_progress( second = await commands._maybe_print_interactive_progress(
SimpleNamespace( SimpleNamespace(
content=" user asked.", content=" user asked.",
metadata={"_progress": True, "_reasoning_delta": True}, event=ProgressEvent(content=" user asked.", reasoning_delta=True),
metadata={},
), ),
None, None,
channels_config, channels_config,
@ -114,7 +120,8 @@ async def test_reasoning_end_flushes_buffered_delta():
delta = await commands._maybe_print_interactive_progress( delta = await commands._maybe_print_interactive_progress(
SimpleNamespace( SimpleNamespace(
content="The user asked", content="The user asked",
metadata={"_progress": True, "_reasoning_delta": True}, event=ProgressEvent(content="The user asked", reasoning_delta=True),
metadata={},
), ),
None, None,
channels_config, channels_config,
@ -123,7 +130,8 @@ async def test_reasoning_end_flushes_buffered_delta():
end = await commands._maybe_print_interactive_progress( end = await commands._maybe_print_interactive_progress(
SimpleNamespace( SimpleNamespace(
content="", content="",
metadata={"_progress": True, "_reasoning_end": True}, event=ProgressEvent(reasoning_end=True),
metadata={},
), ),
None, None,
channels_config, channels_config,
@ -143,7 +151,8 @@ async def test_reasoning_hidden_when_show_reasoning_disabled():
) )
msg = SimpleNamespace( msg = SimpleNamespace(
content="Let me think about this...", 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: 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( msg = SimpleNamespace(
content="working on it...", content="working on it...",
metadata={"_progress": True}, event=ProgressEvent(content="working on it..."),
metadata={},
) )
async def fake_print(text: str, thinking=None, renderer=None): 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( msg = SimpleNamespace(
content="Let me think about this...", content="Let me think about this...",
metadata={"_progress": True, "_reasoning": True}, event=ProgressEvent(content="Let me think about this...", reasoning=True),
metadata={},
) )
with patch( with patch(

View File

@ -49,10 +49,6 @@ async def test_maybe_continue_turn_queues_internal_message():
"message_id": "msg-1", "message_id": "msg-1",
"origin_message_id": "msg-0", "origin_message_id": "msg-0",
"_wants_stream": True, "_wants_stream": True,
"_stream_id": "stream-1",
"_stream_delta": True,
"_stream_end": True,
"_resuming": True,
"webui": 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["message_id"] == "msg-1"
assert queued.metadata["origin_message_id"] == "msg-0" assert queued.metadata["origin_message_id"] == "msg-0"
assert queued.metadata["_wants_stream"] is True 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 "Finish the migration." in queued.content
assert ctx.all_messages == messages[:-1] assert ctx.all_messages == messages[:-1]
assert ctx.final_content == "" assert ctx.final_content == ""

View File

@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import GoalStatusEvent
from nanobot.session import webui_turns as wth 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) assert isinstance(t0, float)
call = bus.publish_outbound.await_args[0][0] call = bus.publish_outbound.await_args[0][0]
assert call.chat_id == "chat-a" 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 @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 assert wth.websocket_turn_wall_started_at("chat-a") == 1234.5
call = bus.publish_outbound.await_args[0][0] 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 @pytest.mark.asyncio