diff --git a/nanobot/channels/websocket/runtime.py b/nanobot/channels/websocket/runtime.py index cdd8e4f89..5dd1bcfb5 100644 --- a/nanobot/channels/websocket/runtime.py +++ b/nanobot/channels/websocket/runtime.py @@ -32,6 +32,7 @@ from nanobot.bus.outbound_events import ( ) from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel +from nanobot.command.builtin import builtin_command_starts_agent_turn from nanobot.config.schema import Base from nanobot.runtime_context import ( RUNTIME_CONTEXT_INPUT_META, @@ -43,7 +44,14 @@ from nanobot.security.workspace_access import ( WorkspaceScopeError, ) from nanobot.session.goal_state import goal_state_ws_blob -from nanobot.session.webui_turns import websocket_turn_wall_started_at +from nanobot.session.webui_turns import ( + clear_websocket_turn_if_current, + mark_websocket_turn_transcript_persistence_failed, + register_queued_websocket_turn_if_idle, + websocket_turn_id, + websocket_turn_transcript_persistence_failed, + websocket_turn_wall_started_at, +) from nanobot.webui.cli_apps_api import normalize_cli_app_mentions from nanobot.webui.forking import handle_webui_fork_chat from nanobot.webui.gateway_services import GatewayServices @@ -57,6 +65,11 @@ from nanobot.webui.http_utils import ( query_first as _query_first, ) from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions +from nanobot.webui.metadata import ( + WEBSOCKET_TURN_OWNER_METADATA_KEY, + WEBUI_TURN_METADATA_KEY, +) +from nanobot.webui.transcript import WEBUI_TRANSCRIPT_INCOMPLETE_KEY from nanobot.webui.transcription_ws import webui_transcription_event from nanobot.webui.websocket_logging import websockets_server_logger @@ -317,7 +330,12 @@ class WebSocketChannel(BaseChannel): t0 = websocket_turn_wall_started_at(chat_id) if t0 is None: return - await self.send_goal_status(chat_id, "running", started_at=t0) + await self.send_goal_status( + chat_id, + "running", + started_at=t0, + turn_id=websocket_turn_id(chat_id), + ) async def _hydrate_after_subscribe(self, chat_id: str) -> None: """Replay persisted or actively running per-chat state after subscribe.""" @@ -633,17 +651,40 @@ class WebSocketChannel(BaseChannel): if not _is_valid_chat_id(cid): await self._send_event(connection, "error", detail="invalid chat_id") return + raw_turn_id = envelope.get("turn_id") + turn_id = raw_turn_id if isinstance(raw_turn_id, str) and raw_turn_id else None + rejection_fields = { + "chat_id": cid, + **({"turn_id": turn_id} if turn_id else {}), + } + # The allowlist can change while an authenticated websocket stays + # open. Reject the exact application turn before hydration, + # transcript persistence, or an acceptance ACK; BaseChannel's + # silent authorization return must not look like successful ingress. + if not self.is_allowed(client_id): + await self._send_event( + connection, + "error", + detail="access_denied", + **rejection_fields, + ) + return if not isinstance(content, str): - await self._send_event(connection, "error", detail="missing content") + await self._send_event( + connection, + "error", + detail="missing content", + **rejection_fields, + ) return message_rejection = self._ingress.validate_text(content) if message_rejection is not None: await self._send_event( connection, "error", - chat_id=cid, detail="message_rejected", reason=message_rejection, + **rejection_fields, ) return @@ -656,6 +697,7 @@ class WebSocketChannel(BaseChannel): "error", detail="attachment_rejected", reason="malformed", + **rejection_fields, ) return media_paths, reason = self._media.store_inbound_attachments(raw_media) @@ -665,12 +707,18 @@ class WebSocketChannel(BaseChannel): "error", detail="attachment_rejected", reason=reason, + **rejection_fields, ) return # Allow media-only turns (content may be empty when attachments are present). if not content.strip() and not media_paths: - await self._send_event(connection, "error", detail="missing content") + await self._send_event( + connection, + "error", + detail="missing content", + **rejection_fields, + ) return # Auto-attach on first use so clients can one-shot without a separate attach. self._attach(connection, cid) @@ -686,10 +734,23 @@ class WebSocketChannel(BaseChannel): controls_available=self._workspace_controls_available(connection), ), chat_id=cid, + turn_id=turn_id, ) if scope is None: return + # Hydration and scope resolution can yield. Re-check immediately + # before transcript/bus mutation so a mid-flight revocation cannot + # fall through BaseChannel's silent deny and still receive an ACK. + if not self.is_allowed(client_id): + await self._send_event( + connection, + "error", + detail="access_denied", + **rejection_fields, + ) + return + metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)} if envelope.get("webui") is True: metadata["webui"] = True @@ -702,29 +763,48 @@ class WebSocketChannel(BaseChannel): metadata["mcp_presets"] = mcp_presets metadata[WORKSPACE_SCOPE_METADATA_KEY] = scope.metadata() self._workspaces.persist_scope(cid, scope) - if metadata.get("webui") is True and self.is_allowed(client_id): - self._transcripts.append_user_message( - cid, - content, + is_webui = metadata.get("webui") is True + queued_owner = None + if is_webui and builtin_command_starts_agent_turn(content): + queued_owner = register_queued_websocket_turn_if_idle(cid, turn_id) + if queued_owner is not None: + metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = queued_owner + accepted = False + try: + if is_webui: + self._transcripts.append_user_message( + cid, + content, + metadata=metadata, + media_paths=media_paths or None, + cli_apps=cli_apps or None, + mcp_presets=mcp_presets or None, + ) + if is_webui and connection in self._webui_connections: + quote = webui_quote_runtime_context({ + WEBUI_QUOTE_METADATA: envelope.get("quoted_context"), + }) + if quote is not None: + metadata[RUNTIME_CONTEXT_INPUT_META] = [quote] + await self._handle_message( + sender_id=client_id, + chat_id=cid, + content=content, + media=media_paths or None, metadata=metadata, - media_paths=media_paths or None, - cli_apps=cli_apps or None, - mcp_presets=mcp_presets or None, + is_dm=False, + ) + accepted = True + finally: + if not accepted and queued_owner is not None: + clear_websocket_turn_if_current(cid, queued_owner) + if is_webui and turn_id: + await self._send_event( + connection, + "message_accepted", + chat_id=cid, + turn_id=turn_id, ) - if metadata.get("webui") is True and connection in self._webui_connections: - quote = webui_quote_runtime_context({ - WEBUI_QUOTE_METADATA: envelope.get("quoted_context"), - }) - if quote is not None: - metadata[RUNTIME_CONTEXT_INPUT_META] = [quote] - await self._handle_message( - sender_id=client_id, - chat_id=cid, - content=content, - media=media_paths or None, - metadata=metadata, - is_dm=False, - ) return await self._send_event(connection, "error", detail=f"unknown type: {t!r}") @@ -734,6 +814,7 @@ class WebSocketChannel(BaseChannel): resolver: Callable[[], Any], *, chat_id: str | None = None, + turn_id: str | None = None, ) -> Any | None: try: return resolver() @@ -744,6 +825,7 @@ class WebSocketChannel(BaseChannel): detail="workspace_scope_rejected", reason=exc.message, **({"chat_id": chat_id} if chat_id else {}), + **({"turn_id": turn_id} if turn_id else {}), ) return None @@ -782,6 +864,37 @@ class WebSocketChannel(BaseChannel): self.logger.exception("send failed{}", label) raise + def _persist_turn_transcript_event( + self, + chat_id: str, + event: dict[str, Any], + *, + metadata: dict[str, Any] | None, + phase: str, + include_source: bool = False, + transcript_overrides: dict[str, Any] | None = None, + ) -> bool: + """Persist one canonical turn event and retain unsafe owners on failure.""" + persisted = self._transcripts.prepare_and_append( + chat_id, + event, + metadata=metadata, + phase=phase, + include_source=include_source, + transcript_overrides=transcript_overrides, + ) + if ( + not persisted + and phase in {"answer", "complete"} + and (metadata or {}).get("webui") is True + ): + owner = (metadata or {}).get(WEBSOCKET_TURN_OWNER_METADATA_KEY) + mark_websocket_turn_transcript_persistence_failed( + chat_id, + owner if isinstance(owner, str) else None, + ) + return persisted + async def send(self, msg: OutboundMessage) -> None: event = outbound_event_from_message(msg) progress_event = event if isinstance(event, ProgressEvent) else None @@ -818,21 +931,38 @@ class WebSocketChannel(BaseChannel): await self.send_goal_state(msg.chat_id, event.goal_state or {"active": False}) return if isinstance(event, GoalStatusEvent): - if conns: - if event.status in ("running", "idle"): + turn_id = (msg.metadata or {}).get(WEBUI_TURN_METADATA_KEY) + current_turn_id = turn_id if isinstance(turn_id, str) else None + turn_owner = (msg.metadata or {}).get(WEBSOCKET_TURN_OWNER_METADATA_KEY) + current_turn_owner = turn_owner if isinstance(turn_owner, str) else None + try: + if conns and event.status in ("running", "idle"): await self.send_goal_status( msg.chat_id, event.status, started_at=event.started_at, + turn_id=current_turn_id, + ) + finally: + if event.status == "idle": + # Cancellation/direct runs may have no turn_end, so idle is + # still terminal. A failed canonical completion write is + # the one case that must remain pending for safe resume. + clear_websocket_turn_if_current( + msg.chat_id, + current_turn_owner, + preserve_persistence_failure=True, ) return # Signal that the agent has fully finished processing the current turn. if isinstance(event, TurnEndEvent): + turn_owner = (msg.metadata or {}).get(WEBSOCKET_TURN_OWNER_METADATA_KEY) await self.send_turn_end( msg.chat_id, latency_ms=event.latency_ms, goal_state=event.goal_state, metadata=msg.metadata, + turn_owner=turn_owner if isinstance(turn_owner, str) else None, ) await self.send_session_updated(msg.chat_id, scope="thread") return @@ -884,7 +1014,7 @@ class WebSocketChannel(BaseChannel): elif progress_event: payload["kind"] = "progress" phase = "activity" if payload.get("kind") in ("tool_hint", "progress") else "answer" - self._transcripts.prepare_and_append( + self._persist_turn_transcript_event( msg.chat_id, payload, metadata=msg.metadata, @@ -922,7 +1052,7 @@ class WebSocketChannel(BaseChannel): } if stream_id is not None: body["stream_id"] = stream_id - self._transcripts.prepare_and_append( + self._persist_turn_transcript_event( chat_id, body, metadata=meta, @@ -950,7 +1080,7 @@ class WebSocketChannel(BaseChannel): } if stream_id is not None: body["stream_id"] = stream_id - self._transcripts.prepare_and_append( + self._persist_turn_transcript_event( chat_id, body, metadata=meta, @@ -974,7 +1104,7 @@ class WebSocketChannel(BaseChannel): "chat_id": chat_id, "edits": edits, } - self._transcripts.prepare_and_append( + self._persist_turn_transcript_event( chat_id, payload, metadata=metadata, @@ -1026,7 +1156,7 @@ class WebSocketChannel(BaseChannel): body["resuming"] = True if stream_end and merge_next: body["merge_next"] = True - self._transcripts.prepare_and_append( + self._persist_turn_transcript_event( chat_id, body, metadata=meta, @@ -1045,6 +1175,7 @@ class WebSocketChannel(BaseChannel): *, goal_state: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None, + turn_owner: str | None = None, ) -> None: """Signal that the agent has fully finished processing the current turn.""" conns = list(self._subs.get(chat_id, ())) @@ -1053,12 +1184,27 @@ class WebSocketChannel(BaseChannel): body["latency_ms"] = int(latency_ms) if goal_state is not None: body["goal_state"] = goal_state - self._transcripts.prepare_and_append( + canonical_webui_turn = (metadata or {}).get("webui") is True + prior_persistence_failure = ( + canonical_webui_turn + and websocket_turn_transcript_persistence_failed(chat_id, turn_owner) + ) + persisted = self._persist_turn_transcript_event( chat_id, body, metadata=metadata, phase="complete", + transcript_overrides=( + {WEBUI_TRANSCRIPT_INCOMPLETE_KEY: True} + if prior_persistence_failure + else None + ), ) + if persisted: + # A successful completion either has a complete transcript or now + # carries a durable incomplete marker. The HTTP replay path can + # recover the latter from session history after a gateway restart. + clear_websocket_turn_if_current(chat_id, turn_owner) raw = json.dumps(body, ensure_ascii=False) if not conns: return @@ -1081,6 +1227,7 @@ class WebSocketChannel(BaseChannel): status: str, *, started_at: float | None = None, + turn_id: str | None = None, ) -> None: """Notify subscribed clients that a turn started or finished (wall-clock hint).""" conns = list(self._subs.get(chat_id, ())) @@ -1093,6 +1240,8 @@ class WebSocketChannel(BaseChannel): } if status == "running" and started_at is not None: body["started_at"] = started_at + if turn_id: + body["turn_id"] = turn_id raw = json.dumps(body, ensure_ascii=False) for connection in conns: await self._safe_send_to(connection, raw, label=" goal_status ") diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index 92eb84bb7..a9f543eff 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -49,8 +49,13 @@ from nanobot.webui.http_utils import ( from nanobot.webui.http_utils import ( parse_request_path as _parse_request_path, ) +from nanobot.webui.metadata import WEBSOCKET_TURN_OWNER_METADATA_KEY from nanobot.webui.settings_api import settings_payload, update_provider_settings -from nanobot.webui.transcript import append_transcript_object, read_transcript_lines +from nanobot.webui.transcript import ( + append_transcript_object, + build_webui_thread_response, + read_transcript_lines, +) from .ws_test_client import http_get as _http_get @@ -164,11 +169,20 @@ async def test_start_extends_http_open_timeout_for_slow_settings_routes( @pytest.fixture(autouse=True) def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None: + wth._WEBSOCKET_ACTIVE_TURNS.clear() + wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear() + wth._WEBSOCKET_TURN_IDS.clear() + wth._WEBSOCKET_TURN_OWNERS.clear() monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) monkeypatch.setattr( "nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui", ) + 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 @@ -743,6 +757,7 @@ async def test_webui_scope_rejects_running_scope_change(bus: MagicMock, tmp_path "chat_id": "chat-running", "content": "hello", "webui": True, + "turn_id": "turn-scope-rejected", "workspace_scope": { "project_path": str(other), "access_mode": "full", @@ -757,6 +772,7 @@ async def test_webui_scope_rejects_running_scope_change(bus: MagicMock, tmp_path assert payload["detail"] == "workspace_scope_rejected" assert payload["reason"] == "chat_running" assert payload["chat_id"] == "chat-running" + assert payload["turn_id"] == "turn-scope-rejected" bus.publish_inbound.assert_not_awaited() @@ -1602,6 +1618,434 @@ async def test_send_turn_end_emits_turn_end_event() -> None: ] +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("active_owner", "event_owner", "expected_cleared"), + [ + ("owner-current", "owner-current", True), + ("owner-new", "owner-old", False), + ], +) +async def test_turn_end_persists_and_conditionally_clears_when_fanout_fails( + active_owner: str, + event_owner: str, + expected_cleared: bool, +) -> None: + bus = MagicMock() + channel = WebSocketChannel( + {"enabled": True, "allowFrom": ["*"]}, + bus, + gateway=_basic_handler(bus), + ) + mock_ws = AsyncMock() + mock_ws.send.side_effect = RuntimeError("fanout failed") + chat_id = f"turn-end-failure-{expected_cleared}" + channel._attach(mock_ws, chat_id) + wth._WEBSOCKET_TURN_WALL_STARTED_AT[chat_id] = 1234.5 + wth._WEBSOCKET_TURN_OWNERS[chat_id] = active_owner + + try: + with pytest.raises(RuntimeError, match="fanout failed"): + await channel.send(OutboundMessage( + channel="websocket", + chat_id=chat_id, + content="", + metadata={WEBSOCKET_TURN_OWNER_METADATA_KEY: event_owner}, + event=TurnEndEvent(), + )) + + assert read_transcript_lines(f"websocket:{chat_id}")[-1]["event"] == "turn_end" + assert (wth.websocket_turn_wall_started_at(chat_id) is None) is expected_cleared + if not expected_cleared: + assert wth._WEBSOCKET_TURN_OWNERS[chat_id] == active_owner + finally: + wth._WEBSOCKET_TURN_WALL_STARTED_AT.pop(chat_id, None) + wth._WEBSOCKET_TURN_IDS.pop(chat_id, None) + wth._WEBSOCKET_TURN_OWNERS.pop(chat_id, None) + + +@pytest.mark.asyncio +async def test_turn_end_keeps_registry_when_transcript_persistence_fails( + monkeypatch, +) -> None: + from nanobot.bus.events import InboundMessage + + bus = MagicMock() + bus.publish_outbound = AsyncMock() + channel = WebSocketChannel( + {"enabled": True, "allowFrom": ["*"]}, + bus, + gateway=_basic_handler(bus), + ) + chat_id = "turn-end-persistence-failure" + owner = "owner-persist" + turn_id = "turn-persist" + inbound = InboundMessage( + channel="websocket", + sender_id="u", + chat_id=chat_id, + content="hi", + metadata={ + WEBSOCKET_TURN_OWNER_METADATA_KEY: owner, + "webui_turn_id": turn_id, + "webui": True, + }, + ) + await wth.publish_turn_run_status(bus, inbound, "running", started_at=1234.5) + append = MagicMock(side_effect=OSError("disk full")) + monkeypatch.setattr("nanobot.webui.transcript.append_transcript_object", append) + + await channel.send(OutboundMessage( + channel="websocket", + chat_id=chat_id, + content="", + metadata={ + WEBSOCKET_TURN_OWNER_METADATA_KEY: owner, + "webui_turn_id": turn_id, + "webui": True, + }, + event=TurnEndEvent(), + )) + + append.assert_called_once() + assert wth.websocket_turn_wall_started_at(chat_id) == 1234.5 + assert wth.websocket_turn_id(chat_id) == turn_id + assert wth._WEBSOCKET_TURN_OWNERS[chat_id] == owner + + await channel.send(OutboundMessage( + channel="websocket", + chat_id=chat_id, + content="", + metadata=dict(inbound.metadata), + event=GoalStatusEvent(status="idle"), + )) + + # The normal WebUI idle event follows turn_end. It must not convert a + # failed canonical completion write into an apparently settled HTTP + # snapshot. + assert wth.websocket_turn_wall_started_at(chat_id) == 1234.5 + assert wth.websocket_turn_id(chat_id) == turn_id + assert wth._WEBSOCKET_TURN_OWNERS[chat_id] == owner + + +@pytest.mark.asyncio +async def test_durable_incomplete_marker_stays_pending_without_safe_session_recovery( + monkeypatch, +) -> None: + from nanobot.bus.events import InboundMessage + from nanobot.webui.transcript import build_webui_thread_response + + bus = MagicMock() + bus.publish_outbound = AsyncMock() + channel = WebSocketChannel( + {"enabled": True, "allowFrom": ["*"]}, + bus, + gateway=_basic_handler(bus), + ) + chat_id = "answer-persistence-failure" + key = f"websocket:{chat_id}" + owner = "owner-answer" + turn_id = "turn-answer" + append_transcript_object( + key, + {"event": "user", "chat_id": chat_id, "text": "question", "turn_id": turn_id}, + ) + inbound = InboundMessage( + channel="websocket", + sender_id="u", + chat_id=chat_id, + content="question", + metadata={ + WEBSOCKET_TURN_OWNER_METADATA_KEY: owner, + "webui_turn_id": turn_id, + "webui": True, + }, + ) + await wth.publish_turn_run_status(bus, inbound, "running", started_at=1234.5) + + original_append = append_transcript_object + + def fail_answer(session_key: str, event: dict[str, Any]) -> None: + if event.get("event") == "message": + raise OSError("transient disk failure") + original_append(session_key, event) + + monkeypatch.setattr("nanobot.webui.transcript.append_transcript_object", fail_answer) + + await channel.send(OutboundMessage( + channel="websocket", + chat_id=chat_id, + content="answer", + metadata=dict(inbound.metadata), + )) + await channel.send(OutboundMessage( + channel="websocket", + chat_id=chat_id, + content="", + metadata=dict(inbound.metadata), + event=TurnEndEvent(), + )) + await channel.send(OutboundMessage( + channel="websocket", + chat_id=chat_id, + content="", + metadata=dict(inbound.metadata), + event=GoalStatusEvent(status="idle"), + )) + + # Simulate a gateway restart: no process-local owner survives, so the + # persisted marker must be sufficient to reject canonical completion. + wth._WEBSOCKET_ACTIVE_TURNS.clear() + wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear() + wth._WEBSOCKET_TURN_IDS.clear() + wth._WEBSOCKET_TURN_OWNERS.clear() + body = build_webui_thread_response( + key, + active_turn_started_at=wth.websocket_turn_wall_started_at(chat_id), + active_turn_id=wth.websocket_turn_id(chat_id), + active_turn_transcript_persistence_failed=( + wth.websocket_turn_transcript_persistence_failed(chat_id) + ), + ) + assert body is not None + assert read_transcript_lines(key)[-1]["transcript_incomplete"] is True + assert body["completed_turn_ids"] == [] + assert [(message["role"], message["content"]) for message in body["messages"]] == [ + ("user", "question"), + ] + assert body["has_pending_tool_calls"] is True + assert chat_id not in wth._WEBSOCKET_TURN_OWNERS + + +@pytest.mark.asyncio +async def test_http_replay_recovers_marked_answer_from_session_after_gateway_restart( + tmp_path, + monkeypatch, +) -> None: + from urllib.parse import quote + + from websockets.datastructures import Headers + from websockets.http11 import Request + + from nanobot.bus.events import InboundMessage + + chat_id = "answer-recovery-after-restart" + key = f"websocket:{chat_id}" + owner = "owner-answer-recovery" + turn_id = "turn-answer-recovery" + sessions_path = tmp_path / "sessions" + sessions = SessionManager(sessions_path) + session = sessions.get_or_create(key) + session.add_message("user", "question") + session.add_message("assistant", "durable answer") + sessions.save(session) + append_transcript_object( + key, + { + "event": "user", + "chat_id": chat_id, + "text": "question", + "turn_id": turn_id, + }, + ) + + bus = MagicMock() + bus.publish_outbound = AsyncMock() + channel = WebSocketChannel( + {"enabled": True, "allowFrom": ["*"]}, + bus, + gateway=_basic_handler(bus, session_manager=sessions), + ) + inbound = InboundMessage( + channel="websocket", + sender_id="u", + chat_id=chat_id, + content="question", + metadata={ + WEBSOCKET_TURN_OWNER_METADATA_KEY: owner, + "webui_turn_id": turn_id, + "webui": True, + }, + ) + await wth.publish_turn_run_status(bus, inbound, "running", started_at=1234.5) + + original_append = append_transcript_object + + def fail_answer(session_key: str, event: dict[str, Any]) -> None: + if event.get("event") == "message": + raise OSError("transient disk failure") + original_append(session_key, event) + + monkeypatch.setattr("nanobot.webui.transcript.append_transcript_object", fail_answer) + await channel.send(OutboundMessage( + channel="websocket", + chat_id=chat_id, + content="durable answer", + metadata=dict(inbound.metadata), + )) + await channel.send(OutboundMessage( + channel="websocket", + chat_id=chat_id, + content="", + metadata=dict(inbound.metadata), + event=TurnEndEvent(), + )) + + persisted_lines = read_transcript_lines(key) + assert persisted_lines[-1]["event"] == "turn_end" + assert persisted_lines[-1]["transcript_incomplete"] is True + + # Drop all process-local state and construct a fresh HTTP/session layer. + wth._WEBSOCKET_ACTIVE_TURNS.clear() + wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear() + wth._WEBSOCKET_TURN_IDS.clear() + wth._WEBSOCKET_TURN_OWNERS.clear() + restarted_channel = WebSocketChannel( + {"enabled": True, "allowFrom": ["*"]}, + bus, + gateway=_basic_handler( + bus, + session_manager=SessionManager(sessions_path), + ), + ) + restarted_channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0 + encoded_key = quote(key, safe="") + request = Request( + f"/api/sessions/{encoded_key}/webui-thread", + Headers([("Authorization", "Bearer tok")]), + ) + + response = restarted_channel.gateway.http._handle_webui_thread_get( + request, + encoded_key, + ) + + assert response.status_code == 200 + body = json.loads(response.body.decode()) + assert [(message["role"], message["content"]) for message in body["messages"]] == [ + ("user", "question"), + ("assistant", "durable answer"), + ] + assert body["completed_turn_ids"] == [turn_id] + assert body["has_pending_tool_calls"] is False + assert body["active_turn_id"] is None + + +@pytest.mark.asyncio +async def test_webui_idle_clears_owner_when_no_completion_write_failed() -> None: + from nanobot.bus.events import InboundMessage + + bus = MagicMock() + bus.publish_outbound = AsyncMock() + channel = WebSocketChannel( + {"enabled": True, "allowFrom": ["*"]}, + bus, + gateway=_basic_handler(bus), + ) + chat_id = "cancelled-webui-turn" + owner = "owner-cancelled" + inbound = InboundMessage( + channel="websocket", + sender_id="u", + chat_id=chat_id, + content="hi", + metadata={ + WEBSOCKET_TURN_OWNER_METADATA_KEY: owner, + "webui_turn_id": "turn-cancelled", + "webui": True, + }, + ) + await wth.publish_turn_run_status(bus, inbound, "running", started_at=1234.5) + + await channel.send(OutboundMessage( + channel="websocket", + chat_id=chat_id, + content="", + metadata=dict(inbound.metadata), + event=GoalStatusEvent(status="idle"), + )) + + assert wth.websocket_turn_wall_started_at(chat_id) is None + assert wth.websocket_turn_id(chat_id) is None + assert chat_id not in wth._WEBSOCKET_ACTIVE_TURNS + + +@pytest.mark.asyncio +async def test_non_webui_transcript_failure_does_not_block_idle_cleanup( + monkeypatch, +) -> None: + from nanobot.bus.events import InboundMessage + + bus = MagicMock() + bus.publish_outbound = AsyncMock() + channel = WebSocketChannel( + {"enabled": True, "allowFrom": ["*"]}, + bus, + gateway=_basic_handler(bus), + ) + chat_id = "direct-non-webui-failure" + owner = "owner-direct" + inbound = InboundMessage( + channel="websocket", + sender_id="runtime", + chat_id=chat_id, + content="direct", + metadata={WEBSOCKET_TURN_OWNER_METADATA_KEY: owner}, + ) + await wth.publish_turn_run_status(bus, inbound, "running", started_at=1234.5) + monkeypatch.setattr( + "nanobot.webui.transcript.append_transcript_object", + MagicMock(side_effect=OSError("disk full")), + ) + + await channel.send(OutboundMessage( + channel="websocket", + chat_id=chat_id, + content="direct answer", + metadata=dict(inbound.metadata), + )) + + assert wth.websocket_turn_transcript_persistence_failed(chat_id, owner) is False + await channel.send(OutboundMessage( + channel="websocket", + chat_id=chat_id, + content="", + metadata=dict(inbound.metadata), + event=GoalStatusEvent(status="idle"), + )) + assert wth.websocket_turn_wall_started_at(chat_id) is None + assert chat_id not in wth._WEBSOCKET_ACTIVE_TURNS + + +@pytest.mark.asyncio +async def test_idle_clears_matching_owner_when_fanout_fails() -> None: + bus = MagicMock() + channel = WebSocketChannel( + {"enabled": True, "allowFrom": ["*"]}, + bus, + gateway=_basic_handler(bus), + ) + mock_ws = AsyncMock() + mock_ws.send.side_effect = RuntimeError("fanout failed") + chat_id = "idle-failure" + owner = "owner-idle" + channel._attach(mock_ws, chat_id) + wth._WEBSOCKET_TURN_WALL_STARTED_AT[chat_id] = 1234.5 + wth._WEBSOCKET_TURN_OWNERS[chat_id] = owner + + with pytest.raises(RuntimeError, match="fanout failed"): + await channel.send(OutboundMessage( + channel="websocket", + chat_id=chat_id, + content="", + metadata={WEBSOCKET_TURN_OWNER_METADATA_KEY: owner}, + event=GoalStatusEvent(status="idle"), + )) + + assert wth.websocket_turn_wall_started_at(chat_id) is None + assert chat_id not in wth._WEBSOCKET_TURN_OWNERS + + @pytest.mark.asyncio async def test_send_turn_end_includes_latency_ms_when_present() -> None: bus = MagicMock() @@ -1654,6 +2098,7 @@ async def test_send_goal_status_running_emits_event_with_started_at() -> None: channel="websocket", chat_id="chat-1", content="", + metadata={"webui_turn_id": "turn-running"}, event=GoalStatusEvent(status="running", started_at=1_700_000_000.5), )) @@ -1664,6 +2109,7 @@ async def test_send_goal_status_running_emits_event_with_started_at() -> None: "chat_id": "chat-1", "status": "running", "started_at": 1_700_000_000.5, + "turn_id": "turn-running", } @@ -1678,12 +2124,18 @@ async def test_send_goal_status_idle_omits_started_at() -> None: channel="websocket", chat_id="chat-1", content="", + metadata={"webui_turn_id": "turn-idle"}, event=GoalStatusEvent(status="idle", started_at=99.0), )) mock_ws.send.assert_awaited_once() body = json.loads(mock_ws.send.await_args.args[0]) - assert body == {"event": "goal_status", "chat_id": "chat-1", "status": "idle"} + assert body == { + "event": "goal_status", + "chat_id": "chat-1", + "status": "idle", + "turn_id": "turn-idle", + } @pytest.mark.asyncio @@ -2725,6 +3177,147 @@ async def test_allow_from_rejects_unauthorized_client_id(bus: MagicMock) -> None await server_task +@pytest.mark.asyncio +async def test_open_connection_rejects_revoked_webui_turn_without_acceptance_ack( + bus: MagicMock, +) -> None: + channel = _ch(bus, allowFrom=["alice"]) + conn = AsyncMock() + conn.remote_address = ("127.0.0.1", 50123) + + await channel._dispatch_envelope( + conn, + "revoked-client", + { + "type": "message", + "chat_id": "chat-revoked", + "content": "must not enter the bus", + "webui": True, + "turn_id": "turn-revoked", + }, + ) + + payloads = [json.loads(call.args[0]) for call in conn.send.await_args_list] + assert payloads == [ + { + "event": "error", + "detail": "access_denied", + "chat_id": "chat-revoked", + "turn_id": "turn-revoked", + } + ] + bus.publish_inbound.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_midflight_allowlist_revocation_rejects_turn_without_ack( + bus: MagicMock, +) -> None: + channel = _ch(bus) + channel.is_allowed = MagicMock(side_effect=[True, False]) + conn = AsyncMock() + conn.remote_address = ("127.0.0.1", 50123) + + await channel._dispatch_envelope( + conn, + "webui-client", + { + "type": "message", + "chat_id": "chat-midflight-revoked", + "content": "must not be acknowledged", + "webui": True, + "turn_id": "turn-midflight-revoked", + }, + ) + + payloads = [json.loads(call.args[0]) for call in conn.send.await_args_list] + assert payloads[-1] == { + "event": "error", + "detail": "access_denied", + "chat_id": "chat-midflight-revoked", + "turn_id": "turn-midflight-revoked", + } + assert all(payload["event"] != "message_accepted" for payload in payloads) + bus.publish_inbound.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_authorized_webui_turn_is_acked_after_bus_acceptance( + bus: MagicMock, +) -> None: + channel = _ch(bus) + conn = AsyncMock() + conn.remote_address = ("127.0.0.1", 50123) + + await channel._dispatch_envelope( + conn, + "webui-client", + { + "type": "message", + "chat_id": "chat-accepted", + "content": "accepted", + "webui": True, + "turn_id": "turn-accepted", + }, + ) + + bus.publish_inbound.assert_awaited_once() + inbound = bus.publish_inbound.await_args.args[0] + owner = inbound.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] + assert wth.websocket_turn_id("chat-accepted") == "turn-accepted" + assert wth.websocket_turn_wall_started_at("chat-accepted") is not None + assert wth.websocket_turn_owner_is_registered( + "chat-accepted", + owner, + "turn-accepted", + ) + thread = build_webui_thread_response( + "websocket:chat-accepted", + active_turn_started_at=wth.websocket_turn_wall_started_at("chat-accepted"), + active_turn_id=wth.websocket_turn_id("chat-accepted"), + ) + assert thread is not None + assert thread["active_turn_id"] == "turn-accepted" + assert thread["has_pending_tool_calls"] is True + payloads = [json.loads(call.args[0]) for call in conn.send.await_args_list] + assert payloads[-1] == { + "event": "message_accepted", + "chat_id": "chat-accepted", + "turn_id": "turn-accepted", + } + + +@pytest.mark.asyncio +async def test_side_channel_command_does_not_register_queued_turn( + bus: MagicMock, +) -> None: + channel = _ch(bus) + conn = AsyncMock() + conn.remote_address = ("127.0.0.1", 50123) + + await channel._dispatch_envelope( + conn, + "webui-client", + { + "type": "message", + "chat_id": "chat-status", + "content": "/status", + "webui": True, + "turn_id": "turn-status", + }, + ) + + inbound = bus.publish_inbound.await_args.args[0] + assert WEBSOCKET_TURN_OWNER_METADATA_KEY not in inbound.metadata + assert wth.websocket_turn_wall_started_at("chat-status") is None + payloads = [json.loads(call.args[0]) for call in conn.send.await_args_list] + assert payloads[-1] == { + "event": "message_accepted", + "chat_id": "chat-status", + "turn_id": "turn-status", + } + + @pytest.mark.asyncio async def test_client_id_truncation(bus: MagicMock) -> None: port = 29883 @@ -3238,6 +3831,255 @@ def test_handle_webui_thread_get_returns_json(tmp_path, monkeypatch) -> None: assert len(body["messages"]) == 1 assert body["messages"][0]["role"] == "user" assert body["messages"][0]["content"] == "hi" + assert body["has_pending_tool_calls"] is False + + +def test_handle_webui_thread_get_reports_registered_turn_as_pending( + tmp_path, + monkeypatch, +) -> None: + from urllib.parse import quote + + from websockets.datastructures import Headers + from websockets.http11 import Request + + from nanobot.webui.transcript import append_transcript_object + + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + monkeypatch.setattr( + "nanobot.session.webui_turns.websocket_turn_wall_started_at", + lambda chat_id: 1_700_000_000.0 if chat_id == "running" else None, + ) + monkeypatch.setattr( + "nanobot.session.webui_turns.websocket_turn_id", + lambda chat_id: "turn-running" if chat_id == "running" else None, + ) + key = "websocket:running" + append_transcript_object( + key, + { + "event": "user", + "chat_id": "running", + "text": "hi", + "turn_id": "turn-running", + }, + ) + bus = MagicMock() + channel = _ch(bus) + channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0 + enc = quote(key, safe="") + req = Request(f"/api/sessions/{enc}/webui-thread", Headers([("Authorization", "Bearer tok")])) + + resp = channel.gateway.http._handle_webui_thread_get(req, enc) + + assert resp.status_code == 200 + body = json.loads(resp.body.decode()) + assert body["messages"][0]["content"] == "hi" + assert body["has_pending_tool_calls"] is True + + +@pytest.mark.asyncio +async def test_idle_registry_stays_pending_until_turn_end_is_persisted( + tmp_path, + monkeypatch, +) -> None: + from urllib.parse import quote + + from websockets.datastructures import Headers + from websockets.http11 import Request + + from nanobot.bus.events import InboundMessage + from nanobot.session import webui_turns as wth + from nanobot.webui.transcript import append_transcript_object + + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + key = "websocket:idle-order" + turn_id = "turn-idle-order" + append_transcript_object( + key, + { + "event": "user", + "chat_id": "idle-order", + "text": "hi", + "turn_id": turn_id, + }, + ) + bus = MagicMock() + bus.publish_outbound = AsyncMock() + inbound = InboundMessage( + channel="websocket", + sender_id="u", + chat_id="idle-order", + content="hi", + metadata={"webui_turn_id": turn_id}, + ) + channel = _ch(bus) + channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0 + enc = quote(key, safe="") + request = Request( + f"/api/sessions/{enc}/webui-thread", + Headers([("Authorization", "Bearer tok")]), + ) + + try: + await wth.publish_turn_run_status(bus, inbound, "running") + await wth.publish_turn_run_status(bus, inbound, "idle") + + before_delivery = channel.gateway.http._handle_webui_thread_get(request, enc) + assert json.loads(before_delivery.body.decode())["has_pending_tool_calls"] is True + + await channel.send(OutboundMessage( + channel="websocket", + chat_id="idle-order", + content="", + metadata=dict(inbound.metadata), + event=TurnEndEvent(), + )) + + after_delivery = channel.gateway.http._handle_webui_thread_get(request, enc) + assert json.loads(after_delivery.body.decode())["has_pending_tool_calls"] is False + assert wth.websocket_turn_wall_started_at("idle-order") is None + assert wth.websocket_turn_id("idle-order") is None + finally: + wth._WEBSOCKET_TURN_WALL_STARTED_AT.pop("idle-order", None) + wth._WEBSOCKET_TURN_IDS.pop("idle-order", None) + wth._WEBSOCKET_TURN_OWNERS.pop("idle-order", None) + + +@pytest.mark.asyncio +async def test_webui_thread_api_restores_older_owner_after_latest_completes() -> None: + from urllib.parse import quote + + from websockets.datastructures import Headers + from websockets.http11 import Request + + from nanobot.bus.events import InboundMessage + + chat_id = "concurrent-projection" + key = f"websocket:{chat_id}" + append_transcript_object( + key, + { + "event": "user", + "chat_id": chat_id, + "text": "first", + "turn_id": "turn-first", + }, + ) + bus = MagicMock() + bus.publish_outbound = AsyncMock() + first = InboundMessage( + channel="websocket", + sender_id="u", + chat_id=chat_id, + content="first", + metadata={ + WEBSOCKET_TURN_OWNER_METADATA_KEY: "owner-first", + "webui_turn_id": "turn-first", + }, + session_key_override="websocket:session-first", + ) + second = InboundMessage( + channel="websocket", + sender_id="u", + chat_id=chat_id, + content="second", + metadata={ + WEBSOCKET_TURN_OWNER_METADATA_KEY: "owner-second", + "webui_turn_id": "turn-second", + }, + session_key_override="websocket:session-second", + ) + await wth.publish_turn_run_status(bus, first, "running", started_at=100.0) + await wth.publish_turn_run_status(bus, second, "running", started_at=200.0) + assert wth.clear_websocket_turn_if_current(chat_id, "owner-second") is True + + channel = _ch(bus) + channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0 + enc = quote(key, safe="") + request = Request( + f"/api/sessions/{enc}/webui-thread", + Headers([("Authorization", "Bearer tok")]), + ) + response = channel.gateway.http._handle_webui_thread_get(request, enc) + + assert response.status_code == 200 + payload = json.loads(response.body.decode()) + assert payload["has_pending_tool_calls"] is True + assert wth.websocket_turn_wall_started_at(chat_id) == 100.0 + assert wth.websocket_turn_id(chat_id) == "turn-first" + assert wth._WEBSOCKET_TURN_OWNERS[chat_id] == "owner-first" + + +@pytest.mark.parametrize( + ("active_turn_id", "expected_pending"), + [ + ("turn-complete", False), + ("turn-next", True), + ], +) +def test_handle_webui_thread_get_reconciles_registered_turn_with_turn_end( + tmp_path, + monkeypatch, + active_turn_id: str, + expected_pending: bool, +) -> None: + from urllib.parse import quote + + from websockets.datastructures import Headers + from websockets.http11 import Request + + from nanobot.webui.transcript import append_transcript_object + + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + monkeypatch.setattr( + "nanobot.session.webui_turns.websocket_turn_wall_started_at", + lambda chat_id: 1_700_000_000.0 if chat_id == "running" else None, + ) + monkeypatch.setattr( + "nanobot.session.webui_turns.websocket_turn_id", + lambda chat_id: active_turn_id if chat_id == "running" else None, + ) + key = "websocket:running" + append_transcript_object( + key, + { + "event": "user", + "chat_id": "running", + "text": "hi", + "turn_id": "turn-complete", + }, + ) + append_transcript_object( + key, + { + "event": "message", + "chat_id": "running", + "text": "done", + "turn_id": "turn-complete", + }, + ) + append_transcript_object( + key, + { + "event": "turn_end", + "chat_id": "running", + "turn_id": "turn-complete", + }, + ) + bus = MagicMock() + channel = _ch(bus) + channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0 + enc = quote(key, safe="") + req = Request(f"/api/sessions/{enc}/webui-thread", Headers([("Authorization", "Bearer tok")])) + + resp = channel.gateway.http._handle_webui_thread_get(req, enc) + + assert resp.status_code == 200 + body = json.loads(resp.body.decode()) + assert body["messages"][-1]["content"] == "done" + assert body["has_pending_tool_calls"] is expected_pending + assert body["active_turn_id"] == active_turn_id def test_handle_webui_thread_get_accepts_pagination_query(tmp_path, monkeypatch) -> None: diff --git a/nanobot/channels/websocket/tests/test_websocket_envelope_media.py b/nanobot/channels/websocket/tests/test_websocket_envelope_media.py index 94dd25119..85a24e8db 100644 --- a/nanobot/channels/websocket/tests/test_websocket_envelope_media.py +++ b/nanobot/channels/websocket/tests/test_websocket_envelope_media.py @@ -19,6 +19,7 @@ from nanobot.channels.websocket.runtime import ( WebSocketChannel, WebSocketConfig, ) +from nanobot.session import webui_turns as wth from nanobot.webui.gateway_services import build_gateway_services @@ -59,6 +60,19 @@ def _make_channel() -> WebSocketChannel: return channel +@pytest.fixture(autouse=True) +def isolate_websocket_turn_state() -> 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() + + # -- max_message_bytes bump ---------------------------------------------------- @@ -94,6 +108,28 @@ async def test_message_without_media_backward_compatible() -> None: assert call.kwargs["media"] is None +@pytest.mark.asyncio +async def test_webui_message_acceptance_echoes_turn_id() -> None: + channel = _make_channel() + mock_conn = AsyncMock() + envelope = { + "type": "message", + "chat_id": "abc123", + "content": "hello", + "webui": True, + "turn_id": "turn-accepted", + } + + await channel._dispatch_envelope(mock_conn, "client-1", envelope) + + channel._handle_message.assert_awaited_once() + assert json.loads(mock_conn.send.await_args.args[0]) == { + "event": "message_accepted", + "chat_id": "abc123", + "turn_id": "turn-accepted", + } + + @pytest.mark.asyncio async def test_message_text_policy_is_independent_from_transport_limit() -> None: channel = _make_channel() @@ -102,6 +138,7 @@ async def test_message_text_policy_is_independent_from_transport_limit() -> None "type": "message", "chat_id": "abc123", "content": "你" * 22_000, + "turn_id": "turn-text-policy", } await channel._dispatch_envelope(mock_conn, "client-1", envelope) @@ -113,6 +150,7 @@ async def test_message_text_policy_is_independent_from_transport_limit() -> None "chat_id": "abc123", "detail": "message_rejected", "reason": "text_too_large", + "turn_id": "turn-text-policy", } @@ -235,6 +273,7 @@ async def test_message_rejected_when_more_than_four_images(tmp_path) -> None: "chat_id": "abc123", "content": "hi", "media": [{"data_url": _tiny_png_data_url()}] * 5, + "turn_id": "turn-attachments", } with patch( @@ -246,8 +285,10 @@ async def test_message_rejected_when_more_than_four_images(tmp_path) -> None: mock_conn.send.assert_awaited_once() err = json.loads(mock_conn.send.call_args[0][0]) assert err["event"] == "error" + assert err["chat_id"] == "abc123" assert err["detail"] == "attachment_rejected" assert err["reason"] == "too_many_images" + assert err["turn_id"] == "turn-attachments" @pytest.mark.asyncio diff --git a/nanobot/channels/websocket/tests/test_websocket_reconnect_idle.py b/nanobot/channels/websocket/tests/test_websocket_reconnect_idle.py index eddd2bea6..951b7d7cd 100644 --- a/nanobot/channels/websocket/tests/test_websocket_reconnect_idle.py +++ b/nanobot/channels/websocket/tests/test_websocket_reconnect_idle.py @@ -53,9 +53,19 @@ async def test_hydrate_after_subscribe_pushes_running_when_turn_active(): channel.send_goal_state = mock_send_goal_state channel.send_goal_status = mock_send_goal_status - with patch("nanobot.channels.websocket.runtime.websocket_turn_wall_started_at", return_value=1234567890.0): + with ( + patch( + "nanobot.channels.websocket.runtime.websocket_turn_wall_started_at", + return_value=1234567890.0, + ), + patch( + "nanobot.channels.websocket.runtime.websocket_turn_id", + return_value="turn-active", + ), + ): await channel._hydrate_after_subscribe("test-chat") running_events = [e for e in sent_events if e[0] == "goal_status" and e[2] == "running"] assert len(running_events) == 1 assert running_events[0][3]["started_at"] == 1234567890.0 + assert running_events[0][3]["turn_id"] == "turn-active" diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index 3ba687830..4da0b01de 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -14,7 +14,7 @@ from typing import Literal from nanobot import __version__ from nanobot.agent.goal_permission import goal_mutation_permission from nanobot.bus.events import OutboundMessage -from nanobot.command.router import CommandContext, CommandRouter +from nanobot.command.router import CommandContext, CommandRouter, normalize_command_text from nanobot.utils.helpers import build_status_content from nanobot.utils.restart import set_restart_notice_to_env from nanobot.utils.workspace_prompts import initialize_workspace_prompt @@ -180,6 +180,21 @@ def builtin_command_palette() -> list[dict[str, str | bool]]: return [spec.as_dict() for spec in BUILTIN_COMMAND_SPECS] +def builtin_command_starts_agent_turn(text: str) -> bool: + """Return whether WebUI ingress should expect a normal agent lifecycle.""" + normalized = normalize_command_text(text) + command, separator, args = normalized.partition(" ") + spec = next( + (item for item in BUILTIN_COMMAND_SPECS if item.command == command.lower()), + None, + ) + if spec is None or (separator and not spec.accepts_args): + return True + if spec.lifecycle == "agent_turn": + return True + return spec.lifecycle == "agent_turn_with_args" and bool(args.strip()) + + async def cmd_stop(ctx: CommandContext) -> OutboundMessage: """Cancel all active tasks and subagents for the session.""" loop = ctx.loop diff --git a/nanobot/session/webui_turns.py b/nanobot/session/webui_turns.py index 38967ccce..37bfc731e 100644 --- a/nanobot/session/webui_turns.py +++ b/nanobot/session/webui_turns.py @@ -42,7 +42,10 @@ from nanobot.session.history_visibility import is_hidden_history_message from nanobot.session.manager import Session, SessionManager from nanobot.utils.helpers import strip_think, truncate_text from nanobot.utils.llm_runtime import LLMRuntime -from nanobot.webui.metadata import WEBUI_TURN_METADATA_KEY +from nanobot.webui.metadata import ( + WEBSOCKET_TURN_OWNER_METADATA_KEY, + WEBUI_TURN_METADATA_KEY, +) WEBUI_SESSION_METADATA_KEY = "webui" WEBUI_TITLE_METADATA_KEY = "title" @@ -51,9 +54,42 @@ TITLE_MAX_CHARS = 60 TITLE_GENERATION_MAX_TOKENS = 96 TITLE_GENERATION_REASONING_EFFORT = "none" -# Wall-clock turn start per ``chat_id`` (websocket only). Survives browser refresh while the -# gateway process stays up; cleared on idle/stop and implicitly dropped on restart. +# Latest active turn projection per ``chat_id`` (websocket only). It survives browser refresh +# while the gateway process stays up and is implicitly dropped on restart. _WEBSOCKET_TURN_WALL_STARTED_AT: dict[str, float] = {} +_WEBSOCKET_TURN_IDS: dict[str, str] = {} +_WEBSOCKET_TURN_OWNERS: dict[str, str] = {} + + +@dataclass(frozen=True) +class _WebsocketTurn: + started_at: float + turn_id: str | None + transcript_persistence_failed: bool = False + + +# All in-flight lifecycle owners per chat, in admission order. The three maps +# above remain the latest-owner projection consumed by the HTTP API. +_WEBSOCKET_ACTIVE_TURNS: dict[str, dict[str, _WebsocketTurn]] = {} + + +def _sync_websocket_turn_projection(chat_id: str) -> None: + turns = _WEBSOCKET_ACTIVE_TURNS.get(chat_id) + if not turns: + _WEBSOCKET_ACTIVE_TURNS.pop(chat_id, None) + _WEBSOCKET_TURN_WALL_STARTED_AT.pop(chat_id, None) + _WEBSOCKET_TURN_IDS.pop(chat_id, None) + _WEBSOCKET_TURN_OWNERS.pop(chat_id, None) + return + + owner = next(reversed(turns)) + turn = turns[owner] + _WEBSOCKET_TURN_WALL_STARTED_AT[chat_id] = turn.started_at + _WEBSOCKET_TURN_OWNERS[chat_id] = owner + if turn.turn_id is None: + _WEBSOCKET_TURN_IDS.pop(chat_id, None) + else: + _WEBSOCKET_TURN_IDS[chat_id] = turn.turn_id def mark_webui_session(session: Session, metadata: dict[str, Any]) -> bool: @@ -203,6 +239,96 @@ def websocket_turn_wall_started_at(chat_id: str) -> float | None: return _WEBSOCKET_TURN_WALL_STARTED_AT.get(chat_id) +def websocket_turn_id(chat_id: str) -> str | None: + """Return the WebUI identity of the active turn, when one was provided.""" + return _WEBSOCKET_TURN_IDS.get(chat_id) + + +def register_queued_websocket_turn_if_idle( + chat_id: str, + turn_id: str | None, +) -> str | None: + """Track an accepted WebUI turn while it waits for AgentLoop admission.""" + if websocket_turn_wall_started_at(chat_id) is not None: + return None + owner = uuid4().hex + _WEBSOCKET_ACTIVE_TURNS.setdefault(chat_id, {})[owner] = _WebsocketTurn( + started_at=time.time(), + turn_id=turn_id, + ) + _sync_websocket_turn_projection(chat_id) + return owner + + +def websocket_turn_owner_is_registered( + chat_id: str, + owner: str, + turn_id: str | None, +) -> bool: + """Return whether websocket ingress registered this owner for the turn.""" + turn = _WEBSOCKET_ACTIVE_TURNS.get(chat_id, {}).get(owner) + return turn is not None and turn.turn_id == turn_id + + +def websocket_turn_transcript_persistence_failed( + chat_id: str, + owner: str | None = None, +) -> bool: + """Return whether one active owner has an incomplete canonical transcript.""" + turns = _WEBSOCKET_ACTIVE_TURNS.get(chat_id) + if not turns: + return False + selected_owner = owner or next(reversed(turns)) + turn = turns.get(selected_owner) + return turn.transcript_persistence_failed if turn is not None else False + + +def mark_websocket_turn_transcript_persistence_failed( + chat_id: str, + owner: str | None, +) -> bool: + """Keep a turn active when any canonical display event could not be written.""" + if not owner: + return False + turns = _WEBSOCKET_ACTIVE_TURNS.get(chat_id) + if turns is None or owner not in turns: + return False + turns[owner] = replace(turns[owner], transcript_persistence_failed=True) + return True + + +def clear_websocket_turn_if_current( + chat_id: str, + owner: str | None, + *, + preserve_persistence_failure: bool = False, +) -> bool: + """Clear one lifecycle owner without disturbing concurrent turns for the chat.""" + if not owner: + return False + turns = _WEBSOCKET_ACTIVE_TURNS.get(chat_id) + if turns is not None: + if owner not in turns: + return False + if preserve_persistence_failure and turns[owner].transcript_persistence_failed: + return False + turns.pop(owner) + _sync_websocket_turn_projection(chat_id) + return True + + # Compatibility for callers/tests that populated the legacy projection + # directly before the multi-owner registry existed. + if ( + chat_id in _WEBSOCKET_TURN_WALL_STARTED_AT + and _WEBSOCKET_TURN_OWNERS.get(chat_id) == owner + ): + _WEBSOCKET_TURN_WALL_STARTED_AT.pop(chat_id, None) + _WEBSOCKET_TURN_IDS.pop(chat_id, None) + _WEBSOCKET_TURN_OWNERS.pop(chat_id, None) + return True + return False + + def build_bus_progress_callback( bus: MessageBus, msg: InboundMessage, @@ -229,9 +355,17 @@ async def publish_turn_run_status( else: t0 = time.time() started_at_event = t0 - _WEBSOCKET_TURN_WALL_STARTED_AT[cid] = t0 - else: - _WEBSOCKET_TURN_WALL_STARTED_AT.pop(cid, None) + owner = msg.metadata.get(WEBSOCKET_TURN_OWNER_METADATA_KEY) + if not isinstance(owner, str) or not owner: + owner = uuid4().hex + msg.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = owner + turn_id = msg.metadata.get(WEBUI_TURN_METADATA_KEY) + current_turn_id = turn_id if isinstance(turn_id, str) and turn_id else None + turns = _WEBSOCKET_ACTIVE_TURNS.setdefault(cid, {}) + # Re-registration makes this owner the latest projection. + turns.pop(owner, None) + turns[owner] = _WebsocketTurn(started_at=t0, turn_id=current_turn_id) + _sync_websocket_turn_projection(cid) await bus.publish_outbound( outbound_message_for_event( channel=msg.channel, @@ -254,25 +388,50 @@ class WebuiTurnRoutePolicy: route: TurnRoute, ) -> TurnRoute: """Make an independently dispatched late subagent result visible in WebUI.""" + routed = route if ( - msg.channel != "system" - or msg.sender_id != "subagent" - or msg.metadata.get("injected_event") != "subagent_result" - or route.channel != "websocket" + msg.channel == "system" + and msg.sender_id == "subagent" + and msg.metadata.get("injected_event") == "subagent_result" + and route.channel == "websocket" ): - return route + session = self.sessions.get_or_create(session_key) + if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is True: + metadata = dict(route.metadata) + metadata.update({ + WEBUI_SESSION_METADATA_KEY: True, + "_wants_stream": True, + WEBUI_TURN_METADATA_KEY: f"subagent:{uuid4().hex}", + }) + routed = replace(route, metadata=metadata, publish_lifecycle=True) - session = self.sessions.get_or_create(session_key) - if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True: - return route + if routed.channel == "websocket" and routed.publish_lifecycle: + metadata = dict(routed.metadata) + turn_id = metadata.get(WEBUI_TURN_METADATA_KEY) + current_turn_id = turn_id if isinstance(turn_id, str) and turn_id else None + queued_owner = metadata.get(WEBSOCKET_TURN_OWNER_METADATA_KEY) + owner = ( + queued_owner + if ( + msg.channel == "websocket" + and isinstance(queued_owner, str) + and websocket_turn_owner_is_registered( + str(msg.chat_id), + queued_owner, + current_turn_id, + ) + ) + else uuid4().hex + ) + metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = owner + routed = replace(routed, metadata=metadata) + # Direct websocket turns publish their final idle transition from + # the original input message. Carry the same server-owned identity + # there, overwriting any untrusted client-supplied value. + if msg.channel == "websocket": + msg.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = owner - metadata = dict(route.metadata) - metadata.update({ - WEBUI_SESSION_METADATA_KEY: True, - "_wants_stream": True, - WEBUI_TURN_METADATA_KEY: f"subagent:{uuid4().hex}", - }) - return replace(route, metadata=metadata, publish_lifecycle=True) + return routed def build_webui_fallback_model_observer(bus: MessageBus) -> FallbackModelObserver: diff --git a/nanobot/webui/metadata.py b/nanobot/webui/metadata.py index 03d426bfd..c00613b36 100644 --- a/nanobot/webui/metadata.py +++ b/nanobot/webui/metadata.py @@ -1,4 +1,5 @@ """Shared WebUI metadata keys.""" WEBUI_TURN_METADATA_KEY = "webui_turn_id" +WEBSOCKET_TURN_OWNER_METADATA_KEY = "_websocket_turn_owner" WEBUI_MESSAGE_SOURCE_METADATA_KEY = "_webui_message_source" diff --git a/nanobot/webui/transcript.py b/nanobot/webui/transcript.py index 2c8a73290..54c79d71e 100644 --- a/nanobot/webui/transcript.py +++ b/nanobot/webui/transcript.py @@ -25,6 +25,7 @@ from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3 WEBUI_FORK_MARKER_EVENT = "fork_marker" +WEBUI_TRANSCRIPT_INCOMPLETE_KEY = "transcript_incomplete" _MAX_TRANSCRIPT_FILE_BYTES = 8 * 1024 * 1024 _TARGET_ACTIVE_TRANSCRIPT_BYTES = _MAX_TRANSCRIPT_FILE_BYTES // 2 _TRANSCRIPT_SEGMENT_MANIFEST_VERSION = 2 @@ -151,6 +152,12 @@ class _TranscriptChunkRef(NamedTuple): user_count: int +class _SessionBackfillTurn(NamedTuple): + user_event: dict[str, Any] + assistant_signature: tuple[str, ...] + assistant_records: tuple[dict[str, Any], ...] + + def _record_json_line(record: dict[str, Any]) -> str: return json.dumps(record, ensure_ascii=False, separators=(",", ":")) @@ -665,7 +672,7 @@ class WebUITranscriptRecorder: phase: str | None = None, include_source: bool = False, transcript_overrides: dict[str, Any] | None = None, - ) -> None: + ) -> bool: self.prepare_event( chat_id, event, @@ -676,7 +683,7 @@ class WebUITranscriptRecorder: record = dict(event) if transcript_overrides: record.update(transcript_overrides) - self.append(chat_id, record) + return self.append(chat_id, record) def append_user_message( self, @@ -687,9 +694,9 @@ class WebUITranscriptRecorder: media_paths: list[str] | None = None, cli_apps: list[dict[str, Any]] | None = None, mcp_presets: list[dict[str, Any]] | None = None, - ) -> None: + ) -> bool: if text.strip() == "/stop" and not media_paths: - return + return False payload = build_user_transcript_event( chat_id, text, @@ -698,15 +705,17 @@ class WebUITranscriptRecorder: mcp_presets=mcp_presets, ) if payload is None: - return - self.prepare_and_append(chat_id, payload, metadata=metadata, phase="user") + return False + return self.prepare_and_append(chat_id, payload, metadata=metadata, phase="user") - def append(self, chat_id: str, event: dict[str, Any]) -> None: + def append(self, chat_id: str, event: dict[str, Any]) -> bool: try: dup = json.loads(json.dumps(event, ensure_ascii=False)) append_transcript_object(f"websocket:{chat_id}", dup) except (OSError, ValueError, TypeError) as e: self._log.warning("webui transcript append failed: {}", e) + return False + return True def _next_turn_seq(self, chat_id: str, turn_id: str) -> int: key = (chat_id, turn_id) @@ -921,32 +930,69 @@ def _assistant_text_signature(value: Any) -> str: return value.strip() if isinstance(value, str) else "" +def _session_assistant_event( + session_key: str, + message: dict[str, Any], +) -> dict[str, Any] | None: + if message.get("role") != "assistant" or is_hidden_history_message(message): + return None + message = public_history_message(message) + content = message.get("content") + text = content if isinstance(content, str) else "" + media = message.get("media") + media_paths = [str(path) for path in media] if isinstance(media, list) else [] + media_paths = [path for path in media_paths if path] + if not text.strip() and not media_paths: + return None + chat_id = session_key.split(":", 1)[1] if ":" in session_key else session_key + event: dict[str, Any] = { + "event": "message", + "chat_id": chat_id, + "text": text, + } + if media_paths: + event["media"] = media_paths + latency_ms = message.get("latency_ms") + if isinstance(latency_ms, int | float) and latency_ms >= 0: + event["latency_ms"] = int(latency_ms) + return event + + def _session_backfill_turns( session_key: str, session_messages: list[dict[str, Any]], -) -> list[tuple[dict[str, Any], tuple[str, ...]]]: - turns: list[tuple[dict[str, Any], tuple[str, ...]]] = [] +) -> list[_SessionBackfillTurn]: + turns: list[_SessionBackfillTurn] = [] current_user: dict[str, Any] | None = None - assistant_texts: list[str] = [] + assistant_records: list[dict[str, Any]] = [] def flush() -> None: - if current_user is None: + if current_user is None or not assistant_records: return - signature = tuple(text for text in assistant_texts if text) - if signature: - turns.append((current_user, signature)) + signature = tuple( + text + for record in assistant_records + if (text := _assistant_text_signature(record.get("text"))) + ) + turns.append( + _SessionBackfillTurn( + current_user, + signature, + tuple(dict(record) for record in assistant_records), + ) + ) for message in session_messages: role = message.get("role") if role == "user": flush() current_user = _session_user_event(session_key, message) - assistant_texts = [] + assistant_records = [] continue if role == "assistant" and current_user is not None: - text = _assistant_text_signature(message.get("content")) - if text: - assistant_texts.append(text) + record = _session_assistant_event(session_key, message) + if record is not None: + assistant_records.append(record) flush() return turns @@ -976,7 +1022,7 @@ def _transcript_turn_signature(records: list[dict[str, Any]]) -> tuple[str, ...] def _find_unique_session_turn( - session_turns: list[tuple[dict[str, Any], tuple[str, ...]]], + session_turns: list[_SessionBackfillTurn], signature: tuple[str, ...], start: int, ) -> int | None: @@ -984,7 +1030,7 @@ def _find_unique_session_turn( return None found: int | None = None for index in range(start, len(session_turns)): - if session_turns[index][1] != signature: + if session_turns[index].assistant_signature != signature: continue if found is not None: return None @@ -992,6 +1038,101 @@ def _find_unique_session_turn( return found +def _user_recovery_signature(event: dict[str, Any]) -> str: + fields = { + key: event[key] + for key in ("text", "media_paths", "cli_apps", "mcp_presets") + if key in event + } + return json.dumps(fields, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def _find_unique_session_turn_by_user( + session_turns: list[_SessionBackfillTurn], + user_event: dict[str, Any], +) -> _SessionBackfillTurn | None: + signature = _user_recovery_signature(user_event) + matches = [ + turn + for turn in session_turns + if _user_recovery_signature(turn.user_event) == signature + ] + return matches[0] if len(matches) == 1 else None + + +def _is_recoverable_answer_record(record: dict[str, Any]) -> bool: + event = record.get("event") + if event in {"delta", "stream_end"}: + return True + return event == "message" and record.get("kind") not in { + "tool_hint", + "progress", + "reasoning", + } + + +def recover_incomplete_turns_from_session( + lines: list[dict[str, Any]], + session_messages: list[dict[str, Any]] | None, + *, + session_key: str, +) -> list[dict[str, Any]]: + """Recover marked transcript answers only when one durable session turn matches.""" + if not lines or not session_messages: + return lines + session_turns = _session_backfill_turns(session_key, session_messages) + if not session_turns: + return lines + + recovered: list[dict[str, Any]] = [] + for turn in _split_transcript_turns(lines): + turn_end = turn[-1] if turn else None + if ( + not isinstance(turn_end, dict) + or turn_end.get("event") != "turn_end" + or turn_end.get(WEBUI_TRANSCRIPT_INCOMPLETE_KEY) is not True + ): + recovered.extend(turn) + continue + + user_events = [record for record in turn if record.get("event") == "user"] + if len(user_events) != 1: + recovered.extend(turn) + continue + session_turn = _find_unique_session_turn_by_user(session_turns, user_events[0]) + if session_turn is None or not session_turn.assistant_records: + recovered.extend(turn) + continue + + stable_end_ms = _valid_created_at_ms(turn_end.get("created_at_ms")) + turn_id = turn_end.get("turn_id") + answer_records: list[dict[str, Any]] = [] + for index, source in enumerate(session_turn.assistant_records): + answer = dict(source) + if isinstance(turn_id, str) and turn_id: + answer["turn_id"] = turn_id + answer["turn_phase"] = "answer" + if stable_end_ms is not None: + answer["created_at_ms"] = max( + 0, + stable_end_ms - len(session_turn.assistant_records) + index, + ) + answer_records.append(answer) + + # Session history is the durable source of the completed answer. Keep + # traces/reasoning/file edits, but replace any partial answer fragments. + recovered.extend( + record + for record in turn[:-1] + if not _is_recoverable_answer_record(record) + ) + recovered.extend(answer_records) + completed_end = dict(turn_end) + completed_end.pop(WEBUI_TRANSCRIPT_INCOMPLETE_KEY, None) + recovered.append(completed_end) + return recovered + + def _with_backfilled_user( records: list[dict[str, Any]], user_event: dict[str, Any], @@ -1972,8 +2113,36 @@ def fork_boundary_message_count(lines: list[dict[str, Any]]) -> int | None: return None -def has_pending_tool_calls(lines: list[dict[str, Any]]) -> bool: +def has_pending_tool_calls( + lines: list[dict[str, Any]], + *, + active_turn_started_at: float | None = None, + active_turn_id: str | None = None, + active_turn_transcript_persistence_failed: bool = False, +) -> bool: """Return True when the selected transcript tail looks like an unfinished turn.""" + # An older canonical turn can remain unsafe even after a later turn + # completes. Recovery removes this marker only after matching durable + # session history, so no later turn_end may hide it. + if any( + rec.get(WEBUI_TRANSCRIPT_INCOMPLETE_KEY) is True + for rec in lines + ): + return True + if active_turn_started_at is not None: + if active_turn_transcript_persistence_failed: + return True + if active_turn_id is None: + return True + for rec in reversed(lines): + transcript_turn_id = rec.get("turn_id") + if not isinstance(transcript_turn_id, str) or not transcript_turn_id: + continue + if transcript_turn_id != active_turn_id: + return True + return rec.get("event") != "turn_end" + return True + for rec in reversed(lines): ev = rec.get("event") if ev == "turn_end": @@ -1995,6 +2164,24 @@ def has_pending_tool_calls(lines: list[dict[str, Any]]) -> bool: return False +def completed_turn_ids(lines: list[dict[str, Any]]) -> list[str]: + """Return stable identities for turns with an explicitly persisted completion.""" + completed: list[str] = [] + seen: set[str] = set() + for rec in lines: + if ( + rec.get("event") != "turn_end" + or rec.get(WEBUI_TRANSCRIPT_INCOMPLETE_KEY) is True + ): + continue + turn_id = rec.get("turn_id") + if not isinstance(turn_id, str) or not turn_id or turn_id in seen: + continue + seen.add(turn_id) + completed.append(turn_id) + return completed + + def build_webui_thread_response( session_key: str, *, @@ -2002,6 +2189,9 @@ def build_webui_thread_response( augment_assistant_media: Callable[[list[str]], list[dict[str, Any]]] | None = None, augment_assistant_text: Callable[[str], str] | None = None, session_messages: list[dict[str, Any]] | None = None, + active_turn_started_at: float | None = None, + active_turn_id: str | None = None, + active_turn_transcript_persistence_failed: bool = False, limit: int | None = None, direction: str | None = None, before: str | None = None, @@ -2013,9 +2203,14 @@ def build_webui_thread_response( lines, page = _select_transcript_page(session_key, limit=limit, before=before) else: lines = read_transcript_lines(session_key) - if not lines: + if not lines and active_turn_started_at is None: return None lines = inject_missing_user_events_from_session(session_key, lines, session_messages) + lines = recover_incomplete_turns_from_session( + lines, + session_messages, + session_key=session_key, + ) fork_boundary = fork_boundary_message_count(lines) msgs = replay_transcript_to_ui_messages( lines, @@ -2027,7 +2222,16 @@ def build_webui_thread_response( "schemaVersion": WEBUI_TRANSCRIPT_SCHEMA_VERSION, "sessionKey": session_key, "messages": msgs, - "has_pending_tool_calls": has_pending_tool_calls(lines), + "completed_turn_ids": completed_turn_ids(lines), + "has_pending_tool_calls": has_pending_tool_calls( + lines, + active_turn_started_at=active_turn_started_at, + active_turn_id=active_turn_id, + active_turn_transcript_persistence_failed=( + active_turn_transcript_persistence_failed + ), + ), + "active_turn_id": active_turn_id, } if page is not None: page["loaded_message_count"] = len(msgs) diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index af2b2a3ac..fb3a373b6 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -474,6 +474,18 @@ class GatewayHTTPHandler: if direction is not None and direction not in {"latest"}: return _http_error(400, "invalid direction") before = _query_first(query, "before") + from nanobot.session.webui_turns import ( + websocket_turn_id, + websocket_turn_transcript_persistence_failed, + websocket_turn_wall_started_at, + ) + + chat_id = decoded_key.split(":", 1)[1] + active_turn_started_at = websocket_turn_wall_started_at(chat_id) + active_turn_id = websocket_turn_id(chat_id) + active_turn_transcript_persistence_failed = ( + websocket_turn_transcript_persistence_failed(chat_id) + ) data = build_webui_thread_response( decoded_key, augment_user_media=self.media.augment_transcript_media, @@ -483,6 +495,11 @@ class GatewayHTTPHandler: workspace_path=scope.project_path, ), session_messages=session_messages, + active_turn_started_at=active_turn_started_at, + active_turn_id=active_turn_id, + active_turn_transcript_persistence_failed=( + active_turn_transcript_persistence_failed + ), limit=limit, direction=direction, before=before, diff --git a/tests/agent/test_loop_direct_websocket_status.py b/tests/agent/test_loop_direct_websocket_status.py index 1c18a25d5..4f0a908c0 100644 --- a/tests/agent/test_loop_direct_websocket_status.py +++ b/tests/agent/test_loop_direct_websocket_status.py @@ -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 diff --git a/tests/agent/test_loop_progress.py b/tests/agent/test_loop_progress.py index 220ae646d..e67fbfbef 100644 --- a/tests/agent/test_loop_progress.py +++ b/tests/agent/test_loop_progress.py @@ -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", } diff --git a/tests/agent/test_turn_delivery.py b/tests/agent/test_turn_delivery.py index 24988a20c..48a8970c8 100644 --- a/tests/agent/test_turn_delivery.py +++ b/tests/agent/test_turn_delivery.py @@ -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", diff --git a/tests/command/test_router_dispatchable.py b/tests/command/test_router_dispatchable.py index e03ca0083..6c3fbadf5 100644 --- a/tests/command/test_router_dispatchable.py +++ b/tests/command/test_router_dispatchable.py @@ -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).""" diff --git a/tests/utils/test_webui_transcript.py b/tests/utils/test_webui_transcript.py index 921982f6b..f0d7de726 100644 --- a/tests/utils/test_webui_transcript.py +++ b/tests/utils/test_webui_transcript.py @@ -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: diff --git a/tests/utils/test_webui_turn_helpers.py b/tests/utils/test_webui_turn_helpers.py index c019de30c..7a113eae9 100644 --- a/tests/utils/test_webui_turn_helpers.py +++ b/tests/utils/test_webui_turn_helpers.py @@ -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 diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 2e9e74e95..1f5ef9782 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -738,6 +738,7 @@ export default function App() { } else { client.updateUrl(url); } + client.updateMaxFrameBytes(boot.limits?.transport.max_frame_bytes); setState((current) => current.status === "ready" && current.client === client ? { @@ -769,6 +770,7 @@ export default function App() { const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities); const client = new NanobotClient({ url, + maxFrameBytes: boot.limits?.transport.max_frame_bytes, socketFactory: runtimeHost.socketFactory, onReauth: async () => { try { @@ -1206,6 +1208,7 @@ function Shell({ useEffect(() => { return client.onError((error) => { if (error.kind !== "workspace_scope_rejected") return; + if (error.chatId && error.chatId !== activeChatIdRef.current) return; setWorkspaceError(t("errors.workspaceScopeRejected.body")); void refreshWorkspaces(); }); diff --git a/webui/src/components/thread/StreamErrorNotice.tsx b/webui/src/components/thread/StreamErrorNotice.tsx index c4d07b7fa..4875b7353 100644 --- a/webui/src/components/thread/StreamErrorNotice.tsx +++ b/webui/src/components/thread/StreamErrorNotice.tsx @@ -67,6 +67,11 @@ function resolveCopy( title: t("errors.workspaceScopeRejected.title"), body: t("errors.workspaceScopeRejected.body"), }; + case "turn_rejected": + return { + title: t("errors.turnRejected.title"), + body: t("errors.turnRejected.body"), + }; default: { // Exhaustiveness guard: if a new StreamError kind is added, TS will // complain here until we add a corresponding i18n branch. diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index a889a88bd..9219ef6d3 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -31,6 +31,7 @@ import { installedMcpPresetsFromPayload, isMcpPresetsPayload, } from "@/lib/mcp-preset-events"; +import type { CanonicalRunSnapshot } from "@/lib/nanobot-client"; import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand"; import type { ChatSummary, @@ -44,13 +45,65 @@ import type { import { projectWebuiThreadMessages } from "@/lib/thread-display-compat"; import { useClient } from "@/providers/ClientProvider"; -type MessageShape = Pick; +type MessageShape = Pick; + +interface PendingCanonicalHydrate { + historyLineage: number; + historyVersion: number; + runGeneration: number; + uiBaseline: MessageShape[]; + uiLineage: number | null; + uiRevision: number; +} + +interface PendingHistoryLineageCommit { + lineage: number; + messages: UIMessage[]; +} + +interface PendingCanonicalCommit { + canonicalSnapshot: CanonicalRunSnapshot; + completedTurnIds: string[]; + expectedUiRevision: number; + historyLineage: number; + historyVersion: number; + hydrate: PendingCanonicalHydrate; + messages: UIMessage[]; + previousMessages: UIMessage[]; +} function sameMessageShape(a: MessageShape, b: MessageShape): boolean { return ( a.role === b.role && (a.kind ?? "") === (b.kind ?? "") && a.content === b.content + && (!a.turnId || !b.turnId || a.turnId === b.turnId) + ); +} + +function snapshotPreservesMessage( + current: MessageShape, + candidate: MessageShape, + allowCompletedTurnReplacement: boolean, +): boolean { + if (sameMessageShape(current, candidate)) return true; + if ( + allowCompletedTurnReplacement + && current.role === "assistant" + && candidate.role === current.role + && (candidate.kind ?? "") === (current.kind ?? "") + && !!current.turnId + && candidate.turnId === current.turnId + ) { + return true; + } + return ( + current.role === "assistant" + && current.isStreaming === true + && candidate.role === current.role + && (candidate.kind ?? "") === (current.kind ?? "") + && (!current.turnId || !candidate.turnId || candidate.turnId === current.turnId) + && candidate.content.startsWith(current.content) ); } @@ -64,28 +117,44 @@ function durableMessageShape(message: UIMessage): MessageShape | null { role: message.role, kind: message.kind, content: message.content, + isStreaming: message.isStreaming, + turnId: message.turnId, }; } -function preservesDurableMessages(current: UIMessage[], snapshot: UIMessage[]): boolean { - // Canonical history refreshes can race with live websocket messages after fork/send. - // Never accept a refreshed snapshot that drops a user/assistant message already shown. - const expected = current - .map(durableMessageShape) - .filter((message): message is MessageShape => message !== null); - if (expected.length === 0) return true; - const candidates = snapshot +function durableMessageShapes(messages: UIMessage[]): MessageShape[] { + return messages .map(durableMessageShape) .filter((message): message is MessageShape => message !== null); +} +function preservesMessageShapes( + expected: MessageShape[], + candidates: MessageShape[], + allowCompletedTurnReplacement: boolean, +): boolean { let cursor = 0; + let previousCandidate: MessageShape | null = null; for (const message of expected) { + if ( + allowCompletedTurnReplacement + && previousCandidate?.role === "assistant" + && message.role === "assistant" + && !!message.turnId + && message.turnId === previousCandidate.turnId + ) { + // A delayed websocket delta can briefly create a second bubble after an + // HTTP completion snapshot. The completed replay is authoritative for + // that turn, so both local fragments may map to its single assistant row. + continue; + } let found = false; while (cursor < candidates.length) { const candidate = candidates[cursor]; cursor += 1; - if (sameMessageShape(message, candidate)) { + if (snapshotPreservesMessage(message, candidate, allowCompletedTurnReplacement)) { found = true; + previousCandidate = candidate; break; } } @@ -94,11 +163,55 @@ function preservesDurableMessages(current: UIMessage[], snapshot: UIMessage[]): return true; } -function isStaleThreadSnapshot(current: UIMessage[], snapshot: UIMessage[]): boolean { +function preservesDurableMessages( + current: UIMessage[], + snapshot: UIMessage[], + allowCompletedTurnReplacement = false, +): boolean { + // Canonical history refreshes can race with live websocket messages after fork/send. + // Never accept a refreshed snapshot that drops a user/assistant message already shown. + const expected = durableMessageShapes(current); + if (expected.length === 0) return true; + return preservesMessageShapes( + expected, + durableMessageShapes(snapshot), + allowCompletedTurnReplacement, + ); +} + +function resetDropsPostRequestDurableTail( + baseline: MessageShape[], + current: UIMessage[], + snapshot: UIMessage[], +): boolean { + const currentDurable = durableMessageShapes(current); + let stablePrefixLength = 0; + while ( + stablePrefixLength < baseline.length + && stablePrefixLength < currentDurable.length + && sameMessageShape(baseline[stablePrefixLength], currentDurable[stablePrefixLength]) + ) { + stablePrefixLength += 1; + } + const postRequestTail = currentDurable.slice(stablePrefixLength); + if (postRequestTail.length === 0) return false; + return !preservesMessageShapes( + postRequestTail, + durableMessageShapes(snapshot), + true, + ); +} + +function isStaleThreadSnapshot( + current: UIMessage[], + snapshot: UIMessage[], + allowCompletedTurnReplacement = false, +): boolean { if (current.length === 0) return false; if (snapshot.length === 0) return true; - if (!preservesDurableMessages(current, snapshot)) return true; + if (!preservesDurableMessages(current, snapshot, allowCompletedTurnReplacement)) return true; if (snapshot.length >= current.length) return false; + if (allowCompletedTurnReplacement) return false; return snapshot.every((message, index) => sameMessageShape(current[index], message)); } @@ -114,6 +227,30 @@ function latestActiveTurnId(messages: UIMessage[]): string | null { return null; } +function completedAssistantTurnIds(messages: UIMessage[]): string[] { + return Array.from(new Set( + messages + .filter((message) => message.role === "assistant" && !!message.turnId) + .map((message) => message.turnId as string), + )); +} + +function canonicalRunSnapshot( + messages: UIMessage[], + hasPendingToolCalls: boolean, + activeTurnId: string | null, +): CanonicalRunSnapshot { + return { + observedTurnIds: Array.from(new Set( + messages + .filter((message) => message.role === "user" && !!message.turnId) + .map((message) => message.turnId as string), + )), + hasPendingToolCalls, + activeTurnId, + }; +} + const FILE_PREVIEW_DEFAULT_WIDTH = 544; const FILE_PREVIEW_MIN_WIDTH = 360; const FILE_PREVIEW_MAX_WIDTH = 860; @@ -432,6 +569,10 @@ export function ThreadShell({ hasMoreBefore, userMessageOffset, hasPendingToolCalls, + completedTurnIds, + continuity: historyContinuity, + lineage: historyLineage, + activeTurnId: historyActiveTurnId, refresh: refreshHistory, version: historyVersion, forkBoundaryMessageCount, @@ -474,9 +615,16 @@ export function ThreadShell({ const prevChatIdForCacheRef = useRef(null); /** Skip one message-cache write right after chatId changes (messages may not match yet). */ const skipLayoutCacheRef = useRef(false); - const appliedHistoryVersionRef = useRef>(new Map()); - const pendingCanonicalHydrateRef = useRef>(new Set()); + const pendingCanonicalHydrateRef = useRef>(new Map()); + const pendingCanonicalCommitRef = useRef>(new Map()); + const pendingHistoryLineageCommitRef = useRef>( + new Map(), + ); + const completedCanonicalHydrateVersionRef = useRef>(new Map()); + const committedHistoryLineageRef = useRef>(new Map()); const sessionKeyByChatIdRef = useRef>(new Map()); + const currentUiMessagesRef = useRef(null); + const uiRevisionRef = useRef(0); const initial = useMemo(() => { if (!chatId) return historical; @@ -497,11 +645,25 @@ export function ThreadShell({ send, transcribeAudio, stop, + reconcileTurnComplete, setMessages, streamError, dismissStreamError, } = useNanobotStream(chatId, initial, hasPendingToolCalls, handleTurnEnd); + useLayoutEffect(() => { + if (currentUiMessagesRef.current === messages) return; + currentUiMessagesRef.current = messages; + uiRevisionRef.current += 1; + if (!chatId) return; + const lineageCommit = pendingHistoryLineageCommitRef.current.get(chatId); + if (!lineageCommit) return; + pendingHistoryLineageCommitRef.current.delete(chatId); + if (lineageCommit.messages === messages) { + committedHistoryLineageRef.current.set(chatId, lineageCommit.lineage); + } + }, [chatId, messages]); + useEffect(() => { if (chatId && historyKey) sessionKeyByChatIdRef.current.set(chatId, historyKey); }, [chatId, historyKey]); @@ -685,47 +847,196 @@ export function ThreadShell({ useEffect(() => { if (!chatId || loading) return; const cached = messageCacheRef.current.get(chatId); - const appliedVersion = appliedHistoryVersionRef.current.get(chatId) ?? 0; - const hasPendingCanonicalHydrate = pendingCanonicalHydrateRef.current.has(chatId); - const hasNewCanonicalHistory = hasPendingCanonicalHydrate && historyVersion > appliedVersion; + const pendingCanonicalHydrate = pendingCanonicalHydrateRef.current.get(chatId); + const hasNewCanonicalHistory = ( + pendingCanonicalHydrate !== undefined + && historyVersion > pendingCanonicalHydrate.historyVersion + ); // When the user switches away and back, keep the local in-memory thread // state (including not-yet-persisted messages) instead of replacing it with // whatever the history endpoint currently knows about. Once a fresh // canonical replay arrives (e.g. after ``session_updated`` refresh), prefer it // so rendering converges to the same shape as a manual refresh. - setMessages((prev) => { - const normalizedHistory = projectWebuiThreadMessages(historical); - const keepLiveMessages = (messagesToKeep: UIMessage[]) => { - const projected = projectWebuiThreadMessages(messagesToKeep); - messageCacheRef.current.set(chatId, projected); - return projected; - }; - if (hasNewCanonicalHistory && historical.length > 0) { - if (isStaleThreadSnapshot(prev, normalizedHistory)) return keepLiveMessages(prev); - pendingCanonicalHydrateRef.current.delete(chatId); - appliedHistoryVersionRef.current.set(chatId, historyVersion); - messageCacheRef.current.set(chatId, normalizedHistory); - return normalizedHistory; + const normalizedHistory = projectWebuiThreadMessages(historical); + const keepLiveMessages = (current: UIMessage[]) => projectWebuiThreadMessages(current); + if (hasNewCanonicalHistory && pendingCanonicalHydrate) { + // Transcript replay strips streaming metadata and uses persisted ids. + // Never adopt it while the turn is active: even if no assistant delta + // arrived locally yet, the next resumed delta must create/continue the + // live cursor rather than append to an immutable replay row. + if (hasPendingToolCalls) { + setMessages((current) => keepLiveMessages(current)); + return; } + const authoritativeReset = ( + pendingCanonicalHydrate.uiLineage !== null + && historyLineage !== pendingCanonicalHydrate.uiLineage + && ( + historyContinuity === "reset" + || ( + historyContinuity === "overlap" + && historyLineage === pendingCanonicalHydrate.historyLineage + ) + ) + ); + const responseUiRevision = uiRevisionRef.current; + const resetDropsRenderedTail = ( + authoritativeReset + && responseUiRevision !== pendingCanonicalHydrate.uiRevision + && resetDropsPostRequestDurableTail( + pendingCanonicalHydrate.uiBaseline, + messages, + normalizedHistory, + ) + ); + if ( + authoritativeReset + ? resetDropsRenderedTail + : isStaleThreadSnapshot(messages, normalizedHistory, true) + ) { + setMessages((current) => keepLiveMessages(current)); + return; + } + const canonicalCompletedTurnIds = Array.from(new Set([ + ...completedTurnIds, + ...completedAssistantTurnIds(normalizedHistory), + ])); + const canonicalSnapshot = canonicalRunSnapshot( + normalizedHistory, + hasPendingToolCalls, + historyActiveTurnId, + ); + if (!client.canReconcileCanonicalCompletion( + chatId, + pendingCanonicalHydrate.runGeneration, + canonicalCompletedTurnIds, + canonicalSnapshot, + )) { + setMessages((current) => keepLiveMessages(current)); + return; + } + pendingCanonicalCommitRef.current.set(chatId, { + canonicalSnapshot, + completedTurnIds: canonicalCompletedTurnIds, + expectedUiRevision: responseUiRevision + 1, + historyLineage, + historyVersion, + hydrate: pendingCanonicalHydrate, + messages: normalizedHistory, + previousMessages: messages, + }); + setMessages((current) => { + if (current !== messages) return current; + if ( + authoritativeReset + ? resetDropsRenderedTail + : isStaleThreadSnapshot(current, normalizedHistory, true) + ) { + return keepLiveMessages(current); + } + return normalizedHistory; + }); + return; + } + const adoptsNormalizedHistory = cached && cached.length > 0 + ? ( + normalizedHistory.length > cached.length + && !isStaleThreadSnapshot(messages, normalizedHistory) + ) + : !isStaleThreadSnapshot(messages, normalizedHistory); + if (adoptsNormalizedHistory) { + pendingHistoryLineageCommitRef.current.set(chatId, { + lineage: historyLineage, + messages: normalizedHistory, + }); + } + setMessages((current) => { if (cached && cached.length > 0) { if ( normalizedHistory.length > cached.length - && !isStaleThreadSnapshot(prev, normalizedHistory) + && !isStaleThreadSnapshot(current, normalizedHistory) ) { - messageCacheRef.current.set(chatId, normalizedHistory); - appliedHistoryVersionRef.current.set(chatId, historyVersion); return normalizedHistory; } - if (isStaleThreadSnapshot(prev, cached)) return keepLiveMessages(prev); - return cached; + return isStaleThreadSnapshot(current, cached) ? keepLiveMessages(current) : cached; } - if (isStaleThreadSnapshot(prev, normalizedHistory)) return keepLiveMessages(prev); - appliedHistoryVersionRef.current.set(chatId, historyVersion); - if (normalizedHistory.length > 0) messageCacheRef.current.set(chatId, normalizedHistory); - return normalizedHistory; + return isStaleThreadSnapshot(current, normalizedHistory) + ? keepLiveMessages(current) + : normalizedHistory; }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loading, chatId, historical, historyVersion]); + }, [ + loading, + chatId, + client, + completedTurnIds, + historical, + historyVersion, + historyContinuity, + historyLineage, + historyActiveTurnId, + hasPendingToolCalls, + ]); + + useLayoutEffect(() => { + if (!chatId) return; + const commit = pendingCanonicalCommitRef.current.get(chatId); + if (!commit) return; + if ( + commit.historyVersion !== historyVersion + || commit.historyLineage !== historyLineage + || commit.messages !== messages + ) { + pendingCanonicalCommitRef.current.delete(chatId); + return; + } + if (pendingCanonicalHydrateRef.current.get(chatId) !== commit.hydrate) { + pendingCanonicalCommitRef.current.delete(chatId); + return; + } + if (uiRevisionRef.current !== commit.expectedUiRevision) { + pendingCanonicalCommitRef.current.delete(chatId); + const fallback = messageCacheRef.current.get(chatId) ?? commit.previousMessages; + messageCacheRef.current.set(chatId, fallback); + setMessages((current) => current === commit.messages ? fallback : current); + return; + } + if (!client.reconcileCanonicalCompletion( + chatId, + commit.hydrate.runGeneration, + commit.completedTurnIds, + commit.canonicalSnapshot, + )) { + pendingCanonicalCommitRef.current.delete(chatId); + const fallback = messageCacheRef.current.get(chatId) ?? commit.previousMessages; + messageCacheRef.current.set(chatId, fallback); + setMessages((current) => current === commit.messages ? fallback : current); + return; + } + pendingCanonicalHydrateRef.current.delete(chatId); + pendingCanonicalCommitRef.current.delete(chatId); + committedHistoryLineageRef.current.set(chatId, historyLineage); + completedCanonicalHydrateVersionRef.current.set(chatId, historyVersion); + }, [chatId, client, historyLineage, historyVersion, messages, setMessages]); + + useEffect(() => { + if (!chatId || hasPendingToolCalls) return; + if (completedCanonicalHydrateVersionRef.current.get(chatId) !== historyVersion) return; + completedCanonicalHydrateVersionRef.current.delete(chatId); + reconcileTurnComplete(); + }, [chatId, hasPendingToolCalls, historyVersion, messages, reconcileTurnComplete]); + + const refreshCanonicalHistory = useCallback(() => { + if (!chatId) return; + pendingCanonicalHydrateRef.current.set(chatId, { + historyLineage, + historyVersion, + runGeneration: client.getRunGeneration(chatId), + uiBaseline: durableMessageShapes(currentUiMessagesRef.current ?? []), + uiLineage: committedHistoryLineageRef.current.get(chatId) ?? null, + uiRevision: uiRevisionRef.current, + }); + refreshHistory(); + }, [chatId, client, historyLineage, historyVersion, refreshHistory]); useEffect(() => { if (!chatId) return; @@ -735,10 +1046,30 @@ export function ThreadShell({ // A turn-end thread refresh can arrive while the viewport is easing the // final layout change. User-driven scrolling already disables following, // so keep an active programmatic follow alive across canonical hydration. - pendingCanonicalHydrateRef.current.add(chatId); - refreshHistory(); + refreshCanonicalHistory(); }); - }, [chatId, client, refreshHistory]); + }, [chatId, client, refreshCanonicalHistory]); + + useEffect(() => { + const refreshOnReturn = () => { + if (document.visibilityState !== "visible") return; + refreshCanonicalHistory(); + }; + document.addEventListener("visibilitychange", refreshOnReturn); + return () => document.removeEventListener("visibilitychange", refreshOnReturn); + }, [refreshCanonicalHistory]); + + useEffect(() => { + let refreshOnNextOpen = client.status !== "open"; + return client.onStatus((status) => { + if (status !== "open") { + refreshOnNextOpen = true; + return; + } + if (refreshOnNextOpen) refreshCanonicalHistory(); + refreshOnNextOpen = false; + }); + }, [client, refreshCanonicalHistory]); useEffect(() => { if (chatId) return; @@ -944,8 +1275,8 @@ export function ThreadShell({ const forkedChatId = await onForkChat(chatId, beforeUserIndex); if (!forkedChatId) return; messageCacheRef.current.delete(forkedChatId); - appliedHistoryVersionRef.current.delete(forkedChatId); - pendingCanonicalHydrateRef.current.add(forkedChatId); + pendingCanonicalHydrateRef.current.delete(forkedChatId); + completedCanonicalHydrateVersionRef.current.delete(forkedChatId); }, [chatId, onForkChat], ); diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index 1f18e2007..e0bcde04a 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -540,6 +540,8 @@ export function useNanobotStream( ) => SubmittedTurn | null; transcribeAudio: (dataUrl: string, options?: { durationMs?: number }) => Promise; stop: () => void; + /** Mark an accepted canonical snapshot as the definitive end of the active turn. */ + reconcileTurnComplete: () => void; setMessages: React.Dispatch>; /** Latest transport-level fault raised since the last ``dismissStreamError``. * ``null`` when there is nothing to show. */ @@ -581,10 +583,6 @@ export function useNanobotStream( * backend changes. */ const streamEndTimerRef = useRef | null>(null); - useEffect(() => { - return client.onError((err) => setStreamError(err)); - }, [client]); - const dismissStreamError = useCallback(() => setStreamError(null), []); const clearPendingStreamWork = useCallback(() => { @@ -654,6 +652,66 @@ export function useNanobotStream( return !!closedStreamId; }, []); + const applyStreamError = useCallback((err: StreamError) => { + // One multiplexed client serves every thread. A correlated send fault + // belongs only to its target chat. An uncorrelated transport close can + // still be shown in the mounted thread, but cannot roll back any turn. + if (!chatId || (err.chatId && err.chatId !== chatId)) return; + setStreamError(err); + if (!err.turnId) return; + + const rejectedTurnId = err.turnId; + pendingStreamEventsRef.current = pendingStreamEventsRef.current.filter( + (event) => event.turn.turnId !== rejectedTurnId, + ); + sideChannelTurnIdsRef.current.delete(rejectedTurnId); + cancelStreamEndTimer(); + setMessages((prev) => { + const rejectedRows = prev.filter((message) => message.turnId === rejectedTurnId); + if (rejectedRows.length === 0) return prev; + const rejectedIds = new Set(rejectedRows.map((message) => message.id)); + const rejectedSegments = new Set( + rejectedRows + .map((message) => message.activitySegmentId) + .filter((segmentId): segmentId is string => typeof segmentId === "string"), + ); + if ( + activeAssistantRef.current + && rejectedIds.has(activeAssistantRef.current.id) + ) { + activeAssistantRef.current = null; + } + if (buffer.current && rejectedIds.has(buffer.current.messageId)) { + buffer.current = null; + } + for (const id of rejectedIds) closedAssistantStreamIdsRef.current.delete(id); + if ( + activitySegmentRef.current + && rejectedSegments.has(activitySegmentRef.current) + ) { + activitySegmentRef.current = null; + } + if ( + fileEditSegmentRef.current + && rejectedSegments.has(fileEditSegmentRef.current) + ) { + fileEditSegmentRef.current = null; + } + return prev.filter((message) => message.turnId !== rejectedTurnId); + }); + + const remainingStartedAt = client.getRunStartedAt(chatId); + const hasRemainingRun = ( + remainingStartedAt !== null + || client.hasUnsettledRun(chatId) + ); + setRunStartedAt(remainingStartedAt); + setIsStreaming(hasRemainingRun); + if (!hasRemainingRun) suppressStreamUntilTurnEndRef.current = false; + }, [cancelStreamEndTimer, chatId, client]); + + useEffect(() => client.onError(applyStreamError), [applyStreamError, client]); + const resolveActiveAssistantIndex = useCallback(( prev: UIMessage[], turn: UIMessageTurnFields = {}, @@ -849,6 +907,15 @@ export function useNanobotStream( return () => document.removeEventListener("visibilitychange", flushOnReturn); }, [flushPendingStreamEvents]); + useEffect(() => { + return client.onStatus((status) => { + if (status !== "reconnecting" && status !== "closed") return; + // A transport drop does not prove the backend turn completed. Keep the + // semantic running state intact so queued guidance is not flushed early. + cancelStreamEndTimer(); + }); + }, [cancelStreamEndTimer, client]); + // Reset local state when switching chats. Do not reset on every // ``initialMessages`` update: a brand-new chat can receive an empty/404 // history response after the optimistic first message has already rendered. @@ -883,6 +950,31 @@ export function useNanobotStream( if (!chatId) return; const handle = (ev: InboundEvent) => { + if (ev.event === "error") { + if (ev.detail === "message_too_big") { + applyStreamError({ + kind: "message_too_big", + chatId, + turnId: ev.turn_id, + }); + } else if (ev.detail === "workspace_scope_rejected") { + applyStreamError({ + kind: "workspace_scope_rejected", + reason: ev.reason, + chatId, + turnId: ev.turn_id, + }); + } else if (ev.turn_id) { + applyStreamError({ + kind: "turn_rejected", + detail: ev.detail, + reason: ev.reason, + chatId, + turnId: ev.turn_id, + }); + } + return; + } const sideChannelEvent = isSideChannelEvent(ev); if ( streamEndTimerRef.current !== null @@ -1187,8 +1279,7 @@ export function useNanobotStream( }); return; } - // ``attached`` / ``error`` frames aren't actionable here; the client - // shell handles them separately. + // ``attached`` frames aren't actionable here. }; const unsub = client.onChat(chatId, handle); @@ -1202,6 +1293,7 @@ export function useNanobotStream( cancelStreamEndTimer(); }; }, [ + applyStreamError, cancelStreamEndTimer, chatId, client, @@ -1271,12 +1363,16 @@ export function useNanobotStream( }); if (!sideChannel) setIsStreaming(true); const wireMedia = hasAttachments ? images!.map((i) => i.media) : undefined; - const wireOptions = { ...options, turnId }; - delete wireOptions.quotedContext; - delete wireOptions.sideChannel; - delete wireOptions.finalizeActiveTurn; - delete wireOptions.continueActiveTurn; - client.sendMessage(chatId, outboundContent, wireMedia, wireOptions); + const clientOptions = { + ...options, + turnId, + ...((sideChannel || continueActiveTurn) ? { startsNewRun: false } : {}), + }; + delete clientOptions.quotedContext; + delete clientOptions.sideChannel; + delete clientOptions.finalizeActiveTurn; + delete clientOptions.continueActiveTurn; + client.sendMessage(chatId, outboundContent, wireMedia, clientOptions); return { turnId, userMessageId, sideChannel }; }, [cancelStreamEndTimer, chatId, clearActivitySegment, client, flushPendingStreamEvents], @@ -1297,6 +1393,18 @@ export function useNanobotStream( client.sendMessage(chatId, "/stop"); }, [chatId, clearActivitySegment, client, flushPendingStreamEvents]); + const reconcileTurnComplete = useCallback(() => { + cancelStreamEndTimer(); + clearPendingStreamWork(); + buffer.current = null; + activeAssistantRef.current = null; + closedAssistantStreamIdsRef.current.clear(); + clearActivitySegment(); + suppressStreamUntilTurnEndRef.current = false; + setRunStartedAt(null); + setIsStreaming(false); + }, [cancelStreamEndTimer, clearActivitySegment, clearPendingStreamWork]); + const transcribeAudio = useCallback( (dataUrl: string, options?: { durationMs?: number }) => client.transcribeAudio(dataUrl, options), @@ -1312,6 +1420,7 @@ export function useNanobotStream( send, transcribeAudio, stop, + reconcileTurnComplete, setMessages, streamError, dismissStreamError, diff --git a/webui/src/hooks/useSessions.ts b/webui/src/hooks/useSessions.ts index f1faea403..512d39a65 100644 --- a/webui/src/hooks/useSessions.ts +++ b/webui/src/hooks/useSessions.ts @@ -24,6 +24,8 @@ const INITIAL_HISTORY_PAGE_LIMIT = 160; const OLDER_HISTORY_PAGE_LIMIT = 120; const CHAT_CREATE_TIMEOUT_MS = 60_000; +export type SessionHistoryContinuity = "initial" | "overlap" | "reset"; + function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] { return messages.map((m, idx) => ({ ...m, @@ -32,6 +34,63 @@ function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] { })); } +function sameSemanticMessage(a: UIMessage, b: UIMessage): boolean { + return ( + a.role === b.role + && (a.kind ?? "") === (b.kind ?? "") + && a.content === b.content + && (!a.turnId || !b.turnId || a.turnId === b.turnId) + ); +} + +function longestSemanticOverlap(previous: UIMessage[], latest: UIMessage[]): number { + const maxOverlap = Math.min(previous.length, latest.length); + for (let overlap = maxOverlap; overlap > 0; overlap -= 1) { + const previousStart = previous.length - overlap; + let matches = true; + for (let index = 0; index < overlap; index += 1) { + if (!sameSemanticMessage(previous[previousStart + index], latest[index])) { + matches = false; + break; + } + } + if (matches) return overlap; + } + return 0; +} + +function mergeLatestHistory( + previous: UIMessage[], + latest: UIMessage[], + initial: boolean, +): { + continuity: SessionHistoryContinuity; + messages: UIMessage[]; + retainedPrefixLength: number; +} { + if (initial) { + return { + continuity: "initial", + messages: latest, + retainedPrefixLength: 0, + }; + } + const overlapLength = longestSemanticOverlap(previous, latest); + if (overlapLength === 0) { + return { + continuity: "reset", + messages: latest, + retainedPrefixLength: 0, + }; + } + const retainedPrefixLength = previous.length - overlapLength; + return { + continuity: "overlap", + messages: [...previous.slice(0, retainedPrefixLength), ...latest], + retainedPrefixLength, + }; +} + function hasPendingToolCallsFromThread( body: Awaited>, messages: UIMessage[], @@ -42,6 +101,17 @@ function hasPendingToolCallsFromThread( return hasPendingAgentActivity(messages); } +function completedTurnIdsFromThread( + body: Awaited>, +): string[] { + if (!Array.isArray(body?.completed_turn_ids)) return []; + return Array.from(new Set( + body.completed_turn_ids.filter( + (turnId): turnId is string => typeof turnId === "string" && turnId.length > 0, + ), + )); +} + /** Sidebar state: fetches the full session list and exposes create / delete actions. */ export function useSessions(): { sessions: ChatSummary[]; @@ -191,11 +261,20 @@ export function useSessionHistory(key: string | null): { userMessageOffset: number; version: number; forkBoundaryMessageCount: number | null; - /** ``true`` when the replayed transcript ends with a trace row (turn still in flight). */ + /** ``true`` when the server reports that the turn is still in flight. */ hasPendingToolCalls: boolean; + /** Turn identities backed by explicit persisted completion events. */ + completedTurnIds: string[]; + /** Relationship between the latest canonical page and its predecessor. */ + continuity: SessionHistoryContinuity; + /** Stable across overlapping latest pages; changes on initial load or reset. */ + lineage: number; + /** Exact active turn when supplied by a current gateway. */ + activeTurnId: string | null; } { const { token } = useClient(); const loadingOlderRef = useRef(false); + const historyVersionRef = useRef(0); const [refreshSeq, setRefreshSeq] = useState(0); const refresh = useCallback(() => { setRefreshSeq((value) => value + 1); @@ -207,11 +286,15 @@ export function useSessionHistory(key: string | null): { loadingOlder: boolean; error: string | null; hasPendingToolCalls: boolean; + completedTurnIds: string[]; forkBoundaryMessageCount: number | null; beforeCursor: string | null; hasMoreBefore: boolean; userMessageOffset: number; version: number; + continuity: SessionHistoryContinuity; + lineage: number; + activeTurnId: string | null; }>({ key: null, messages: [], @@ -219,11 +302,15 @@ export function useSessionHistory(key: string | null): { loadingOlder: false, error: null, hasPendingToolCalls: false, + completedTurnIds: [], forkBoundaryMessageCount: null, beforeCursor: null, hasMoreBefore: false, userMessageOffset: 0, version: 0, + continuity: "initial", + lineage: 0, + activeTurnId: null, }); useEffect(() => { @@ -235,11 +322,15 @@ export function useSessionHistory(key: string | null): { loadingOlder: false, error: null, hasPendingToolCalls: false, + completedTurnIds: [], forkBoundaryMessageCount: null, beforeCursor: null, hasMoreBefore: false, userMessageOffset: 0, version: 0, + continuity: "initial", + lineage: 0, + activeTurnId: null, }); return; } @@ -255,11 +346,15 @@ export function useSessionHistory(key: string | null): { loadingOlder: false, error: null, hasPendingToolCalls: false, + completedTurnIds: [], forkBoundaryMessageCount: null, beforeCursor: null, hasMoreBefore: false, userMessageOffset: 0, version: 0, + continuity: "initial", + lineage: 0, + activeTurnId: null, }); (async () => { try { @@ -268,56 +363,83 @@ export function useSessionHistory(key: string | null): { direction: "latest", }); if (cancelled) return; - if (!body?.messages?.length) { - setState((prev) => ({ + historyVersionRef.current += 1; + const responseVersion = historyVersionRef.current; + const completedTurnIds = completedTurnIdsFromThread(body); + const ui = persistedMessagesToUi(body?.messages ?? []); + const hasPending = hasPendingToolCallsFromThread(body, ui); + const forkBoundary = typeof body?.fork_boundary_message_count === "number" + ? Math.max(0, Math.min(body.fork_boundary_message_count, ui.length)) + : null; + setState((prev) => { + const merged = prev.key === key + ? mergeLatestHistory(prev.messages, ui, prev.lineage === 0) + : mergeLatestHistory([], ui, true); + const retainedPrefix = merged.retainedPrefixLength > 0; + const retainedForkBoundary = ( + retainedPrefix + && prev.forkBoundaryMessageCount !== null + && prev.forkBoundaryMessageCount <= merged.retainedPrefixLength + ) + ? prev.forkBoundaryMessageCount + : null; + return { key, - messages: [], + messages: merged.messages, loading: false, loadingOlder: false, error: null, - hasPendingToolCalls: false, - forkBoundaryMessageCount: null, - beforeCursor: null, - hasMoreBefore: false, - userMessageOffset: 0, - version: prev.key === key ? prev.version + 1 : 1, - })); - return; - } - const ui = persistedMessagesToUi(body.messages); - const hasPending = hasPendingToolCallsFromThread(body, ui); - const forkBoundary = typeof body.fork_boundary_message_count === "number" - ? Math.max(0, Math.min(body.fork_boundary_message_count, ui.length)) - : null; - setState((prev) => ({ - key, - messages: ui, - loading: false, - loadingOlder: false, - error: null, - hasPendingToolCalls: hasPending, - forkBoundaryMessageCount: forkBoundary, - beforeCursor: body.page?.before_cursor ?? null, - hasMoreBefore: body.page?.has_more_before === true, - userMessageOffset: Math.max(0, body.page?.user_message_offset ?? 0), - version: prev.key === key ? prev.version + 1 : 1, - })); + hasPendingToolCalls: hasPending, + completedTurnIds, + forkBoundaryMessageCount: forkBoundary === null + ? retainedForkBoundary + : forkBoundary + merged.retainedPrefixLength, + beforeCursor: retainedPrefix + ? prev.beforeCursor + : body?.page?.before_cursor ?? null, + hasMoreBefore: retainedPrefix + ? prev.hasMoreBefore + : body?.page?.has_more_before === true, + userMessageOffset: retainedPrefix + ? prev.userMessageOffset + : Math.max(0, body?.page?.user_message_offset ?? 0), + version: responseVersion, + continuity: merged.continuity, + lineage: merged.continuity === "overlap" + ? prev.lineage + : responseVersion, + activeTurnId: typeof body?.active_turn_id === "string" + ? body.active_turn_id + : null, + }; + }); } catch (e) { if (cancelled) return; if (e instanceof ApiError && e.status === 404) { - setState((prev) => ({ - key, - messages: [], - loading: false, - loadingOlder: false, - error: null, - hasPendingToolCalls: false, - forkBoundaryMessageCount: null, - beforeCursor: null, - hasMoreBefore: false, - userMessageOffset: 0, - version: prev.key === key ? prev.version + 1 : 1, - })); + historyVersionRef.current += 1; + const responseVersion = historyVersionRef.current; + setState((prev) => { + const continuity = prev.key === key && prev.lineage > 0 + ? "reset" + : "initial"; + return { + key, + messages: [], + loading: false, + loadingOlder: false, + error: null, + hasPendingToolCalls: false, + completedTurnIds: [], + forkBoundaryMessageCount: null, + beforeCursor: null, + hasMoreBefore: false, + userMessageOffset: 0, + version: responseVersion, + continuity, + lineage: responseVersion, + activeTurnId: null, + }; + }); } else { setState((prev) => ({ key, @@ -326,11 +448,15 @@ export function useSessionHistory(key: string | null): { loadingOlder: false, error: (e as Error).message, hasPendingToolCalls: false, + completedTurnIds: [], forkBoundaryMessageCount: null, beforeCursor: null, hasMoreBefore: false, userMessageOffset: 0, version: prev.key === key ? prev.version : 0, + continuity: prev.key === key ? prev.continuity : "initial", + lineage: prev.key === key ? prev.lineage : 0, + activeTurnId: prev.key === key ? prev.activeTurnId : null, })); } } @@ -342,17 +468,26 @@ export function useSessionHistory(key: string | null): { const loadOlder = useCallback(async () => { if (!key || loadingOlderRef.current) return; - const before = state.key === key ? state.beforeCursor : null; - if (!before || !state.hasMoreBefore) return; + const requestKey = key; + const requestLineage = state.key === requestKey ? state.lineage : 0; + const beforeCursor = state.key === requestKey ? state.beforeCursor : null; + if (!beforeCursor || !state.hasMoreBefore || requestLineage === 0) return; + const matchesRequest = (candidate: typeof state) => ( + candidate.key === requestKey + && candidate.lineage === requestLineage + && candidate.beforeCursor === beforeCursor + ); loadingOlderRef.current = true; - setState((prev) => prev.key === key ? { ...prev, loadingOlder: true, error: null } : prev); + setState((prev) => matchesRequest(prev) + ? { ...prev, loadingOlder: true, error: null } + : prev); try { - const body = await fetchWebuiThread(token, key, { + const body = await fetchWebuiThread(token, requestKey, { limit: OLDER_HISTORY_PAGE_LIMIT, - before, + before: beforeCursor, }); setState((prev) => { - if (prev.key !== key) return prev; + if (!matchesRequest(prev)) return prev; if (!body?.messages?.length) { return { ...prev, @@ -369,21 +504,21 @@ export function useSessionHistory(key: string | null): { ? null : prev.forkBoundaryMessageCount + older.length; const nextMessages = [...older, ...prev.messages]; + // An older page cannot change the authoritative latest-turn lifecycle + // state or masquerade as a completed latest-page refresh. return { ...prev, messages: nextMessages, loadingOlder: false, error: null, - hasPendingToolCalls: hasPendingAgentActivity(nextMessages), forkBoundaryMessageCount: olderBoundary ?? shiftedBoundary, beforeCursor: body.page?.before_cursor ?? null, hasMoreBefore: body.page?.has_more_before === true, userMessageOffset: Math.max(0, body.page?.user_message_offset ?? 0), - version: prev.version + 1, }; }); } catch (e) { - setState((prev) => prev.key === key + setState((prev) => matchesRequest(prev) ? { ...prev, loadingOlder: false, @@ -398,6 +533,7 @@ export function useSessionHistory(key: string | null): { state.beforeCursor, state.hasMoreBefore, state.key, + state.lineage, token, ]); @@ -414,6 +550,10 @@ export function useSessionHistory(key: string | null): { version: 0, forkBoundaryMessageCount: null, hasPendingToolCalls: false, + completedTurnIds: [], + continuity: "initial", + lineage: 0, + activeTurnId: null, }; } @@ -432,6 +572,10 @@ export function useSessionHistory(key: string | null): { version: 0, forkBoundaryMessageCount: null, hasPendingToolCalls: false, + completedTurnIds: [], + continuity: "initial", + lineage: 0, + activeTurnId: null, }; } @@ -447,6 +591,10 @@ export function useSessionHistory(key: string | null): { version: state.version, forkBoundaryMessageCount: state.forkBoundaryMessageCount, hasPendingToolCalls: state.hasPendingToolCalls, + completedTurnIds: state.completedTurnIds, + continuity: state.continuity, + lineage: state.lineage, + activeTurnId: state.activeTurnId, }; } diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index bf3260be3..417495294 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -1251,6 +1251,10 @@ "workspaceScopeRejected": { "title": "Workspace was not changed", "body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway." + }, + "turnRejected": { + "title": "Message was not sent", + "body": "The gateway rejected this message. Review its text or attachments, then try again." } }, "workspace": { diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index b86bcda50..2a37f01a9 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -1238,6 +1238,10 @@ "workspaceScopeRejected": { "title": "El espacio de trabajo no cambió", "body": "El gateway rechazó el proyecto o modo de acceso solicitado, así que Nanobot conservó el espacio de trabajo anterior." + }, + "turnRejected": { + "title": "El mensaje no se envió", + "body": "El gateway rechazó este mensaje. Revisa el texto o los archivos adjuntos e inténtalo de nuevo." } }, "workspace": { diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 0cc55a48c..1e61365b2 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -1237,6 +1237,10 @@ "workspaceScopeRejected": { "title": "L’espace de travail n’a pas changé", "body": "La passerelle a refusé le projet ou le mode d’accès demandé ; Nanobot a conservé l’espace de travail précédent." + }, + "turnRejected": { + "title": "Le message n’a pas été envoyé", + "body": "La passerelle a refusé ce message. Vérifiez le texte ou les pièces jointes, puis réessayez." } }, "workspace": { diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 21d75275e..60223028a 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -1237,6 +1237,10 @@ "workspaceScopeRejected": { "title": "Workspace tidak berubah", "body": "Gateway menolak proyek atau mode akses yang diminta, jadi Nanobot tetap memakai workspace sebelumnya." + }, + "turnRejected": { + "title": "Pesan tidak terkirim", + "body": "Gateway menolak pesan ini. Periksa teks atau lampiran, lalu coba lagi." } }, "workspace": { diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 901bc9d1b..0e256f775 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -1237,6 +1237,10 @@ "workspaceScopeRejected": { "title": "ワークスペースは変更されませんでした", "body": "要求されたプロジェクトまたはアクセスモードがゲートウェイで拒否されたため、Nanobot は以前のワークスペースをそのまま使用しています。" + }, + "turnRejected": { + "title": "メッセージは送信されませんでした", + "body": "ゲートウェイがこのメッセージを拒否しました。本文または添付ファイルを確認して、もう一度お試しください。" } }, "workspace": { diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index dd2ad5de4..716bea6da 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -1237,6 +1237,10 @@ "workspaceScopeRejected": { "title": "작업공간이 변경되지 않았습니다", "body": "요청한 프로젝트 또는 접근 모드가 게이트웨이에서 거부되어 Nanobot이 이전 작업공간을 계속 사용합니다." + }, + "turnRejected": { + "title": "메시지가 전송되지 않았습니다", + "body": "게이트웨이가 이 메시지를 거부했습니다. 텍스트나 첨부 파일을 확인한 후 다시 시도하세요." } }, "workspace": { diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json index 0692324e2..f78f15218 100644 --- a/webui/src/i18n/locales/pt-BR/common.json +++ b/webui/src/i18n/locales/pt-BR/common.json @@ -1251,6 +1251,10 @@ "workspaceScopeRejected": { "title": "O workspace não foi alterado", "body": "O nanobot manteve o workspace anterior porque o projeto ou modo de acesso solicitado foi rejeitado pelo gateway." + }, + "turnRejected": { + "title": "A mensagem não foi enviada", + "body": "O gateway rejeitou esta mensagem. Revise o texto ou os anexos e tente novamente." } }, "workspace": { diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 4541861da..6d633874b 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -1237,6 +1237,10 @@ "workspaceScopeRejected": { "title": "Workspace không thay đổi", "body": "Gateway đã từ chối dự án hoặc chế độ truy cập được yêu cầu, nên Nanobot giữ workspace trước đó." + }, + "turnRejected": { + "title": "Tin nhắn chưa được gửi", + "body": "Gateway đã từ chối tin nhắn này. Hãy kiểm tra nội dung hoặc tệp đính kèm rồi thử lại." } }, "workspace": { diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 1540bbff8..40a6feafd 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -1251,6 +1251,10 @@ "workspaceScopeRejected": { "title": "工作区未更改", "body": "网关拒绝了请求的项目或访问权限,Nanobot 已继续使用之前的工作区。" + }, + "turnRejected": { + "title": "消息未发送", + "body": "网关拒绝了这条消息。请检查消息内容或附件后重试。" } }, "workspace": { diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 5dca74778..2d02c80da 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -1237,6 +1237,10 @@ "workspaceScopeRejected": { "title": "工作區未變更", "body": "閘道拒絕要求的專案或存取模式,因此 Nanobot 繼續使用先前的工作區。" + }, + "turnRejected": { + "title": "訊息未傳送", + "body": "閘道拒絕了這則訊息。請檢查內容或附件後再試一次。" } }, "workspace": { diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 898b6a668..1a4501d32 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -185,6 +185,7 @@ export async function fetchWebuiThread( const res = await fetchWithTimeout(url, { headers: { Authorization: `Bearer ${token}` }, credentials: "same-origin", + cache: "no-store", }); if (res.status === 404) return null; if (!res.ok) throw new ApiError(res.status, `HTTP ${res.status}`); diff --git a/webui/src/lib/nanobot-client.ts b/webui/src/lib/nanobot-client.ts index 706805010..b7f2b2b5a 100644 --- a/webui/src/lib/nanobot-client.ts +++ b/webui/src/lib/nanobot-client.ts @@ -83,8 +83,20 @@ export type StreamError = /** Server rejected the inbound frame as too large (WS close code 1009). * This is the transport fallback after text and attachment policies have * already been checked independently. */ - | { kind: "message_too_big" } - | { kind: "workspace_scope_rejected"; reason?: string; chatId?: string }; + | { kind: "message_too_big"; chatId?: string; turnId?: string } + | { + kind: "workspace_scope_rejected"; + reason?: string; + chatId?: string; + turnId?: string; + } + | { + kind: "turn_rejected"; + detail?: string; + reason?: string; + chatId: string; + turnId: string; + }; type ErrorHandler = (error: StreamError) => void; @@ -95,6 +107,13 @@ interface PendingRequest { } const SYSTEM_COMMAND_TURN_PREFIX = "webui-system:"; +const TURN_REJECTION_DETAILS = new Set([ + "access_denied", + "attachment_rejected", + "message_rejected", + "missing content", + "workspace_scope_rejected", +]); export function isSystemCommandTurnId(value: string | null | undefined): value is string { return typeof value === "string" && value.startsWith(SYSTEM_COMMAND_TURN_PREFIX); @@ -103,6 +122,8 @@ export function isSystemCommandTurnId(value: string | null | undefined): value i export interface NanobotClientOptions { url: string; reconnect?: boolean; + /** Maximum UTF-8 bytes accepted for one websocket message. */ + maxFrameBytes?: number; /** Called when a connection drops so the app can refresh its token. */ onReauth?: () => Promise; /** Inject a custom WebSocket factory (used by unit tests). */ @@ -111,6 +132,24 @@ export interface NanobotClientOptions { maxBackoffMs?: number; } +export interface CanonicalRunSnapshot { + /** User turn ids present in the canonical transcript page. */ + observedTurnIds: readonly string[]; + /** Whether the server still considers the transcript tail active. */ + hasPendingToolCalls: boolean; + /** Exact active turn when supplied by a current gateway. */ + activeTurnId?: string | null; +} + +type PendingMessageState = "queued" | "sent" | "unknown" | "accepted"; + +interface PendingMessageSend { + chatId: string; + turnId: string; + startsNewRun: boolean; + state: PendingMessageState; +} + /** * Singleton WebSocket client that multiplexes chat streams. * @@ -134,6 +173,23 @@ export class NanobotClient { private knownChats = new Set(); /** Wall-clock run strip: updated from ``goal_status`` even with no ``onChat`` subscriber. */ private runStartedAtByChatId = new Map(); + /** Per-turn clocks let a rejected newer turn fall back without borrowing its timer. */ + private runStartedAtByTurnKey = new Map(); + /** Monotonic per-chat generation for local sends and observed backend runs. */ + private runGenerationByChatId = new Map(); + /** Turn associated with the latest generation, retained after idle for reconciliation. */ + private latestRunTurnIdByChatId = new Map(); + /** Submitted or running turns not yet closed by lifecycle or canonical state. */ + private unsettledRunTurnIdsByChatId = new Map>(); + /** Correlated WebUI sends retained until protocol/canonical disposition. */ + private pendingMessageSends = new Map(); + /** Message sends written to the current socket but not yet acknowledged. */ + private socketPendingMessageSendKeys = new Set(); + /** Last application frame written, used only for conservative 1009 attribution. */ + private lastSocketMessageSendKey: string | null = null; + /** Canonically completed turns whose delayed websocket frames must be ignored. */ + private canonicalCompletedTurnIdsByChatId = new Map>(); + private static readonly COMPLETED_TURN_FENCE_MAX = 256; /** Latest ``goal_state`` snapshot per ``chat_id`` (multi-session isolation). */ private goalStateByChatId = new Map(); private pendingNewChat: PendingRequest | null = null; @@ -145,6 +201,7 @@ export class NanobotClient { private reconnectTimer: ReturnType | null = null; private readonly shouldReconnect: boolean; private readonly maxBackoffMs: number; + private maxFrameBytes: number | undefined; private socketFactory: (url: string) => WebSocket; private currentUrl: string; private status_: ConnectionStatus = "idle"; @@ -156,6 +213,7 @@ export class NanobotClient { constructor(private options: NanobotClientOptions) { this.shouldReconnect = options.reconnect ?? true; this.maxBackoffMs = options.maxBackoffMs ?? 15_000; + this.maxFrameBytes = this.normalizeMaxFrameBytes(options.maxFrameBytes); this.socketFactory = options.socketFactory ?? createDefaultSocket; this.currentUrl = options.url; } @@ -222,27 +280,386 @@ export class NanobotClient { return v === undefined ? null : v; } + /** Refresh transport policy after bootstrap token renewal. */ + updateMaxFrameBytes(maxFrameBytes?: number): void { + this.maxFrameBytes = this.normalizeMaxFrameBytes(maxFrameBytes); + } + + /** Generation captured when an HTTP thread reconciliation starts. */ + getRunGeneration(chatId: string): number { + return this.runGenerationByChatId.get(chatId) ?? 0; + } + + /** Whether a locally submitted lifecycle turn still lacks a terminal disposition. */ + hasUnsettledRun(chatId: string): boolean { + return (this.unsettledRunTurnIdsByChatId.get(chatId)?.size ?? 0) > 0; + } + + private normalizeMaxFrameBytes(value: number | undefined): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return undefined; + } + return Math.floor(value); + } + + private canonicalTurnWillSettle( + chatId: string, + turnId: string, + completed: ReadonlySet, + observed: ReadonlySet, + snapshot?: CanonicalRunSnapshot, + ): boolean { + if (completed.has(turnId)) return true; + if (!snapshot || snapshot.activeTurnId === turnId) return false; + if (snapshot.hasPendingToolCalls) return false; + if (observed.has(turnId)) return true; + const pending = this.pendingMessageSends.get(this.runSendKey(chatId, turnId)); + return pending?.state === "unknown" || pending?.state === "accepted"; + } + + private settleNonLifecycleCanonicalSends( + chatId: string, + completed: ReadonlySet, + observed: ReadonlySet, + snapshot?: CanonicalRunSnapshot, + ): void { + for (const pending of [...this.pendingMessageSends.values()]) { + if (pending.chatId !== chatId || pending.startsNewRun) continue; + if (!this.canonicalTurnWillSettle( + chatId, + pending.turnId, + completed, + observed, + snapshot, + )) continue; + this.clearPendingMessageSend(chatId, pending.turnId); + } + } + + private prunePendingInboundTurn(chatId: string, turnId: string): void { + const pending = this.pendingInboundByChat.get(chatId); + if (!pending) return; + const remaining = pending.filter((event) => ( + !("turn_id" in event) + || event.turn_id !== turnId + )); + if (remaining.length > 0) this.pendingInboundByChat.set(chatId, remaining); + else this.pendingInboundByChat.delete(chatId); + } + + /** + * Pure preflight for canonical reconciliation. + * + * Unlike ``reconcileCanonicalCompletion``, this does not add completion + * fences, prune queued frames, settle turns, or emit run-status updates. + */ + canReconcileCanonicalCompletion( + chatId: string, + expectedRunGeneration: number, + completedTurnIds: readonly string[], + snapshot?: CanonicalRunSnapshot, + ): boolean { + const completed = new Set(this.canonicalCompletedTurnIdsByChatId.get(chatId)); + for (const turnId of completedTurnIds) { + if (turnId) completed.add(turnId); + } + const observed = new Set( + snapshot?.observedTurnIds.filter((turnId) => turnId.length > 0) ?? [], + ); + const willSettle = (turnId: string): boolean => this.canonicalTurnWillSettle( + chatId, + turnId, + completed, + observed, + snapshot, + ); + const latestRunTurnId = this.latestRunTurnIdByChatId.get(chatId); + const latestRunIsRepresented = ( + typeof latestRunTurnId === "string" + && ( + completed.has(latestRunTurnId) + || ( + observed.has(latestRunTurnId) + && willSettle(latestRunTurnId) + ) + ) + ); + const unsettledTurnIds = this.unsettledRunTurnIdsByChatId.get(chatId); + const hasUnrepresentedTurn = ( + unsettledTurnIds !== undefined + && Array.from(unsettledTurnIds).some((turnId) => !willSettle(turnId)) + ); + const hasUnidentifiedActiveRun = ( + this.runStartedAtByChatId.has(chatId) + && latestRunTurnId === undefined + && (snapshot === undefined || snapshot.hasPendingToolCalls) + ); + if (hasUnrepresentedTurn || hasUnidentifiedActiveRun) return false; + return ( + this.getRunGeneration(chatId) === expectedRunGeneration + || latestRunIsRepresented + ); + } + + /** + * Atomically accept an HTTP snapshot as completed if no unrepresented run + * started while the request was in flight. + * + * Completed turn ids are fenced even when the snapshot loses the generation + * race: delayed websocket frames for older turns must never mutate newer UI. + */ + reconcileCanonicalCompletion( + chatId: string, + expectedRunGeneration: number, + completedTurnIds: readonly string[], + snapshot?: CanonicalRunSnapshot, + ): boolean { + const fences = this.canonicalCompletedTurnIdsByChatId.get(chatId) ?? new Set(); + for (const turnId of completedTurnIds) { + if (!turnId) continue; + fences.add(turnId); + } + while (fences.size > NanobotClient.COMPLETED_TURN_FENCE_MAX) { + const oldest = fences.values().next().value; + if (typeof oldest !== "string") break; + fences.delete(oldest); + } + if (fences.size > 0) this.canonicalCompletedTurnIdsByChatId.set(chatId, fences); + const pendingInbound = this.pendingInboundByChat.get(chatId); + if (pendingInbound) { + const remaining = pendingInbound.filter((event) => { + const turnId = "turn_id" in event && typeof event.turn_id === "string" + ? event.turn_id + : null; + return turnId === null || !fences.has(turnId); + }); + if (remaining.length > 0) this.pendingInboundByChat.set(chatId, remaining); + else this.pendingInboundByChat.delete(chatId); + } + + if (!this.canReconcileCanonicalCompletion( + chatId, + expectedRunGeneration, + [], + snapshot, + )) { + return false; + } + + const completed = new Set(fences); + const observed = new Set( + snapshot?.observedTurnIds.filter((turnId) => turnId.length > 0) ?? [], + ); + const unsettledTurnIds = this.unsettledRunTurnIdsByChatId.get(chatId); + if (unsettledTurnIds) { + for (const turnId of [...unsettledTurnIds]) { + if (!this.canonicalTurnWillSettle( + chatId, + turnId, + completed, + observed, + snapshot, + )) continue; + unsettledTurnIds.delete(turnId); + this.clearPendingMessageSend(chatId, turnId); + this.runStartedAtByTurnKey.delete(this.runSendKey(chatId, turnId)); + } + if (unsettledTurnIds.size === 0) this.unsettledRunTurnIdsByChatId.delete(chatId); + } + this.settleNonLifecycleCanonicalSends(chatId, completed, observed, snapshot); + if (this.runStartedAtByChatId.delete(chatId)) { + this.emitRunStatus(chatId, null); + } + return true; + } + /** Last ``goal_state`` payload for *chatId*, if any frame has arrived this connection. */ getGoalState(chatId: string): GoalStateWsPayload | undefined { return this.goalStateByChatId.get(chatId); } + private advanceRunGeneration(chatId: string, turnId?: string): void { + this.runGenerationByChatId.set(chatId, this.getRunGeneration(chatId) + 1); + if (turnId) { + this.latestRunTurnIdByChatId.set(chatId, turnId); + const unsettled = this.unsettledRunTurnIdsByChatId.get(chatId) ?? new Set(); + unsettled.add(turnId); + this.unsettledRunTurnIdsByChatId.set(chatId, unsettled); + } else { + this.latestRunTurnIdByChatId.delete(chatId); + } + } + + private settleRunTurn(chatId: string, turnId?: string): void { + if (!turnId) return; + this.clearPendingMessageSend(chatId, turnId); + this.runStartedAtByTurnKey.delete(this.runSendKey(chatId, turnId)); + const unsettled = this.unsettledRunTurnIdsByChatId.get(chatId); + if (!unsettled) return; + unsettled.delete(turnId); + if (unsettled.size === 0) this.unsettledRunTurnIdsByChatId.delete(chatId); + } + + private runSendKey(chatId: string, turnId: string): string { + return `${chatId}\u0000${turnId}`; + } + + private trackPendingMessageSend( + chatId: string, + turnId: string, + startsNewRun: boolean, + ): void { + const key = this.runSendKey(chatId, turnId); + this.pendingMessageSends.set(key, { + chatId, + turnId, + startsNewRun, + state: "queued", + }); + } + + private clearPendingMessageSend(chatId: string, turnId: string): void { + const key = this.runSendKey(chatId, turnId); + this.pendingMessageSends.delete(key); + this.socketPendingMessageSendKeys.delete(key); + this.sendQueue = this.sendQueue.filter((frame) => !( + frame.type === "message" + && frame.chat_id === chatId + && frame.turn_id === turnId + )); + } + + private recordRunAcceptance(chatId: string, turnId?: string): void { + if (!turnId) return; + const key = this.runSendKey(chatId, turnId); + const pending = this.pendingMessageSends.get(key); + if (!pending) return; + this.socketPendingMessageSendKeys.delete(key); + if (!pending.startsNewRun) { + this.pendingMessageSends.delete(key); + return; + } + pending.state = "accepted"; + } + + private recordRunRejection(chatId: string, turnId?: string): void { + if (!turnId) return; + const rejectedLatest = this.latestRunTurnIdByChatId.get(chatId) === turnId; + this.settleRunTurn(chatId, turnId); + this.prunePendingInboundTurn(chatId, turnId); + if (!rejectedLatest) return; + + const unsettled = this.unsettledRunTurnIdsByChatId.get(chatId); + const previousTurnId = unsettled ? Array.from(unsettled).at(-1) : undefined; + if (previousTurnId) { + this.latestRunTurnIdByChatId.set(chatId, previousTurnId); + const previousStartedAt = this.runStartedAtByTurnKey.get( + this.runSendKey(chatId, previousTurnId), + ); + const currentStartedAt = this.runStartedAtByChatId.get(chatId); + if (previousStartedAt === undefined) { + if (this.runStartedAtByChatId.delete(chatId)) { + this.emitRunStatus(chatId, null); + } + } else { + this.runStartedAtByChatId.set(chatId, previousStartedAt); + if (currentStartedAt !== previousStartedAt) { + this.emitRunStatus(chatId, previousStartedAt); + } + } + return; + } + this.latestRunTurnIdByChatId.delete(chatId); + if (this.runStartedAtByChatId.delete(chatId)) { + this.emitRunStatus(chatId, null); + } + } + + private legacyRejectionTarget(ev: Extract): { + chatId: string; + turnId: string; + } | null { + if (!ev.detail || !TURN_REJECTION_DETAILS.has(ev.detail)) return null; + if ( + ev.detail === "workspace_scope_rejected" + && ev.chat_id === undefined + && this.pendingNewChat + ) return null; + const candidates = [...this.pendingMessageSends.values()].filter((pending) => ( + // A legacy error can only reject a frame currently awaiting its first + // server disposition. Accepted or prior-connection unknown sends are + // not safe candidates for an uncorrelated frame. + pending.state === "sent" + && (ev.chat_id === undefined || pending.chatId === ev.chat_id) + )); + if (candidates.length !== 1) return null; + const [candidate] = candidates; + if ( + this.lastSocketMessageSendKey + !== this.runSendKey(candidate.chatId, candidate.turnId) + ) return null; + return { chatId: candidate.chatId, turnId: candidate.turnId }; + } + + private uniqueUnsettledTurnId(chatId: string): string | null { + const unsettled = this.unsettledRunTurnIdsByChatId.get(chatId); + if (!unsettled || unsettled.size !== 1) return null; + return unsettled.values().next().value ?? null; + } + + private isCanonicalCompletedTurnEvent(chatId: string, ev: InboundEvent): boolean { + const turnId = "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : null; + return ( + turnId !== null + && this.canonicalCompletedTurnIdsByChatId.get(chatId)?.has(turnId) === true + ); + } + + private isSupersededRunCompletion(chatId: string, ev: InboundEvent): boolean { + if ( + ev.event !== "turn_end" + && !(ev.event === "goal_status" && ev.status === "idle") + ) { + return false; + } + const turnId = "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : undefined; + const latestRunTurnId = this.latestRunTurnIdByChatId.get(chatId); + if (turnId === undefined && latestRunTurnId !== undefined) return true; + return ( + turnId !== undefined + && latestRunTurnId !== undefined + && turnId !== latestRunTurnId + ); + } + + private recordRunCompletion(chatId: string, turnId?: string): void { + this.settleRunTurn(chatId, turnId); + const latestRunTurnId = this.latestRunTurnIdByChatId.get(chatId); + const closesCurrentRun = latestRunTurnId === undefined || turnId === latestRunTurnId; + if (closesCurrentRun && this.runStartedAtByChatId.delete(chatId)) { + this.emitRunStatus(chatId, null); + } + } + private recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent): void { if (ev.event === "turn_end") { - if (this.runStartedAtByChatId.has(chatId)) { - this.runStartedAtByChatId.delete(chatId); - this.emitRunStatus(chatId, null); - } + this.recordRunCompletion(chatId, ev.turn_id); return; } if (ev.event !== "goal_status") return; if (ev.status === "running" && typeof ev.started_at === "number") { + this.advanceRunGeneration(chatId, ev.turn_id); + if (ev.turn_id) { + this.runStartedAtByTurnKey.set( + this.runSendKey(chatId, ev.turn_id), + ev.started_at, + ); + } const previous = this.runStartedAtByChatId.get(chatId); this.runStartedAtByChatId.set(chatId, ev.started_at); if (previous !== ev.started_at) this.emitRunStatus(chatId, ev.started_at); - } else if (this.runStartedAtByChatId.has(chatId)) { - this.runStartedAtByChatId.delete(chatId); - this.emitRunStatus(chatId, null); + } else { + this.recordRunCompletion(chatId, ev.turn_id); } } @@ -390,6 +807,8 @@ export class NanobotClient { quotedContext?: string; workspaceScope?: WorkspaceScopePayload | null; turnId?: string; + /** False for side-channel or injected messages that do not own a lifecycle. */ + startsNewRun?: boolean; }, ): void { this.knownChats.add(chatId); @@ -405,6 +824,22 @@ export class NanobotClient { ...(options?.turnId ? { turn_id: options.turnId } : {}), webui: true, }; + if (!this.frameFitsTransport(frame)) { + if (options?.turnId && isSystemCommandTurnId(options.turnId)) { + this.rejectSystemCommand(options.turnId, "message_too_big"); + } + this.emitError({ + kind: "message_too_big", + chatId, + ...(options?.turnId ? { turnId: options.turnId } : {}), + }); + return; + } + if (options?.turnId && !isSystemCommandTurnId(options.turnId)) { + const startsNewRun = options.startsNewRun !== false; + if (startsNewRun) this.advanceRunGeneration(chatId, options.turnId); + this.trackPendingMessageSend(chatId, options.turnId, startsNewRun); + } this.queueSend(frame); } @@ -442,6 +877,7 @@ export class NanobotClient { if (this.runStartedAtByChatId.size === 0) return; const chatIds = [...this.runStartedAtByChatId.keys()]; this.runStartedAtByChatId.clear(); + this.runStartedAtByTurnKey.clear(); for (const chatId of chatIds) this.emitRunStatus(chatId, null); } @@ -476,16 +912,61 @@ export class NanobotClient { console.log("[nanobot ws inbound]", summarizeInboundWsPayload(parsed)); } + if (parsed.event === "error" && !parsed.turn_id) { + const fallback = this.legacyRejectionTarget(parsed); + if (fallback) { + parsed = { + ...parsed, + chat_id: parsed.chat_id ?? fallback.chatId, + turn_id: fallback.turnId, + }; + } + } + if ( + (parsed.event === "goal_status" || parsed.event === "turn_end") + && !parsed.turn_id + ) { + const fallbackTurnId = this.uniqueUnsettledTurnId(parsed.chat_id); + if (fallbackTurnId) parsed = { ...parsed, turn_id: fallbackTurnId }; + } + const turnId = "turn_id" in parsed && typeof parsed.turn_id === "string" ? parsed.turn_id : null; + if (parsed.event === "message_accepted") { + this.recordRunAcceptance(parsed.chat_id, parsed.turn_id); + return; + } if (isSystemCommandTurnId(turnId)) { - if (parsed.event === "message" || parsed.event === "turn_end") { + if (parsed.event === "error") { + this.rejectSystemCommand( + turnId, + [parsed.detail, parsed.reason].filter(Boolean).join(":") || "server error", + ); + } else if (parsed.event === "message" || parsed.event === "turn_end") { this.resolveSystemCommand(turnId); } return; } + const correlatedChatId = (parsed as { chat_id?: string }).chat_id; + if (parsed.event === "error" && correlatedChatId && turnId) { + this.recordRunRejection(correlatedChatId, turnId); + if (parsed.detail !== "workspace_scope_rejected") { + this.emitError({ + kind: "turn_rejected", + detail: parsed.detail, + reason: parsed.reason, + chatId: correlatedChatId, + turnId, + }); + } + } else if (parsed.event !== "error" && correlatedChatId && turnId) { + // Lifecycle traffic is also an implicit acceptance signal for clients + // connected to an older gateway that doesn't emit message_accepted. + this.recordRunAcceptance(correlatedChatId, turnId); + } + if (parsed.event === "ready") { this.readyChatId = parsed.chat_id; this.knownChats.add(parsed.chat_id); @@ -528,6 +1009,7 @@ export class NanobotClient { kind: "workspace_scope_rejected", reason: parsed.reason, chatId: parsed.chat_id, + turnId: parsed.turn_id, }); if (this.pendingNewChat) { clearTimeout(this.pendingNewChat.timer); @@ -546,7 +1028,10 @@ export class NanobotClient { const chatId = (parsed as { chat_id?: string }).chat_id; if (chatId) { + if (this.isCanonicalCompletedTurnEvent(chatId, parsed)) return; + const supersededRunCompletion = this.isSupersededRunCompletion(chatId, parsed); this.recordGoalStatusForRunStrip(chatId, parsed); + if (supersededRunCompletion) return; this.recordGoalStateSnapshot(chatId, parsed); this.dispatch(chatId, parsed); } @@ -611,9 +1096,44 @@ export class NanobotClient { // display the error even while the client transparently reconnects. // Browsers populate ``CloseEvent.code`` with the wire-level close code; // 1009 = Message Too Big (server's max frame guard). + const unacknowledged = Array.from(this.socketPendingMessageSendKeys) + .map((key) => this.pendingMessageSends.get(key)) + .filter((pending): pending is PendingMessageSend => pending !== undefined); if (event?.code === 1009) { - this.emitError({ kind: "message_too_big" }); + const soleKey = unacknowledged.length === 1 + ? this.runSendKey(unacknowledged[0].chatId, unacknowledged[0].turnId) + : null; + if ( + unacknowledged.length === 1 + && this.lastSocketMessageSendKey === soleKey + ) { + const [rejected] = unacknowledged; + this.recordRunRejection(rejected.chatId, rejected.turnId); + this.emitError({ + kind: "message_too_big", + chatId: rejected.chatId, + turnId: rejected.turnId, + }); + this.dispatch(rejected.chatId, { + event: "error", + detail: "message_too_big", + chat_id: rejected.chatId, + turn_id: rejected.turnId, + }); + } else { + // A close frame identifies no offending application message. Never + // roll back multiple chats merely because they shared one socket. + this.emitError({ kind: "message_too_big" }); + } } + for (const pending of unacknowledged) { + const current = this.pendingMessageSends.get( + this.runSendKey(pending.chatId, pending.turnId), + ); + if (current?.state === "sent") current.state = "unknown"; + } + this.socketPendingMessageSendKeys.clear(); + this.lastSocketMessageSendKey = null; if (this.intentionallyClosed || !this.shouldReconnect) { this.setStatus("closed"); return; @@ -671,6 +1191,14 @@ export class NanobotClient { pending.resolve(); } + private rejectSystemCommand(turnId: string, detail: string): void { + const pending = this.pendingSystemCommands.get(turnId); + if (!pending) return; + clearTimeout(pending.timer); + this.pendingSystemCommands.delete(turnId); + pending.reject(new Error(detail)); + } + private scheduleReconnect(): void { this.clearRunStatusesForReconnect(); this.setStatus("reconnecting"); @@ -699,10 +1227,25 @@ export class NanobotClient { } } + private frameFitsTransport(frame: Outbound): boolean { + if (this.maxFrameBytes === undefined) return true; + return new TextEncoder().encode(JSON.stringify(frame)).byteLength <= this.maxFrameBytes; + } + private rawSend(frame: Outbound): void { if (!this.socket) return; try { this.socket.send(JSON.stringify(frame)); + this.lastSocketMessageSendKey = null; + if (frame.type === "message" && frame.turn_id) { + const key = this.runSendKey(frame.chat_id, frame.turn_id); + const pending = this.pendingMessageSends.get(key); + if (pending) { + pending.state = "sent"; + this.socketPendingMessageSendKeys.add(key); + this.lastSocketMessageSendKey = key; + } + } } catch { // Send failure will materialize as a close; queue the frame for retry. this.sendQueue.push(frame); diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 1db55490d..a0ef20047 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -1082,6 +1082,7 @@ export interface InboundTurnMetadata { export type InboundEvent = | { event: "ready"; chat_id: string; client_id: string } | { event: "attached"; chat_id: string } + | { event: "message_accepted"; chat_id: string; turn_id: string } | ({ event: "message"; chat_id: string; @@ -1149,14 +1150,14 @@ export type InboundEvent = /** Authoritative sustained-goal snapshot for this chat (same shape as ``goal_state`` events). */ goal_state?: GoalStateWsPayload; } & InboundTurnMetadata) - | { + | ({ event: "goal_status"; chat_id: string; /** Turn executing (user message through agent loop). */ status: "running" | "idle"; /** Server ``time.time()`` when ``status`` is ``running``. */ started_at?: number; - } + } & InboundTurnMetadata) | { event: "goal_state"; chat_id: string; @@ -1175,7 +1176,14 @@ export type InboundEvent = detail?: string; provider?: string; } - | { event: "error"; chat_id?: string; detail?: string; reason?: string }; + | { + event: "error"; + chat_id?: string; + detail?: string; + reason?: string; + /** Present when this error rejects a specific outbound WebUI turn. */ + turn_id?: string; + }; /** Base64-encoded file attached to an outbound ``message`` envelope. * @@ -1224,7 +1232,11 @@ export interface WebuiThreadPersistedPayload { savedAt?: string; messages: UIMessage[]; fork_boundary_message_count?: number; + /** Turn ids backed by an explicit persisted ``turn_end`` event. */ + completed_turn_ids?: string[]; has_pending_tool_calls?: boolean; + /** Exact active turn when supplied by a current gateway. */ + active_turn_id?: string | null; page?: WebuiThreadPagePayload; workspace_scope?: WorkspaceScopePayload; } diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index e2e6103a3..e620dc58c 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -77,6 +77,7 @@ describe("webui API helpers", () => { expect.objectContaining({ headers: { Authorization: "Bearer tok" }, credentials: "same-origin", + cache: "no-store", }), ); }); diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 2bf0c7752..63f48cdee 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -217,6 +217,7 @@ vi.mock("@/lib/nanobot-client", () => { attach = attachSpy; close = vi.fn(); updateUrl = updateUrlSpy; + updateMaxFrameBytes = vi.fn(); } return { NanobotClient: MockClient }; diff --git a/webui/src/tests/nanobot-client.test.ts b/webui/src/tests/nanobot-client.test.ts index 7284f7c10..bdecddc6d 100644 --- a/webui/src/tests/nanobot-client.test.ts +++ b/webui/src/tests/nanobot-client.test.ts @@ -238,6 +238,891 @@ describe("NanobotClient", () => { expect(handler).toHaveBeenLastCalledWith("chat-strip", null); }); + it("rejects a completed snapshot when a newer run is not represented", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + client.connect(); + lastSocket().fakeOpen(); + const requestGeneration = client.getRunGeneration("chat-race"); + + lastSocket().fakeMessage({ + event: "goal_status", + chat_id: "chat-race", + status: "running", + started_at: 12_345, + turn_id: "turn-new", + }); + + expect( + client.reconcileCanonicalCompletion("chat-race", requestGeneration, ["turn-old"]), + ).toBe(false); + expect(client.getRunStartedAt("chat-race")).toBe(12_345); + }); + + it("rejects a user-only snapshot for a submitted turn that has not completed", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + client.connect(); + lastSocket().fakeOpen(); + client.sendMessage("chat-submitted", "question", undefined, { turnId: "turn-submitted" }); + const requestGeneration = client.getRunGeneration("chat-submitted"); + + expect( + client.reconcileCanonicalCompletion("chat-submitted", requestGeneration, []), + ).toBe(false); + }); + + it("does not register injected guidance as an independently unsettled run", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + client.connect(); + lastSocket().fakeOpen(); + lastSocket().fakeMessage({ + event: "goal_status", + chat_id: "chat-guidance", + status: "running", + started_at: 12_345, + turn_id: "turn-active", + }); + const requestGeneration = client.getRunGeneration("chat-guidance"); + + client.sendMessage("chat-guidance", "focus on sources", undefined, { + turnId: "turn-guidance", + startsNewRun: false, + }); + + expect(client.getRunGeneration("chat-guidance")).toBe(requestGeneration); + expect( + client.reconcileCanonicalCompletion( + "chat-guidance", + requestGeneration, + ["turn-active"], + ), + ).toBe(true); + }); + + it("accepts an explicitly completed turn with no assistant row", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + client.connect(); + lastSocket().fakeOpen(); + client.sendMessage("chat-empty-answer", "question", undefined, { + turnId: "turn-empty-answer", + }); + const requestGeneration = client.getRunGeneration("chat-empty-answer"); + + expect( + client.reconcileCanonicalCompletion( + "chat-empty-answer", + requestGeneration, + ["turn-empty-answer"], + ), + ).toBe(true); + }); + + it.each([ + "message_rejected", + "attachment_rejected", + "workspace_scope_rejected", + ])("settles a specifically rejected outbound turn (%s)", (detail) => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + client.connect(); + lastSocket().fakeOpen(); + client.sendMessage("chat-rejected", "question", undefined, { + turnId: "turn-rejected", + }); + const requestGeneration = client.getRunGeneration("chat-rejected"); + + expect( + client.reconcileCanonicalCompletion("chat-rejected", requestGeneration, []), + ).toBe(false); + + lastSocket().fakeMessage({ + event: "error", + chat_id: "chat-rejected", + turn_id: "turn-rejected", + detail, + reason: "policy", + }); + + expect( + client.reconcileCanonicalCompletion("chat-rejected", requestGeneration, []), + ).toBe(true); + }); + + it("does not let an older rejection settle or stop a newer run", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + client.connect(); + lastSocket().fakeOpen(); + client.sendMessage("chat-rejection-race", "first", undefined, { + turnId: "turn-old", + }); + client.sendMessage("chat-rejection-race", "second", undefined, { + turnId: "turn-new", + }); + lastSocket().fakeMessage({ + event: "goal_status", + chat_id: "chat-rejection-race", + status: "running", + started_at: 2_000, + turn_id: "turn-new", + }); + const requestGeneration = client.getRunGeneration("chat-rejection-race"); + + lastSocket().fakeMessage({ + event: "error", + chat_id: "chat-rejection-race", + turn_id: "turn-old", + detail: "message_rejected", + reason: "text_too_large", + }); + + expect(client.getRunStartedAt("chat-rejection-race")).toBe(2_000); + expect( + client.reconcileCanonicalCompletion("chat-rejection-race", requestGeneration, []), + ).toBe(false); + }); + + it("restores the previous turn clock when the newer running turn is rejected", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + client.connect(); + lastSocket().fakeOpen(); + client.sendMessage("chat-reject-newer-clock", "first", undefined, { + turnId: "turn-clock-first", + }); + lastSocket().fakeMessage({ + event: "goal_status", + chat_id: "chat-reject-newer-clock", + status: "running", + started_at: 1_000, + turn_id: "turn-clock-first", + }); + client.sendMessage("chat-reject-newer-clock", "second", undefined, { + turnId: "turn-clock-second", + }); + lastSocket().fakeMessage({ + event: "goal_status", + chat_id: "chat-reject-newer-clock", + status: "running", + started_at: 2_000, + turn_id: "turn-clock-second", + }); + + lastSocket().fakeMessage({ + event: "error", + chat_id: "chat-reject-newer-clock", + turn_id: "turn-clock-second", + detail: "message_rejected", + }); + + expect(client.getRunStartedAt("chat-reject-newer-clock")).toBe(1_000); + expect(client.hasUnsettledRun("chat-reject-newer-clock")).toBe(true); + }); + + it("rolls back lifecycle sends that close 1009 before server acceptance", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = []; + client.onError((error) => errors.push(error)); + client.connect(); + lastSocket().fakeOpen(); + client.sendMessage("chat-too-big", "oversized", undefined, { + turnId: "turn-too-big", + }); + const requestGeneration = client.getRunGeneration("chat-too-big"); + + lastSocket().fakeCloseWithCode(1009); + + expect(errors).toEqual([{ + kind: "message_too_big", + chatId: "chat-too-big", + turnId: "turn-too-big", + }]); + expect( + client.reconcileCanonicalCompletion("chat-too-big", requestGeneration, []), + ).toBe(true); + }); + + it("preserves an accepted older run when a newer send closes 1009", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = []; + client.onError((error) => errors.push(error)); + client.connect(); + lastSocket().fakeOpen(); + client.sendMessage("chat-too-big-race", "first", undefined, { + turnId: "turn-accepted", + }); + lastSocket().fakeMessage({ + event: "message_accepted", + chat_id: "chat-too-big-race", + turn_id: "turn-accepted", + }); + lastSocket().fakeMessage({ + event: "goal_status", + chat_id: "chat-too-big-race", + status: "running", + started_at: 1_000, + turn_id: "turn-accepted", + }); + client.sendMessage("chat-too-big-race", "oversized", undefined, { + turnId: "turn-rejected", + }); + const requestGeneration = client.getRunGeneration("chat-too-big-race"); + + lastSocket().fakeCloseWithCode(1009); + + expect(errors).toEqual([{ + kind: "message_too_big", + chatId: "chat-too-big-race", + turnId: "turn-rejected", + }]); + expect(client.getRunStartedAt("chat-too-big-race")).toBe(1_000); + expect( + client.reconcileCanonicalCompletion( + "chat-too-big-race", + requestGeneration, + [], + ), + ).toBe(false); + }); + + it("does not roll back a lifecycle send after its acceptance ACK", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + client.connect(); + lastSocket().fakeOpen(); + client.sendMessage("chat-accepted", "question", undefined, { + turnId: "turn-accepted", + }); + const requestGeneration = client.getRunGeneration("chat-accepted"); + lastSocket().fakeMessage({ + event: "message_accepted", + chat_id: "chat-accepted", + turn_id: "turn-accepted", + }); + + lastSocket().fakeCloseWithCode(1009); + + expect( + client.reconcileCanonicalCompletion("chat-accepted", requestGeneration, []), + ).toBe(false); + }); + + it("preflights exact websocket frame bytes and rejects only the oversized turn", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + maxFrameBytes: 180, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = []; + client.onError((error) => errors.push(error)); + client.connect(); + lastSocket().fakeOpen(); + const sentBefore = lastSocket().sent.length; + + client.sendMessage("chat-preflight-size", "x".repeat(500), undefined, { + turnId: "turn-preflight-size", + }); + + expect(lastSocket().sent).toHaveLength(sentBefore); + expect(client.hasUnsettledRun("chat-preflight-size")).toBe(false); + expect(errors).toEqual([{ + kind: "message_too_big", + chatId: "chat-preflight-size", + turnId: "turn-preflight-size", + }]); + }); + + it("does not attribute a fallback 1009 close across multiple unacknowledged chats", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = []; + client.onError((error) => errors.push(error)); + client.connect(); + lastSocket().fakeOpen(); + client.sendMessage("chat-size-a", "first", undefined, { turnId: "turn-size-a" }); + client.sendMessage("chat-size-b", "second", undefined, { turnId: "turn-size-b" }); + + lastSocket().fakeCloseWithCode(1009); + + expect(errors).toEqual([{ kind: "message_too_big" }]); + expect(client.hasUnsettledRun("chat-size-a")).toBe(true); + expect(client.hasUnsettledRun("chat-size-b")).toBe(true); + }); + + it("does not attribute 1009 to an unacknowledged message when another frame followed it", async () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = []; + client.onError((error) => errors.push(error)); + client.connect(); + lastSocket().fakeOpen(); + client.sendMessage("chat-before-audio", "question", undefined, { + turnId: "turn-before-audio", + }); + const transcription = client.transcribeAudio("data:audio/webm;base64,AAAA"); + + lastSocket().fakeCloseWithCode(1009); + + await expect(transcription).rejects.toThrow("socket closed"); + expect(errors).toEqual([{ kind: "message_too_big" }]); + expect(client.hasUnsettledRun("chat-before-audio")).toBe(true); + }); + + it("settles an unknown send absent from an idle canonical snapshot after disconnect", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + client.connect(); + lastSocket().fakeOpen(); + client.sendMessage("chat-never-arrived", "question", undefined, { + turnId: "turn-never-arrived", + }); + const requestGeneration = client.getRunGeneration("chat-never-arrived"); + + lastSocket().close(); + + const snapshot = { + observedTurnIds: [], + hasPendingToolCalls: false, + activeTurnId: null, + }; + expect( + client.canReconcileCanonicalCompletion( + "chat-never-arrived", + requestGeneration, + [], + snapshot, + ), + ).toBe(true); + expect( + client.reconcileCanonicalCompletion( + "chat-never-arrived", + requestGeneration, + [], + snapshot, + ), + ).toBe(true); + expect(client.hasUnsettledRun("chat-never-arrived")).toBe(false); + }); + + it("keeps an ACK-lost observed turn active, then settles it from an idle snapshot", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + client.connect(); + lastSocket().fakeOpen(); + client.sendMessage("chat-ack-lost", "question", undefined, { + turnId: "turn-ack-lost", + }); + const requestGeneration = client.getRunGeneration("chat-ack-lost"); + lastSocket().close(); + + expect( + client.canReconcileCanonicalCompletion( + "chat-ack-lost", + requestGeneration, + [], + { + observedTurnIds: ["turn-ack-lost"], + hasPendingToolCalls: true, + activeTurnId: "turn-ack-lost", + }, + ), + ).toBe(false); + expect( + client.reconcileCanonicalCompletion( + "chat-ack-lost", + requestGeneration, + [], + { + observedTurnIds: ["turn-ack-lost"], + hasPendingToolCalls: false, + activeTurnId: null, + }, + ), + ).toBe(true); + expect(client.hasUnsettledRun("chat-ack-lost")).toBe(false); + }); + + it("settles an accepted turn that never reached running from canonical idle", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + client.connect(); + lastSocket().fakeOpen(); + client.sendMessage("chat-accepted-idle", "question", undefined, { + turnId: "turn-accepted-idle", + }); + const requestGeneration = client.getRunGeneration("chat-accepted-idle"); + lastSocket().fakeMessage({ + event: "message_accepted", + chat_id: "chat-accepted-idle", + turn_id: "turn-accepted-idle", + }); + + expect( + client.reconcileCanonicalCompletion( + "chat-accepted-idle", + requestGeneration, + [], + { + observedTurnIds: ["turn-accepted-idle"], + hasPendingToolCalls: false, + activeTurnId: null, + }, + ), + ).toBe(true); + expect(client.hasUnsettledRun("chat-accepted-idle")).toBe(false); + }); + + it("does not let a pre-send idle response erase a newly accepted turn", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + client.connect(); + lastSocket().fakeOpen(); + const requestGeneration = client.getRunGeneration("chat-stale-idle"); + client.sendMessage("chat-stale-idle", "question", undefined, { + turnId: "turn-after-request", + }); + lastSocket().fakeMessage({ + event: "message_accepted", + chat_id: "chat-stale-idle", + turn_id: "turn-after-request", + }); + + expect( + client.reconcileCanonicalCompletion( + "chat-stale-idle", + requestGeneration, + [], + { + observedTurnIds: [], + hasPendingToolCalls: false, + activeTurnId: null, + }, + ), + ).toBe(false); + expect(client.hasUnsettledRun("chat-stale-idle")).toBe(true); + }); + + it("correlates a legacy rejection only to one currently sent turn", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = []; + client.onError((error) => errors.push(error)); + client.connect(); + lastSocket().fakeOpen(); + client.sendMessage("chat-legacy-reject", "question", undefined, { + turnId: "turn-legacy-reject", + }); + + lastSocket().fakeMessage({ + event: "error", + chat_id: "chat-legacy-reject", + detail: "message_rejected", + reason: "text_too_large", + }); + + expect(client.hasUnsettledRun("chat-legacy-reject")).toBe(false); + expect(errors).toEqual([expect.objectContaining({ + kind: "turn_rejected", + chatId: "chat-legacy-reject", + turnId: "turn-legacy-reject", + })]); + }); + + it("correlates legacy lifecycle completion when exactly one turn is unsettled", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + const handler = vi.fn(); + client.onChat("chat-legacy-idle", handler); + client.connect(); + lastSocket().fakeOpen(); + client.sendMessage("chat-legacy-idle", "question", undefined, { + turnId: "turn-legacy-idle", + }); + lastSocket().fakeMessage({ + event: "goal_status", + chat_id: "chat-legacy-idle", + status: "running", + started_at: 4321, + }); + + lastSocket().fakeMessage({ + event: "goal_status", + chat_id: "chat-legacy-idle", + status: "idle", + }); + + expect(client.hasUnsettledRun("chat-legacy-idle")).toBe(false); + expect(client.getRunStartedAt("chat-legacy-idle")).toBeNull(); + expect(handler).toHaveBeenLastCalledWith(expect.objectContaining({ + event: "goal_status", + status: "idle", + turn_id: "turn-legacy-idle", + })); + }); + + it("does not apply an uncorrelated legacy idle to multiple unsettled turns", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + const handler = vi.fn(); + client.onChat("chat-legacy-ambiguous", handler); + client.connect(); + lastSocket().fakeOpen(); + client.sendMessage("chat-legacy-ambiguous", "first", undefined, { + turnId: "turn-legacy-first", + }); + client.sendMessage("chat-legacy-ambiguous", "second", undefined, { + turnId: "turn-legacy-second", + }); + handler.mockClear(); + + lastSocket().fakeMessage({ + event: "goal_status", + chat_id: "chat-legacy-ambiguous", + status: "idle", + }); + + expect(client.hasUnsettledRun("chat-legacy-ambiguous")).toBe(true); + expect(handler).not.toHaveBeenCalled(); + }); + + it("does not correlate a legacy scope error to an already accepted turn", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = []; + client.onError((error) => errors.push(error)); + client.connect(); + lastSocket().fakeOpen(); + client.sendMessage("chat-legacy-scope", "question", undefined, { + turnId: "turn-already-accepted", + }); + lastSocket().fakeMessage({ + event: "message_accepted", + chat_id: "chat-legacy-scope", + turn_id: "turn-already-accepted", + }); + + lastSocket().fakeMessage({ + event: "error", + chat_id: "chat-legacy-scope", + detail: "workspace_scope_rejected", + reason: "chat_running", + }); + + expect(client.hasUnsettledRun("chat-legacy-scope")).toBe(true); + expect(errors).toEqual([{ + kind: "workspace_scope_rejected", + reason: "chat_running", + chatId: "chat-legacy-scope", + turnId: undefined, + }]); + }); + + it("does not correlate a scope-control rejection to a preceding unacknowledged message", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + client.connect(); + lastSocket().fakeOpen(); + client.sendMessage("chat-scope-control", "question", undefined, { + turnId: "turn-before-scope-control", + }); + client.setWorkspaceScope("chat-scope-control", { + project_path: "/tmp/project", + project_name: "project", + access_mode: "restricted", + }); + + lastSocket().fakeMessage({ + event: "error", + chat_id: "chat-scope-control", + detail: "workspace_scope_rejected", + reason: "chat_running", + }); + + expect(client.hasUnsettledRun("chat-scope-control")).toBe(true); + }); + + it("does not correlate a new-chat scope rejection to an unrelated sent turn", async () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + client.connect(); + lastSocket().fakeOpen(); + client.sendMessage("chat-unrelated-scope", "question", undefined, { + turnId: "turn-unrelated-scope", + }); + const pendingChat = client.newChat(5_000, { + project_path: "/missing", + project_name: "missing", + access_mode: "restricted", + }); + + lastSocket().fakeMessage({ + event: "error", + detail: "workspace_scope_rejected", + reason: "project_path must be an existing directory", + }); + + await expect(pendingChat).rejects.toThrow("workspace_scope_rejected"); + expect(client.hasUnsettledRun("chat-unrelated-scope")).toBe(true); + }); + + it("rejects a correlated system command instead of leaving it pending", async () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + client.connect(); + lastSocket().fakeOpen(); + const pending = client.sendSystemCommand("chat-system-reject", "/model invalid"); + const sent = JSON.parse(lastSocket().sent.at(-1) ?? "{}") as { turn_id?: string }; + expect(sent.turn_id).toMatch(/^webui-system:/); + + lastSocket().fakeMessage({ + event: "error", + chat_id: "chat-system-reject", + turn_id: sent.turn_id, + detail: "message_rejected", + reason: "invalid_command", + }); + + await expect(pending).rejects.toThrow("message_rejected:invalid_command"); + }); + + it("ignores a delayed idle event from an older turn after a new run starts", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + const chatHandler = vi.fn(); + const runHandler = vi.fn(); + client.onChat("chat-delayed-idle", chatHandler); + client.onRunStatus(runHandler); + client.connect(); + lastSocket().fakeOpen(); + lastSocket().fakeMessage({ + event: "goal_status", + chat_id: "chat-delayed-idle", + status: "running", + started_at: 1_000, + turn_id: "turn-old", + }); + client.sendMessage("chat-delayed-idle", "next question", undefined, { + turnId: "turn-new", + }); + lastSocket().fakeMessage({ + event: "goal_status", + chat_id: "chat-delayed-idle", + status: "running", + started_at: 2_000, + turn_id: "turn-new", + }); + chatHandler.mockClear(); + runHandler.mockClear(); + + lastSocket().fakeMessage({ + event: "goal_status", + chat_id: "chat-delayed-idle", + status: "idle", + turn_id: "turn-old", + }); + + expect(client.getRunStartedAt("chat-delayed-idle")).toBe(2_000); + expect(runHandler).not.toHaveBeenCalled(); + expect(chatHandler).not.toHaveBeenCalled(); + }); + + it("accepts a completed snapshot that represents a delayed running frame", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + client.connect(); + lastSocket().fakeOpen(); + const requestGeneration = client.getRunGeneration("chat-delayed-run"); + + lastSocket().fakeMessage({ + event: "goal_status", + chat_id: "chat-delayed-run", + status: "running", + started_at: 12_345, + turn_id: "turn-complete", + }); + + expect( + client.reconcileCanonicalCompletion( + "chat-delayed-run", + requestGeneration, + ["turn-complete"], + ), + ).toBe(true); + expect(client.getRunStartedAt("chat-delayed-run")).toBeNull(); + }); + + it("preflights canonical completion without fencing or settling the turn", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + const chatHandler = vi.fn(); + client.onChat("chat-preflight", chatHandler); + client.connect(); + lastSocket().fakeOpen(); + client.sendMessage("chat-preflight", "question", undefined, { + turnId: "turn-preflight", + }); + const requestGeneration = client.getRunGeneration("chat-preflight"); + + expect( + client.canReconcileCanonicalCompletion( + "chat-preflight", + requestGeneration, + ["turn-preflight"], + ), + ).toBe(true); + expect( + client.canReconcileCanonicalCompletion("chat-preflight", requestGeneration, []), + ).toBe(false); + + lastSocket().fakeMessage({ + event: "delta", + chat_id: "chat-preflight", + turn_id: "turn-preflight", + text: "still live", + }); + expect(chatHandler).toHaveBeenCalledWith( + expect.objectContaining({ event: "delta", text: "still live" }), + ); + }); + + it("clears the run cache and fences delayed frames after canonical completion", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + const chatHandler = vi.fn(); + const runHandler = vi.fn(); + client.onChat("chat-canonical", chatHandler); + client.onRunStatus(runHandler); + client.connect(); + lastSocket().fakeOpen(); + lastSocket().fakeMessage({ + event: "goal_status", + chat_id: "chat-canonical", + status: "running", + started_at: 12_345, + turn_id: "turn-canonical", + }); + const requestGeneration = client.getRunGeneration("chat-canonical"); + + expect( + client.reconcileCanonicalCompletion( + "chat-canonical", + requestGeneration, + ["turn-canonical"], + ), + ).toBe(true); + expect(client.getRunStartedAt("chat-canonical")).toBeNull(); + expect(runHandler).toHaveBeenLastCalledWith("chat-canonical", null); + const deliveredBeforeLateFrames = chatHandler.mock.calls.length; + + lastSocket().fakeMessage({ + event: "delta", + chat_id: "chat-canonical", + text: " delayed", + turn_id: "turn-canonical", + }); + lastSocket().fakeMessage({ + event: "turn_end", + chat_id: "chat-canonical", + turn_id: "turn-canonical", + }); + lastSocket().fakeMessage({ + event: "goal_status", + chat_id: "chat-canonical", + status: "idle", + turn_id: "turn-canonical", + }); + + expect(chatHandler).toHaveBeenCalledTimes(deliveredBeforeLateFrames); + expect(client.getRunStartedAt("chat-canonical")).toBeNull(); + }); + it("notifies run status subscribers and replays running chats", () => { const client = new NanobotClient({ url: "ws://test", diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index 3c2852868..6954bb2b3 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -1,30 +1,103 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; -import type { ReactNode } from "react"; +import { StrictMode, type ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { preloadMarkdownText } from "@/components/MarkdownText"; import { ThreadCameraController } from "@/components/thread/thread-camera"; import { ThreadShell } from "@/components/thread/ThreadShell"; import { CLI_APPS_CHANGED_EVENT } from "@/lib/cli-app-events"; +import type { CanonicalRunSnapshot } from "@/lib/nanobot-client"; import { ClientProvider } from "@/providers/ClientProvider"; -import type { CliAppsPayload, SettingsPayload, UIMessage } from "@/lib/types"; +import type { CliAppsPayload, ConnectionStatus, SettingsPayload, UIMessage } from "@/lib/types"; const HERO_GREETING_PATTERN = /What should we work on\?|Where should we start\?|What are we building today\?|What should we tackle together\?/; function makeClient() { const errorHandlers = new Set<(err: { kind: string }) => void>(); + const statusHandlers = new Set<(status: ConnectionStatus) => void>(); const chatHandlers = new Map void>>(); const runtimeModelHandlers = new Set< (modelName: string | null, modelPreset?: string | null) => void >(); const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>(); const runStartedAtByChatId = new Map(); + const runGenerationByChatId = new Map(); + const latestRunTurnIdByChatId = new Map(); + const completedTurnIdsByChatId = new Map>(); const goalStateByChatId = new Map(); + let status: ConnectionStatus = "open"; + const advanceRunGeneration = (chatId: string, turnId?: string) => { + runGenerationByChatId.set(chatId, (runGenerationByChatId.get(chatId) ?? 0) + 1); + if (turnId) latestRunTurnIdByChatId.set(chatId, turnId); + else latestRunTurnIdByChatId.delete(chatId); + }; + const sendMessage = vi.fn(( + chatId: string, + _content: string, + _media?: unknown, + options?: { turnId?: string; startsNewRun?: boolean }, + ) => { + if (options?.turnId && options.startsNewRun !== false) { + advanceRunGeneration(chatId, options.turnId); + } + }); + const canReconcileCanonicalCompletion = vi.fn(( + chatId: string, + expectedRunGeneration: number, + completedTurnIds: readonly string[], + snapshot?: CanonicalRunSnapshot, + ) => { + const existingFences = completedTurnIdsByChatId.get(chatId); + const prospectiveFences = new Set(completedTurnIds); + const observedTurnIds = new Set(snapshot?.observedTurnIds ?? []); + const isRepresented = (turnId: string) => ( + prospectiveFences.has(turnId) + || existingFences?.has(turnId) === true + || ( + snapshot?.hasPendingToolCalls === false + && observedTurnIds.has(turnId) + ) + ); + const currentGeneration = runGenerationByChatId.get(chatId) ?? 0; + const latestTurnId = latestRunTurnIdByChatId.get(chatId); + return ( + currentGeneration === expectedRunGeneration + || (typeof latestTurnId === "string" && isRepresented(latestTurnId)) + ); + }); + const reconcileCanonicalCompletion = vi.fn(( + chatId: string, + expectedRunGeneration: number, + completedTurnIds: readonly string[], + snapshot?: CanonicalRunSnapshot, + ) => { + if (!canReconcileCanonicalCompletion( + chatId, + expectedRunGeneration, + completedTurnIds, + snapshot, + )) { + return false; + } + const fences = completedTurnIdsByChatId.get(chatId) ?? new Set(); + for (const turnId of completedTurnIds) fences.add(turnId); + completedTurnIdsByChatId.set(chatId, fences); + runStartedAtByChatId.delete(chatId); + return true; + }); return { - status: "open" as const, + get status() { + return status; + }, defaultChatId: null as string | null, - onStatus: () => () => {}, + onStatus: (handler: (nextStatus: ConnectionStatus) => void) => { + statusHandlers.add(handler); + handler(status); + return () => { + statusHandlers.delete(handler); + }; + }, onRuntimeModelUpdate: ( handler: (modelName: string | null, modelPreset?: string | null) => void, ) => { @@ -34,6 +107,9 @@ function makeClient() { }; }, getRunStartedAt: (chatId: string) => runStartedAtByChatId.get(chatId) ?? null, + getRunGeneration: (chatId: string) => runGenerationByChatId.get(chatId) ?? 0, + canReconcileCanonicalCompletion, + reconcileCanonicalCompletion, getGoalState: (chatId: string) => goalStateByChatId.get(chatId), onChat: (chatId: string, handler: (ev: import("@/lib/types").InboundEvent) => void) => { let handlers = chatHandlers.get(chatId); @@ -61,12 +137,19 @@ function makeClient() { _emitError(err: { kind: string }) { for (const h of errorHandlers) h(err); }, + _emitStatus(nextStatus: ConnectionStatus) { + status = nextStatus; + for (const h of statusHandlers) h(status); + }, _emitChat(chatId: string, ev: import("@/lib/types").InboundEvent) { + const turnId = "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : null; + if (turnId && completedTurnIdsByChatId.get(chatId)?.has(turnId)) return; if ( ev.event === "goal_status" && ev.status === "running" && typeof ev.started_at === "number" ) { + advanceRunGeneration(chatId, ev.turn_id); runStartedAtByChatId.set(chatId, ev.started_at); } else if ( (ev.event === "goal_status" && ev.status === "idle") @@ -85,7 +168,7 @@ function makeClient() { _emitSessionUpdate(chatId: string, scope?: string) { for (const h of sessionUpdateHandlers) h(chatId, scope); }, - sendMessage: vi.fn(), + sendMessage, sendSystemCommand: vi.fn().mockResolvedValue(undefined), newChat: vi.fn(), forkChat: vi.fn(), @@ -135,7 +218,7 @@ function session(chatId: string, modelPreset?: string | null) { } function transcriptFromSimpleMessages( - rows: Array<{ role: "user" | "assistant"; content: string }>, + rows: Array<{ role: "user" | "assistant"; content: string; turnId?: string }>, ): { schemaVersion: number; messages: UIMessage[] } { return { schemaVersion: 3, @@ -143,6 +226,7 @@ function transcriptFromSimpleMessages( id: `m-${i}`, role: m.role, content: m.content, + ...(m.turnId ? { turnId: m.turnId } : {}), createdAt: 1000 + i, })), }; @@ -1495,6 +1579,1215 @@ describe("ThreadShell", () => { expect(screen.getByText("second fork question")).toBeInTheDocument(); }); + it("recovers a truncated streamed answer after reconnecting", async () => { + const client = makeClient(); + let historyCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("websocket%3Aresume-chat/webui-thread")) { + historyCalls += 1; + return httpJson( + transcriptFromSimpleMessages( + historyCalls === 1 + ? [{ role: "user", content: "question" }] + : [ + { role: "user", content: "question" }, + { role: "assistant", content: "partial answer completed while away" }, + ], + ), + ); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + + render( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + + await waitFor(() => expect(screen.getByText("question")).toBeInTheDocument()); + act(() => { + client._emitChat("resume-chat", { + event: "goal_status", + chat_id: "resume-chat", + status: "running", + started_at: 1_700, + }); + client._emitChat("resume-chat", { + event: "delta", + chat_id: "resume-chat", + text: "partial answer", + }); + }); + await waitFor(() => expect(screen.getByText("partial answer")).toBeInTheDocument()); + expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument(); + expect(historyCalls).toBe(1); + + act(() => client._emitStatus("reconnecting")); + expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument(); + act(() => client._emitStatus("open")); + + await waitFor(() => expect(historyCalls).toBe(2)); + await waitFor(() => + expect(screen.getByText("partial answer completed while away")).toBeInTheDocument(), + ); + expect(screen.queryByText("partial answer")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Stop response" })).not.toBeInTheDocument(); + }); + + it("refreshes after opening when mounted while the socket is reconnecting", async () => { + const client = makeClient(); + client._emitStatus("reconnecting"); + let historyCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + if (String(input).includes("websocket%3Amount-during-reconnect/webui-thread")) { + historyCalls += 1; + return httpJson(transcriptFromSimpleMessages( + historyCalls === 1 + ? [{ role: "user", content: "question before reconnect" }] + : [ + { role: "user", content: "question before reconnect" }, + { role: "assistant", content: "answer completed before open" }, + ], + )); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + + render( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + + await waitFor(() => expect(screen.getByText("question before reconnect")).toBeInTheDocument()); + expect(historyCalls).toBe(1); + + act(() => client._emitStatus("open")); + + await waitFor(() => expect(historyCalls).toBe(2)); + await waitFor(() => + expect(screen.getByText("answer completed before open")).toBeInTheDocument(), + ); + }); + + it("adopts a disjoint authoritative latest-window reset after overlap falls out", async () => { + const client = makeClient(); + let historyCalls = 0; + const canonicalTurnId = "turn-new-window"; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + if (String(input).includes("websocket%3Awindow-reset-chat/webui-thread")) { + historyCalls += 1; + return httpJson({ + ...transcriptFromSimpleMessages( + historyCalls === 1 + ? [ + { + role: "assistant", + content: "row from the expired latest window", + turnId: "turn-old-window", + }, + ] + : [ + { + role: "user", + content: "question in the new latest window", + turnId: canonicalTurnId, + }, + { + role: "assistant", + content: "answer in the new latest window", + turnId: canonicalTurnId, + }, + ], + ), + has_pending_tool_calls: false, + }); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + + render( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + + await waitFor(() => + expect(screen.getByText("row from the expired latest window")).toBeInTheDocument(), + ); + + act(() => document.dispatchEvent(new Event("visibilitychange"))); + + await waitFor(() => + expect(screen.getByText("answer in the new latest window")).toBeInTheDocument(), + ); + expect(screen.queryByText("row from the expired latest window")).not.toBeInTheDocument(); + expect(client.reconcileCanonicalCompletion).toHaveBeenCalledWith( + "window-reset-chat", + expect.any(Number), + expect.arrayContaining([canonicalTurnId]), + { + observedTurnIds: [canonicalTurnId], + hasPendingToolCalls: false, + activeTurnId: null, + }, + ); + }); + + it("recovers an uncommitted reset lineage on the next foreground hydrate", async () => { + const client = makeClient(); + let chatACalls = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("websocket%3Alineage-chat-a/webui-thread")) { + chatACalls += 1; + return httpJson({ + ...transcriptFromSimpleMessages( + chatACalls === 1 + ? [{ role: "assistant", content: "committed old lineage" }] + : [{ role: "assistant", content: "disjoint new lineage" }], + ), + has_pending_tool_calls: false, + }); + } + if (url.includes("websocket%3Alineage-chat-b/webui-thread")) { + return httpJson(transcriptFromSimpleMessages([ + { role: "assistant", content: "other lineage chat" }, + ])); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + const view = (chatId: string) => wrap( + client, + {}} + onNewChat={() => {}} + />, + ); + const { rerender } = render(view("lineage-chat-a")); + + await waitFor(() => expect(screen.getByText("committed old lineage")).toBeInTheDocument()); + rerender(view("lineage-chat-b")); + await waitFor(() => expect(screen.getByText("other lineage chat")).toBeInTheDocument()); + rerender(view("lineage-chat-a")); + await waitFor(() => expect(chatACalls).toBe(2)); + + expect(screen.getByText("committed old lineage")).toBeInTheDocument(); + expect(screen.queryByText("disjoint new lineage")).not.toBeInTheDocument(); + + act(() => document.dispatchEvent(new Event("visibilitychange"))); + + await waitFor(() => expect(chatACalls).toBe(3)); + await waitFor(() => expect(screen.getByText("disjoint new lineage")).toBeInTheDocument()); + expect(screen.queryByText("committed old lineage")).not.toBeInTheDocument(); + }); + + it("does not reset away a durable UI tail that arrives after the request", async () => { + const client = makeClient(); + let historyCalls = 0; + let resolveRefresh: + | ((value: ReturnType) => void) + | null = null; + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + if (!String(input).includes("websocket%3Areset-tail-race/webui-thread")) { + return Promise.resolve({ + ok: false, + status: 404, + json: async () => ({}), + }); + } + historyCalls += 1; + if (historyCalls === 1) { + return Promise.resolve(httpJson(transcriptFromSimpleMessages([ + { role: "assistant", content: "old canonical row" }, + ]))); + } + return new Promise((resolve) => { + resolveRefresh = resolve; + }); + }), + ); + + render( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + + await waitFor(() => expect(screen.getByText("old canonical row")).toBeInTheDocument()); + act(() => document.dispatchEvent(new Event("visibilitychange"))); + await waitFor(() => expect(historyCalls).toBe(2)); + + act(() => { + client._emitChat("reset-tail-race", { + event: "message", + chat_id: "reset-tail-race", + text: "local durable row after request", + }); + }); + await waitFor(() => + expect(screen.getByText("local durable row after request")).toBeInTheDocument(), + ); + + await act(async () => { + resolveRefresh?.(httpJson({ + ...transcriptFromSimpleMessages([ + { role: "assistant", content: "disjoint canonical reset row" }, + ]), + has_pending_tool_calls: false, + })); + await Promise.resolve(); + }); + + expect(screen.getByText("old canonical row")).toBeInTheDocument(); + expect(screen.getByText("local durable row after request")).toBeInTheDocument(); + expect(screen.queryByText("disjoint canonical reset row")).not.toBeInTheDocument(); + }); + + it("safely commits an empty canonical reset for a rejected local turn", async () => { + const client = makeClient(); + let historyCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + if (String(input).includes("websocket%3Aempty-reset-chat/webui-thread")) { + historyCalls += 1; + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + + render( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + + await waitFor(() => expect(historyCalls).toBe(1)); + const input = screen.getByRole("textbox", { name: "Message input" }); + fireEvent.change(input, { target: { value: "rejected local turn" } }); + fireEvent.click(screen.getByRole("button", { name: "Send message" })); + await waitFor(() => expect(screen.getByText("rejected local turn")).toBeInTheDocument()); + + act(() => document.dispatchEvent(new Event("visibilitychange"))); + + await waitFor(() => expect(historyCalls).toBe(2)); + await waitFor(() => + expect(screen.queryByText("rejected local turn")).not.toBeInTheDocument(), + ); + expect(client.reconcileCanonicalCompletion).toHaveBeenCalledWith( + "empty-reset-chat", + expect.any(Number), + [], + { + observedTurnIds: [], + hasPendingToolCalls: false, + activeTurnId: null, + }, + ); + }); + + it("runs canonical reconciliation once when React replays state calculations", async () => { + const client = makeClient(); + const turnId = "turn-strict-canonical"; + let canonicalComplete = false; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + if (String(input).includes("websocket%3Astrict-canonical/webui-thread")) { + return httpJson({ + ...transcriptFromSimpleMessages( + canonicalComplete + ? [ + { role: "user", content: "strict question", turnId }, + { role: "assistant", content: "strict canonical answer", turnId }, + ] + : [{ role: "user", content: "strict question", turnId }], + ), + has_pending_tool_calls: !canonicalComplete, + }); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + + render( + wrap( + client, + + {}} + onNewChat={() => {}} + /> + , + ), + ); + + await waitFor(() => expect(screen.getByText("strict question")).toBeInTheDocument()); + act(() => { + client._emitChat("strict-canonical", { + event: "goal_status", + chat_id: "strict-canonical", + status: "running", + started_at: 2_100, + turn_id: turnId, + }); + client._emitChat("strict-canonical", { + event: "delta", + chat_id: "strict-canonical", + text: "strict partial", + turn_id: turnId, + }); + }); + await waitFor(() => expect(screen.getByText("strict partial")).toBeInTheDocument()); + client.reconcileCanonicalCompletion.mockClear(); + const reconcileAfterCommit = client.reconcileCanonicalCompletion.getMockImplementation(); + client.reconcileCanonicalCompletion.mockImplementation((...args) => { + expect(screen.getByText("strict canonical answer")).toBeInTheDocument(); + return reconcileAfterCommit?.(...args) ?? false; + }); + canonicalComplete = true; + + act(() => document.dispatchEvent(new Event("visibilitychange"))); + + await waitFor(() => expect(screen.getByText("strict canonical answer")).toBeInTheDocument()); + expect(client.reconcileCanonicalCompletion).toHaveBeenCalledTimes(1); + expect(client.canReconcileCanonicalCompletion).toHaveBeenCalled(); + }); + + it("rolls back a committed candidate when the final lifecycle recheck loses", async () => { + const client = makeClient(); + const turnId = "turn-layout-recheck"; + let historyCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + if (String(input).includes("websocket%3Alayout-recheck/webui-thread")) { + historyCalls += 1; + return httpJson({ + ...transcriptFromSimpleMessages( + historyCalls === 1 + ? [{ role: "user", content: "layout question", turnId }] + : [ + { role: "user", content: "layout question", turnId }, + { role: "assistant", content: "layout canonical answer", turnId }, + ], + ), + has_pending_tool_calls: historyCalls === 1, + }); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + + render( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + + await waitFor(() => expect(screen.getByText("layout question")).toBeInTheDocument()); + act(() => { + client._emitChat("layout-recheck", { + event: "goal_status", + chat_id: "layout-recheck", + status: "running", + started_at: 2_200, + turn_id: turnId, + }); + client._emitChat("layout-recheck", { + event: "delta", + chat_id: "layout-recheck", + text: "layout partial", + turn_id: turnId, + }); + }); + await waitFor(() => expect(screen.getByText("layout partial")).toBeInTheDocument()); + + const reconcileAfterReject = client.reconcileCanonicalCompletion.getMockImplementation(); + client.reconcileCanonicalCompletion + .mockImplementationOnce(() => false) + .mockImplementation((...args) => reconcileAfterReject?.(...args) ?? false); + + act(() => document.dispatchEvent(new Event("visibilitychange"))); + + await waitFor(() => expect(historyCalls).toBe(2)); + await waitFor(() => + expect(client.reconcileCanonicalCompletion).toHaveBeenCalledTimes(1), + ); + expect(screen.getByText("layout partial")).toBeInTheDocument(); + expect(screen.queryByText("layout canonical answer")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument(); + + act(() => document.dispatchEvent(new Event("visibilitychange"))); + + await waitFor(() => expect(historyCalls).toBe(3)); + await waitFor(() => expect(screen.getByText("layout canonical answer")).toBeInTheDocument()); + expect(screen.queryByText("layout partial")).not.toBeInTheDocument(); + expect(client.reconcileCanonicalCompletion).toHaveBeenCalledTimes(2); + }); + + it("accepts the first reconnect refresh after switching away and back", async () => { + const client = makeClient(); + const oldTurnId = "turn-old"; + let newTurnId = ""; + let chatACalls = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("websocket%3Achat-version-a/webui-thread")) { + chatACalls += 1; + const rows = chatACalls <= 2 + ? [ + { role: "user" as const, content: "old question", turnId: oldTurnId }, + { role: "assistant" as const, content: "old answer", turnId: oldTurnId }, + ] + : [ + { role: "user" as const, content: "old question", turnId: oldTurnId }, + { role: "assistant" as const, content: "old answer", turnId: oldTurnId }, + { role: "user" as const, content: "new question", turnId: newTurnId }, + { + role: "assistant" as const, + content: "partial answer completed", + turnId: newTurnId, + }, + ]; + return httpJson({ + ...transcriptFromSimpleMessages(rows), + has_pending_tool_calls: false, + }); + } + if (url.includes("websocket%3Achat-version-b/webui-thread")) { + return httpJson(transcriptFromSimpleMessages([ + { role: "user", content: "other chat" }, + ])); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + const view = (chatId: string) => wrap( + client, + {}} + onNewChat={() => {}} + />, + ); + const { rerender } = render(view("chat-version-a")); + + await waitFor(() => expect(screen.getByText("old answer")).toBeInTheDocument()); + act(() => client._emitSessionUpdate("chat-version-a")); + await waitFor(() => expect(chatACalls).toBe(2)); + + fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), { + target: { value: "new question" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Send message" })); + await waitFor(() => expect(client.sendMessage).toHaveBeenCalledTimes(1)); + newTurnId = ( + client.sendMessage.mock.calls[0]?.[3] as { turnId?: string } | undefined + )?.turnId ?? ""; + expect(newTurnId).not.toBe(""); + act(() => { + client._emitChat("chat-version-a", { + event: "goal_status", + chat_id: "chat-version-a", + status: "running", + started_at: 2_000, + turn_id: newTurnId, + }); + client._emitChat("chat-version-a", { + event: "delta", + chat_id: "chat-version-a", + text: "partial answer", + turn_id: newTurnId, + }); + }); + await waitFor(() => expect(screen.getByText("partial answer")).toBeInTheDocument()); + + rerender(view("chat-version-b")); + await waitFor(() => expect(screen.getByText("other chat")).toBeInTheDocument()); + rerender(view("chat-version-a")); + await waitFor(() => expect(chatACalls).toBe(3)); + expect(screen.getByText("partial answer")).toBeInTheDocument(); + expect(screen.queryByText("partial answer completed")).not.toBeInTheDocument(); + + act(() => client._emitStatus("reconnecting")); + act(() => client._emitStatus("open")); + + await waitFor(() => expect(chatACalls).toBe(4)); + await waitFor(() => + expect(screen.getByText("partial answer completed")).toBeInTheDocument(), + ); + expect(screen.queryByText("partial answer")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Stop response" })).not.toBeInTheDocument(); + }); + + it("does not let an older completed snapshot clear a run that starts in flight", async () => { + const client = makeClient(); + const oldTurnId = "turn-before-refresh"; + let historyCalls = 0; + let resolveRefresh: + | ((value: { ok: boolean; status: number; json: () => Promise }) => void) + | null = null; + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (!url.includes("websocket%3Arun-generation-chat/webui-thread")) { + return Promise.resolve({ + ok: false, + status: 404, + json: async () => ({}), + }); + } + historyCalls += 1; + if (historyCalls === 1) { + return Promise.resolve(httpJson(transcriptFromSimpleMessages([ + { role: "user", content: "old question", turnId: oldTurnId }, + { role: "assistant", content: "old answer", turnId: oldTurnId }, + ]))); + } + return new Promise((resolve) => { + resolveRefresh = resolve; + }); + }), + ); + + render( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + + await waitFor(() => expect(screen.getByText("old answer")).toBeInTheDocument()); + act(() => document.dispatchEvent(new Event("visibilitychange"))); + await waitFor(() => expect(historyCalls).toBe(2)); + + const newTurnId = "turn-started-during-refresh"; + act(() => { + client._emitChat("run-generation-chat", { + event: "goal_status", + chat_id: "run-generation-chat", + status: "running", + started_at: 3_000, + turn_id: newTurnId, + }); + }); + const input = screen.getByRole("textbox", { name: "Message input" }); + fireEvent.change(input, { target: { value: "queued for the new run" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(client.sendMessage).not.toHaveBeenCalled(); + + await act(async () => { + resolveRefresh?.(httpJson({ + ...transcriptFromSimpleMessages([ + { role: "user", content: "old question", turnId: oldTurnId }, + { role: "assistant", content: "old answer", turnId: oldTurnId }, + ]), + has_pending_tool_calls: false, + })); + await Promise.resolve(); + }); + + expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument(); + expect(client.sendMessage).not.toHaveBeenCalled(); + }); + + it("fences websocket frames that arrive after canonical completion", async () => { + const client = makeClient(); + const turnId = "turn-http-won"; + let historyCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("websocket%3Alate-frame-chat/webui-thread")) { + historyCalls += 1; + return httpJson({ + ...transcriptFromSimpleMessages( + historyCalls === 1 + ? [{ role: "user", content: "question", turnId }] + : [ + { role: "user", content: "question", turnId }, + { role: "assistant", content: "canonical complete answer", turnId }, + ], + ), + has_pending_tool_calls: false, + }); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + + render( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + + await waitFor(() => expect(screen.getByText("question")).toBeInTheDocument()); + act(() => { + client._emitChat("late-frame-chat", { + event: "goal_status", + chat_id: "late-frame-chat", + status: "running", + started_at: 4_000, + turn_id: turnId, + }); + client._emitChat("late-frame-chat", { + event: "delta", + chat_id: "late-frame-chat", + text: "partial", + turn_id: turnId, + }); + }); + await waitFor(() => expect(screen.getByText("partial")).toBeInTheDocument()); + + act(() => document.dispatchEvent(new Event("visibilitychange"))); + await waitFor(() => + expect(screen.getByText("canonical complete answer")).toBeInTheDocument(), + ); + + act(() => { + client._emitChat("late-frame-chat", { + event: "delta", + chat_id: "late-frame-chat", + text: " delayed duplicate", + turn_id: turnId, + }); + client._emitChat("late-frame-chat", { + event: "turn_end", + chat_id: "late-frame-chat", + turn_id: turnId, + }); + client._emitSessionUpdate("late-frame-chat"); + }); + + await waitFor(() => expect(historyCalls).toBe(3)); + expect(screen.getAllByText("canonical complete answer")).toHaveLength(1); + expect(screen.queryByText(" delayed duplicate")).not.toBeInTheDocument(); + expect(screen.queryByText("canonical complete answer delayed duplicate")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Stop response" })).not.toBeInTheDocument(); + }); + + it("does not revive a canonically completed run after switching chats", async () => { + const client = makeClient(); + const turnId = "turn-visibility-complete"; + let chatACalls = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("websocket%3Avisibility-complete-a/webui-thread")) { + chatACalls += 1; + return httpJson({ + ...transcriptFromSimpleMessages( + chatACalls === 1 + ? [{ role: "user", content: "question", turnId }] + : [ + { role: "user", content: "question", turnId }, + { role: "assistant", content: "completed while hidden", turnId }, + ], + ), + has_pending_tool_calls: false, + }); + } + if (url.includes("websocket%3Avisibility-complete-b/webui-thread")) { + return httpJson(transcriptFromSimpleMessages([ + { role: "user", content: "other thread" }, + ])); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + const view = (chatId: string) => wrap( + client, + {}} + onNewChat={() => {}} + />, + ); + const { rerender } = render(view("visibility-complete-a")); + + await waitFor(() => expect(screen.getByText("question")).toBeInTheDocument()); + act(() => { + client._emitChat("visibility-complete-a", { + event: "goal_status", + chat_id: "visibility-complete-a", + status: "running", + started_at: 5_000, + turn_id: turnId, + }); + }); + expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument(); + + act(() => document.dispatchEvent(new Event("visibilitychange"))); + await waitFor(() => expect(screen.getByText("completed while hidden")).toBeInTheDocument()); + expect(screen.queryByRole("button", { name: "Stop response" })).not.toBeInTheDocument(); + expect(client.getRunStartedAt("visibility-complete-a")).toBeNull(); + + rerender(view("visibility-complete-b")); + await waitFor(() => expect(screen.getByText("other thread")).toBeInTheDocument()); + rerender(view("visibility-complete-a")); + await waitFor(() => expect(screen.getByText("completed while hidden")).toBeInTheDocument()); + expect(screen.queryByRole("button", { name: "Stop response" })).not.toBeInTheDocument(); + }); + + it("uses explicit completion ids when a completed turn has no assistant row", async () => { + const client = makeClient(); + const turnId = "turn-empty-answer"; + let historyCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + if (String(input).includes("websocket%3Aempty-answer/webui-thread")) { + historyCalls += 1; + return httpJson({ + ...transcriptFromSimpleMessages([ + { role: "user", content: "stop", turnId }, + ]), + has_pending_tool_calls: historyCalls === 1, + completed_turn_ids: historyCalls === 1 ? [] : [turnId], + }); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + render( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + + await waitFor(() => expect(screen.getByText("stop")).toBeInTheDocument()); + act(() => { + client._emitChat("empty-answer", { + event: "goal_status", + chat_id: "empty-answer", + status: "running", + started_at: 5_000, + turn_id: turnId, + }); + }); + expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument(); + + act(() => document.dispatchEvent(new Event("visibilitychange"))); + + await waitFor(() => expect(historyCalls).toBe(2)); + await waitFor(() => expect(client.reconcileCanonicalCompletion).toHaveBeenCalledWith( + "empty-answer", + expect.any(Number), + expect.arrayContaining([turnId]), + expect.objectContaining({ + observedTurnIds: [turnId], + hasPendingToolCalls: false, + }), + )); + expect(screen.queryByRole("button", { name: "Stop response" })).not.toBeInTheDocument(); + }); + + it("converges after reconnecting before the first assistant delta", async () => { + const client = makeClient(); + let historyCalls = 0; + const turnId = "turn-resume-before-delta"; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("websocket%3Abefore-delta-chat/webui-thread")) { + historyCalls += 1; + const transcript = transcriptFromSimpleMessages( + historyCalls === 1 + ? [{ role: "user", content: "question", turnId }] + : [ + { role: "user", content: "question", turnId }, + { + role: "assistant", + content: historyCalls === 2 + ? "missed prefix" + : "missed prefix resumed suffix", + turnId, + }, + ], + ); + return httpJson({ + ...transcript, + has_pending_tool_calls: historyCalls === 2, + }); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + + render( + wrap( + client, + {}} + onGoHome={() => {}} + onNewChat={() => {}} + />, + ), + ); + + await waitFor(() => expect(screen.getByText("question")).toBeInTheDocument()); + act(() => { + client._emitChat("before-delta-chat", { + event: "goal_status", + chat_id: "before-delta-chat", + status: "running", + started_at: 1_700, + turn_id: turnId, + }); + }); + const input = screen.getByRole("textbox", { name: "Message input" }); + fireEvent.change(input, { target: { value: "queued guidance" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(client.sendMessage).not.toHaveBeenCalled(); + + act(() => client._emitStatus("reconnecting")); + act(() => client._emitStatus("open")); + await waitFor(() => expect(historyCalls).toBe(2)); + expect(screen.queryByText("missed prefix")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument(); + expect(client.sendMessage).not.toHaveBeenCalled(); + + act(() => { + client._emitChat("before-delta-chat", { + event: "delta", + chat_id: "before-delta-chat", + text: "resumed suffix", + turn_id: turnId, + }); + }); + await waitFor(() => expect(screen.getByText("resumed suffix")).toBeInTheDocument()); + + act(() => client._emitStatus("reconnecting")); + act(() => client._emitStatus("open")); + await waitFor(() => expect(historyCalls).toBe(3)); + await waitFor(() => + expect(screen.getByText("missed prefix resumed suffix")).toBeInTheDocument(), + ); + expect(screen.queryByText("resumed suffix")).not.toBeInTheDocument(); + await waitFor(() => expectSendMessageWithTurn( + client, + "before-delta-chat", + "queued guidance", + )); + }); + + it("keeps the live answer cursor when a resumed turn is still running", async () => { + const client = makeClient(); + let historyCalls = 0; + const turnId = "turn-active-resume"; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("websocket%3Aactive-resume-chat/webui-thread")) { + historyCalls += 1; + const transcript = transcriptFromSimpleMessages( + historyCalls === 1 + ? [{ role: "user", content: "question", turnId }] + : [ + { role: "user", content: "question", turnId }, + { + role: "assistant", + content: historyCalls === 2 + ? "partial answer missed" + : "partial answer missed resumed", + turnId, + }, + ], + ); + return httpJson( + historyCalls === 1 + ? transcript + : { ...transcript, has_pending_tool_calls: historyCalls === 2 }, + ); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + + render( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + + await waitFor(() => expect(screen.getByText("question")).toBeInTheDocument()); + act(() => { + client._emitChat("active-resume-chat", { + event: "goal_status", + chat_id: "active-resume-chat", + status: "running", + started_at: 1_700, + turn_id: turnId, + }); + client._emitChat("active-resume-chat", { + event: "delta", + chat_id: "active-resume-chat", + text: "partial answer", + turn_id: turnId, + }); + }); + await waitFor(() => expect(screen.getByText("partial answer")).toBeInTheDocument()); + const input = screen.getByRole("textbox", { name: "Message input" }); + fireEvent.change(input, { target: { value: "queued guidance" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(screen.getByText("queued guidance")).toBeInTheDocument(); + expect(client.sendMessage).not.toHaveBeenCalled(); + + act(() => client._emitStatus("reconnecting")); + expect(client.sendMessage).not.toHaveBeenCalled(); + act(() => client._emitStatus("open")); + await waitFor(() => expect(historyCalls).toBe(2)); + expect(client.sendMessage).not.toHaveBeenCalled(); + + act(() => { + client._emitChat("active-resume-chat", { + event: "delta", + chat_id: "active-resume-chat", + text: " resumed", + turn_id: turnId, + }); + }); + + await waitFor(() => expect(screen.getByText("partial answer resumed")).toBeInTheDocument()); + expect(screen.queryByText(" resumed")).not.toBeInTheDocument(); + expect(client.sendMessage).not.toHaveBeenCalled(); + + act(() => client._emitStatus("reconnecting")); + expect(client.sendMessage).not.toHaveBeenCalled(); + act(() => client._emitStatus("open")); + + await waitFor(() => expect(historyCalls).toBe(3)); + await waitFor(() => + expect(screen.getByText("partial answer missed resumed")).toBeInTheDocument(), + ); + expect(screen.queryByText("partial answer resumed")).not.toBeInTheDocument(); + await waitFor(() => expectSendMessageWithTurn( + client, + "active-resume-chat", + "queued guidance", + )); + }); + + it("refreshes the current thread when the page returns to the foreground", async () => { + const client = makeClient(); + let historyCalls = 0; + const visibilityDescriptor = Object.getOwnPropertyDescriptor(document, "visibilityState"); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("websocket%3Avisible-chat/webui-thread")) { + historyCalls += 1; + return httpJson( + transcriptFromSimpleMessages( + historyCalls === 1 + ? [{ role: "user", content: "question" }] + : [ + { role: "user", content: "question" }, + { role: "assistant", content: "answer completed in background" }, + ], + ), + ); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + + try { + render( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + await waitFor(() => expect(screen.getByText("question")).toBeInTheDocument()); + expect(historyCalls).toBe(1); + + act(() => { + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "hidden", + }); + document.dispatchEvent(new Event("visibilitychange")); + }); + expect(historyCalls).toBe(1); + + await act(async () => { + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "visible", + }); + document.dispatchEvent(new Event("visibilitychange")); + await Promise.resolve(); + }); + + await waitFor(() => expect(historyCalls).toBe(2)); + await waitFor(() => + expect(screen.getByText("answer completed in background")).toBeInTheDocument(), + ); + } finally { + if (visibilityDescriptor) { + Object.defineProperty(document, "visibilityState", visibilityDescriptor); + } else { + delete (document as Document & { visibilityState?: DocumentVisibilityState }).visibilityState; + } + } + }); + it("does not refetch thread history on turn_end", async () => { const client = makeClient(); let historyCalls = 0; diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index ebd2db0d9..8ced5bccf 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -3,15 +3,20 @@ import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; import { useNanobotStream } from "@/hooks/useNanobotStream"; -import type { InboundEvent, GoalStateWsPayload } from "@/lib/types"; +import type { StreamError } from "@/lib/nanobot-client"; +import type { ConnectionStatus, InboundEvent, GoalStateWsPayload } from "@/lib/types"; import { ClientProvider } from "@/providers/ClientProvider"; const EMPTY_MESSAGES: import("@/lib/types").UIMessage[] = []; function fakeClient() { const handlers = new Map void>>(); + const statusHandlers = new Set<(status: ConnectionStatus) => void>(); + const errorHandlers = new Set<(error: StreamError) => void>(); const runStartedAtByChatId = new Map(); + const unsettledRunByChatId = new Map(); const goalStateByChatId = new Map(); + let status: ConnectionStatus = "open"; function recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent) { if (ev.event === "turn_end") { @@ -38,10 +43,19 @@ function fakeClient() { return { client: { - status: "open" as const, + get status() { + return status; + }, defaultChatId: null as string | null, - onStatus: () => () => {}, - onError: () => () => {}, + onStatus(handler: (nextStatus: ConnectionStatus) => void) { + statusHandlers.add(handler); + handler(status); + return () => statusHandlers.delete(handler); + }, + onError(handler: (error: StreamError) => void) { + errorHandlers.add(handler); + return () => errorHandlers.delete(handler); + }, getRunStartedAt(chatId: string) { const v = runStartedAtByChatId.get(chatId); return v === undefined ? null : v; @@ -49,6 +63,9 @@ function fakeClient() { getGoalState(chatId: string) { return goalStateByChatId.get(chatId); }, + hasUnsettledRun(chatId: string) { + return unsettledRunByChatId.get(chatId) === true; + }, onChat(chatId: string, h: (ev: InboundEvent) => void) { let set = handlers.get(chatId); if (!set) { @@ -72,6 +89,16 @@ function fakeClient() { const set = handlers.get(chatId); set?.forEach((h) => h(ev)); }, + emitStatus(nextStatus: ConnectionStatus) { + status = nextStatus; + statusHandlers.forEach((handler) => handler(status)); + }, + emitError(error: StreamError) { + errorHandlers.forEach((handler) => handler(error)); + }, + setUnsettled(chatId: string, unsettled: boolean) { + unsettledRunByChatId.set(chatId, unsettled); + }, }; } @@ -180,6 +207,64 @@ describe("useNanobotStream", () => { } }); + it("keeps the turn pending on disconnect without breaking a resumed stream", async () => { + const fake = fakeClient(); + const { result } = renderHook( + () => useNanobotStream("chat-reconnect", EMPTY_MESSAGES), + { wrapper: wrap(fake.client) }, + ); + + act(() => { + fake.emit("chat-reconnect", { + event: "goal_status", + chat_id: "chat-reconnect", + status: "running", + started_at: 1_700, + }); + fake.emit("chat-reconnect", { + event: "delta", + chat_id: "chat-reconnect", + text: "partial", + }); + }); + await flushStreamFrame(); + const assistantId = result.current.messages[0].id; + expect(result.current.isStreaming).toBe(true); + + act(() => fake.emitStatus("reconnecting")); + expect(result.current.runStartedAt).toBe(1_700); + expect(result.current.isStreaming).toBe(true); + expect(result.current.messages[0]).toMatchObject({ + id: assistantId, + content: "partial", + isStreaming: true, + }); + + act(() => { + fake.emitStatus("open"); + fake.emit("chat-reconnect", { + event: "goal_status", + chat_id: "chat-reconnect", + status: "running", + started_at: 1_800, + }); + fake.emit("chat-reconnect", { + event: "delta", + chat_id: "chat-reconnect", + text: " resumed", + }); + }); + await flushStreamFrame(); + + expect(result.current.runStartedAt).toBe(1_800); + expect(result.current.isStreaming).toBe(true); + expect(result.current.messages[0]).toMatchObject({ + id: assistantId, + content: "partial resumed", + isStreaming: true, + }); + }); + it("flushes pending delta text before turn_end finalizes the turn", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-flush", EMPTY_MESSAGES), { @@ -1596,6 +1681,224 @@ describe("useNanobotStream", () => { ); }); + it("removes only the optimistic turn named by a correlated rejection", () => { + const fake = fakeClient(); + const { result } = renderHook( + () => useNanobotStream("chat-reject-one", EMPTY_MESSAGES), + { wrapper: wrap(fake.client) }, + ); + let first: ReturnType = null; + let second: ReturnType = null; + act(() => { + first = result.current.send("first"); + second = result.current.send("second"); + }); + fake.setUnsettled("chat-reject-one", true); + + act(() => { + fake.emitError({ + kind: "turn_rejected", + detail: "message_rejected", + chatId: "chat-reject-one", + turnId: first!.turnId, + }); + }); + + expect(result.current.messages).toEqual([ + expect.objectContaining({ + id: second!.userMessageId, + turnId: second!.turnId, + content: "second", + }), + ]); + expect(result.current.isStreaming).toBe(true); + expect(result.current.streamError).toMatchObject({ + kind: "turn_rejected", + turnId: first!.turnId, + }); + }); + + it("falls back to the previous running turn when the newer turn is rejected", () => { + const fake = fakeClient(); + const { result } = renderHook( + () => useNanobotStream("chat-reject-new", EMPTY_MESSAGES), + { wrapper: wrap(fake.client) }, + ); + let first: ReturnType = null; + let second: ReturnType = null; + act(() => { + first = result.current.send("first"); + fake.emit("chat-reject-new", { + event: "goal_status", + chat_id: "chat-reject-new", + status: "running", + started_at: 1234, + turn_id: first!.turnId, + }); + second = result.current.send("second"); + }); + + act(() => { + fake.emitError({ + kind: "turn_rejected", + detail: "attachment_rejected", + chatId: "chat-reject-new", + turnId: second!.turnId, + }); + }); + + expect(result.current.messages).toEqual([ + expect.objectContaining({ + id: first!.userMessageId, + turnId: first!.turnId, + }), + ]); + expect(result.current.runStartedAt).toBe(1234); + expect(result.current.isStreaming).toBe(true); + }); + + it("ends the spinner and drops pending stream work when the only turn is rejected", async () => { + const fake = fakeClient(); + const { result } = renderHook( + () => useNanobotStream("chat-reject-only", EMPTY_MESSAGES), + { wrapper: wrap(fake.client) }, + ); + let submitted: ReturnType = null; + act(() => { + submitted = result.current.send("only"); + fake.emit("chat-reject-only", { + event: "delta", + chat_id: "chat-reject-only", + turn_id: submitted!.turnId, + text: "must not survive", + }); + }); + + act(() => { + fake.emitError({ + kind: "turn_rejected", + detail: "access_denied", + chatId: "chat-reject-only", + turnId: submitted!.turnId, + }); + }); + await flushStreamFrame(); + + expect(result.current.messages).toEqual([]); + expect(result.current.runStartedAt).toBeNull(); + expect(result.current.isStreaming).toBe(false); + }); + + it("applies a correlated rejection replayed through the chat event queue", () => { + const fake = fakeClient(); + const { result } = renderHook( + () => useNanobotStream("chat-replayed-reject", EMPTY_MESSAGES), + { wrapper: wrap(fake.client) }, + ); + let submitted: ReturnType = null; + act(() => { + submitted = result.current.send("queued optimistic row"); + }); + + act(() => { + fake.emit("chat-replayed-reject", { + event: "error", + detail: "message_rejected", + reason: "policy", + chat_id: "chat-replayed-reject", + turn_id: submitted!.turnId, + }); + }); + + expect(result.current.messages).toEqual([]); + expect(result.current.streamError).toMatchObject({ + kind: "turn_rejected", + chatId: "chat-replayed-reject", + turnId: submitted!.turnId, + }); + }); + + it("does not show or apply an error correlated to another chat", () => { + const fake = fakeClient(); + const { result } = renderHook( + () => useNanobotStream("chat-visible", EMPTY_MESSAGES), + { wrapper: wrap(fake.client) }, + ); + let submitted: ReturnType = null; + act(() => { + submitted = result.current.send("stay"); + }); + + act(() => { + fake.emitError({ + kind: "turn_rejected", + detail: "message_rejected", + chatId: "chat-background", + turnId: submitted!.turnId, + }); + }); + + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages[0].content).toBe("stay"); + expect(result.current.streamError).toBeNull(); + }); + + it("shows an uncorrelated 1009 fault without rolling back the current turn", () => { + const fake = fakeClient(); + const { result } = renderHook( + () => useNanobotStream("chat-generic-1009", EMPTY_MESSAGES), + { wrapper: wrap(fake.client) }, + ); + act(() => { + result.current.send("stay visible"); + fake.emitError({ kind: "message_too_big" }); + }); + + expect(result.current.messages).toEqual([ + expect.objectContaining({ role: "user", content: "stay visible" }), + ]); + expect(result.current.streamError).toEqual({ kind: "message_too_big" }); + }); + + it("removes rejected side-channel guidance without stopping the main run", () => { + const fake = fakeClient(); + const { result } = renderHook( + () => useNanobotStream("chat-side-reject", EMPTY_MESSAGES), + { wrapper: wrap(fake.client) }, + ); + let main: ReturnType = null; + let side: ReturnType = null; + act(() => { + main = result.current.send("main"); + fake.emit("chat-side-reject", { + event: "goal_status", + chat_id: "chat-side-reject", + status: "running", + started_at: 9876, + turn_id: main!.turnId, + }); + side = result.current.send("guidance", undefined, { sideChannel: true }); + }); + + act(() => { + fake.emitError({ + kind: "turn_rejected", + detail: "message_rejected", + chatId: "chat-side-reject", + turnId: side!.turnId, + }); + }); + + expect(result.current.messages).toEqual([ + expect.objectContaining({ + id: main!.userMessageId, + turnId: main!.turnId, + }), + ]); + expect(result.current.runStartedAt).toBe(9876); + expect(result.current.isStreaming).toBe(true); + }); + it("adds optimistic user file attachments as media", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-file-send", EMPTY_MESSAGES), { @@ -1801,6 +2104,7 @@ describe("useNanobotStream", () => { const call = fake.client.sendMessage.mock.calls.at(-1)!; const turnId = call[3]?.turnId; expect(call[3]).not.toHaveProperty("sideChannel"); + expect(call[3]).toMatchObject({ startsNewRun: false }); expect(result.current.isStreaming).toBe(false); act(() => { @@ -1956,6 +2260,7 @@ describe("useNanobotStream", () => { const guideCall = fake.client.sendMessage.mock.calls.at(-1)!; expect(guideCall[3]).not.toHaveProperty("continueActiveTurn"); + expect(guideCall[3]).toMatchObject({ startsNewRun: false }); expect(result.current.messages.map((message) => message.content)).toEqual([ "research this", "Initial findings", diff --git a/webui/src/tests/useSessions.test.tsx b/webui/src/tests/useSessions.test.tsx index e1f1c6d04..82f291827 100644 --- a/webui/src/tests/useSessions.test.tsx +++ b/webui/src/tests/useSessions.test.tsx @@ -450,6 +450,32 @@ describe("useSessions", () => { expect(result.current.hasPendingToolCalls).toBe(false); }); + it("exposes turn ids backed by persisted completion events", async () => { + vi.mocked(api.fetchWebuiThread).mockResolvedValue({ + schemaVersion: 3, + has_pending_tool_calls: false, + completed_turn_ids: ["turn-empty", "", "turn-empty"], + messages: [ + { + id: "u1", + role: "user", + content: "stop", + turnId: "turn-empty", + createdAt: 1, + }, + ], + }); + + const { result } = renderHook(() => useSessionHistory("websocket:chat-empty"), { + wrapper: wrap(fakeClient()), + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.completedTurnIds).toEqual(["turn-empty"]); + expect(result.current.hasPendingToolCalls).toBe(false); + }); + it("does not flag transcript as pending when last row is not a trace", async () => { vi.mocked(api.fetchWebuiThread).mockResolvedValue({ schemaVersion: 3, @@ -520,6 +546,9 @@ describe("useSessions", () => { }); expect(result.current.hasMoreBefore).toBe(true); expect(result.current.userMessageOffset).toBe(1); + const latestVersion = result.current.version; + const latestLineage = result.current.lineage; + expect(result.current.continuity).toBe("initial"); await act(async () => { await result.current.loadOlder(); @@ -537,6 +566,372 @@ describe("useSessions", () => { ]); expect(result.current.hasMoreBefore).toBe(false); expect(result.current.userMessageOffset).toBe(0); + expect(result.current.version).toBe(latestVersion); + expect(result.current.lineage).toBe(latestLineage); + expect(result.current.continuity).toBe("initial"); + }); + + it("preserves a loaded prefix when a canonical latest window overlaps its tail", async () => { + vi.mocked(api.fetchWebuiThread) + .mockResolvedValueOnce({ + schemaVersion: 3, + has_pending_tool_calls: true, + messages: [ + { id: "u2", role: "user", content: "middle question", createdAt: 2 }, + { id: "a2", role: "assistant", content: "middle answer", createdAt: 3 }, + ], + page: { + before_cursor: "cursor-middle", + has_more_before: true, + loaded_message_count: 2, + user_message_offset: 1, + }, + }) + .mockResolvedValueOnce({ + schemaVersion: 3, + messages: [ + { id: "u1", role: "user", content: "old question", createdAt: 0 }, + { id: "a1", role: "assistant", content: "old answer", createdAt: 1 }, + ], + page: { + before_cursor: null, + has_more_before: false, + loaded_message_count: 2, + user_message_offset: 0, + }, + }) + .mockResolvedValueOnce({ + schemaVersion: 3, + has_pending_tool_calls: false, + completed_turn_ids: ["turn-3"], + messages: [ + { id: "a2-replayed", role: "assistant", content: "middle answer", createdAt: 3 }, + { + id: "u3", + role: "user", + content: "latest question", + turnId: "turn-3", + createdAt: 4, + }, + { + id: "a3", + role: "assistant", + content: "latest answer", + turnId: "turn-3", + createdAt: 5, + }, + ], + page: { + before_cursor: "cursor-shifted", + has_more_before: true, + loaded_message_count: 3, + user_message_offset: 1, + }, + }); + + const { result } = renderHook(() => useSessionHistory("websocket:paged-refresh"), { + wrapper: wrap(fakeClient()), + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(async () => { + await result.current.loadOlder(); + }); + const loadedVersion = result.current.version; + const loadedLineage = result.current.lineage; + + act(() => result.current.refresh()); + await waitFor(() => expect(result.current.version).toBeGreaterThan(loadedVersion)); + + expect(result.current.messages.map((message) => message.id)).toEqual([ + "u1", + "a1", + "u2", + "a2-replayed", + "u3", + "a3", + ]); + expect(result.current.hasMoreBefore).toBe(false); + expect(result.current.userMessageOffset).toBe(0); + expect(result.current.hasPendingToolCalls).toBe(false); + expect(result.current.completedTurnIds).toEqual(["turn-3"]); + expect(result.current.continuity).toBe("overlap"); + expect(result.current.lineage).toBe(loadedLineage); + }); + + it("starts a new lineage when more than 160 new rows remove all latest-page overlap", async () => { + const oldWindow = Array.from({ length: 160 }, (_, index) => ({ + id: `old-${index}`, + role: index % 2 === 0 ? "user" as const : "assistant" as const, + content: `old window row ${index}`, + turnId: `old-turn-${Math.floor(index / 2)}`, + createdAt: index, + })); + const newWindow = Array.from({ length: 160 }, (_, index) => ({ + id: `new-${index}`, + role: index % 2 === 0 ? "user" as const : "assistant" as const, + content: `new window row ${index}`, + turnId: `new-turn-${Math.floor(index / 2)}`, + createdAt: 1_000 + index, + })); + vi.mocked(api.fetchWebuiThread) + .mockResolvedValueOnce({ + schemaVersion: 3, + messages: oldWindow, + page: { + before_cursor: "old-window-cursor", + has_more_before: true, + }, + }) + .mockResolvedValueOnce({ + schemaVersion: 3, + messages: newWindow, + page: { + before_cursor: "new-window-cursor", + has_more_before: true, + }, + }); + + const { result } = renderHook(() => useSessionHistory("websocket:window-reset"), { + wrapper: wrap(fakeClient()), + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + const initialLineage = result.current.lineage; + expect(result.current.messages[0]?.id).toBe("old-0"); + + act(() => result.current.refresh()); + await waitFor(() => expect(result.current.messages[0]?.id).toBe("new-0")); + + expect(result.current.messages).toHaveLength(160); + expect(result.current.messages.at(-1)?.id).toBe("new-159"); + expect(result.current.continuity).toBe("reset"); + expect(result.current.lineage).toBeGreaterThan(initialLineage); + expect(result.current.hasMoreBefore).toBe(true); + }); + + it("uses the longest consecutive semantic overlap for legacy unstable replay metadata", async () => { + vi.mocked(api.fetchWebuiThread) + .mockResolvedValueOnce({ + schemaVersion: 3, + messages: [ + { id: "repeat-1-old", role: "user", content: "repeat", createdAt: 10 }, + { id: "answer-1-old", role: "assistant", content: "first answer", createdAt: 11 }, + { id: "repeat-2-old", role: "user", content: "repeat", createdAt: 12 }, + { id: "answer-2-old", role: "assistant", content: "second answer", createdAt: 13 }, + ], + page: { + before_cursor: "legacy-cursor", + has_more_before: true, + }, + }) + .mockResolvedValueOnce({ + schemaVersion: 3, + messages: [ + { id: "old-prefix", role: "user", content: "old prefix", createdAt: 1 }, + ], + page: { + before_cursor: null, + has_more_before: false, + }, + }) + .mockResolvedValueOnce({ + schemaVersion: 3, + messages: [ + { id: "repeat-2-new", role: "user", content: "repeat", createdAt: 9_012 }, + { id: "answer-2-new", role: "assistant", content: "second answer", createdAt: 9_013 }, + { id: "new-tail", role: "assistant", content: "new tail", createdAt: 9_014 }, + ], + page: { + before_cursor: "shifted-legacy-cursor", + has_more_before: true, + }, + }); + + const { result } = renderHook(() => useSessionHistory("websocket:legacy-overlap"), { + wrapper: wrap(fakeClient()), + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(async () => { + await result.current.loadOlder(); + }); + const lineage = result.current.lineage; + + act(() => result.current.refresh()); + await waitFor(() => expect(result.current.messages.at(-1)?.id).toBe("new-tail")); + + expect(result.current.messages.map((message) => message.id)).toEqual([ + "old-prefix", + "repeat-1-old", + "answer-1-old", + "repeat-2-new", + "answer-2-new", + "new-tail", + ]); + expect(result.current.continuity).toBe("overlap"); + expect(result.current.lineage).toBe(lineage); + }); + + it("ignores an older-page response after a latest refresh resets its lineage", async () => { + let resolveOlder: + | ((value: Awaited>) => void) + | null = null; + vi.mocked(api.fetchWebuiThread) + .mockResolvedValueOnce({ + schemaVersion: 3, + messages: [ + { id: "old-latest", role: "assistant", content: "old latest", createdAt: 10 }, + ], + page: { + before_cursor: "cursor-old-lineage", + has_more_before: true, + }, + }) + .mockImplementationOnce(() => new Promise((resolve) => { + resolveOlder = resolve; + })) + .mockResolvedValueOnce({ + schemaVersion: 3, + messages: [ + { id: "new-latest", role: "assistant", content: "new latest", createdAt: 20 }, + ], + page: { + before_cursor: "cursor-new-lineage", + has_more_before: true, + }, + }); + + const { result } = renderHook(() => useSessionHistory("websocket:paged-race"), { + wrapper: wrap(fakeClient()), + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + const oldLineage = result.current.lineage; + let olderRequest: Promise | undefined; + act(() => { + olderRequest = result.current.loadOlder(); + }); + await waitFor(() => expect(api.fetchWebuiThread).toHaveBeenCalledTimes(2)); + + act(() => result.current.refresh()); + await waitFor(() => expect(result.current.messages[0]?.id).toBe("new-latest")); + expect(result.current.continuity).toBe("reset"); + expect(result.current.lineage).toBeGreaterThan(oldLineage); + + await act(async () => { + resolveOlder?.({ + schemaVersion: 3, + messages: [ + { id: "stale-prefix", role: "user", content: "stale prefix", createdAt: 1 }, + ], + page: { + before_cursor: null, + has_more_before: false, + }, + }); + await olderRequest; + }); + + expect(result.current.messages.map((message) => message.id)).toEqual(["new-latest"]); + expect(result.current.hasMoreBefore).toBe(true); + }); + + it("preserves authoritative active state while prepending older history", async () => { + vi.mocked(api.fetchWebuiThread) + .mockResolvedValueOnce({ + schemaVersion: 3, + has_pending_tool_calls: true, + messages: [ + { id: "u2", role: "user", content: "current question", createdAt: 2 }, + { id: "a2", role: "assistant", content: "partial answer", createdAt: 3 }, + ], + page: { + before_cursor: "cursor-active", + has_more_before: true, + loaded_message_count: 2, + user_message_offset: 1, + }, + }) + .mockResolvedValueOnce({ + schemaVersion: 3, + has_pending_tool_calls: false, + messages: [ + { id: "u1", role: "user", content: "old question", createdAt: 0 }, + { id: "a1", role: "assistant", content: "old answer", createdAt: 1 }, + ], + page: { + before_cursor: null, + has_more_before: false, + loaded_message_count: 2, + user_message_offset: 0, + }, + }); + + const { result } = renderHook(() => useSessionHistory("websocket:paged-active"), { + wrapper: wrap(fakeClient()), + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.hasPendingToolCalls).toBe(true); + const latestVersion = result.current.version; + + await act(async () => { + await result.current.loadOlder(); + }); + + expect(result.current.hasPendingToolCalls).toBe(true); + expect(result.current.version).toBe(latestVersion); + }); + + it("preserves authoritative completed state while prepending trace history", async () => { + vi.mocked(api.fetchWebuiThread) + .mockResolvedValueOnce({ + schemaVersion: 3, + has_pending_tool_calls: false, + messages: [ + { + id: "t2", + role: "tool", + kind: "trace", + content: "completed trace", + traces: ["completed trace"], + createdAt: 2, + }, + ], + page: { + before_cursor: "cursor-complete", + has_more_before: true, + loaded_message_count: 1, + user_message_offset: 1, + }, + }) + .mockResolvedValueOnce({ + schemaVersion: 3, + messages: [ + { id: "u1", role: "user", content: "old question", createdAt: 0 }, + { id: "a1", role: "assistant", content: "old answer", createdAt: 1 }, + ], + page: { + before_cursor: null, + has_more_before: false, + loaded_message_count: 2, + user_message_offset: 0, + }, + }); + + const { result } = renderHook(() => useSessionHistory("websocket:paged-complete"), { + wrapper: wrap(fakeClient()), + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.hasPendingToolCalls).toBe(false); + + await act(async () => { + await result.current.loadOlder(); + }); + + expect(result.current.hasPendingToolCalls).toBe(false); }); it("keeps the session in the list when delete fails", async () => {