mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 21:08:34 +03:00
fix(webui): reconcile threads after browser resume
This commit is contained in:
@@ -7,8 +7,11 @@ from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.outbound_events import GoalStatusEvent
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.websocket.runtime import WebSocketChannel
|
||||
from nanobot.providers.base import GenerationSettings, LLMResponse
|
||||
from nanobot.session.webui_turns import WebuiTurnCoordinator
|
||||
from nanobot.session import webui_turns as wth
|
||||
from nanobot.session.webui_turns import WebuiTurnCoordinator, WebuiTurnRoutePolicy
|
||||
from nanobot.webui.metadata import WEBSOCKET_TURN_OWNER_METADATA_KEY
|
||||
|
||||
|
||||
def _make_loop(tmp_path):
|
||||
@@ -32,6 +35,7 @@ def _make_loop(tmp_path):
|
||||
sessions=loop.sessions,
|
||||
schedule_background=lambda coro: loop._schedule_background(coro),
|
||||
).subscribe(loop.runtime_events)
|
||||
loop.turn_delivery_factory.route_policy = WebuiTurnRoutePolicy(loop.sessions)
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
return loop
|
||||
|
||||
@@ -39,29 +43,51 @@ def _make_loop(tmp_path):
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_direct_websocket_clears_run_status(tmp_path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
|
||||
response = await loop.process_direct(
|
||||
"deliver reminder",
|
||||
session_key="cron:reminder-1",
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
gateway = MagicMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
loop.bus,
|
||||
gateway=gateway,
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.content == "done"
|
||||
try:
|
||||
response = await loop.process_direct(
|
||||
"deliver reminder",
|
||||
session_key="cron:reminder-1",
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
)
|
||||
|
||||
events = []
|
||||
while loop.bus.outbound_size:
|
||||
events.append(await loop.bus.consume_outbound())
|
||||
assert response is not None
|
||||
assert response.content == "done"
|
||||
|
||||
statuses = [
|
||||
event.event
|
||||
for event in events
|
||||
if isinstance(event.event, GoalStatusEvent)
|
||||
]
|
||||
assert [status.status for status in statuses] == ["running", "idle"]
|
||||
assert isinstance(statuses[0].started_at, float)
|
||||
assert statuses[1].started_at is None
|
||||
events = []
|
||||
while loop.bus.outbound_size:
|
||||
event = await loop.bus.consume_outbound()
|
||||
events.append(event)
|
||||
await channel.send(event)
|
||||
|
||||
status_messages = [
|
||||
event
|
||||
for event in events
|
||||
if isinstance(event.event, GoalStatusEvent)
|
||||
]
|
||||
statuses = [event.event for event in status_messages]
|
||||
assert [status.status for status in statuses] == ["running", "idle"]
|
||||
assert isinstance(statuses[0].started_at, float)
|
||||
assert statuses[1].started_at is None
|
||||
owners = {
|
||||
event.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
|
||||
for event in status_messages
|
||||
}
|
||||
assert len(owners) == 1
|
||||
assert wth.websocket_turn_wall_started_at("chat-1") is None
|
||||
assert "chat-1" not in wth._WEBSOCKET_ACTIVE_TURNS
|
||||
finally:
|
||||
wth._WEBSOCKET_ACTIVE_TURNS.clear()
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
|
||||
wth._WEBSOCKET_TURN_IDS.clear()
|
||||
wth._WEBSOCKET_TURN_OWNERS.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -28,7 +28,10 @@ from nanobot.utils.progress_events import (
|
||||
invoke_file_edit_progress,
|
||||
on_progress_accepts_file_edit_events,
|
||||
)
|
||||
from nanobot.webui.metadata import WEBUI_TURN_METADATA_KEY
|
||||
from nanobot.webui.metadata import (
|
||||
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
||||
WEBUI_TURN_METADATA_KEY,
|
||||
)
|
||||
|
||||
|
||||
def _make_loop(tmp_path: Path) -> AgentLoop:
|
||||
@@ -903,6 +906,12 @@ class TestToolEventProgress:
|
||||
turn_id = turn_ids.pop()
|
||||
assert isinstance(turn_id, str)
|
||||
assert turn_id.startswith("subagent:")
|
||||
owners = {
|
||||
message.metadata.get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
|
||||
for message in visible_events
|
||||
}
|
||||
assert len(owners) == 1
|
||||
assert isinstance(owners.pop(), str)
|
||||
assert all(
|
||||
(message.channel, message.chat_id) == ("websocket", "chat-a")
|
||||
and message.metadata.get("webui") is True
|
||||
@@ -910,6 +919,7 @@ class TestToolEventProgress:
|
||||
and set(message.metadata) <= {
|
||||
"webui",
|
||||
"_wants_stream",
|
||||
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
||||
WEBUI_TURN_METADATA_KEY,
|
||||
"latency_ms",
|
||||
}
|
||||
|
||||
@@ -1,12 +1,147 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.turn_delivery import TurnDeliveryFactory
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.bus.runtime_events import RuntimeEventBus
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.session.webui_turns import WebuiTurnRoutePolicy
|
||||
from nanobot.webui.metadata import WEBUI_TURN_METADATA_KEY
|
||||
from nanobot.webui.metadata import (
|
||||
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
||||
WEBUI_TURN_METADATA_KEY,
|
||||
)
|
||||
|
||||
|
||||
def test_websocket_lifecycles_get_distinct_internal_owners(tmp_path: Path) -> None:
|
||||
factory = TurnDeliveryFactory(
|
||||
MessageBus(),
|
||||
RuntimeEventBus(),
|
||||
route_policy=WebuiTurnRoutePolicy(SessionManager(tmp_path / "sessions")),
|
||||
)
|
||||
first_msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="user",
|
||||
chat_id="chat-a",
|
||||
content="first",
|
||||
metadata={WEBSOCKET_TURN_OWNER_METADATA_KEY: "attacker-reused-owner"},
|
||||
)
|
||||
second_msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="user",
|
||||
chat_id="chat-a",
|
||||
content="second",
|
||||
metadata={WEBSOCKET_TURN_OWNER_METADATA_KEY: "attacker-reused-owner"},
|
||||
)
|
||||
|
||||
first = factory.create(first_msg, first_msg.session_key)
|
||||
second = factory.create(second_msg, second_msg.session_key)
|
||||
first_owner = first.lifecycle_message.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
|
||||
second_owner = second.lifecycle_message.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
|
||||
|
||||
assert first_owner == first.delivery_message.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
|
||||
assert first_owner != second_owner
|
||||
assert first_owner != "attacker-reused-owner"
|
||||
assert second_owner != "attacker-reused-owner"
|
||||
assert WEBUI_TURN_METADATA_KEY not in first.lifecycle_message.metadata
|
||||
assert first_msg.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] == first_owner
|
||||
assert second_msg.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] == second_owner
|
||||
|
||||
|
||||
def test_websocket_lifecycle_reuses_registered_ingress_owner(tmp_path: Path) -> None:
|
||||
from nanobot.session import webui_turns as wth
|
||||
|
||||
owner = wth.register_queued_websocket_turn_if_idle("chat-queued", "turn-queued")
|
||||
assert owner is not None
|
||||
msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="user",
|
||||
chat_id="chat-queued",
|
||||
content="queued",
|
||||
metadata={
|
||||
WEBSOCKET_TURN_OWNER_METADATA_KEY: owner,
|
||||
WEBUI_TURN_METADATA_KEY: "turn-queued",
|
||||
},
|
||||
)
|
||||
factory = TurnDeliveryFactory(
|
||||
MessageBus(),
|
||||
RuntimeEventBus(),
|
||||
route_policy=WebuiTurnRoutePolicy(SessionManager(tmp_path / "sessions")),
|
||||
)
|
||||
|
||||
try:
|
||||
delivery = factory.create(msg, msg.session_key)
|
||||
|
||||
assert delivery.lifecycle_message.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] == owner
|
||||
assert msg.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] == owner
|
||||
finally:
|
||||
wth.clear_websocket_turn_if_current("chat-queued", owner)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_chat_different_sessions_restore_previous_active_projection(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from nanobot.session import webui_turns as wth
|
||||
|
||||
factory = TurnDeliveryFactory(
|
||||
MessageBus(),
|
||||
RuntimeEventBus(),
|
||||
route_policy=WebuiTurnRoutePolicy(SessionManager(tmp_path / "sessions")),
|
||||
)
|
||||
first_msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="user",
|
||||
chat_id="shared-chat",
|
||||
content="first",
|
||||
metadata={WEBUI_TURN_METADATA_KEY: "turn-first"},
|
||||
session_key_override="websocket:session-first",
|
||||
)
|
||||
second_msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="user",
|
||||
chat_id="shared-chat",
|
||||
content="second",
|
||||
metadata={WEBUI_TURN_METADATA_KEY: "turn-second"},
|
||||
session_key_override="websocket:session-second",
|
||||
)
|
||||
first = factory.create(first_msg, first_msg.session_key)
|
||||
second = factory.create(second_msg, second_msg.session_key)
|
||||
first_owner = first.lifecycle_message.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
|
||||
second_owner = second.lifecycle_message.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
|
||||
bus = MagicMock()
|
||||
bus.publish_outbound = AsyncMock()
|
||||
|
||||
try:
|
||||
await wth.publish_turn_run_status(
|
||||
bus,
|
||||
first.lifecycle_message,
|
||||
"running",
|
||||
started_at=100.0,
|
||||
)
|
||||
await wth.publish_turn_run_status(
|
||||
bus,
|
||||
second.lifecycle_message,
|
||||
"running",
|
||||
started_at=200.0,
|
||||
)
|
||||
|
||||
assert wth.websocket_turn_wall_started_at("shared-chat") == 200.0
|
||||
assert wth.websocket_turn_id("shared-chat") == "turn-second"
|
||||
assert wth.clear_websocket_turn_if_current("shared-chat", second_owner) is True
|
||||
assert wth.websocket_turn_wall_started_at("shared-chat") == 100.0
|
||||
assert wth.websocket_turn_id("shared-chat") == "turn-first"
|
||||
assert wth._WEBSOCKET_TURN_OWNERS["shared-chat"] == first_owner
|
||||
assert wth.clear_websocket_turn_if_current("shared-chat", first_owner) is True
|
||||
assert wth.websocket_turn_wall_started_at("shared-chat") is None
|
||||
finally:
|
||||
wth._WEBSOCKET_ACTIVE_TURNS.pop("shared-chat", None)
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT.pop("shared-chat", None)
|
||||
wth._WEBSOCKET_TURN_IDS.pop("shared-chat", None)
|
||||
wth._WEBSOCKET_TURN_OWNERS.pop("shared-chat", None)
|
||||
|
||||
|
||||
def test_late_subagent_route_requires_webui_owned_session(tmp_path: Path) -> None:
|
||||
@@ -45,6 +180,7 @@ def test_late_subagent_route_requires_webui_owned_session(tmp_path: Path) -> Non
|
||||
assert set(first_visible_route.metadata) == {
|
||||
"webui",
|
||||
"_wants_stream",
|
||||
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
||||
WEBUI_TURN_METADATA_KEY,
|
||||
}
|
||||
assert first_visible_route.metadata["webui"] is True
|
||||
@@ -54,6 +190,10 @@ def test_late_subagent_route_requires_webui_owned_session(tmp_path: Path) -> Non
|
||||
assert first_turn_id.startswith("subagent:")
|
||||
assert second_turn_id.startswith("subagent:")
|
||||
assert first_turn_id != second_turn_id
|
||||
assert (
|
||||
first_visible_route.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
|
||||
!= second_visible_route.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
|
||||
)
|
||||
assert msg.metadata == {
|
||||
"injected_event": "subagent_result",
|
||||
"subagent_task_id": "sub-1",
|
||||
|
||||
@@ -6,7 +6,10 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.command.builtin import register_builtin_commands
|
||||
from nanobot.command.builtin import (
|
||||
builtin_command_starts_agent_turn,
|
||||
register_builtin_commands,
|
||||
)
|
||||
from nanobot.command.router import CommandContext, CommandRouter
|
||||
|
||||
|
||||
@@ -64,6 +67,20 @@ class TestIsDispatchableCommand:
|
||||
assert not router.is_dispatchable_command("/foo bar")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("content", "expected"),
|
||||
[
|
||||
("/status", False),
|
||||
("/history 5", False),
|
||||
("/goal", False),
|
||||
("/goal migrate the database", True),
|
||||
("regular prompt", True),
|
||||
],
|
||||
)
|
||||
def test_builtin_command_agent_turn_lifecycle(content: str, expected: bool) -> None:
|
||||
assert builtin_command_starts_agent_turn(content) is expected
|
||||
|
||||
|
||||
class TestMidTurnCommandDispatchedDirectly:
|
||||
"""Verify that commands matching is_dispatchable_command() are dispatched
|
||||
correctly when session=None (the mid-turn path)."""
|
||||
|
||||
@@ -389,6 +389,7 @@ def test_thread_response_does_not_mark_completed_message_tool_tail_pending(
|
||||
|
||||
assert out is not None
|
||||
assert out["has_pending_tool_calls"] is False
|
||||
assert out["completed_turn_ids"] == [turn_id]
|
||||
assert out["messages"][-1]["kind"] == "trace"
|
||||
assert out["messages"][-2]["content"] == "Cron test"
|
||||
|
||||
@@ -410,6 +411,144 @@ def test_thread_response_marks_unfinished_tool_tail_pending(tmp_path, monkeypatc
|
||||
|
||||
assert out is not None
|
||||
assert out["has_pending_tool_calls"] is True
|
||||
assert out["completed_turn_ids"] == []
|
||||
|
||||
|
||||
def test_thread_response_reports_active_registry_without_transcript(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
|
||||
out = build_webui_thread_response(
|
||||
"websocket:active-without-transcript",
|
||||
active_turn_started_at=1_700_000_000.0,
|
||||
active_turn_id="turn-active",
|
||||
)
|
||||
|
||||
assert out is not None
|
||||
assert out["messages"] == []
|
||||
assert out["completed_turn_ids"] == []
|
||||
assert out["has_pending_tool_calls"] is True
|
||||
assert out["active_turn_id"] == "turn-active"
|
||||
|
||||
|
||||
def test_thread_response_reports_explicit_completion_without_assistant_row(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
key = "websocket:empty-answer"
|
||||
turn_id = "turn-empty-answer"
|
||||
append_transcript_object(
|
||||
key,
|
||||
{"event": "user", "chat_id": "empty-answer", "text": "stop", "turn_id": turn_id},
|
||||
)
|
||||
append_transcript_object(
|
||||
key,
|
||||
{"event": "turn_end", "chat_id": "empty-answer", "turn_id": turn_id},
|
||||
)
|
||||
|
||||
out = build_webui_thread_response(key)
|
||||
|
||||
assert out is not None
|
||||
assert out["messages"][-1]["role"] == "user"
|
||||
assert out["has_pending_tool_calls"] is False
|
||||
assert out["completed_turn_ids"] == [turn_id]
|
||||
|
||||
|
||||
def test_incomplete_turn_with_ambiguous_session_match_stays_pending(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
key = "websocket:ambiguous-incomplete"
|
||||
turn_id = "turn-ambiguous"
|
||||
append_transcript_object(
|
||||
key,
|
||||
{
|
||||
"event": "user",
|
||||
"chat_id": "ambiguous-incomplete",
|
||||
"text": "repeat",
|
||||
"turn_id": turn_id,
|
||||
},
|
||||
)
|
||||
append_transcript_object(
|
||||
key,
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "ambiguous-incomplete",
|
||||
"turn_id": turn_id,
|
||||
"transcript_incomplete": True,
|
||||
},
|
||||
)
|
||||
|
||||
out = build_webui_thread_response(
|
||||
key,
|
||||
session_messages=[
|
||||
{"role": "user", "content": "repeat"},
|
||||
{"role": "assistant", "content": "first answer"},
|
||||
{"role": "user", "content": "repeat"},
|
||||
{"role": "assistant", "content": "second answer"},
|
||||
],
|
||||
)
|
||||
|
||||
assert out is not None
|
||||
assert [(message["role"], message["content"]) for message in out["messages"]] == [
|
||||
("user", "repeat"),
|
||||
]
|
||||
assert out["completed_turn_ids"] == []
|
||||
assert out["has_pending_tool_calls"] is True
|
||||
|
||||
|
||||
def test_later_completion_does_not_hide_older_incomplete_turn(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
key = "websocket:older-incomplete"
|
||||
for event in (
|
||||
{"event": "user", "text": "first", "turn_id": "turn-first"},
|
||||
{
|
||||
"event": "turn_end",
|
||||
"turn_id": "turn-first",
|
||||
"transcript_incomplete": True,
|
||||
},
|
||||
{"event": "user", "text": "second", "turn_id": "turn-second"},
|
||||
{"event": "message", "text": "second answer", "turn_id": "turn-second"},
|
||||
{"event": "turn_end", "turn_id": "turn-second"},
|
||||
):
|
||||
append_transcript_object(
|
||||
key,
|
||||
{"chat_id": "older-incomplete", **event},
|
||||
)
|
||||
|
||||
out = build_webui_thread_response(key)
|
||||
|
||||
assert out is not None
|
||||
assert out["completed_turn_ids"] == ["turn-second"]
|
||||
assert out["has_pending_tool_calls"] is True
|
||||
|
||||
|
||||
def test_active_registry_does_not_hide_a_newer_queued_turn(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
key = "websocket:queued-tail"
|
||||
for event in (
|
||||
{"event": "user", "text": "first", "turn_id": "turn-old"},
|
||||
{"event": "message", "text": "done", "turn_id": "turn-old"},
|
||||
{"event": "turn_end", "turn_id": "turn-old"},
|
||||
{"event": "user", "text": "queued next", "turn_id": "turn-new"},
|
||||
):
|
||||
append_transcript_object(key, {"chat_id": "queued-tail", **event})
|
||||
|
||||
out = build_webui_thread_response(
|
||||
key,
|
||||
active_turn_started_at=1_700_000_000.0,
|
||||
active_turn_id="turn-old",
|
||||
)
|
||||
|
||||
assert out is not None
|
||||
assert out["has_pending_tool_calls"] is True
|
||||
|
||||
|
||||
def test_replay_preserves_turn_metadata(tmp_path, monkeypatch) -> None:
|
||||
|
||||
@@ -8,26 +8,40 @@ from nanobot.agent.tools.context import RequestContext, request_context
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.outbound_events import GoalStatusEvent, TurnModelUpdatedEvent
|
||||
from nanobot.session import webui_turns as wth
|
||||
from nanobot.webui.metadata import WEBSOCKET_TURN_OWNER_METADATA_KEY
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_turn_wall_clock() -> None:
|
||||
wth._WEBSOCKET_ACTIVE_TURNS.clear()
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
|
||||
wth._WEBSOCKET_TURN_IDS.clear()
|
||||
wth._WEBSOCKET_TURN_OWNERS.clear()
|
||||
yield
|
||||
wth._WEBSOCKET_ACTIVE_TURNS.clear()
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
|
||||
wth._WEBSOCKET_TURN_IDS.clear()
|
||||
wth._WEBSOCKET_TURN_OWNERS.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_turn_run_status_running_records_wall_clock() -> None:
|
||||
bus = MagicMock()
|
||||
bus.publish_outbound = AsyncMock()
|
||||
msg = InboundMessage(channel="websocket", sender_id="u", chat_id="chat-a", content="hi")
|
||||
msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u",
|
||||
chat_id="chat-a",
|
||||
content="hi",
|
||||
metadata={"webui_turn_id": "turn-a"},
|
||||
)
|
||||
|
||||
await wth.publish_turn_run_status(bus, msg, "running")
|
||||
|
||||
assert "chat-a" in wth._WEBSOCKET_TURN_WALL_STARTED_AT
|
||||
t0 = wth.websocket_turn_wall_started_at("chat-a")
|
||||
assert isinstance(t0, float)
|
||||
assert wth.websocket_turn_id("chat-a") == "turn-a"
|
||||
call = bus.publish_outbound.await_args[0][0]
|
||||
assert call.chat_id == "chat-a"
|
||||
assert isinstance(call.event, GoalStatusEvent)
|
||||
@@ -49,16 +63,67 @@ async def test_publish_turn_run_status_reuses_explicit_wall_clock() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_turn_run_status_idle_clears_wall_clock() -> None:
|
||||
async def test_publish_turn_run_status_idle_retains_registry_until_delivery() -> None:
|
||||
bus = MagicMock()
|
||||
bus.publish_outbound = AsyncMock()
|
||||
msg = InboundMessage(channel="websocket", sender_id="u", chat_id="chat-b", content="hi")
|
||||
msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u",
|
||||
chat_id="chat-b",
|
||||
content="hi",
|
||||
metadata={"webui_turn_id": "turn-b"},
|
||||
)
|
||||
|
||||
await wth.publish_turn_run_status(bus, msg, "running")
|
||||
assert wth.websocket_turn_wall_started_at("chat-b") is not None
|
||||
assert wth.websocket_turn_id("chat-b") == "turn-b"
|
||||
|
||||
await wth.publish_turn_run_status(bus, msg, "idle")
|
||||
assert wth.websocket_turn_wall_started_at("chat-b") is not None
|
||||
assert wth.websocket_turn_id("chat-b") == "turn-b"
|
||||
|
||||
|
||||
def test_clear_websocket_turn_only_clears_matching_owner() -> None:
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT["chat-b"] = 1234.5
|
||||
wth._WEBSOCKET_TURN_IDS["chat-b"] = "turn-new"
|
||||
wth._WEBSOCKET_TURN_OWNERS["chat-b"] = "owner-new"
|
||||
|
||||
assert wth.clear_websocket_turn_if_current("chat-b", "owner-old") is False
|
||||
assert wth.websocket_turn_wall_started_at("chat-b") == 1234.5
|
||||
assert wth.websocket_turn_id("chat-b") == "turn-new"
|
||||
|
||||
assert wth.clear_websocket_turn_if_current("chat-b", "owner-new") is True
|
||||
assert wth.websocket_turn_wall_started_at("chat-b") is None
|
||||
assert wth.websocket_turn_id("chat-b") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ownerless_turns_receive_distinct_internal_owners() -> None:
|
||||
bus = MagicMock()
|
||||
bus.publish_outbound = AsyncMock()
|
||||
first = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u",
|
||||
chat_id="chat-ownerless",
|
||||
content="first",
|
||||
)
|
||||
second = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u",
|
||||
chat_id="chat-ownerless",
|
||||
content="second",
|
||||
)
|
||||
|
||||
await wth.publish_turn_run_status(bus, first, "running")
|
||||
first_owner = first.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
|
||||
await wth.publish_turn_run_status(bus, second, "running")
|
||||
second_owner = second.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
|
||||
|
||||
assert first_owner != second_owner
|
||||
assert wth.clear_websocket_turn_if_current("chat-ownerless", first_owner) is True
|
||||
assert wth._WEBSOCKET_TURN_OWNERS["chat-ownerless"] == second_owner
|
||||
assert wth.websocket_turn_wall_started_at("chat-ownerless") is not None
|
||||
assert wth.clear_websocket_turn_if_current("chat-ownerless", second_owner) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -70,6 +135,7 @@ async def test_publish_turn_run_status_non_websocket_noop_registry() -> None:
|
||||
await wth.publish_turn_run_status(bus, msg, "running")
|
||||
|
||||
assert wth._WEBSOCKET_TURN_WALL_STARTED_AT == {}
|
||||
assert wth._WEBSOCKET_TURN_IDS == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user