fix(webui): derive temporary chats from session policy

This commit is contained in:
chengyongru 2026-08-07 11:20:08 +08:00
parent 36253685bd
commit f971d7e895
13 changed files with 647 additions and 185 deletions

View File

@ -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,

View File

@ -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

View File

@ -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,

View File

@ -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()

View File

@ -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<ChatSummary | null>(() => {
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 });
}
});

View File

@ -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[] {

View File

@ -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<ThreadViewportHandle | null>(null);
const activeViewportTurnByChatIdRef = useRef<Map<string, string>>(new Map());
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
const knownTemporaryChatIdsRef = useRef(new Set<string>());
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
const prevChatIdForCacheRef = useRef<string | null>(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}

View File

@ -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<T> {
timer: ReturnType<typeof setTimeout>;
}
interface PendingChatRequest extends PendingRequest<string> {
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<string, GoalStateWsPayload>();
private pendingNewChat: PendingRequest<string> | null = null;
private pendingNewChat: PendingChatRequest | null = null;
private pendingTranscriptions = new Map<string, PendingRequest<string>>();
private pendingSystemCommands = new Map<string, PendingRequest<void>>();
// 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<string> {
if (this.pendingNewChat) {
return Promise.reject(new Error("newChat already in flight"));
}
return new Promise<string>((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;

View File

@ -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}`,

View File

@ -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 }

View File

@ -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<string>>();
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"));

View File

@ -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",
});

View File

@ -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..."
/>,
);