diff --git a/nanobot/channels/websocket/runtime.py b/nanobot/channels/websocket/runtime.py index b3d92dd68..1c8a67f50 100644 --- a/nanobot/channels/websocket/runtime.py +++ b/nanobot/channels/websocket/runtime.py @@ -21,10 +21,7 @@ from websockets.exceptions import ConnectionClosed from websockets.http11 import Request as WsRequest from nanobot.bus.events import ( - INBOUND_META_RUNTIME_CONTROL, OUTBOUND_META_AGENT_UI, - RUNTIME_CONTROL_SESSION_DISCARD, - InboundMessage, OutboundMessage, ) from nanobot.bus.outbound_events import ( @@ -89,6 +86,7 @@ from nanobot.webui.session_access import ( session_mentions_runtime_context, ) from nanobot.webui.sidebar_state import write_webui_sidebar_state +from nanobot.webui.temporary_chats import TemporaryChatError 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 @@ -326,23 +324,12 @@ def _parse_inbound_payload(raw: str) -> str | None: # Accept UUIDs and short scoped keys like "unified:default". Keeps the capability # namespace small enough to rule out path traversal / quote injection tricks. _CHAT_ID_RE = re.compile(r"^[A-Za-z0-9_:-]{1,64}$") -_TEMPORARY_CHAT_PREFIX = "temporary-" -_TEMPORARY_CHAT_DISABLED_TOOLS = frozenset({ - "create_goal", - "update_goal", - "spawn", - "cron", -}) def _is_valid_chat_id(value: Any) -> TypeGuard[str]: return isinstance(value, str) and _CHAT_ID_RE.match(value) is not None -def _is_temporary_chat_id(value: Any) -> TypeGuard[str]: - return _is_valid_chat_id(value) and value.startswith(_TEMPORARY_CHAT_PREFIX) - - def _parse_envelope(raw: str) -> dict[str, Any] | None: """Return a typed envelope dict if the frame is a new-style JSON envelope, else None. @@ -412,6 +399,7 @@ class WebSocketChannel(BaseChannel): self._ingress = gateway.ingress self._transcripts = gateway.transcripts self._workspaces = gateway.workspaces + self._temporary_chats = gateway.temporary_chats self._session_access = ( WebuiSessionAccess(gateway.session_manager) if gateway.session_manager is not None @@ -419,7 +407,6 @@ class WebSocketChannel(BaseChannel): ) self._stream_text_buffers: dict[tuple[str, str], list[str]] = {} - self._temporary_media_paths: dict[str, set[str]] = {} # -- Subscription bookkeeping ------------------------------------------- @@ -448,38 +435,15 @@ class WebSocketChannel(BaseChannel): if key[0] == chat_id: self._stream_text_buffers.pop(key, None) - def _discard_temporary_media(self, chat_id: str) -> None: - """Remove uploads owned by one connection-scoped temporary chat.""" - for raw_path in self._temporary_media_paths.pop(chat_id, set()): - try: - Path(raw_path).unlink(missing_ok=True) - except OSError: - self.logger.warning("failed to remove a temporary WebUI attachment") - - async def _discard_temporary_chat( + async def _discard_connection_owned_chat( self, connection: ServerConnection, chat_id: str, ) -> None: - session_key = f"{self.name}:{chat_id}" + await self._temporary_chats.discard(connection, chat_id) self._detach(connection, chat_id) clear_websocket_turns(chat_id) self._clear_stream_buffers(chat_id) - self._discard_temporary_media(chat_id) - if self.gateway.session_manager is not None: - self.gateway.session_manager.invalidate(session_key) - await self.bus.publish_inbound( - InboundMessage( - channel=self.name, - sender_id="webui", - chat_id=chat_id, - content="", - metadata={ - INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_SESSION_DISCARD, - }, - session_key_override=session_key, - ) - ) async def send_webui_protocol_error( self, @@ -513,10 +477,12 @@ class WebSocketChannel(BaseChannel): """Remove *connection* from every subscription set; safe to call multiple times.""" chat_ids = tuple(self._conn_chats.get(connection, ())) for cid in chat_ids: - if _is_temporary_chat_id(cid): - await self._discard_temporary_chat(connection, cid) + if self._temporary_chats.owns(connection, cid): + await self._discard_connection_owned_chat(connection, cid) else: self._detach(connection, cid) + for cid in self._temporary_chats.chat_ids_for_owner(connection): + await self._discard_connection_owned_chat(connection, cid) self._conn_default.pop(connection, None) self._webui_connections.discard(connection) @@ -831,21 +797,46 @@ class WebSocketChannel(BaseChannel): ) await self._hydrate_after_subscribe(new_id) return + if t == "new_temporary_chat": + try: + new_id = self._temporary_chats.create( + connection, + trusted_webui=connection in self._webui_connections, + ) + except TemporaryChatError as exc: + await self._send_event(connection, "error", detail=exc.detail) + return + self._attach(connection, new_id) + await self._send_event( + connection, + "attached", + chat_id=new_id, + temporary=True, + ) + return if t == "fork_chat": await handle_webui_fork_chat(self, connection, envelope) return if t == "discard_temporary_chat": cid = envelope.get("chat_id") - if not _is_temporary_chat_id(cid): + if not _is_valid_chat_id(cid): await self._send_event(connection, "error", detail="invalid temporary chat_id") return - await self._discard_temporary_chat(connection, cid) + try: + await self._discard_connection_owned_chat(connection, cid) + except TemporaryChatError as exc: + await self._send_event(connection, "error", detail=exc.detail, chat_id=cid) return if t == "attach": cid = envelope.get("chat_id") if not _is_valid_chat_id(cid): await self._send_event(connection, "error", detail="invalid chat_id") return + try: + self._temporary_chats.validate_attach(cid) + except TemporaryChatError as exc: + await self._send_event(connection, "error", detail=exc.detail, chat_id=cid) + return self._attach(connection, cid) await self._send_event(connection, "attached", chat_id=cid) await self._hydrate_after_subscribe(cid) @@ -879,6 +870,11 @@ class WebSocketChannel(BaseChannel): if not _is_valid_chat_id(cid): await self._send_event(connection, "error", detail="invalid chat_id") return + try: + self._temporary_chats.validate_workspace_update(cid) + except TemporaryChatError as exc: + await self._send_event(connection, "error", detail=exc.detail, chat_id=cid) + return scope = await self._workspace_scope_or_error( connection, lambda: self._workspaces.scope_for_set_request( @@ -910,7 +906,6 @@ class WebSocketChannel(BaseChannel): if not _is_valid_chat_id(cid): await self._send_event(connection, "error", detail="invalid chat_id") return - temporary = _is_temporary_chat_id(cid) 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 = { @@ -948,28 +943,20 @@ class WebSocketChannel(BaseChannel): ) return - if temporary: - command = content.strip().partition(" ")[0].lower() - if command.startswith("/") and command not in {"/model", "/stop"}: - await self._send_event( - connection, - "error", - detail="temporary_chat_command_rejected", - **rejection_fields, - ) - return - if self.gateway.session_manager is None: - await self._send_event( - connection, - "error", - detail="temporary_chat_unavailable", - **rejection_fields, - ) - return - self.gateway.session_manager.get_or_create_transient( - f"{self.name}:{cid}", - disabled_tools=_TEMPORARY_CHAT_DISABLED_TOOLS, + try: + temporary_policy = self._temporary_chats.message_policy( + connection, + cid, + content, ) + except TemporaryChatError as exc: + await self._send_event( + connection, + "error", + detail=exc.detail, + **rejection_fields, + ) + return raw_media = envelope.get("media") media_paths: list[str] = [] @@ -993,8 +980,8 @@ class WebSocketChannel(BaseChannel): **rejection_fields, ) return - if temporary and media_paths: - self._temporary_media_paths.setdefault(cid, set()).update(media_paths) + if temporary_policy is not None: + self._temporary_chats.register_media(connection, cid, media_paths) # Allow media-only turns (content may be empty when attachments are present). if not content.strip() and not media_paths: @@ -1007,15 +994,15 @@ class WebSocketChannel(BaseChannel): return # Auto-attach on first use so clients can one-shot without a separate attach. self._attach(connection, cid) - if not temporary: + if temporary_policy is None or temporary_policy.hydrate_transcript: await self._hydrate_after_subscribe(cid) # Resolve after hydration so a concurrent downgrade cannot be overwritten. scope = await self._workspace_scope_or_error( connection, lambda: ( - self._workspaces.restricted_default_scope() - if temporary + temporary_policy.workspace_scope + if temporary_policy is not None else self._workspaces.scope_for_message( envelope, chat_id=cid, @@ -1074,7 +1061,13 @@ class WebSocketChannel(BaseChannel): metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = queued_owner accepted = False try: - if is_webui and not temporary: + if ( + is_webui + and ( + temporary_policy is None + or temporary_policy.persist_transcript + ) + ): self._transcripts.append_user_message( cid, content, @@ -1103,8 +1096,16 @@ class WebSocketChannel(BaseChannel): media=media_paths or None, metadata=metadata, is_dm=False, - session_key=f"{self.name}:{cid}" if temporary else None, - require_existing_session=temporary, + session_key=( + temporary_policy.session_key + if temporary_policy is not None + else None + ), + require_existing_session=( + temporary_policy.require_existing_session + if temporary_policy is not None + else False + ), ) accepted = True finally: @@ -1165,8 +1166,7 @@ class WebSocketChannel(BaseChannel): self._conn_default.clear() self._webui_connections.clear() self._tokens.clear() - for chat_id in tuple(self._temporary_media_paths): - self._discard_temporary_media(chat_id) + self._temporary_chats.close() async def _safe_send_to( self, @@ -1196,7 +1196,7 @@ class WebSocketChannel(BaseChannel): transcript_overrides: dict[str, Any] | None = None, ) -> bool: """Persist one canonical turn event and retain unsafe owners on failure.""" - if _is_temporary_chat_id(chat_id): + if not self._temporary_chats.should_persist_transcript(chat_id): return True persisted = self._transcripts.prepare_and_append( chat_id, diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index 464d01860..99f7993da 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -196,6 +196,23 @@ def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None: wth._WEBSOCKET_TURN_OWNERS.clear() +async def _new_temporary_chat( + channel: WebSocketChannel, + connection: AsyncMock, +) -> str: + channel._webui_connections.add(connection) + await channel._dispatch_envelope( + connection, + "webui-client", + {"type": "new_temporary_chat"}, + ) + payload = json.loads(connection.send.await_args.args[0]) + assert payload["event"] == "attached" + assert payload["temporary"] is True + connection.send.reset_mock() + return payload["chat_id"] + + @pytest.mark.asyncio async def test_temporary_chat_is_transient_and_discarded(bus, tmp_path) -> None: sessions = SessionManager(tmp_path) @@ -212,8 +229,7 @@ async def test_temporary_chat_is_transient_and_discarded(bus, tmp_path) -> None: ) connection = AsyncMock() connection.remote_address = ("127.0.0.1", 5000) - channel._webui_connections.add(connection) - chat_id = "temporary-test" + chat_id = await _new_temporary_chat(channel, connection) upload = tmp_path / "temporary-upload.txt" upload.write_text("private attachment", encoding="utf-8") channel.gateway.media.store_inbound_attachments = MagicMock( @@ -286,16 +302,17 @@ async def test_temporary_chat_rejects_persistent_commands(bus, tmp_path, content ) connection = AsyncMock() connection.remote_address = ("127.0.0.1", 5000) + chat_id = await _new_temporary_chat(channel, connection) await channel._dispatch_envelope(connection, "webui-client", { "type": "message", - "chat_id": "temporary-command", + "chat_id": chat_id, "content": content, "webui": True, }) assert bus.publish_inbound.await_count == 0 - assert sessions.get_cached("websocket:temporary-command") is None + assert sessions.get_cached(f"websocket:{chat_id}") is not None assert json.loads(connection.send.await_args.args[0])["detail"] == ( "temporary_chat_command_rejected" ) @@ -314,6 +331,122 @@ async def test_disconnect_discards_temporary_chat(bus, tmp_path) -> None: ), ) connection = AsyncMock() + chat_id = await _new_temporary_chat(channel, connection) + + await channel._dispatch_envelope( + connection, + "webui-client", + { + "type": "message", + "chat_id": chat_id, + "content": "hello", + "webui": True, + }, + ) + await channel._cleanup_connection(connection) + + session_key = f"websocket:{chat_id}" + control = bus.publish_inbound.await_args_list[-1].args[0] + assert control.session_key == session_key + assert control.metadata[INBOUND_META_RUNTIME_CONTROL] == ( + RUNTIME_CONTROL_SESSION_DISCARD + ) + assert sessions.get_cached(session_key) is None + assert chat_id not in channel._subs + + +@pytest.mark.asyncio +async def test_temporary_chat_creation_requires_authenticated_webui_connection(bus, tmp_path) -> None: + sessions = SessionManager(tmp_path) + channel = WebSocketChannel( + {"enabled": True, "allowFrom": ["*"]}, + bus, + gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path), + ) + connection = AsyncMock() + + await channel._dispatch_envelope( + connection, + "generic-websocket-client", + {"type": "new_temporary_chat"}, + ) + + assert json.loads(connection.send.await_args.args[0])["detail"] == "access_denied" + assert sessions.list_sessions() == [] + + +@pytest.mark.asyncio +async def test_temporary_chat_cannot_be_claimed_by_another_connection(bus, tmp_path) -> None: + sessions = SessionManager(tmp_path) + channel = WebSocketChannel( + {"enabled": True, "allowFrom": ["*"]}, + bus, + gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path), + ) + owner = AsyncMock() + other = AsyncMock() + channel._webui_connections.add(other) + chat_id = await _new_temporary_chat(channel, owner) + + await channel._dispatch_envelope( + other, + "other-webui-client", + { + "type": "message", + "chat_id": chat_id, + "content": "claim it", + "webui": True, + }, + ) + + assert json.loads(other.send.await_args.args[0])["detail"] == ( + "temporary_chat_unavailable" + ) + assert bus.publish_inbound.await_count == 0 + assert sessions.get_cached(f"websocket:{chat_id}") is not None + + +@pytest.mark.asyncio +async def test_temporary_chat_cannot_persist_workspace_scope(bus, tmp_path) -> None: + sessions = SessionManager(tmp_path) + channel = WebSocketChannel( + {"enabled": True, "allowFrom": ["*"]}, + bus, + gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path), + ) + connection = AsyncMock() + chat_id = await _new_temporary_chat(channel, connection) + + await channel._dispatch_envelope( + connection, + "webui-client", + { + "type": "set_workspace_scope", + "chat_id": chat_id, + "workspace_scope": { + "project_path": str(tmp_path), + "access_mode": "full", + }, + }, + ) + + payload = json.loads(connection.send.await_args.args[0]) + assert payload["detail"] == "temporary_chat_workspace_rejected" + session = sessions.get_cached(f"websocket:{chat_id}") + assert session is not None + assert WORKSPACE_SCOPE_METADATA_KEY not in session.metadata + assert sessions.list_sessions() == [] + + +@pytest.mark.asyncio +async def test_temporary_looking_id_does_not_define_session_policy(bus, tmp_path) -> None: + sessions = SessionManager(tmp_path) + channel = WebSocketChannel( + {"enabled": True, "allowFrom": ["*"]}, + bus, + gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path), + ) + connection = AsyncMock() channel._webui_connections.add(connection) await channel._dispatch_envelope( @@ -321,21 +454,42 @@ async def test_disconnect_discards_temporary_chat(bus, tmp_path) -> None: "webui-client", { "type": "message", - "chat_id": "temporary-disconnect", - "content": "hello", + "chat_id": "temporary-looking-but-persistent", + "content": "/goal ordinary chat", "webui": True, }, ) - await channel._cleanup_connection(connection) - session_key = "websocket:temporary-disconnect" - control = bus.publish_inbound.await_args_list[-1].args[0] - assert control.session_key == session_key - assert control.metadata[INBOUND_META_RUNTIME_CONTROL] == ( - RUNTIME_CONTROL_SESSION_DISCARD + inbound = bus.publish_inbound.await_args.args[0] + assert inbound.require_existing_session is False + assert inbound.session_key_override is None + session = sessions.get_cached("websocket:temporary-looking-but-persistent") + assert session is not None + assert session.policy.persist is True + + +@pytest.mark.asyncio +async def test_discard_temporary_chat_does_not_detach_persistent_chat(bus, tmp_path) -> None: + sessions = SessionManager(tmp_path) + channel = WebSocketChannel( + {"enabled": True, "allowFrom": ["*"]}, + bus, + gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path), ) - assert sessions.get_cached(session_key) is None - assert "temporary-disconnect" not in channel._subs + connection = AsyncMock() + channel._attach(connection, "ordinary-chat") + + await channel._dispatch_envelope( + connection, + "webui-client", + {"type": "discard_temporary_chat", "chat_id": "ordinary-chat"}, + ) + + assert json.loads(connection.send.await_args.args[0])["detail"] == ( + "temporary_chat_unavailable" + ) + assert connection in channel._subs["ordinary-chat"] + assert "ordinary-chat" in channel._conn_chats[connection] @pytest.mark.asyncio diff --git a/nanobot/webui/gateway_services.py b/nanobot/webui/gateway_services.py index 5bb6702bc..a55a8c713 100644 --- a/nanobot/webui/gateway_services.py +++ b/nanobot/webui/gateway_services.py @@ -11,6 +11,7 @@ from loguru import logger as default_logger from nanobot.webui.gateway_tokens import GatewayTokenStore from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy from nanobot.webui.media_gateway import WebUIMediaGateway +from nanobot.webui.temporary_chats import WebUITemporaryChats from nanobot.webui.transcript import WebUITranscriptRecorder from nanobot.webui.workspaces import WebUIWorkspaceController from nanobot.webui.ws_http import GatewayHTTPHandler @@ -33,6 +34,7 @@ class GatewayServices: ingress: WebUIIngressPolicy transcripts: WebUITranscriptRecorder workspaces: WebUIWorkspaceController + temporary_chats: WebUITemporaryChats session_manager: SessionManager | None cron_service: CronService | None local_trigger_store: LocalTriggerStore | None @@ -82,6 +84,12 @@ def build_gateway_services( default_workspace=workspace_path, default_restrict_to_workspace=default_restrict_to_workspace, ) + temporary_chats = WebUITemporaryChats( + bus=bus, + session_manager=session_manager, + workspaces=workspaces, + logger=logger, + ) http = GatewayHTTPHandler( config=config, session_manager=session_manager, @@ -112,6 +120,7 @@ def build_gateway_services( ingress=ingress, transcripts=transcripts, workspaces=workspaces, + temporary_chats=temporary_chats, session_manager=session_manager, cron_service=cron_service, local_trigger_store=local_trigger_store, diff --git a/nanobot/webui/temporary_chats.py b/nanobot/webui/temporary_chats.py new file mode 100644 index 000000000..4d40d6a50 --- /dev/null +++ b/nanobot/webui/temporary_chats.py @@ -0,0 +1,218 @@ +"""Connection-owned Temporary Chat behavior for the WebUI.""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from nanobot.bus.events import ( + INBOUND_META_RUNTIME_CONTROL, + RUNTIME_CONTROL_SESSION_DISCARD, + InboundMessage, +) +from nanobot.bus.queue import MessageBus +from nanobot.security.workspace_access import WorkspaceScope +from nanobot.session.manager import Session, SessionManager +from nanobot.webui.workspaces import WebUIWorkspaceController + +_TEMPORARY_CHAT_DISABLED_TOOLS = frozenset({ + "create_goal", + "update_goal", + "spawn", + "cron", +}) +_TEMPORARY_CHAT_COMMANDS = frozenset({"/model", "/stop"}) + + +class TemporaryChatError(ValueError): + """A stable WebUI protocol error for a Temporary Chat operation.""" + + def __init__(self, detail: str) -> None: + super().__init__(detail) + self.detail = detail + + +@dataclass(frozen=True) +class TemporaryChatMessagePolicy: + """Server-owned message rules for one active Temporary Chat.""" + + session_key: str + workspace_scope: WorkspaceScope + require_existing_session: bool = True + hydrate_transcript: bool = False + persist_transcript: bool = False + + +class WebUITemporaryChats: + """Own Temporary Chat creation, policy, attachments, and disposal.""" + + def __init__( + self, + *, + bus: MessageBus, + session_manager: SessionManager | None, + workspaces: WebUIWorkspaceController, + logger: Any, + channel_name: str = "websocket", + ) -> None: + self._bus = bus + self._sessions = session_manager + self._workspaces = workspaces + self._logger = logger + self._channel_name = channel_name + self._owners: dict[str, object] = {} + self._owner_chat_ids: dict[object, set[str]] = {} + # Keep active sessions alive if the bounded manager cache evicts them + # between WebUI turns. SessionPolicy remains the authority below. + self._active_sessions: dict[str, Session] = {} + # Retain policy-derived tombstones until shutdown so late outbound + # events cannot create a durable transcript after a chat is discarded. + self._known_transient_chat_ids: set[str] = set() + self._media_paths: dict[str, set[str]] = {} + + def _session_key(self, chat_id: str) -> str: + return f"{self._channel_name}:{chat_id}" + + def _cached_session_is_transient(self, chat_id: str) -> bool: + if self._sessions is None: + return False + session = self._sessions.get_cached(self._session_key(chat_id)) + return session is not None and not session.policy.persist + + def create(self, owner: object, *, trusted_webui: bool) -> str: + """Create a server-identified chat owned by one authenticated WebUI connection.""" + if not trusted_webui: + raise TemporaryChatError("access_denied") + if self._sessions is None: + raise TemporaryChatError("temporary_chat_unavailable") + + chat_id = str(uuid.uuid4()) + session = self._sessions.get_or_create_transient( + self._session_key(chat_id), + disabled_tools=_TEMPORARY_CHAT_DISABLED_TOOLS, + ) + if session.policy.persist: + raise RuntimeError("Temporary Chat must use a non-persistent session policy") + self._owners[chat_id] = owner + self._owner_chat_ids.setdefault(owner, set()).add(chat_id) + self._active_sessions[chat_id] = session + self._known_transient_chat_ids.add(chat_id) + return chat_id + + def message_policy( + self, + owner: object, + chat_id: str, + content: str, + ) -> TemporaryChatMessagePolicy | None: + """Return Temporary Chat rules, or ``None`` for an ordinary chat.""" + if not self._cached_session_is_transient(chat_id): + if chat_id in self._known_transient_chat_ids: + raise TemporaryChatError("temporary_chat_unavailable") + return None + if self._owners.get(chat_id) is not owner or self._sessions is None: + raise TemporaryChatError("temporary_chat_unavailable") + + session = self._sessions.get_cached(self._session_key(chat_id)) + if session is None: + raise TemporaryChatError("temporary_chat_unavailable") + + command = content.strip().split(maxsplit=1)[0].lower() if content.strip() else "" + if command.startswith("/") and command not in _TEMPORARY_CHAT_COMMANDS: + raise TemporaryChatError("temporary_chat_command_rejected") + + return TemporaryChatMessagePolicy( + session_key=self._session_key(chat_id), + workspace_scope=self._workspaces.restricted_default_scope(), + ) + + def validate_attach(self, chat_id: str) -> None: + """Reject attempts to recover a non-persistent session.""" + if not self._cached_session_is_transient(chat_id): + if chat_id in self._known_transient_chat_ids: + raise TemporaryChatError("temporary_chat_unavailable") + return + raise TemporaryChatError("temporary_chat_unavailable") + + def validate_workspace_update(self, chat_id: str) -> None: + """Prevent non-persistent sessions from acquiring durable workspace state.""" + if self._cached_session_is_transient(chat_id): + raise TemporaryChatError("temporary_chat_workspace_rejected") + if chat_id in self._known_transient_chat_ids: + raise TemporaryChatError("temporary_chat_unavailable") + + def register_media(self, owner: object, chat_id: str, paths: list[str]) -> None: + if not paths: + return + if self._owners.get(chat_id) is not owner: + raise TemporaryChatError("temporary_chat_unavailable") + self._media_paths.setdefault(chat_id, set()).update(paths) + + def chat_ids_for_owner(self, owner: object) -> tuple[str, ...]: + return tuple(self._owner_chat_ids.get(owner, ())) + + def owns(self, owner: object, chat_id: str) -> bool: + return self._owners.get(chat_id) is owner + + def should_persist_transcript(self, chat_id: str) -> bool: + """Apply the session policy and retain it for late events after disposal.""" + return ( + not self._cached_session_is_transient(chat_id) + and chat_id not in self._known_transient_chat_ids + ) + + def _discard_media(self, chat_id: str) -> None: + for raw_path in self._media_paths.pop(chat_id, set()): + try: + Path(raw_path).unlink(missing_ok=True) + except OSError: + self._logger.warning("failed to remove a temporary WebUI attachment") + + def _forget_owner(self, owner: object, chat_id: str) -> None: + self._owners.pop(chat_id, None) + chat_ids = self._owner_chat_ids.get(owner) + if chat_ids is None: + return + chat_ids.discard(chat_id) + if not chat_ids: + self._owner_chat_ids.pop(owner, None) + + async def discard(self, owner: object, chat_id: str) -> None: + """Forget one owned chat and cancel any active work through the message bus.""" + if ( + not self._cached_session_is_transient(chat_id) + or self._owners.get(chat_id) is not owner + ): + raise TemporaryChatError("temporary_chat_unavailable") + + session_key = self._session_key(chat_id) + self._forget_owner(owner, chat_id) + self._active_sessions.pop(chat_id, None) + self._discard_media(chat_id) + if self._sessions is not None: + self._sessions.invalidate(session_key) + await self._bus.publish_inbound( + InboundMessage( + channel=self._channel_name, + sender_id="webui", + chat_id=chat_id, + content="", + metadata={ + INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_SESSION_DISCARD, + }, + session_key_override=session_key, + ) + ) + + def close(self) -> None: + """Release process-local resources during gateway shutdown.""" + for chat_id in tuple(self._owners): + self._discard_media(chat_id) + if self._sessions is not None: + self._sessions.invalidate(self._session_key(chat_id)) + self._owners.clear() + self._owner_chat_ids.clear() + self._active_sessions.clear() + self._known_transient_chat_ids.clear() diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 50a39fbb0..7033f6e30 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -64,8 +64,6 @@ import { import { projectNameFromPath, scopeWithAccessMode } from "@/lib/workspace"; import { createTemporaryChatSession, - isTemporaryChatId, - temporaryChatIdFromSessionKey, } from "@/lib/temporary-chat"; type BootState = @@ -101,6 +99,7 @@ type ShellRoute = { view: ShellView; activeKey: string | null; settingsSection: SettingsSectionKey; + temporary?: boolean; }; const loadSettingsView = () => import("@/components/settings/SettingsView"); const SettingsView = lazy(async () => { @@ -235,11 +234,12 @@ function readShellRoute(): ShellRoute { const encoded = path.slice("/temporary/".length); try { const chatId = decodeURIComponent(encoded).trim(); - return isTemporaryChatId(chatId) + return chatId ? { view: "chat", activeKey: `websocket:${chatId}`, settingsSection: "overview", + temporary: true, } : defaultShellRoute(); } catch { @@ -262,8 +262,10 @@ function readShellRoute(): ShellRoute { function shellRouteHash(route: ShellRoute): string { if (route.view === "chat") { - const temporaryChatId = temporaryChatIdFromSessionKey(route.activeKey); - if (temporaryChatId) return `#/temporary/${encodeURIComponent(temporaryChatId)}`; + if (route.temporary && route.activeKey?.startsWith("websocket:")) { + const chatId = route.activeKey.slice("websocket:".length); + return `#/temporary/${encodeURIComponent(chatId)}`; + } return route.activeKey ? `#/chat/${encodeURIComponent(route.activeKey)}` : "#/new"; @@ -1034,7 +1036,8 @@ function Shell({ settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface; const showHostChrome = effectiveRuntimeSurface === "native"; const showMainSidebar = view !== "settings"; - const temporaryChatId = temporaryChatIdFromSessionKey(activeKey); + const activeTemporarySession = activeKey ? temporarySessions[activeKey] ?? null : null; + const temporaryChatId = activeTemporarySession?.chatId ?? null; const temporaryChatActive = view === "chat" && temporaryChatId !== null; const temporaryChatRequested = temporaryChatActive || temporaryChatEnabled; const temporarySessionList = useMemo( @@ -1173,9 +1176,7 @@ function Shell({ const activeSession = useMemo(() => { if (!activeKey) return null; - if (temporaryChatIdFromSessionKey(activeKey)) { - return temporarySessions[activeKey] ?? null; - } + if (temporarySessions[activeKey]) return temporarySessions[activeKey]; return sessions.find((s) => s.key === activeKey) ?? null; }, [sessions, activeKey, temporarySessions]); const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]); @@ -1249,7 +1250,8 @@ function Shell({ pendingCreatedSessionKeyRef.current = null; } if (!activeKey) return; - if (temporaryChatIdFromSessionKey(activeKey)) { + const currentRoute = readShellRoute(); + if (currentRoute.temporary) { if (temporarySessions[activeKey]) return; navigate(defaultShellRoute(), { replace: true }); return; @@ -1258,7 +1260,6 @@ function Shell({ // WebKit can commit the route before useSessions' optimistic insert. // Keep that just-created destination valid until the session list catches up. if (pendingCreatedKey === activeKey) return; - const currentRoute = readShellRoute(); navigate( currentRoute.view === "chat" ? defaultShellRoute() @@ -1478,31 +1479,38 @@ function Shell({ workspaceScope?: WorkspaceScopePayload | null, initialMessage?: string, ) => { - const session = createTemporaryChatSession(); - const restrictedScope = workspaceScope - ? normalizeWorkspaceScope(scopeWithAccessMode(workspaceScope, "restricted")) - : null; - const nextSession: ChatSummary = { - ...session, - preview: initialMessage ?? "", - ...(restrictedScope ? { workspaceScope: restrictedScope } : {}), - }; - setTemporarySessions((current) => ({ - ...current, - [nextSession.key]: nextSession, - })); - setTemporaryChatEnabled(false); - setWorkspaceError(null); - setSessionSearchOpen(false); - navigate({ - view: "chat", - activeKey: nextSession.key, - settingsSection: "overview", - }); - setMobileSidebarOpen(false); - return nextSession.chatId; + try { + const chatId = await client.newTemporaryChat(); + const session = createTemporaryChatSession(chatId); + const restrictedScope = workspaceScope + ? normalizeWorkspaceScope(scopeWithAccessMode(workspaceScope, "restricted")) + : null; + const nextSession: ChatSummary = { + ...session, + preview: initialMessage ?? "", + ...(restrictedScope ? { workspaceScope: restrictedScope } : {}), + }; + setTemporarySessions((current) => ({ + ...current, + [nextSession.key]: nextSession, + })); + setTemporaryChatEnabled(false); + setWorkspaceError(null); + setSessionSearchOpen(false); + navigate({ + view: "chat", + activeKey: nextSession.key, + settingsSection: "overview", + temporary: true, + }); + setMobileSidebarOpen(false); + return nextSession.chatId; + } catch (error) { + console.error("Failed to create temporary chat", error); + return null; + } }, - [navigate], + [client, navigate], ); const onForkChat = useCallback(async ( @@ -1572,7 +1580,8 @@ function Shell({ const onSelectChat = useCallback( (key: string) => { - const selected = temporarySessionsRef.current[key] + const selectedTemporary = temporarySessionsRef.current[key]; + const selected = selectedTemporary ?? sessions.find((session) => session.key === key); const selectedChatId = selected?.chatId; if (selectedChatId) { @@ -1589,7 +1598,12 @@ function Shell({ setDraftWorkspaceScope(null); } setWorkspaceError(null); - navigate({ view: "chat", activeKey: key, settingsSection: "overview" }); + navigate({ + view: "chat", + activeKey: key, + settingsSection: "overview", + ...(selectedTemporary ? { temporary: true } : {}), + }); setMobileSidebarOpen(false); }, [navigate, sessions], @@ -1610,6 +1624,7 @@ function Shell({ view: "chat", activeKey: remaining[0]?.key ?? null, settingsSection: "overview", + ...(remaining[0] ? { temporary: true } : {}), }, { replace: true }); } setMobileSidebarOpen(false); @@ -1897,7 +1912,11 @@ function Shell({ nextRunning.delete(chatId); runningChatIdsRef.current = nextRunning; setRunningChatIds(nextRunning); - if (isTemporaryChatId(chatId)) return; + if ( + Object.values(temporarySessionsRef.current).some( + (session) => session.chatId === chatId, + ) + ) return; setUpdatedChatIds((current) => { const next = new Set(current); if (activeChatIdRef.current === chatId) { @@ -1922,7 +1941,7 @@ function Shell({ if (Object.keys(temporarySessionsRef.current).length === 0) return; temporarySessionsRef.current = {}; setTemporarySessions({}); - if (temporaryChatIdFromSessionKey(readShellRoute().activeKey)) { + if (readShellRoute().temporary) { navigate(defaultShellRoute(), { replace: true }); } }); diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index 4dfb4286e..c51a8b607 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -85,7 +85,6 @@ import { useMediaQuery } from "@/hooks/useMediaQuery"; import type { SendAttachment, SendOptions } from "@/hooks/useNanobotStream"; import { usePageVisibility } from "@/hooks/usePageVisibility"; import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder"; -import { isTemporaryChatId } from "@/lib/temporary-chat"; import type { CliAppInfo, ChatSummary, @@ -444,9 +443,7 @@ function storeSlashRecents(commands: string[]): void { function queuedPromptsStorageKey(key?: string | null): string | null { const clean = key?.trim(); - return clean && !isTemporaryChatId(clean) - ? `${QUEUED_PROMPTS_STORAGE_PREFIX}${clean}` - : null; + return clean ? `${QUEUED_PROMPTS_STORAGE_PREFIX}${clean}` : null; } function normalizeQueuedSessionMentions(value: unknown): SessionMention[] { diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index 36eafa857..956fdcbcf 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -33,7 +33,6 @@ import { } from "@/lib/mcp-preset-events"; import type { CanonicalRunSnapshot, StreamError } from "@/lib/nanobot-client"; import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand"; -import { isTemporaryChatId } from "@/lib/temporary-chat"; import type { ChatSummary, SettingsPayload, @@ -677,6 +676,7 @@ export function ThreadShell({ const viewportRef = useRef(null); const activeViewportTurnByChatIdRef = useRef>(new Map()); const messageCacheRef = useRef>(new Map()); + const knownTemporaryChatIdsRef = useRef(new Set()); /** Last chatId we associated with the in-memory thread (for cache-on-switch). */ const prevChatIdForCacheRef = useRef(null); /** Skip one message-cache write right after chatId changes (messages may not match yet). */ @@ -753,10 +753,12 @@ export function ThreadShell({ useEffect(() => { const retained = new Set(temporaryChatIds); - for (const cachedChatId of messageCacheRef.current.keys()) { - if (isTemporaryChatId(cachedChatId) && !retained.has(cachedChatId)) { + for (const chatId of retained) knownTemporaryChatIdsRef.current.add(chatId); + for (const cachedChatId of knownTemporaryChatIdsRef.current) { + if (!retained.has(cachedChatId)) { messageCacheRef.current.delete(cachedChatId); activeViewportTurnByChatIdRef.current.delete(cachedChatId); + knownTemporaryChatIdsRef.current.delete(cachedChatId); } } }, [temporaryChatIds]); @@ -1435,7 +1437,7 @@ export function ThreadShell({ workspaceScopeDisabled={workspaceScopeDisabled} workspaceError={workspaceError} onWorkspaceScopeChange={onWorkspaceScopeChange} - pendingQueueKey={chatId} + pendingQueueKey={temporary ? null : chatId} transcriptionProvider={settingsSnapshot?.transcription?.provider} ingressLimits={ingressLimits} quotedContext={quotedContext} diff --git a/webui/src/lib/nanobot-client.ts b/webui/src/lib/nanobot-client.ts index 90eaeb20f..a015061ff 100644 --- a/webui/src/lib/nanobot-client.ts +++ b/webui/src/lib/nanobot-client.ts @@ -11,7 +11,6 @@ import type { WorkspaceScopePayload, } from "./types"; import { createHostWebSocket } from "./runtime"; -import { isTemporaryChatId } from "./temporary-chat"; /** WebSocket readyState constants, referenced by value to stay portable * across runtimes that don't expose a global ``WebSocket`` (tests, SSR). */ @@ -109,6 +108,10 @@ interface PendingRequest { timer: ReturnType; } +interface PendingChatRequest extends PendingRequest { + temporary: boolean; +} + const SYSTEM_COMMAND_TURN_PREFIX = "webui-system:"; const TURN_REJECTION_DETAILS = new Set([ "access_denied", @@ -197,7 +200,7 @@ export class NanobotClient { 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; + private pendingNewChat: PendingChatRequest | null = null; private pendingTranscriptions = new Map>(); private pendingSystemCommands = new Map>(); // Frames queued while the socket is not yet OPEN @@ -743,7 +746,7 @@ export class NanobotClient { } discardTemporaryChat(chatId: string): void { - if (!isTemporaryChatId(chatId)) return; + if (!this.temporaryChatIds.has(chatId)) return; if (this.socket?.readyState === WS_OPEN) { this.rawSend({ type: "discard_temporary_chat", chat_id: chatId }); } @@ -760,7 +763,7 @@ export class NanobotClient { this.pendingNewChat = null; reject(new Error("newChat timed out")); }, timeoutMs); - this.pendingNewChat = { resolve, reject, timer }; + this.pendingNewChat = { resolve, reject, timer, temporary: false }; this.queueSend({ type: "new_chat", ...(workspaceScope ? { workspace_scope: workspaceScope } : {}), @@ -768,6 +771,21 @@ export class NanobotClient { }); } + /** Ask the WebUI gateway to create a connection-owned non-persistent chat. */ + newTemporaryChat(timeoutMs: number = 5_000): Promise { + if (this.pendingNewChat) { + return Promise.reject(new Error("newChat already in flight")); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingNewChat = null; + reject(new Error("newTemporaryChat timed out")); + }, timeoutMs); + this.pendingNewChat = { resolve, reject, timer, temporary: true }; + this.queueSend({ type: "new_temporary_chat" }); + }); + } + transcribeAudio( dataUrl: string, options?: { durationMs?: number; timeoutMs?: number }, @@ -804,7 +822,7 @@ export class NanobotClient { this.pendingNewChat = null; reject(new Error("forkChat timed out")); }, timeoutMs); - this.pendingNewChat = { resolve, reject, timer }; + this.pendingNewChat = { resolve, reject, timer, temporary: false }; this.queueSend({ type: "fork_chat", source_chat_id: sourceChatId, @@ -815,10 +833,7 @@ export class NanobotClient { } attach(chatId: string): void { - if (isTemporaryChatId(chatId)) { - this.temporaryChatIds.add(chatId); - return; - } + if (this.temporaryChatIds.has(chatId)) return; this.knownChats.add(chatId); if (this.socket?.readyState === WS_OPEN) { this.queueSend({ type: "attach", chat_id: chatId }); @@ -840,8 +855,7 @@ export class NanobotClient { startsNewRun?: boolean; }, ): void { - const temporary = isTemporaryChatId(chatId); - if (temporary) this.temporaryChatIds.add(chatId); + const temporary = this.temporaryChatIds.has(chatId); if (!temporary) this.knownChats.add(chatId); const frame: Outbound = { type: "message", @@ -891,7 +905,7 @@ export class NanobotClient { } setWorkspaceScope(chatId: string, workspaceScope: WorkspaceScopePayload): void { - if (isTemporaryChatId(chatId)) return; + if (this.temporaryChatIds.has(chatId)) return; this.knownChats.add(chatId); this.queueSend({ type: "set_workspace_scope", @@ -1016,8 +1030,15 @@ export class NanobotClient { } if (parsed.event === "attached") { - this.knownChats.add(parsed.chat_id); - if (this.pendingNewChat) { + if (parsed.temporary === true) { + this.temporaryChatIds.add(parsed.chat_id); + } else { + this.knownChats.add(parsed.chat_id); + } + if ( + this.pendingNewChat + && this.pendingNewChat.temporary === (parsed.temporary === true) + ) { clearTimeout(this.pendingNewChat.timer); this.pendingNewChat.resolve(parsed.chat_id); this.pendingNewChat = null; diff --git a/webui/src/lib/temporary-chat.ts b/webui/src/lib/temporary-chat.ts index ed9112c9b..969ae2f10 100644 --- a/webui/src/lib/temporary-chat.ts +++ b/webui/src/lib/temporary-chat.ts @@ -1,20 +1,8 @@ import type { ChatSummary } from "./types"; -export const TEMPORARY_CHAT_ID_PREFIX = "temporary-"; const WEBSOCKET_SESSION_KEY_PREFIX = "websocket:"; -export function isTemporaryChatId(value: string): boolean { - return value.startsWith(TEMPORARY_CHAT_ID_PREFIX); -} - -export function temporaryChatIdFromSessionKey(value: string | null): string | null { - if (!value?.startsWith(WEBSOCKET_SESSION_KEY_PREFIX)) return null; - const chatId = value.slice(WEBSOCKET_SESSION_KEY_PREFIX.length); - return isTemporaryChatId(chatId) ? chatId : null; -} - -export function createTemporaryChatSession(): ChatSummary { - const chatId = `${TEMPORARY_CHAT_ID_PREFIX}${crypto.randomUUID()}`; +export function createTemporaryChatSession(chatId: string): ChatSummary { const now = new Date().toISOString(); return { key: `${WEBSOCKET_SESSION_KEY_PREFIX}${chatId}`, diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index a17adfa63..b40026832 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -1162,7 +1162,7 @@ export interface InboundTurnMetadata { export type InboundEvent = | { event: "ready"; chat_id: string; client_id: string } - | { event: "attached"; chat_id: string } + | { event: "attached"; chat_id: string; temporary?: boolean } | { event: "message_accepted"; chat_id: string; turn_id: string } | ({ event: "message"; @@ -1338,6 +1338,7 @@ export interface FilePreviewPayload { export type Outbound = | { type: "new_chat"; workspace_scope?: WorkspaceScopePayload } + | { type: "new_temporary_chat" } | { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string } | { type: "attach"; chat_id: string } | { type: "set_sidebar_state"; state: SidebarStatePayload } diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 84b54ab43..d36db0f82 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -20,6 +20,7 @@ const updateUrlSpy = vi.fn(); const attachSpy = vi.fn(); const setSidebarStateSpy = vi.fn(); const discardTemporaryChatSpy = vi.fn(); +const newTemporaryChatSpy = vi.fn<() => Promise>(); const sendMessageSpy = vi.fn(); const statusHandlers = new Set<(status: ConnectionStatus) => void>(); const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>(); @@ -238,6 +239,7 @@ vi.mock("@/lib/nanobot-client", async (importOriginal) => { getGoalState = () => undefined; sendMessage = sendMessageSpy; newChat = vi.fn(); + newTemporaryChat = newTemporaryChatSpy; attach = attachSpy; setSidebarState = setSidebarStateSpy; discardTemporaryChat = discardTemporaryChatSpy; @@ -270,6 +272,10 @@ describe("App layout", () => { attachSpy.mockReset(); setSidebarStateSpy.mockReset(); discardTemporaryChatSpy.mockReset(); + let temporaryChatCounter = 0; + newTemporaryChatSpy.mockImplementation(async () => ( + `00000000-0000-4000-8000-${String(++temporaryChatCounter).padStart(12, "0")}` + )); sendMessageSpy.mockReset(); statusHandlers.clear(); runStatusHandlers.clear(); @@ -425,9 +431,9 @@ describe("App layout", () => { }); fireEvent.click(screen.getByRole("button", { name: "Send message" })); - await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/temporary-/)); + await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/[0-9a-f-]+$/)); const firstHash = window.location.hash; - expect(firstHash).toMatch(/^#\/temporary\/temporary-/); + expect(firstHash).toMatch(/^#\/temporary\/[0-9a-f-]+$/); expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument(); expect(createChatSpy).not.toHaveBeenCalled(); @@ -441,9 +447,9 @@ describe("App layout", () => { target: { value: "second private message" }, }); fireEvent.click(screen.getByRole("button", { name: "Send message" })); - await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/temporary-/)); + await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/[0-9a-f-]+$/)); const secondHash = window.location.hash; - expect(secondHash).toMatch(/^#\/temporary\/temporary-/); + expect(secondHash).toMatch(/^#\/temporary\/[0-9a-f-]+$/); expect(secondHash).not.toBe(firstHash); expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument(); expect(discardTemporaryChatSpy).not.toHaveBeenCalled(); @@ -481,8 +487,8 @@ describe("App layout", () => { const discardedChatIds = discardTemporaryChatSpy.mock.calls.map(([chatId]) => chatId); expect(new Set(discardedChatIds).size).toBe(2); expect(discardedChatIds).toEqual([ - expect.stringMatching(/^temporary-/), - expect.stringMatching(/^temporary-/), + "00000000-0000-4000-8000-000000000001", + "00000000-0000-4000-8000-000000000002", ]); }); @@ -544,7 +550,7 @@ describe("App layout", () => { target: { value: "start temporary chat" }, }); fireEvent.click(screen.getByRole("button", { name: "Send message" })); - await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/temporary-/)); + await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/[0-9a-f-]+$/)); expect(screen.queryByText("Not saved")).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Clear temporary chat" })).not.toBeInTheDocument(); @@ -560,7 +566,7 @@ describe("App layout", () => { target: { value: "do not lose this" }, }); fireEvent.click(screen.getByRole("button", { name: "Send message" })); - await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/temporary-/)); + await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/[0-9a-f-]+$/)); const beforeUnload = new Event("beforeunload", { cancelable: true }); act(() => window.dispatchEvent(beforeUnload)); @@ -579,7 +585,7 @@ describe("App layout", () => { target: { value: "connection-sensitive message" }, }); fireEvent.click(screen.getByRole("button", { name: "Send message" })); - await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/temporary-/)); + await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/[0-9a-f-]+$/)); act(() => { statusHandlers.forEach((handler) => handler("reconnecting")); diff --git a/webui/src/tests/nanobot-client.test.ts b/webui/src/tests/nanobot-client.test.ts index 542bbe47c..9656f0195 100644 --- a/webui/src/tests/nanobot-client.test.ts +++ b/webui/src/tests/nanobot-client.test.ts @@ -71,19 +71,25 @@ afterEach(() => { }); describe("NanobotClient", () => { - it("keeps temporary chats out of attachment and reconnect state", () => { + it("keeps temporary chats out of attachment and reconnect state", async () => { const client = new NanobotClient({ url: "ws://test", reconnect: false, socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, }); - const chatId = "temporary-test"; + const chatId = "temp-server-id"; client.connect(); + lastSocket().fakeOpen(); + const creation = client.newTemporaryChat(); + expect(JSON.parse(lastSocket().sent.at(-1) as string)).toEqual({ + type: "new_temporary_chat", + }); + lastSocket().fakeMessage({ event: "attached", chat_id: chatId, temporary: true }); + await expect(creation).resolves.toBe(chatId); + lastSocket().sent = []; client.onChat(chatId, vi.fn()); client.sendMessage(chatId, "hello", undefined, { turnId: "turn-1" }); - lastSocket().fakeOpen(); - expect(lastSocket().sent.map((raw) => JSON.parse(raw))).toEqual([ { type: "message", @@ -101,6 +107,30 @@ describe("NanobotClient", () => { }); }); + it("waits for the temporary attachment when creating a temporary chat", async () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + client.connect(); + lastSocket().fakeOpen(); + + const creation = client.newTemporaryChat(); + let resolved = false; + void creation.then(() => { resolved = true; }); + lastSocket().fakeMessage({ event: "attached", chat_id: "ordinary-chat" }); + await Promise.resolve(); + expect(resolved).toBe(false); + + lastSocket().fakeMessage({ + event: "attached", + chat_id: "server-temporary-chat", + temporary: true, + }); + await expect(creation).resolves.toBe("server-temporary-chat"); + }); + it("forgets every temporary chat when the socket drops", async () => { const client = new NanobotClient({ url: "ws://test", @@ -112,20 +142,37 @@ describe("NanobotClient", () => { const secondHandler = vi.fn(); client.connect(); lastSocket().fakeOpen(); - client.onChat("temporary-drop-a", firstHandler); - client.onChat("temporary-drop-b", secondHandler); + const firstCreation = client.newTemporaryChat(); + lastSocket().fakeMessage({ + event: "attached", + chat_id: "temp-drop-a", + temporary: true, + }); + await firstCreation; + const secondCreation = client.newTemporaryChat(); + lastSocket().fakeMessage({ + event: "attached", + chat_id: "temp-drop-b", + temporary: true, + }); + await secondCreation; + lastSocket().sent = []; + client.onChat("temp-drop-a", firstHandler); + client.onChat("temp-drop-b", secondHandler); + firstHandler.mockClear(); + secondHandler.mockClear(); lastSocket().close(); await vi.advanceTimersByTimeAsync(1); lastSocket().fakeOpen(); lastSocket().fakeMessage({ event: "message", - chat_id: "temporary-drop-a", + chat_id: "temp-drop-a", text: "stale first chat", }); lastSocket().fakeMessage({ event: "message", - chat_id: "temporary-drop-b", + chat_id: "temp-drop-b", text: "stale second chat", }); diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index ee4d48084..6efaad45d 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -2944,7 +2944,7 @@ describe("ThreadComposer", () => { onSend={onSend} onStop={vi.fn()} isStreaming - pendingQueueKey="temporary-private" + pendingQueueKey={null} placeholder="Type your message..." />, ); @@ -2966,7 +2966,7 @@ describe("ThreadComposer", () => { onSend={onSend} onStop={vi.fn()} isStreaming - pendingQueueKey="temporary-private" + pendingQueueKey={null} placeholder="Type your message..." />, );