fix(webui): reconcile threads after browser resume

This commit is contained in:
chengyongru
2026-07-28 16:25:08 +08:00
committed by chengyongru
parent 78cf68c291
commit ae089aa3ae
39 changed files with 6151 additions and 243 deletions
+139
View File
@@ -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:
+69 -3
View File
@@ -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