mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-04 02:01:48 +03:00
feat(runtime): add user-controlled turn recovery
This commit is contained in:
@@ -15,6 +15,9 @@ import httpx
|
||||
import pytest
|
||||
import websockets
|
||||
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.session.recovery import PENDING_USER_TURN_KEY, RUNTIME_CHECKPOINT_KEY
|
||||
|
||||
_BOOTSTRAP_SECRET = "smoke-secret"
|
||||
|
||||
|
||||
@@ -206,3 +209,84 @@ async def test_gateway_webui_bootstrap_message_and_thread_hydration(tmp_path: Pa
|
||||
assert any("shell-ok" in text for text in contents)
|
||||
finally:
|
||||
_stop_gateway(process)
|
||||
|
||||
|
||||
def test_gateway_restart_restores_a_completed_answer_without_replaying_model(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Exercise recovery through two real gateway processes and durable files."""
|
||||
ws_port = _free_port()
|
||||
gateway_port = _free_port()
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
config_path = tmp_path / "config.json"
|
||||
first_log = tmp_path / "gateway-first.log"
|
||||
second_log = tmp_path / "gateway-second.log"
|
||||
_write_smoke_config(
|
||||
config_path,
|
||||
workspace=workspace,
|
||||
ws_port=ws_port,
|
||||
gateway_port=gateway_port,
|
||||
)
|
||||
base_url = f"http://127.0.0.1:{ws_port}"
|
||||
|
||||
first = _start_gateway(config_path, first_log)
|
||||
try:
|
||||
_wait_for_bootstrap(base_url, first, first_log)
|
||||
finally:
|
||||
_stop_gateway(first)
|
||||
|
||||
sessions_root = tmp_path / "sessions"
|
||||
sessions = SessionManager(workspace, sessions_root=sessions_root)
|
||||
session = sessions.get_or_create("websocket:recovery-smoke")
|
||||
session.messages.append({"role": "user", "content": "recover this answer"})
|
||||
session.metadata["webui"] = True
|
||||
session.metadata[PENDING_USER_TURN_KEY] = True
|
||||
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
|
||||
"phase": "final_response",
|
||||
"assistant_message": {
|
||||
"role": "assistant",
|
||||
"content": "restored without another model request",
|
||||
},
|
||||
"completed_tool_results": [],
|
||||
"pending_tool_calls": [],
|
||||
}
|
||||
sessions.save(session, fsync=True)
|
||||
|
||||
second = _start_gateway(config_path, second_log)
|
||||
try:
|
||||
bootstrap = _wait_for_bootstrap(base_url, second, second_log)
|
||||
deadline = time.monotonic() + 20
|
||||
restored = None
|
||||
while time.monotonic() < deadline:
|
||||
restored = SessionManager(
|
||||
workspace,
|
||||
sessions_root=sessions_root,
|
||||
).get_or_create("websocket:recovery-smoke")
|
||||
if any(
|
||||
message.get("content") == "restored without another model request"
|
||||
for message in restored.messages
|
||||
):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
logs = second_log.read_text(encoding="utf-8", errors="replace")
|
||||
raise AssertionError(f"answer was not recovered after restart\n{logs}")
|
||||
|
||||
assert restored is not None
|
||||
assert PENDING_USER_TURN_KEY not in restored.metadata
|
||||
assert RUNTIME_CHECKPOINT_KEY not in restored.metadata
|
||||
assert restored.metadata["webui_recovery"]["reason"] == "answer_restored"
|
||||
|
||||
async def assert_attach_state() -> None:
|
||||
ws_url = f'{bootstrap["ws_url"]}?token={bootstrap["token"]}&client_id=recovery-smoke'
|
||||
async with websockets.connect(ws_url) as ws:
|
||||
await _recv_until(ws, "ready")
|
||||
await ws.send(json.dumps({"type": "attach", "chat_id": "recovery-smoke"}))
|
||||
attached = await _recv_until(ws, "attached")
|
||||
assert attached["recovery_state"]["status"] == "recovered"
|
||||
assert attached["recovery_state"]["reason"] == "answer_restored"
|
||||
|
||||
asyncio.run(assert_attach_state())
|
||||
finally:
|
||||
_stop_gateway(second)
|
||||
|
||||
@@ -18,6 +18,7 @@ from nanobot.session.automation_turns import AUTOMATION_HISTORY_META
|
||||
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
|
||||
from nanobot.session.recovery import RECOVERY_METADATA_KEY
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -66,6 +67,30 @@ def test_webui_session_list_refreshes_after_model_preset_rename(tmp_path: Path)
|
||||
assert list_webui_sessions(manager)[0]["model_preset"] == "Codex"
|
||||
|
||||
|
||||
def test_webui_session_list_surfaces_pending_recovery_state(tmp_path: Path) -> None:
|
||||
manager = SessionManager(tmp_path)
|
||||
session = manager.get_or_create("websocket:needs-attention")
|
||||
session.add_message("user", "the interrupted task")
|
||||
session.metadata[RECOVERY_METADATA_KEY] = {
|
||||
"status": "awaiting_user",
|
||||
"recovery_id": "recovery-123",
|
||||
"reason": "uncertain_tool_state",
|
||||
"attempts": 1,
|
||||
# Private checkpoint details must never leak into the sidebar index.
|
||||
"checkpoint": {"tool_args": "secret"},
|
||||
}
|
||||
manager.save(session)
|
||||
|
||||
row = list_webui_sessions(manager)[0]
|
||||
|
||||
assert row["recovery_state"] == {
|
||||
"status": "awaiting_user",
|
||||
"recovery_id": "recovery-123",
|
||||
"reason": "uncertain_tool_state",
|
||||
"attempts": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_webui_session_index_uses_unique_temp_file(tmp_path: Path) -> None:
|
||||
manager = SessionManager(tmp_path)
|
||||
session = manager.get_or_create("websocket:unique-index-temp")
|
||||
|
||||
Reference in New Issue
Block a user