mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-04 02:01:48 +03:00
refactor: simplify cross-session messaging
This commit is contained in:
@@ -4,10 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import nanobot.webui.transcript as transcript_module
|
||||
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
||||
from nanobot.session.session_messages import (
|
||||
SESSION_MESSAGE_METADATA_KEY,
|
||||
SESSION_REPLY_TIMEOUT_METADATA_KEY,
|
||||
)
|
||||
from nanobot.webui.transcript import (
|
||||
WEBUI_TRANSCRIPT_SCHEMA_VERSION,
|
||||
append_fork_marker,
|
||||
@@ -41,34 +37,6 @@ def test_append_stamps_created_at_ms(tmp_path, monkeypatch) -> None:
|
||||
assert lines[0]["created_at_ms"] == 1_700_000_000_000
|
||||
|
||||
|
||||
def test_session_input_splits_active_assistant_stream() -> None:
|
||||
lines = [
|
||||
{"event": "delta", "text": "First"},
|
||||
{
|
||||
"event": "user",
|
||||
"text": "Peer input",
|
||||
"session_message": {
|
||||
"direction": "incoming",
|
||||
"message_id": "message-1",
|
||||
"session": {"id": "handle-1", "name": "jules", "color_slot": 1},
|
||||
},
|
||||
},
|
||||
{"event": "delta", "text": "Tail"},
|
||||
{"event": "stream_end"},
|
||||
{"event": "delta", "text": "Second"},
|
||||
{"event": "turn_end"},
|
||||
]
|
||||
|
||||
messages = replay_transcript_to_ui_messages(lines)
|
||||
|
||||
assert [(message["role"], message["content"]) for message in messages] == [
|
||||
("assistant", "First"),
|
||||
("user", "Peer input"),
|
||||
("assistant", "Tail"),
|
||||
("assistant", "Second"),
|
||||
]
|
||||
|
||||
|
||||
def _force_small_transcript_budget(monkeypatch, *, limit: int = 520, target: int = 260) -> None:
|
||||
monkeypatch.setattr("nanobot.webui.transcript._MAX_TRANSCRIPT_FILE_BYTES", limit)
|
||||
monkeypatch.setattr("nanobot.webui.transcript._ACTIVE_TRANSCRIPT_ROTATE_BYTES", limit)
|
||||
@@ -387,33 +355,6 @@ def test_write_session_messages_as_transcript_builds_canonical_prefix(
|
||||
assert [m["content"] for m in msgs] == ["round1", "answer1"]
|
||||
|
||||
|
||||
def test_write_session_messages_as_transcript_hides_empty_session_reply_timeout_input(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
|
||||
write_session_messages_as_transcript(
|
||||
"websocket:fork",
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "",
|
||||
SESSION_REPLY_TIMEOUT_METADATA_KEY: {"private": True},
|
||||
},
|
||||
{"role": "assistant", "content": "No follow-up needed."},
|
||||
],
|
||||
)
|
||||
|
||||
assert read_transcript_lines("websocket:fork") == [
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "fork",
|
||||
"text": "No follow-up needed.",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_direct_transcript_replay_generates_stable_message_ids() -> None:
|
||||
lines = [
|
||||
{"event": "user", "chat_id": "stable", "text": "question"},
|
||||
@@ -907,64 +848,6 @@ def test_build_response_restores_session_users_for_legacy_transcript(
|
||||
]
|
||||
|
||||
|
||||
def test_build_response_restores_session_source_from_session_history(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
key = "websocket:reviewer"
|
||||
append_transcript_object(
|
||||
key,
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "reviewer",
|
||||
"text": "Review complete",
|
||||
"source": {"kind": "session", "label": "@lead"},
|
||||
},
|
||||
)
|
||||
append_transcript_object(key, {"event": "turn_end", "chat_id": "reviewer"})
|
||||
|
||||
out = build_webui_thread_response(
|
||||
key,
|
||||
session_messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Review this change",
|
||||
SESSION_MESSAGE_METADATA_KEY: {
|
||||
"message_id": "handle-message-1",
|
||||
"created_at_ms": 1,
|
||||
"expect_reply": True,
|
||||
"source": {
|
||||
"name": "lead",
|
||||
"session_key": "websocket:lead",
|
||||
"handle_id": "handle_0123456789abcdef0123456789abcdef",
|
||||
"color_slot": 3,
|
||||
},
|
||||
"target": {
|
||||
"name": "reviewer",
|
||||
"session_key": key,
|
||||
},
|
||||
},
|
||||
},
|
||||
{"role": "assistant", "content": "Review complete"},
|
||||
],
|
||||
)
|
||||
|
||||
assert out is not None
|
||||
session_input, answer = out["messages"]
|
||||
assert session_input["content"] == "Review this change"
|
||||
assert session_input["sessionMessage"] == {
|
||||
"direction": "incoming",
|
||||
"message_id": "handle-message-1",
|
||||
"session": {
|
||||
"id": "handle_0123456789abcdef0123456789abcdef",
|
||||
"name": "lead",
|
||||
"color_slot": 3,
|
||||
},
|
||||
}
|
||||
assert answer["source"] == {"kind": "session", "label": "@lead"}
|
||||
|
||||
|
||||
def test_complete_transcript_does_not_load_session_messages(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
key = "websocket:complete-fast-path"
|
||||
|
||||
@@ -1,35 +1,29 @@
|
||||
"""Tests for WebSocket turn timing strip bookkeeping."""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.context import RequestContext, request_context
|
||||
from nanobot.agent.turn_delivery import TurnRoute
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.outbound_events import (
|
||||
GoalStatusEvent,
|
||||
TurnModelUpdatedEvent,
|
||||
)
|
||||
from nanobot.bus.outbound_events import GoalStatusEvent, TurnModelUpdatedEvent, UserInputEvent
|
||||
from nanobot.bus.runtime_events import (
|
||||
RuntimeEventBus,
|
||||
RuntimeEventContext,
|
||||
SessionTurnStarted,
|
||||
TurnRuntimeAdmitted,
|
||||
UserInputAccepted,
|
||||
)
|
||||
from nanobot.providers.base import GenerationSettings
|
||||
from nanobot.session import webui_turns as wth
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.session.session_handles import session_handle_for_key
|
||||
from nanobot.session.session_messages import SESSION_MESSAGE_METADATA_KEY
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
from nanobot.webui.metadata import WEBSOCKET_TURN_OWNER_METADATA_KEY
|
||||
from nanobot.webui.transcript import read_transcript_lines
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_turn_wall_clock(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
def _clear_turn_wall_clock() -> None:
|
||||
wth._WEBSOCKET_ACTIVE_TURNS.clear()
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
|
||||
wth._WEBSOCKET_TURN_IDS.clear()
|
||||
@@ -226,6 +220,57 @@ async def test_admitted_runtime_publishes_chat_scoped_model_and_preset(tmp_path)
|
||||
assert outbound.event.model_preset == "Codex"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_input_is_projected_by_the_webui_coordinator(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
bus = MagicMock()
|
||||
bus.publish_outbound = AsyncMock()
|
||||
sessions = SessionManager(tmp_path)
|
||||
target_session = sessions.get_or_create("websocket:target")
|
||||
target_session.metadata["webui"] = True
|
||||
sessions.save(target_session)
|
||||
source = session_handle_for_key("websocket:source")
|
||||
envelope = {
|
||||
"message_id": "message-1",
|
||||
"created_at_ms": 123,
|
||||
"expect_reply": False,
|
||||
"source_session_key": "websocket:source",
|
||||
"target_session_key": "websocket:target",
|
||||
}
|
||||
append_input = MagicMock()
|
||||
monkeypatch.setattr(wth, "append_session_message_input", append_input)
|
||||
runtime_events = RuntimeEventBus()
|
||||
coordinator = wth.WebuiTurnCoordinator(
|
||||
bus=bus,
|
||||
sessions=sessions,
|
||||
schedule_background=lambda coro: coro.close(),
|
||||
)
|
||||
coordinator.subscribe(runtime_events)
|
||||
|
||||
await runtime_events.publish(UserInputAccepted(
|
||||
context=RuntimeEventContext(
|
||||
channel="system",
|
||||
chat_id="websocket:target",
|
||||
session_key="websocket:target",
|
||||
metadata={SESSION_MESSAGE_METADATA_KEY: envelope},
|
||||
),
|
||||
content="Review this",
|
||||
))
|
||||
|
||||
append_input.assert_called_once()
|
||||
outbound = bus.publish_outbound.await_args.args[0]
|
||||
assert outbound.channel == "websocket"
|
||||
assert outbound.chat_id == "target"
|
||||
assert isinstance(outbound.event, UserInputEvent)
|
||||
assert outbound.event.content == "Review this"
|
||||
assert outbound.event.provenance["session_message"]["session"] == {
|
||||
"id": source.id,
|
||||
"name": source.name,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_model_ignores_non_websocket_requests() -> None:
|
||||
bus = MagicMock()
|
||||
@@ -236,90 +281,3 @@ async def test_fallback_model_ignores_non_websocket_requests() -> None:
|
||||
await observer("fallback")
|
||||
|
||||
bus.publish_outbound.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_route_does_not_duplicate_already_projected_input(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
sessions = SessionManager(tmp_path / "sessions")
|
||||
target = sessions.get_or_create("websocket:target")
|
||||
target.metadata["webui"] = True
|
||||
sessions.save(target)
|
||||
metadata = {
|
||||
SESSION_MESSAGE_METADATA_KEY: {
|
||||
"message_id": "message-1",
|
||||
"created_at_ms": 1234,
|
||||
"expect_reply": True,
|
||||
"source": {
|
||||
"name": "reviewer",
|
||||
"session_key": "websocket:source",
|
||||
"handle_id": "handle_11111111111111111111111111111111",
|
||||
"color_slot": 3,
|
||||
},
|
||||
"target": {
|
||||
"name": "implementer",
|
||||
"session_key": "websocket:target",
|
||||
},
|
||||
}
|
||||
}
|
||||
msg = InboundMessage(
|
||||
channel="system",
|
||||
sender_id="session",
|
||||
chat_id="websocket:target",
|
||||
content="Please review this.",
|
||||
metadata=metadata,
|
||||
session_key_override="websocket:target",
|
||||
require_existing_session=True,
|
||||
)
|
||||
|
||||
routed = wth.WebuiTurnRoutePolicy(sessions)(
|
||||
msg,
|
||||
"websocket:target",
|
||||
TurnRoute(channel="websocket", chat_id="target"),
|
||||
)
|
||||
|
||||
assert routed.publish_lifecycle is True
|
||||
assert read_transcript_lines("websocket:target") == []
|
||||
|
||||
bus = MagicMock()
|
||||
bus.publish_outbound = AsyncMock()
|
||||
coordinator = wth.WebuiTurnCoordinator(
|
||||
bus=bus,
|
||||
sessions=sessions,
|
||||
schedule_background=lambda _task: None,
|
||||
)
|
||||
await coordinator._handle_session_turn_started(SessionTurnStarted(
|
||||
context=RuntimeEventContext(
|
||||
channel=routed.channel,
|
||||
chat_id=routed.chat_id,
|
||||
session_key="websocket:target",
|
||||
metadata=routed.metadata,
|
||||
),
|
||||
content=msg.content,
|
||||
))
|
||||
|
||||
assert read_transcript_lines("websocket:target") == []
|
||||
bus.publish_outbound.assert_not_awaited()
|
||||
|
||||
|
||||
def _session_message_metadata() -> dict[str, Any]:
|
||||
return {
|
||||
SESSION_MESSAGE_METADATA_KEY: {
|
||||
"message_id": "message-1",
|
||||
"created_at_ms": 1,
|
||||
"expect_reply": True,
|
||||
"source": {
|
||||
"name": "reviewer",
|
||||
"session_key": "websocket:source",
|
||||
"handle_id": "handle_11111111111111111111111111111111",
|
||||
"color_slot": 1,
|
||||
},
|
||||
"target": {
|
||||
"name": "implementer",
|
||||
"session_key": "websocket:target",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user