fix(webui): derive temporary chats from session policy

This commit is contained in:
chengyongru
2026-08-07 17:08:22 +08:00
parent 36253685bd
commit f971d7e895
13 changed files with 647 additions and 185 deletions
+72 -72
View File
@@ -21,10 +21,7 @@ from websockets.exceptions import ConnectionClosed
from websockets.http11 import Request as WsRequest from websockets.http11 import Request as WsRequest
from nanobot.bus.events import ( from nanobot.bus.events import (
INBOUND_META_RUNTIME_CONTROL,
OUTBOUND_META_AGENT_UI, OUTBOUND_META_AGENT_UI,
RUNTIME_CONTROL_SESSION_DISCARD,
InboundMessage,
OutboundMessage, OutboundMessage,
) )
from nanobot.bus.outbound_events import ( from nanobot.bus.outbound_events import (
@@ -89,6 +86,7 @@ from nanobot.webui.session_access import (
session_mentions_runtime_context, session_mentions_runtime_context,
) )
from nanobot.webui.sidebar_state import write_webui_sidebar_state 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.transcript import WEBUI_TRANSCRIPT_INCOMPLETE_KEY
from nanobot.webui.transcription_ws import webui_transcription_event from nanobot.webui.transcription_ws import webui_transcription_event
from nanobot.webui.websocket_logging import websockets_server_logger 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 # Accept UUIDs and short scoped keys like "unified:default". Keeps the capability
# namespace small enough to rule out path traversal / quote injection tricks. # namespace small enough to rule out path traversal / quote injection tricks.
_CHAT_ID_RE = re.compile(r"^[A-Za-z0-9_:-]{1,64}$") _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]: def _is_valid_chat_id(value: Any) -> TypeGuard[str]:
return isinstance(value, str) and _CHAT_ID_RE.match(value) is not None 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: 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. """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._ingress = gateway.ingress
self._transcripts = gateway.transcripts self._transcripts = gateway.transcripts
self._workspaces = gateway.workspaces self._workspaces = gateway.workspaces
self._temporary_chats = gateway.temporary_chats
self._session_access = ( self._session_access = (
WebuiSessionAccess(gateway.session_manager) WebuiSessionAccess(gateway.session_manager)
if gateway.session_manager is not None 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._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
self._temporary_media_paths: dict[str, set[str]] = {}
# -- Subscription bookkeeping ------------------------------------------- # -- Subscription bookkeeping -------------------------------------------
@@ -448,38 +435,15 @@ class WebSocketChannel(BaseChannel):
if key[0] == chat_id: if key[0] == chat_id:
self._stream_text_buffers.pop(key, None) self._stream_text_buffers.pop(key, None)
def _discard_temporary_media(self, chat_id: str) -> None: async def _discard_connection_owned_chat(
"""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(
self, self,
connection: ServerConnection, connection: ServerConnection,
chat_id: str, chat_id: str,
) -> None: ) -> None:
session_key = f"{self.name}:{chat_id}" await self._temporary_chats.discard(connection, chat_id)
self._detach(connection, chat_id) self._detach(connection, chat_id)
clear_websocket_turns(chat_id) clear_websocket_turns(chat_id)
self._clear_stream_buffers(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( async def send_webui_protocol_error(
self, self,
@@ -513,10 +477,12 @@ class WebSocketChannel(BaseChannel):
"""Remove *connection* from every subscription set; safe to call multiple times.""" """Remove *connection* from every subscription set; safe to call multiple times."""
chat_ids = tuple(self._conn_chats.get(connection, ())) chat_ids = tuple(self._conn_chats.get(connection, ()))
for cid in chat_ids: for cid in chat_ids:
if _is_temporary_chat_id(cid): if self._temporary_chats.owns(connection, cid):
await self._discard_temporary_chat(connection, cid) await self._discard_connection_owned_chat(connection, cid)
else: else:
self._detach(connection, cid) 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._conn_default.pop(connection, None)
self._webui_connections.discard(connection) self._webui_connections.discard(connection)
@@ -831,21 +797,46 @@ class WebSocketChannel(BaseChannel):
) )
await self._hydrate_after_subscribe(new_id) await self._hydrate_after_subscribe(new_id)
return 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": if t == "fork_chat":
await handle_webui_fork_chat(self, connection, envelope) await handle_webui_fork_chat(self, connection, envelope)
return return
if t == "discard_temporary_chat": if t == "discard_temporary_chat":
cid = envelope.get("chat_id") 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") await self._send_event(connection, "error", detail="invalid temporary chat_id")
return 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 return
if t == "attach": if t == "attach":
cid = envelope.get("chat_id") cid = envelope.get("chat_id")
if not _is_valid_chat_id(cid): if not _is_valid_chat_id(cid):
await self._send_event(connection, "error", detail="invalid chat_id") await self._send_event(connection, "error", detail="invalid chat_id")
return 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) self._attach(connection, cid)
await self._send_event(connection, "attached", chat_id=cid) await self._send_event(connection, "attached", chat_id=cid)
await self._hydrate_after_subscribe(cid) await self._hydrate_after_subscribe(cid)
@@ -879,6 +870,11 @@ class WebSocketChannel(BaseChannel):
if not _is_valid_chat_id(cid): if not _is_valid_chat_id(cid):
await self._send_event(connection, "error", detail="invalid chat_id") await self._send_event(connection, "error", detail="invalid chat_id")
return 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( scope = await self._workspace_scope_or_error(
connection, connection,
lambda: self._workspaces.scope_for_set_request( lambda: self._workspaces.scope_for_set_request(
@@ -910,7 +906,6 @@ class WebSocketChannel(BaseChannel):
if not _is_valid_chat_id(cid): if not _is_valid_chat_id(cid):
await self._send_event(connection, "error", detail="invalid chat_id") await self._send_event(connection, "error", detail="invalid chat_id")
return return
temporary = _is_temporary_chat_id(cid)
raw_turn_id = envelope.get("turn_id") raw_turn_id = envelope.get("turn_id")
turn_id = raw_turn_id if isinstance(raw_turn_id, str) and raw_turn_id else None turn_id = raw_turn_id if isinstance(raw_turn_id, str) and raw_turn_id else None
rejection_fields = { rejection_fields = {
@@ -948,28 +943,20 @@ class WebSocketChannel(BaseChannel):
) )
return return
if temporary: try:
command = content.strip().partition(" ")[0].lower() temporary_policy = self._temporary_chats.message_policy(
if command.startswith("/") and command not in {"/model", "/stop"}: connection,
cid,
content,
)
except TemporaryChatError as exc:
await self._send_event( await self._send_event(
connection, connection,
"error", "error",
detail="temporary_chat_command_rejected", detail=exc.detail,
**rejection_fields, **rejection_fields,
) )
return 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,
)
raw_media = envelope.get("media") raw_media = envelope.get("media")
media_paths: list[str] = [] media_paths: list[str] = []
@@ -993,8 +980,8 @@ class WebSocketChannel(BaseChannel):
**rejection_fields, **rejection_fields,
) )
return return
if temporary and media_paths: if temporary_policy is not None:
self._temporary_media_paths.setdefault(cid, set()).update(media_paths) self._temporary_chats.register_media(connection, cid, media_paths)
# Allow media-only turns (content may be empty when attachments are present). # Allow media-only turns (content may be empty when attachments are present).
if not content.strip() and not media_paths: if not content.strip() and not media_paths:
@@ -1007,15 +994,15 @@ class WebSocketChannel(BaseChannel):
return return
# Auto-attach on first use so clients can one-shot without a separate attach. # Auto-attach on first use so clients can one-shot without a separate attach.
self._attach(connection, cid) self._attach(connection, cid)
if not temporary: if temporary_policy is None or temporary_policy.hydrate_transcript:
await self._hydrate_after_subscribe(cid) await self._hydrate_after_subscribe(cid)
# Resolve after hydration so a concurrent downgrade cannot be overwritten. # Resolve after hydration so a concurrent downgrade cannot be overwritten.
scope = await self._workspace_scope_or_error( scope = await self._workspace_scope_or_error(
connection, connection,
lambda: ( lambda: (
self._workspaces.restricted_default_scope() temporary_policy.workspace_scope
if temporary if temporary_policy is not None
else self._workspaces.scope_for_message( else self._workspaces.scope_for_message(
envelope, envelope,
chat_id=cid, chat_id=cid,
@@ -1074,7 +1061,13 @@ class WebSocketChannel(BaseChannel):
metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = queued_owner metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = queued_owner
accepted = False accepted = False
try: 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( self._transcripts.append_user_message(
cid, cid,
content, content,
@@ -1103,8 +1096,16 @@ class WebSocketChannel(BaseChannel):
media=media_paths or None, media=media_paths or None,
metadata=metadata, metadata=metadata,
is_dm=False, is_dm=False,
session_key=f"{self.name}:{cid}" if temporary else None, session_key=(
require_existing_session=temporary, 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 accepted = True
finally: finally:
@@ -1165,8 +1166,7 @@ class WebSocketChannel(BaseChannel):
self._conn_default.clear() self._conn_default.clear()
self._webui_connections.clear() self._webui_connections.clear()
self._tokens.clear() self._tokens.clear()
for chat_id in tuple(self._temporary_media_paths): self._temporary_chats.close()
self._discard_temporary_media(chat_id)
async def _safe_send_to( async def _safe_send_to(
self, self,
@@ -1196,7 +1196,7 @@ class WebSocketChannel(BaseChannel):
transcript_overrides: dict[str, Any] | None = None, transcript_overrides: dict[str, Any] | None = None,
) -> bool: ) -> bool:
"""Persist one canonical turn event and retain unsafe owners on failure.""" """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 return True
persisted = self._transcripts.prepare_and_append( persisted = self._transcripts.prepare_and_append(
chat_id, chat_id,
@@ -196,6 +196,23 @@ def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None:
wth._WEBSOCKET_TURN_OWNERS.clear() 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 @pytest.mark.asyncio
async def test_temporary_chat_is_transient_and_discarded(bus, tmp_path) -> None: async def test_temporary_chat_is_transient_and_discarded(bus, tmp_path) -> None:
sessions = SessionManager(tmp_path) sessions = SessionManager(tmp_path)
@@ -212,8 +229,7 @@ async def test_temporary_chat_is_transient_and_discarded(bus, tmp_path) -> None:
) )
connection = AsyncMock() connection = AsyncMock()
connection.remote_address = ("127.0.0.1", 5000) connection.remote_address = ("127.0.0.1", 5000)
channel._webui_connections.add(connection) chat_id = await _new_temporary_chat(channel, connection)
chat_id = "temporary-test"
upload = tmp_path / "temporary-upload.txt" upload = tmp_path / "temporary-upload.txt"
upload.write_text("private attachment", encoding="utf-8") upload.write_text("private attachment", encoding="utf-8")
channel.gateway.media.store_inbound_attachments = MagicMock( 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 = AsyncMock()
connection.remote_address = ("127.0.0.1", 5000) connection.remote_address = ("127.0.0.1", 5000)
chat_id = await _new_temporary_chat(channel, connection)
await channel._dispatch_envelope(connection, "webui-client", { await channel._dispatch_envelope(connection, "webui-client", {
"type": "message", "type": "message",
"chat_id": "temporary-command", "chat_id": chat_id,
"content": content, "content": content,
"webui": True, "webui": True,
}) })
assert bus.publish_inbound.await_count == 0 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"] == ( assert json.loads(connection.send.await_args.args[0])["detail"] == (
"temporary_chat_command_rejected" "temporary_chat_command_rejected"
) )
@@ -314,6 +331,122 @@ async def test_disconnect_discards_temporary_chat(bus, tmp_path) -> None:
), ),
) )
connection = AsyncMock() 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) channel._webui_connections.add(connection)
await channel._dispatch_envelope( await channel._dispatch_envelope(
@@ -321,21 +454,42 @@ async def test_disconnect_discards_temporary_chat(bus, tmp_path) -> None:
"webui-client", "webui-client",
{ {
"type": "message", "type": "message",
"chat_id": "temporary-disconnect", "chat_id": "temporary-looking-but-persistent",
"content": "hello", "content": "/goal ordinary chat",
"webui": True, "webui": True,
}, },
) )
await channel._cleanup_connection(connection)
session_key = "websocket:temporary-disconnect" inbound = bus.publish_inbound.await_args.args[0]
control = bus.publish_inbound.await_args_list[-1].args[0] assert inbound.require_existing_session is False
assert control.session_key == session_key assert inbound.session_key_override is None
assert control.metadata[INBOUND_META_RUNTIME_CONTROL] == ( session = sessions.get_cached("websocket:temporary-looking-but-persistent")
RUNTIME_CONTROL_SESSION_DISCARD 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 connection = AsyncMock()
assert "temporary-disconnect" not in channel._subs 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 @pytest.mark.asyncio
+9
View File
@@ -11,6 +11,7 @@ from loguru import logger as default_logger
from nanobot.webui.gateway_tokens import GatewayTokenStore from nanobot.webui.gateway_tokens import GatewayTokenStore
from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy
from nanobot.webui.media_gateway import WebUIMediaGateway from nanobot.webui.media_gateway import WebUIMediaGateway
from nanobot.webui.temporary_chats import WebUITemporaryChats
from nanobot.webui.transcript import WebUITranscriptRecorder from nanobot.webui.transcript import WebUITranscriptRecorder
from nanobot.webui.workspaces import WebUIWorkspaceController from nanobot.webui.workspaces import WebUIWorkspaceController
from nanobot.webui.ws_http import GatewayHTTPHandler from nanobot.webui.ws_http import GatewayHTTPHandler
@@ -33,6 +34,7 @@ class GatewayServices:
ingress: WebUIIngressPolicy ingress: WebUIIngressPolicy
transcripts: WebUITranscriptRecorder transcripts: WebUITranscriptRecorder
workspaces: WebUIWorkspaceController workspaces: WebUIWorkspaceController
temporary_chats: WebUITemporaryChats
session_manager: SessionManager | None session_manager: SessionManager | None
cron_service: CronService | None cron_service: CronService | None
local_trigger_store: LocalTriggerStore | None local_trigger_store: LocalTriggerStore | None
@@ -82,6 +84,12 @@ def build_gateway_services(
default_workspace=workspace_path, default_workspace=workspace_path,
default_restrict_to_workspace=default_restrict_to_workspace, default_restrict_to_workspace=default_restrict_to_workspace,
) )
temporary_chats = WebUITemporaryChats(
bus=bus,
session_manager=session_manager,
workspaces=workspaces,
logger=logger,
)
http = GatewayHTTPHandler( http = GatewayHTTPHandler(
config=config, config=config,
session_manager=session_manager, session_manager=session_manager,
@@ -112,6 +120,7 @@ def build_gateway_services(
ingress=ingress, ingress=ingress,
transcripts=transcripts, transcripts=transcripts,
workspaces=workspaces, workspaces=workspaces,
temporary_chats=temporary_chats,
session_manager=session_manager, session_manager=session_manager,
cron_service=cron_service, cron_service=cron_service,
local_trigger_store=local_trigger_store, local_trigger_store=local_trigger_store,
+218
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()
+36 -17
View File
@@ -64,8 +64,6 @@ import {
import { projectNameFromPath, scopeWithAccessMode } from "@/lib/workspace"; import { projectNameFromPath, scopeWithAccessMode } from "@/lib/workspace";
import { import {
createTemporaryChatSession, createTemporaryChatSession,
isTemporaryChatId,
temporaryChatIdFromSessionKey,
} from "@/lib/temporary-chat"; } from "@/lib/temporary-chat";
type BootState = type BootState =
@@ -101,6 +99,7 @@ type ShellRoute = {
view: ShellView; view: ShellView;
activeKey: string | null; activeKey: string | null;
settingsSection: SettingsSectionKey; settingsSection: SettingsSectionKey;
temporary?: boolean;
}; };
const loadSettingsView = () => import("@/components/settings/SettingsView"); const loadSettingsView = () => import("@/components/settings/SettingsView");
const SettingsView = lazy(async () => { const SettingsView = lazy(async () => {
@@ -235,11 +234,12 @@ function readShellRoute(): ShellRoute {
const encoded = path.slice("/temporary/".length); const encoded = path.slice("/temporary/".length);
try { try {
const chatId = decodeURIComponent(encoded).trim(); const chatId = decodeURIComponent(encoded).trim();
return isTemporaryChatId(chatId) return chatId
? { ? {
view: "chat", view: "chat",
activeKey: `websocket:${chatId}`, activeKey: `websocket:${chatId}`,
settingsSection: "overview", settingsSection: "overview",
temporary: true,
} }
: defaultShellRoute(); : defaultShellRoute();
} catch { } catch {
@@ -262,8 +262,10 @@ function readShellRoute(): ShellRoute {
function shellRouteHash(route: ShellRoute): string { function shellRouteHash(route: ShellRoute): string {
if (route.view === "chat") { if (route.view === "chat") {
const temporaryChatId = temporaryChatIdFromSessionKey(route.activeKey); if (route.temporary && route.activeKey?.startsWith("websocket:")) {
if (temporaryChatId) return `#/temporary/${encodeURIComponent(temporaryChatId)}`; const chatId = route.activeKey.slice("websocket:".length);
return `#/temporary/${encodeURIComponent(chatId)}`;
}
return route.activeKey return route.activeKey
? `#/chat/${encodeURIComponent(route.activeKey)}` ? `#/chat/${encodeURIComponent(route.activeKey)}`
: "#/new"; : "#/new";
@@ -1034,7 +1036,8 @@ function Shell({
settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface; settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface;
const showHostChrome = effectiveRuntimeSurface === "native"; const showHostChrome = effectiveRuntimeSurface === "native";
const showMainSidebar = view !== "settings"; 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 temporaryChatActive = view === "chat" && temporaryChatId !== null;
const temporaryChatRequested = temporaryChatActive || temporaryChatEnabled; const temporaryChatRequested = temporaryChatActive || temporaryChatEnabled;
const temporarySessionList = useMemo( const temporarySessionList = useMemo(
@@ -1173,9 +1176,7 @@ function Shell({
const activeSession = useMemo<ChatSummary | null>(() => { const activeSession = useMemo<ChatSummary | null>(() => {
if (!activeKey) return null; if (!activeKey) return null;
if (temporaryChatIdFromSessionKey(activeKey)) { if (temporarySessions[activeKey]) return temporarySessions[activeKey];
return temporarySessions[activeKey] ?? null;
}
return sessions.find((s) => s.key === activeKey) ?? null; return sessions.find((s) => s.key === activeKey) ?? null;
}, [sessions, activeKey, temporarySessions]); }, [sessions, activeKey, temporarySessions]);
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]); const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
@@ -1249,7 +1250,8 @@ function Shell({
pendingCreatedSessionKeyRef.current = null; pendingCreatedSessionKeyRef.current = null;
} }
if (!activeKey) return; if (!activeKey) return;
if (temporaryChatIdFromSessionKey(activeKey)) { const currentRoute = readShellRoute();
if (currentRoute.temporary) {
if (temporarySessions[activeKey]) return; if (temporarySessions[activeKey]) return;
navigate(defaultShellRoute(), { replace: true }); navigate(defaultShellRoute(), { replace: true });
return; return;
@@ -1258,7 +1260,6 @@ function Shell({
// WebKit can commit the route before useSessions' optimistic insert. // WebKit can commit the route before useSessions' optimistic insert.
// Keep that just-created destination valid until the session list catches up. // Keep that just-created destination valid until the session list catches up.
if (pendingCreatedKey === activeKey) return; if (pendingCreatedKey === activeKey) return;
const currentRoute = readShellRoute();
navigate( navigate(
currentRoute.view === "chat" currentRoute.view === "chat"
? defaultShellRoute() ? defaultShellRoute()
@@ -1478,7 +1479,9 @@ function Shell({
workspaceScope?: WorkspaceScopePayload | null, workspaceScope?: WorkspaceScopePayload | null,
initialMessage?: string, initialMessage?: string,
) => { ) => {
const session = createTemporaryChatSession(); try {
const chatId = await client.newTemporaryChat();
const session = createTemporaryChatSession(chatId);
const restrictedScope = workspaceScope const restrictedScope = workspaceScope
? normalizeWorkspaceScope(scopeWithAccessMode(workspaceScope, "restricted")) ? normalizeWorkspaceScope(scopeWithAccessMode(workspaceScope, "restricted"))
: null; : null;
@@ -1498,11 +1501,16 @@ function Shell({
view: "chat", view: "chat",
activeKey: nextSession.key, activeKey: nextSession.key,
settingsSection: "overview", settingsSection: "overview",
temporary: true,
}); });
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
return nextSession.chatId; return nextSession.chatId;
} catch (error) {
console.error("Failed to create temporary chat", error);
return null;
}
}, },
[navigate], [client, navigate],
); );
const onForkChat = useCallback(async ( const onForkChat = useCallback(async (
@@ -1572,7 +1580,8 @@ function Shell({
const onSelectChat = useCallback( const onSelectChat = useCallback(
(key: string) => { (key: string) => {
const selected = temporarySessionsRef.current[key] const selectedTemporary = temporarySessionsRef.current[key];
const selected = selectedTemporary
?? sessions.find((session) => session.key === key); ?? sessions.find((session) => session.key === key);
const selectedChatId = selected?.chatId; const selectedChatId = selected?.chatId;
if (selectedChatId) { if (selectedChatId) {
@@ -1589,7 +1598,12 @@ function Shell({
setDraftWorkspaceScope(null); setDraftWorkspaceScope(null);
} }
setWorkspaceError(null); setWorkspaceError(null);
navigate({ view: "chat", activeKey: key, settingsSection: "overview" }); navigate({
view: "chat",
activeKey: key,
settingsSection: "overview",
...(selectedTemporary ? { temporary: true } : {}),
});
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
}, },
[navigate, sessions], [navigate, sessions],
@@ -1610,6 +1624,7 @@ function Shell({
view: "chat", view: "chat",
activeKey: remaining[0]?.key ?? null, activeKey: remaining[0]?.key ?? null,
settingsSection: "overview", settingsSection: "overview",
...(remaining[0] ? { temporary: true } : {}),
}, { replace: true }); }, { replace: true });
} }
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
@@ -1897,7 +1912,11 @@ function Shell({
nextRunning.delete(chatId); nextRunning.delete(chatId);
runningChatIdsRef.current = nextRunning; runningChatIdsRef.current = nextRunning;
setRunningChatIds(nextRunning); setRunningChatIds(nextRunning);
if (isTemporaryChatId(chatId)) return; if (
Object.values(temporarySessionsRef.current).some(
(session) => session.chatId === chatId,
)
) return;
setUpdatedChatIds((current) => { setUpdatedChatIds((current) => {
const next = new Set(current); const next = new Set(current);
if (activeChatIdRef.current === chatId) { if (activeChatIdRef.current === chatId) {
@@ -1922,7 +1941,7 @@ function Shell({
if (Object.keys(temporarySessionsRef.current).length === 0) return; if (Object.keys(temporarySessionsRef.current).length === 0) return;
temporarySessionsRef.current = {}; temporarySessionsRef.current = {};
setTemporarySessions({}); setTemporarySessions({});
if (temporaryChatIdFromSessionKey(readShellRoute().activeKey)) { if (readShellRoute().temporary) {
navigate(defaultShellRoute(), { replace: true }); navigate(defaultShellRoute(), { replace: true });
} }
}); });
@@ -85,7 +85,6 @@ import { useMediaQuery } from "@/hooks/useMediaQuery";
import type { SendAttachment, SendOptions } from "@/hooks/useNanobotStream"; import type { SendAttachment, SendOptions } from "@/hooks/useNanobotStream";
import { usePageVisibility } from "@/hooks/usePageVisibility"; import { usePageVisibility } from "@/hooks/usePageVisibility";
import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder"; import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder";
import { isTemporaryChatId } from "@/lib/temporary-chat";
import type { import type {
CliAppInfo, CliAppInfo,
ChatSummary, ChatSummary,
@@ -444,9 +443,7 @@ function storeSlashRecents(commands: string[]): void {
function queuedPromptsStorageKey(key?: string | null): string | null { function queuedPromptsStorageKey(key?: string | null): string | null {
const clean = key?.trim(); const clean = key?.trim();
return clean && !isTemporaryChatId(clean) return clean ? `${QUEUED_PROMPTS_STORAGE_PREFIX}${clean}` : null;
? `${QUEUED_PROMPTS_STORAGE_PREFIX}${clean}`
: null;
} }
function normalizeQueuedSessionMentions(value: unknown): SessionMention[] { function normalizeQueuedSessionMentions(value: unknown): SessionMention[] {
+6 -4
View File
@@ -33,7 +33,6 @@ import {
} from "@/lib/mcp-preset-events"; } from "@/lib/mcp-preset-events";
import type { CanonicalRunSnapshot, StreamError } from "@/lib/nanobot-client"; import type { CanonicalRunSnapshot, StreamError } from "@/lib/nanobot-client";
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand"; import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
import { isTemporaryChatId } from "@/lib/temporary-chat";
import type { import type {
ChatSummary, ChatSummary,
SettingsPayload, SettingsPayload,
@@ -677,6 +676,7 @@ export function ThreadShell({
const viewportRef = useRef<ThreadViewportHandle | null>(null); const viewportRef = useRef<ThreadViewportHandle | null>(null);
const activeViewportTurnByChatIdRef = useRef<Map<string, string>>(new Map()); const activeViewportTurnByChatIdRef = useRef<Map<string, string>>(new Map());
const messageCacheRef = useRef<Map<string, UIMessage[]>>(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). */ /** Last chatId we associated with the in-memory thread (for cache-on-switch). */
const prevChatIdForCacheRef = useRef<string | null>(null); const prevChatIdForCacheRef = useRef<string | null>(null);
/** Skip one message-cache write right after chatId changes (messages may not match yet). */ /** Skip one message-cache write right after chatId changes (messages may not match yet). */
@@ -753,10 +753,12 @@ export function ThreadShell({
useEffect(() => { useEffect(() => {
const retained = new Set(temporaryChatIds); const retained = new Set(temporaryChatIds);
for (const cachedChatId of messageCacheRef.current.keys()) { for (const chatId of retained) knownTemporaryChatIdsRef.current.add(chatId);
if (isTemporaryChatId(cachedChatId) && !retained.has(cachedChatId)) { for (const cachedChatId of knownTemporaryChatIdsRef.current) {
if (!retained.has(cachedChatId)) {
messageCacheRef.current.delete(cachedChatId); messageCacheRef.current.delete(cachedChatId);
activeViewportTurnByChatIdRef.current.delete(cachedChatId); activeViewportTurnByChatIdRef.current.delete(cachedChatId);
knownTemporaryChatIdsRef.current.delete(cachedChatId);
} }
} }
}, [temporaryChatIds]); }, [temporaryChatIds]);
@@ -1435,7 +1437,7 @@ export function ThreadShell({
workspaceScopeDisabled={workspaceScopeDisabled} workspaceScopeDisabled={workspaceScopeDisabled}
workspaceError={workspaceError} workspaceError={workspaceError}
onWorkspaceScopeChange={onWorkspaceScopeChange} onWorkspaceScopeChange={onWorkspaceScopeChange}
pendingQueueKey={chatId} pendingQueueKey={temporary ? null : chatId}
transcriptionProvider={settingsSnapshot?.transcription?.provider} transcriptionProvider={settingsSnapshot?.transcription?.provider}
ingressLimits={ingressLimits} ingressLimits={ingressLimits}
quotedContext={quotedContext} quotedContext={quotedContext}
+34 -13
View File
@@ -11,7 +11,6 @@ import type {
WorkspaceScopePayload, WorkspaceScopePayload,
} from "./types"; } from "./types";
import { createHostWebSocket } from "./runtime"; import { createHostWebSocket } from "./runtime";
import { isTemporaryChatId } from "./temporary-chat";
/** WebSocket readyState constants, referenced by value to stay portable /** WebSocket readyState constants, referenced by value to stay portable
* across runtimes that don't expose a global ``WebSocket`` (tests, SSR). */ * across runtimes that don't expose a global ``WebSocket`` (tests, SSR). */
@@ -109,6 +108,10 @@ interface PendingRequest<T> {
timer: ReturnType<typeof setTimeout>; timer: ReturnType<typeof setTimeout>;
} }
interface PendingChatRequest extends PendingRequest<string> {
temporary: boolean;
}
const SYSTEM_COMMAND_TURN_PREFIX = "webui-system:"; const SYSTEM_COMMAND_TURN_PREFIX = "webui-system:";
const TURN_REJECTION_DETAILS = new Set([ const TURN_REJECTION_DETAILS = new Set([
"access_denied", "access_denied",
@@ -197,7 +200,7 @@ export class NanobotClient {
private static readonly COMPLETED_TURN_FENCE_MAX = 256; private static readonly COMPLETED_TURN_FENCE_MAX = 256;
/** Latest ``goal_state`` snapshot per ``chat_id`` (multi-session isolation). */ /** Latest ``goal_state`` snapshot per ``chat_id`` (multi-session isolation). */
private goalStateByChatId = new Map<string, GoalStateWsPayload>(); 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 pendingTranscriptions = new Map<string, PendingRequest<string>>();
private pendingSystemCommands = new Map<string, PendingRequest<void>>(); private pendingSystemCommands = new Map<string, PendingRequest<void>>();
// Frames queued while the socket is not yet OPEN // Frames queued while the socket is not yet OPEN
@@ -743,7 +746,7 @@ export class NanobotClient {
} }
discardTemporaryChat(chatId: string): void { discardTemporaryChat(chatId: string): void {
if (!isTemporaryChatId(chatId)) return; if (!this.temporaryChatIds.has(chatId)) return;
if (this.socket?.readyState === WS_OPEN) { if (this.socket?.readyState === WS_OPEN) {
this.rawSend({ type: "discard_temporary_chat", chat_id: chatId }); this.rawSend({ type: "discard_temporary_chat", chat_id: chatId });
} }
@@ -760,7 +763,7 @@ export class NanobotClient {
this.pendingNewChat = null; this.pendingNewChat = null;
reject(new Error("newChat timed out")); reject(new Error("newChat timed out"));
}, timeoutMs); }, timeoutMs);
this.pendingNewChat = { resolve, reject, timer }; this.pendingNewChat = { resolve, reject, timer, temporary: false };
this.queueSend({ this.queueSend({
type: "new_chat", type: "new_chat",
...(workspaceScope ? { workspace_scope: workspaceScope } : {}), ...(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( transcribeAudio(
dataUrl: string, dataUrl: string,
options?: { durationMs?: number; timeoutMs?: number }, options?: { durationMs?: number; timeoutMs?: number },
@@ -804,7 +822,7 @@ export class NanobotClient {
this.pendingNewChat = null; this.pendingNewChat = null;
reject(new Error("forkChat timed out")); reject(new Error("forkChat timed out"));
}, timeoutMs); }, timeoutMs);
this.pendingNewChat = { resolve, reject, timer }; this.pendingNewChat = { resolve, reject, timer, temporary: false };
this.queueSend({ this.queueSend({
type: "fork_chat", type: "fork_chat",
source_chat_id: sourceChatId, source_chat_id: sourceChatId,
@@ -815,10 +833,7 @@ export class NanobotClient {
} }
attach(chatId: string): void { attach(chatId: string): void {
if (isTemporaryChatId(chatId)) { if (this.temporaryChatIds.has(chatId)) return;
this.temporaryChatIds.add(chatId);
return;
}
this.knownChats.add(chatId); this.knownChats.add(chatId);
if (this.socket?.readyState === WS_OPEN) { if (this.socket?.readyState === WS_OPEN) {
this.queueSend({ type: "attach", chat_id: chatId }); this.queueSend({ type: "attach", chat_id: chatId });
@@ -840,8 +855,7 @@ export class NanobotClient {
startsNewRun?: boolean; startsNewRun?: boolean;
}, },
): void { ): void {
const temporary = isTemporaryChatId(chatId); const temporary = this.temporaryChatIds.has(chatId);
if (temporary) this.temporaryChatIds.add(chatId);
if (!temporary) this.knownChats.add(chatId); if (!temporary) this.knownChats.add(chatId);
const frame: Outbound = { const frame: Outbound = {
type: "message", type: "message",
@@ -891,7 +905,7 @@ export class NanobotClient {
} }
setWorkspaceScope(chatId: string, workspaceScope: WorkspaceScopePayload): void { setWorkspaceScope(chatId: string, workspaceScope: WorkspaceScopePayload): void {
if (isTemporaryChatId(chatId)) return; if (this.temporaryChatIds.has(chatId)) return;
this.knownChats.add(chatId); this.knownChats.add(chatId);
this.queueSend({ this.queueSend({
type: "set_workspace_scope", type: "set_workspace_scope",
@@ -1016,8 +1030,15 @@ export class NanobotClient {
} }
if (parsed.event === "attached") { if (parsed.event === "attached") {
if (parsed.temporary === true) {
this.temporaryChatIds.add(parsed.chat_id);
} else {
this.knownChats.add(parsed.chat_id); this.knownChats.add(parsed.chat_id);
if (this.pendingNewChat) { }
if (
this.pendingNewChat
&& this.pendingNewChat.temporary === (parsed.temporary === true)
) {
clearTimeout(this.pendingNewChat.timer); clearTimeout(this.pendingNewChat.timer);
this.pendingNewChat.resolve(parsed.chat_id); this.pendingNewChat.resolve(parsed.chat_id);
this.pendingNewChat = null; this.pendingNewChat = null;
+1 -13
View File
@@ -1,20 +1,8 @@
import type { ChatSummary } from "./types"; import type { ChatSummary } from "./types";
export const TEMPORARY_CHAT_ID_PREFIX = "temporary-";
const WEBSOCKET_SESSION_KEY_PREFIX = "websocket:"; const WEBSOCKET_SESSION_KEY_PREFIX = "websocket:";
export function isTemporaryChatId(value: string): boolean { export function createTemporaryChatSession(chatId: string): ChatSummary {
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()}`;
const now = new Date().toISOString(); const now = new Date().toISOString();
return { return {
key: `${WEBSOCKET_SESSION_KEY_PREFIX}${chatId}`, key: `${WEBSOCKET_SESSION_KEY_PREFIX}${chatId}`,
+2 -1
View File
@@ -1162,7 +1162,7 @@ export interface InboundTurnMetadata {
export type InboundEvent = export type InboundEvent =
| { event: "ready"; chat_id: string; client_id: string } | { 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_accepted"; chat_id: string; turn_id: string }
| ({ | ({
event: "message"; event: "message";
@@ -1338,6 +1338,7 @@ export interface FilePreviewPayload {
export type Outbound = export type Outbound =
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload } | { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
| { type: "new_temporary_chat" }
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string } | { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
| { type: "attach"; chat_id: string } | { type: "attach"; chat_id: string }
| { type: "set_sidebar_state"; state: SidebarStatePayload } | { type: "set_sidebar_state"; state: SidebarStatePayload }
+15 -9
View File
@@ -20,6 +20,7 @@ const updateUrlSpy = vi.fn();
const attachSpy = vi.fn(); const attachSpy = vi.fn();
const setSidebarStateSpy = vi.fn(); const setSidebarStateSpy = vi.fn();
const discardTemporaryChatSpy = vi.fn(); const discardTemporaryChatSpy = vi.fn();
const newTemporaryChatSpy = vi.fn<() => Promise<string>>();
const sendMessageSpy = vi.fn(); const sendMessageSpy = vi.fn();
const statusHandlers = new Set<(status: ConnectionStatus) => void>(); const statusHandlers = new Set<(status: ConnectionStatus) => void>();
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>(); const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
@@ -238,6 +239,7 @@ vi.mock("@/lib/nanobot-client", async (importOriginal) => {
getGoalState = () => undefined; getGoalState = () => undefined;
sendMessage = sendMessageSpy; sendMessage = sendMessageSpy;
newChat = vi.fn(); newChat = vi.fn();
newTemporaryChat = newTemporaryChatSpy;
attach = attachSpy; attach = attachSpy;
setSidebarState = setSidebarStateSpy; setSidebarState = setSidebarStateSpy;
discardTemporaryChat = discardTemporaryChatSpy; discardTemporaryChat = discardTemporaryChatSpy;
@@ -270,6 +272,10 @@ describe("App layout", () => {
attachSpy.mockReset(); attachSpy.mockReset();
setSidebarStateSpy.mockReset(); setSidebarStateSpy.mockReset();
discardTemporaryChatSpy.mockReset(); discardTemporaryChatSpy.mockReset();
let temporaryChatCounter = 0;
newTemporaryChatSpy.mockImplementation(async () => (
`00000000-0000-4000-8000-${String(++temporaryChatCounter).padStart(12, "0")}`
));
sendMessageSpy.mockReset(); sendMessageSpy.mockReset();
statusHandlers.clear(); statusHandlers.clear();
runStatusHandlers.clear(); runStatusHandlers.clear();
@@ -425,9 +431,9 @@ describe("App layout", () => {
}); });
fireEvent.click(screen.getByRole("button", { name: "Send 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 firstHash = window.location.hash; 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(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
expect(createChatSpy).not.toHaveBeenCalled(); expect(createChatSpy).not.toHaveBeenCalled();
@@ -441,9 +447,9 @@ describe("App layout", () => {
target: { value: "second private message" }, target: { value: "second private message" },
}); });
fireEvent.click(screen.getByRole("button", { name: "Send 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; const secondHash = window.location.hash;
expect(secondHash).toMatch(/^#\/temporary\/temporary-/); expect(secondHash).toMatch(/^#\/temporary\/[0-9a-f-]+$/);
expect(secondHash).not.toBe(firstHash); expect(secondHash).not.toBe(firstHash);
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
expect(discardTemporaryChatSpy).not.toHaveBeenCalled(); expect(discardTemporaryChatSpy).not.toHaveBeenCalled();
@@ -481,8 +487,8 @@ describe("App layout", () => {
const discardedChatIds = discardTemporaryChatSpy.mock.calls.map(([chatId]) => chatId); const discardedChatIds = discardTemporaryChatSpy.mock.calls.map(([chatId]) => chatId);
expect(new Set(discardedChatIds).size).toBe(2); expect(new Set(discardedChatIds).size).toBe(2);
expect(discardedChatIds).toEqual([ expect(discardedChatIds).toEqual([
expect.stringMatching(/^temporary-/), "00000000-0000-4000-8000-000000000001",
expect.stringMatching(/^temporary-/), "00000000-0000-4000-8000-000000000002",
]); ]);
}); });
@@ -544,7 +550,7 @@ describe("App layout", () => {
target: { value: "start temporary chat" }, target: { value: "start temporary chat" },
}); });
fireEvent.click(screen.getByRole("button", { name: "Send 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-]+$/));
expect(screen.queryByText("Not saved")).not.toBeInTheDocument(); expect(screen.queryByText("Not saved")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Clear temporary chat" })).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" }, target: { value: "do not lose this" },
}); });
fireEvent.click(screen.getByRole("button", { name: "Send 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 beforeUnload = new Event("beforeunload", { cancelable: true }); const beforeUnload = new Event("beforeunload", { cancelable: true });
act(() => window.dispatchEvent(beforeUnload)); act(() => window.dispatchEvent(beforeUnload));
@@ -579,7 +585,7 @@ describe("App layout", () => {
target: { value: "connection-sensitive message" }, target: { value: "connection-sensitive message" },
}); });
fireEvent.click(screen.getByRole("button", { name: "Send 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(() => { act(() => {
statusHandlers.forEach((handler) => handler("reconnecting")); statusHandlers.forEach((handler) => handler("reconnecting"));
+55 -8
View File
@@ -71,19 +71,25 @@ afterEach(() => {
}); });
describe("NanobotClient", () => { 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({ const client = new NanobotClient({
url: "ws://test", url: "ws://test",
reconnect: false, reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
}); });
const chatId = "temporary-test"; const chatId = "temp-server-id";
client.connect(); 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.onChat(chatId, vi.fn());
client.sendMessage(chatId, "hello", undefined, { turnId: "turn-1" }); client.sendMessage(chatId, "hello", undefined, { turnId: "turn-1" });
lastSocket().fakeOpen();
expect(lastSocket().sent.map((raw) => JSON.parse(raw))).toEqual([ expect(lastSocket().sent.map((raw) => JSON.parse(raw))).toEqual([
{ {
type: "message", 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 () => { it("forgets every temporary chat when the socket drops", async () => {
const client = new NanobotClient({ const client = new NanobotClient({
url: "ws://test", url: "ws://test",
@@ -112,20 +142,37 @@ describe("NanobotClient", () => {
const secondHandler = vi.fn(); const secondHandler = vi.fn();
client.connect(); client.connect();
lastSocket().fakeOpen(); lastSocket().fakeOpen();
client.onChat("temporary-drop-a", firstHandler); const firstCreation = client.newTemporaryChat();
client.onChat("temporary-drop-b", secondHandler); 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(); lastSocket().close();
await vi.advanceTimersByTimeAsync(1); await vi.advanceTimersByTimeAsync(1);
lastSocket().fakeOpen(); lastSocket().fakeOpen();
lastSocket().fakeMessage({ lastSocket().fakeMessage({
event: "message", event: "message",
chat_id: "temporary-drop-a", chat_id: "temp-drop-a",
text: "stale first chat", text: "stale first chat",
}); });
lastSocket().fakeMessage({ lastSocket().fakeMessage({
event: "message", event: "message",
chat_id: "temporary-drop-b", chat_id: "temp-drop-b",
text: "stale second chat", text: "stale second chat",
}); });
+2 -2
View File
@@ -2944,7 +2944,7 @@ describe("ThreadComposer", () => {
onSend={onSend} onSend={onSend}
onStop={vi.fn()} onStop={vi.fn()}
isStreaming isStreaming
pendingQueueKey="temporary-private" pendingQueueKey={null}
placeholder="Type your message..." placeholder="Type your message..."
/>, />,
); );
@@ -2966,7 +2966,7 @@ describe("ThreadComposer", () => {
onSend={onSend} onSend={onSend}
onStop={vi.fn()} onStop={vi.fn()}
isStreaming isStreaming
pendingQueueKey="temporary-private" pendingQueueKey={null}
placeholder="Type your message..." placeholder="Type your message..."
/>, />,
); );