feat(runtime): add user-controlled turn recovery

This commit is contained in:
Xubin Ren
2026-08-24 00:58:04 +08:00
parent ffa58aa5ef
commit 12029f8812
60 changed files with 4027 additions and 169 deletions
+98 -2
View File
@@ -26,7 +26,7 @@ def _make_injection_callback(queue: asyncio.Queue):
return inject_cb
def _make_loop(tmp_path):
def _make_loop(tmp_path, *, recovery_admission=None):
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
@@ -39,7 +39,12 @@ def _make_loop(tmp_path):
patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr:
mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
mock_sub_mgr.return_value.close = AsyncMock()
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path)
loop = AgentLoop(
bus=bus,
provider=provider,
workspace=tmp_path,
recovery_admission=recovery_admission,
)
return loop
@pytest.mark.asyncio
@@ -759,6 +764,20 @@ async def test_runner_merges_multiple_injected_user_messages_without_losing_medi
)
def test_runner_merge_keeps_all_recovery_followup_ids() -> None:
"""Merged follow-ups stay acknowledged together after a later save."""
from nanobot.agent.runner import AgentRunner
from nanobot.session.recovery import PENDING_FOLLOWUP_ID_KEY
messages = [{"role": "user", "content": "first", PENDING_FOLLOWUP_ID_KEY: "one"}]
AgentRunner._append_injected_messages(
messages,
[{"role": "user", "content": "second", PENDING_FOLLOWUP_ID_KEY: "two"}],
)
assert messages[-1][PENDING_FOLLOWUP_ID_KEY] == ["one", "two"]
def test_runner_merge_preserves_runtime_markers_with_media() -> None:
from nanobot.agent.runner import AgentRunner
from nanobot.runtime_context import (
@@ -967,6 +986,38 @@ async def test_followup_routed_to_pending_queue(tmp_path):
assert queued_msg.session_key == UNIFIED_SESSION_KEY
@pytest.mark.asyncio
async def test_websocket_followup_is_admitted_before_recovery_queue(tmp_path):
"""Recovery admission runs before a newer WebUI message is injected."""
from nanobot.bus.events import InboundMessage
admission = MagicMock()
admission.admit = AsyncMock(return_value=True)
loop = _make_loop(tmp_path, recovery_admission=admission)
loop._dispatch = AsyncMock() # type: ignore[method-assign]
session_key = "websocket:chat"
pending = asyncio.Queue(maxsize=20)
loop._pending_queues[session_key] = pending
run_task = asyncio.create_task(loop.run())
msg = InboundMessage(
channel="websocket",
sender_id="u",
chat_id="chat",
content="new request",
)
await loop.bus.publish_inbound(msg)
queued_msg = await asyncio.wait_for(pending.get(), timeout=2)
admission.admit.assert_awaited_once_with(msg)
assert queued_msg.content == msg.content
assert queued_msg.metadata["_recovery_followup_id"]
loop.stop()
await asyncio.wait_for(run_task, timeout=2)
@pytest.mark.asyncio
async def test_mid_turn_subagent_result_does_not_resolve_a_new_turn_route(tmp_path):
"""Injected results stay inside the active turn instead of opening a side turn."""
@@ -1314,6 +1365,51 @@ async def test_pending_queue_full_falls_back_to_queued_task(tmp_path):
assert pending.qsize() == 1
@pytest.mark.asyncio
async def test_pending_queue_overflow_keeps_websocket_followup_durable(tmp_path):
"""Fallback dispatch must not acknowledge a WebUI message before it commits."""
from nanobot.bus.events import InboundMessage
from nanobot.session.manager import Session
from nanobot.session.recovery import pending_followups
loop = _make_loop(tmp_path)
dispatched = asyncio.Event()
release_dispatch = asyncio.Event()
async def _dispatch(_msg):
dispatched.set()
await release_dispatch.wait()
loop._dispatch = AsyncMock(side_effect=_dispatch) # type: ignore[method-assign]
session = Session(key="websocket:c")
loop.sessions.get_or_create.return_value = session
pending = asyncio.Queue(maxsize=1)
pending.put_nowait(
InboundMessage(channel="websocket", sender_id="u", chat_id="c", content="already queued")
)
loop._pending_queues["websocket:c"] = pending
run_task = asyncio.create_task(loop.run())
await loop.bus.publish_inbound(
InboundMessage(
channel="websocket",
sender_id="u",
chat_id="c",
content="durable follow-up",
metadata={"webui": True},
)
)
await asyncio.wait_for(dispatched.wait(), timeout=2)
assert [message.content for message in pending_followups(session)] == ["durable follow-up"]
dispatched_msg = loop._dispatch.await_args.args[0]
assert dispatched_msg.metadata["_recovery_followup_id"]
release_dispatch.set()
loop.stop()
await asyncio.wait_for(run_task, timeout=2)
@pytest.mark.asyncio
async def test_dispatch_republishes_leftover_queue_messages(tmp_path):
"""Messages left in the pending queue after _dispatch are re-published to the bus.