refactor(bus): type outbound runtime events
This commit is contained in:
+34
-22
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ""
|
||||||
+11
-14
@@ -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,
|
||||||
|
event=ProgressEvent(
|
||||||
content=content,
|
content=content,
|
||||||
metadata=meta,
|
tool_hint=tool_hint,
|
||||||
|
reasoning_delta=reasoning,
|
||||||
|
reasoning_end=reasoning_end,
|
||||||
|
tool_events=tool_events,
|
||||||
|
file_edit_events=file_edit_events,
|
||||||
|
),
|
||||||
|
metadata=msg.metadata,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+37
-17
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+92
-50
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
outbound_message_for_event(
|
||||||
channel="websocket",
|
channel="websocket",
|
||||||
chat_id="*",
|
chat_id="*",
|
||||||
content="",
|
event=RuntimeModelUpdatedEvent(model=model, model_preset=model_preset),
|
||||||
metadata={
|
)
|
||||||
"_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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+23
-14
@@ -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."""
|
||||||
|
|||||||
+25
-14
@@ -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] = {}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from nanobot.bus.events import OutboundMessage
|
||||||
|
from nanobot.bus.outbound_events import (
|
||||||
|
ProgressEvent,
|
||||||
|
StreamDeltaEvent,
|
||||||
|
StreamedResponseEvent,
|
||||||
|
StreamEndEvent,
|
||||||
|
outbound_event_from_message,
|
||||||
|
outbound_message_for_event,
|
||||||
|
replace_outbound_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_event_lives_on_outbound_message_event_field() -> None:
|
||||||
|
tool_events = [{"phase": "start", "name": "read_file"}]
|
||||||
|
file_edit_events = [{"phase": "end", "path": "app.py"}]
|
||||||
|
|
||||||
|
msg = outbound_message_for_event(
|
||||||
|
channel="websocket",
|
||||||
|
chat_id="chat-1",
|
||||||
|
event=ProgressEvent(
|
||||||
|
content="working",
|
||||||
|
tool_hint=True,
|
||||||
|
reasoning_delta=True,
|
||||||
|
stream_id="r1",
|
||||||
|
tool_events=tool_events,
|
||||||
|
file_edit_events=file_edit_events,
|
||||||
|
),
|
||||||
|
metadata={"origin_message_id": "m1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert msg.content == "working"
|
||||||
|
assert msg.metadata == {"origin_message_id": "m1"}
|
||||||
|
|
||||||
|
event = outbound_event_from_message(msg)
|
||||||
|
assert isinstance(event, ProgressEvent)
|
||||||
|
assert event.content == "working"
|
||||||
|
assert event.tool_hint is True
|
||||||
|
assert event.reasoning_delta is True
|
||||||
|
assert event.stream_id == "r1"
|
||||||
|
assert event.tool_events == tool_events
|
||||||
|
assert event.file_edit_events == file_edit_events
|
||||||
|
|
||||||
|
|
||||||
|
def test_normal_outbound_message_has_no_runtime_event() -> None:
|
||||||
|
msg = OutboundMessage(channel="websocket", chat_id="chat-1", content="hello")
|
||||||
|
|
||||||
|
assert outbound_event_from_message(msg) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_flags_do_not_create_runtime_events() -> None:
|
||||||
|
msg = OutboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
chat_id="chat-1",
|
||||||
|
content="legacy progress",
|
||||||
|
metadata={
|
||||||
|
"_progress": True,
|
||||||
|
"_stream_delta": True,
|
||||||
|
"_goal_status": True,
|
||||||
|
"message_id": "platform-routing-context",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert outbound_event_from_message(msg) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_replace_outbound_event_keeps_routing_metadata() -> None:
|
||||||
|
msg = outbound_message_for_event(
|
||||||
|
channel="websocket",
|
||||||
|
chat_id="chat-1",
|
||||||
|
event=StreamDeltaEvent(content="hello", stream_id="s1"),
|
||||||
|
metadata={"message_id": "m1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = replace_outbound_event(
|
||||||
|
msg,
|
||||||
|
StreamEndEvent(stream_id="s1", resuming=True),
|
||||||
|
content="hello world",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated.content == "hello world"
|
||||||
|
assert updated.metadata == {"message_id": "m1"}
|
||||||
|
assert isinstance(updated.event, StreamEndEvent)
|
||||||
|
assert updated.event.stream_id == "s1"
|
||||||
|
assert updated.event.resuming is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_streamed_response_event_keeps_final_content_outside_event_payload() -> None:
|
||||||
|
msg = outbound_message_for_event(
|
||||||
|
channel="cli",
|
||||||
|
chat_id="direct",
|
||||||
|
event=StreamedResponseEvent(),
|
||||||
|
content="final answer",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert msg.content == "final answer"
|
||||||
|
assert isinstance(outbound_event_from_message(msg), StreamedResponseEvent)
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
"""Tests for ChannelManager delta coalescing to reduce streaming latency."""
|
"""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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"}],
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 == []
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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", "
|
await channel.send_delta("chat-1", "
|
||||||
await channel.send_delta("chat-1", "diagram.png)", {"_stream_delta": True, "_stream_id": "sid"})
|
await channel.send_delta("chat-1", "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",
|
||||||
"",
|
"",
|
||||||
{"_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())
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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": {},
|
||||||
},
|
},
|
||||||
)()
|
)()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 == ""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user