feat(webui): add lightweight session messaging via mentions
This commit is contained in:
@@ -33,6 +33,7 @@ from nanobot.bus.outbound_events import (
|
||||
GoalStatusEvent,
|
||||
ProgressEvent,
|
||||
RuntimeModelUpdatedEvent,
|
||||
SessionMessageInputEvent,
|
||||
SessionUpdatedEvent,
|
||||
TurnEndEvent,
|
||||
TurnModelUpdatedEvent,
|
||||
@@ -86,6 +87,7 @@ from nanobot.webui.metadata import (
|
||||
WEBUI_TURN_METADATA_KEY,
|
||||
)
|
||||
from nanobot.webui.session_access import (
|
||||
SessionHandleMention,
|
||||
SessionMention,
|
||||
WebuiSessionAccess,
|
||||
session_mentions_runtime_context,
|
||||
@@ -1195,9 +1197,11 @@ class WebSocketChannel(BaseChannel):
|
||||
if mcp_presets:
|
||||
metadata["mcp_presets"] = mcp_presets
|
||||
session_mentions: list[SessionMention] = []
|
||||
session_handles: list[SessionHandleMention] = []
|
||||
if (
|
||||
trusted_webui
|
||||
and self._session_access is not None
|
||||
and temporary_policy is None
|
||||
):
|
||||
session_mentions = await asyncio.to_thread(
|
||||
self._session_access.normalize_mentions,
|
||||
@@ -1206,6 +1210,15 @@ class WebSocketChannel(BaseChannel):
|
||||
)
|
||||
if session_mentions:
|
||||
metadata["session_mentions"] = session_mentions
|
||||
raw_session_handles = envelope.get("session_handles")
|
||||
if raw_session_handles is not None:
|
||||
session_handles = await asyncio.to_thread(
|
||||
self._session_access.normalize_session_handles,
|
||||
raw_session_handles,
|
||||
source_session_key=f"{self.name}:{cid}",
|
||||
)
|
||||
if session_handles:
|
||||
metadata["session_handles"] = session_handles
|
||||
metadata[WORKSPACE_SCOPE_METADATA_KEY] = scope.metadata()
|
||||
self._workspaces.persist_scope(cid, scope)
|
||||
is_webui = metadata.get("webui") is True
|
||||
@@ -1231,6 +1244,7 @@ class WebSocketChannel(BaseChannel):
|
||||
cli_apps=cli_apps or None,
|
||||
mcp_presets=mcp_presets or None,
|
||||
session_mentions=session_mentions or None,
|
||||
session_handles=session_handles or None,
|
||||
)
|
||||
if trusted_webui:
|
||||
context_blocks: list[RuntimeContextBlock] = []
|
||||
@@ -1239,9 +1253,9 @@ class WebSocketChannel(BaseChannel):
|
||||
})
|
||||
if quote is not None:
|
||||
context_blocks.append(quote)
|
||||
session_context = session_mentions_runtime_context(session_mentions)
|
||||
if session_context is not None:
|
||||
context_blocks.append(session_context)
|
||||
reference_context = session_mentions_runtime_context(session_mentions)
|
||||
if reference_context is not None:
|
||||
context_blocks.append(reference_context)
|
||||
if context_blocks:
|
||||
metadata[RUNTIME_CONTEXT_INPUT_META] = context_blocks
|
||||
await self._handle_message(
|
||||
@@ -1259,7 +1273,7 @@ class WebSocketChannel(BaseChannel):
|
||||
require_existing_session=(
|
||||
temporary_policy.require_existing_session
|
||||
if temporary_policy is not None
|
||||
else False
|
||||
else is_webui
|
||||
),
|
||||
)
|
||||
accepted = True
|
||||
@@ -1668,6 +1682,7 @@ class WebSocketChannel(BaseChannel):
|
||||
if isinstance(
|
||||
event,
|
||||
ProgressEvent
|
||||
| SessionMessageInputEvent
|
||||
| TurnEndEvent
|
||||
| SessionUpdatedEvent
|
||||
| GoalStatusEvent
|
||||
@@ -1685,6 +1700,16 @@ class WebSocketChannel(BaseChannel):
|
||||
context_window_tokens=event.context_window_tokens,
|
||||
)
|
||||
return
|
||||
if isinstance(event, SessionMessageInputEvent):
|
||||
if conns:
|
||||
await self.send_session_message_input(
|
||||
msg.chat_id,
|
||||
content=event.content,
|
||||
created_at_ms=event.created_at_ms,
|
||||
session_message=event.session_message,
|
||||
metadata=msg.metadata,
|
||||
)
|
||||
return
|
||||
if isinstance(event, GoalStateSyncEvent):
|
||||
if conns:
|
||||
await self.send_goal_state(msg.chat_id, event.goal_state or {"active": False})
|
||||
@@ -2039,6 +2064,34 @@ class WebSocketChannel(BaseChannel):
|
||||
for connection in conns:
|
||||
await self._safe_send_to(connection, raw, label=" session_updated ")
|
||||
|
||||
async def send_session_message_input(
|
||||
self,
|
||||
chat_id: str,
|
||||
*,
|
||||
content: str,
|
||||
created_at_ms: int,
|
||||
session_message: dict[str, Any],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Project a session message before the target model starts responding."""
|
||||
conns = list(self._subs.get(chat_id, ()))
|
||||
if not conns:
|
||||
return
|
||||
body: dict[str, Any] = {
|
||||
"event": "session_message",
|
||||
"chat_id": chat_id,
|
||||
"text": content,
|
||||
"created_at_ms": created_at_ms,
|
||||
"session_message": session_message,
|
||||
"turn_phase": "user",
|
||||
}
|
||||
turn_id = (metadata or {}).get(WEBUI_TURN_METADATA_KEY)
|
||||
if isinstance(turn_id, str) and turn_id:
|
||||
body["turn_id"] = turn_id
|
||||
raw = json.dumps(body, ensure_ascii=False)
|
||||
for connection in conns:
|
||||
await self._safe_send_to(connection, raw, label=" session_message ")
|
||||
|
||||
async def send_runtime_model_updated(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -28,6 +28,7 @@ from nanobot.bus.outbound_events import (
|
||||
GoalStatusEvent,
|
||||
ProgressEvent,
|
||||
RuntimeModelUpdatedEvent,
|
||||
SessionMessageInputEvent,
|
||||
SessionUpdatedEvent,
|
||||
TurnEndEvent,
|
||||
TurnModelUpdatedEvent,
|
||||
@@ -539,7 +540,7 @@ async def test_temporary_looking_id_does_not_define_session_policy(bus, tmp_path
|
||||
)
|
||||
|
||||
inbound = bus.publish_inbound.await_args.args[0]
|
||||
assert inbound.require_existing_session is False
|
||||
assert inbound.require_existing_session is True
|
||||
assert inbound.session_key_override is None
|
||||
session = sessions.get_cached("websocket:temporary-looking-but-persistent")
|
||||
assert session is not None
|
||||
@@ -2065,6 +2066,57 @@ def test_attach_fields_restore_the_session_model_and_latest_usage() -> None:
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_projects_session_message_only_to_the_target_chat() -> None:
|
||||
bus = MessageBus()
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
target = AsyncMock()
|
||||
other = AsyncMock()
|
||||
channel._attach(target, "target")
|
||||
channel._attach(other, "other")
|
||||
|
||||
await channel.send(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id="target",
|
||||
content="Review this now.",
|
||||
metadata={WEBUI_TURN_METADATA_KEY: "session-message-turn-1"},
|
||||
event=SessionMessageInputEvent(
|
||||
content="Review this now.",
|
||||
created_at_ms=1234,
|
||||
session_message={
|
||||
"direction": "incoming",
|
||||
"message_id": "session-message-1",
|
||||
"session": {
|
||||
"id": "handle_11111111111111111111111111111111",
|
||||
"name": "kai",
|
||||
"session_key": "websocket:source",
|
||||
"color_slot": 2,
|
||||
},
|
||||
},
|
||||
),
|
||||
))
|
||||
|
||||
assert json.loads(target.send.await_args.args[0]) == {
|
||||
"event": "session_message",
|
||||
"chat_id": "target",
|
||||
"text": "Review this now.",
|
||||
"created_at_ms": 1234,
|
||||
"session_message": {
|
||||
"direction": "incoming",
|
||||
"message_id": "session-message-1",
|
||||
"session": {
|
||||
"id": "handle_11111111111111111111111111111111",
|
||||
"name": "kai",
|
||||
"session_key": "websocket:source",
|
||||
"color_slot": 2,
|
||||
},
|
||||
},
|
||||
"turn_phase": "user",
|
||||
"turn_id": "session-message-turn-1",
|
||||
}
|
||||
other.send.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_stages_external_media_as_signed_url(monkeypatch, tmp_path) -> None:
|
||||
bus = MagicMock()
|
||||
@@ -4919,7 +4971,7 @@ def test_parse_envelope_rejects_legacy_and_garbage() -> None:
|
||||
assert _parse_envelope('{"type":123}') is None
|
||||
|
||||
|
||||
def test_sessions_list_includes_active_run_started_at(monkeypatch) -> None:
|
||||
def test_sessions_list_includes_active_run_started_at(monkeypatch, tmp_path: Path) -> None:
|
||||
from websockets.datastructures import Headers
|
||||
from websockets.http11 import Request
|
||||
|
||||
@@ -4927,7 +4979,7 @@ def test_sessions_list_includes_active_run_started_at(monkeypatch) -> None:
|
||||
from nanobot.webui import ws_http as ws_http_module
|
||||
|
||||
bus = MagicMock()
|
||||
session_manager = MagicMock()
|
||||
session_manager = SessionManager(tmp_path / "sessions")
|
||||
sessions = [
|
||||
{
|
||||
"key": "websocket:chat-1",
|
||||
@@ -4936,6 +4988,7 @@ def test_sessions_list_includes_active_run_started_at(monkeypatch) -> None:
|
||||
"title": "Running",
|
||||
"preview": "work",
|
||||
"model_preset": "fast",
|
||||
"_persisted_webui": True,
|
||||
"path": "/private/path",
|
||||
},
|
||||
{
|
||||
@@ -4963,8 +5016,13 @@ def test_sessions_list_includes_active_run_started_at(monkeypatch) -> None:
|
||||
assert resp.status_code == 200
|
||||
body = json.loads(resp.body.decode())
|
||||
workspace_scope = body["sessions"][0].pop("workspace_scope")
|
||||
handle = body["sessions"][0].pop("handle")
|
||||
assert workspace_scope["project_path"] == str(channel.gateway.media.workspace_path)
|
||||
assert workspace_scope["access_mode"] in {"restricted", "full"}
|
||||
assert handle["id"].startswith("handle_")
|
||||
assert handle["name"].isascii()
|
||||
assert handle["name"].islower()
|
||||
assert 0 <= handle["color_slot"] < 8
|
||||
assert body["sessions"] == [
|
||||
{
|
||||
"key": "websocket:chat-1",
|
||||
|
||||
@@ -19,10 +19,12 @@ from nanobot.channels.websocket.runtime import (
|
||||
WebSocketChannel,
|
||||
WebSocketConfig,
|
||||
)
|
||||
from nanobot.runtime_context import RUNTIME_CONTEXT_INPUT_META
|
||||
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
|
||||
from nanobot.session import webui_turns as wth
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.session.session_handles import SessionHandleDirectory, SessionHandleSnapshot
|
||||
from nanobot.webui.gateway_services import build_gateway_services
|
||||
from nanobot.webui.transcript import append_transcript_object, read_transcript_lines
|
||||
|
||||
|
||||
def _tiny_png_data_url() -> str:
|
||||
@@ -232,39 +234,176 @@ async def test_message_forwards_normalized_cli_app_attachments() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_message_forwards_verified_session_mentions(tmp_path) -> None:
|
||||
async def test_webui_message_preserves_verified_session_handles_in_focused_chat(tmp_path) -> None:
|
||||
manager = SessionManager(tmp_path)
|
||||
current = manager.get_or_create("websocket:current")
|
||||
current.metadata.update({
|
||||
"title": "Current",
|
||||
"webui": True,
|
||||
WORKSPACE_SCOPE_METADATA_KEY: {
|
||||
"project_path": str(Path.cwd().resolve()),
|
||||
"access_mode": "full",
|
||||
},
|
||||
})
|
||||
manager.save(current)
|
||||
target = manager.get_or_create("websocket:pricing")
|
||||
target.metadata.update({"title": "Pricing", "title_user_edited": True})
|
||||
target.metadata.update({
|
||||
"title": "Pricing",
|
||||
"title_user_edited": True,
|
||||
"webui": True,
|
||||
WORKSPACE_SCOPE_METADATA_KEY: {
|
||||
"project_path": str(Path.cwd().resolve()),
|
||||
"access_mode": "full",
|
||||
},
|
||||
})
|
||||
target.add_message("user", "Discuss cloud storage")
|
||||
manager.save(target)
|
||||
directory = SessionHandleDirectory(manager)
|
||||
handles = directory.ensure_many(["websocket:current", "websocket:pricing"])
|
||||
target_identity = handles["websocket:pricing"]
|
||||
channel = _make_channel(manager)
|
||||
mock_conn = AsyncMock()
|
||||
channel._webui_connections.add(mock_conn)
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "current",
|
||||
"content": "Use @pricing",
|
||||
"content": f"@{target_identity.name} review the launch plan",
|
||||
"webui": True,
|
||||
"session_mentions": [{
|
||||
"name": "pricing",
|
||||
"session_handles": [{
|
||||
"id": target_identity.id,
|
||||
"name": target_identity.name,
|
||||
"session_key": "websocket:pricing",
|
||||
"title": "Untrusted title",
|
||||
"color_slot": (target_identity.color_slot + 1) % 8,
|
||||
}],
|
||||
}
|
||||
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_awaited_once()
|
||||
assert channel._handle_message.call_args.kwargs["chat_id"] == "current"
|
||||
assert channel._handle_message.call_args.kwargs["content"] == (
|
||||
f"@{target_identity.name} review the launch plan"
|
||||
)
|
||||
metadata = channel._handle_message.call_args.kwargs["metadata"]
|
||||
assert metadata["session_mentions"] == [{
|
||||
"name": "pricing",
|
||||
assert metadata["session_handles"] == [{
|
||||
"id": target_identity.id,
|
||||
"name": target_identity.name,
|
||||
"session_key": "websocket:pricing",
|
||||
"title": "Pricing",
|
||||
"color_slot": target_identity.color_slot,
|
||||
}]
|
||||
[block] = metadata[RUNTIME_CONTEXT_INPUT_META]
|
||||
assert block.source == "session_mentions"
|
||||
assert "websocket:pricing" in block.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_webui_chat_can_structurally_mention_its_own_identity(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
manager = SessionManager(tmp_path)
|
||||
channel = _make_channel(manager)
|
||||
mock_conn = AsyncMock()
|
||||
channel._webui_connections.add(mock_conn)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
mock_conn,
|
||||
"client-1",
|
||||
{"type": "new_chat"},
|
||||
)
|
||||
|
||||
events = [json.loads(call.args[0]) for call in mock_conn.send.await_args_list]
|
||||
chat_id = next(event["chat_id"] for event in events if event["event"] == "attached")
|
||||
target_key = f"websocket:{chat_id}"
|
||||
target_identity = SessionHandleDirectory(manager).ensure_many([target_key])[target_key]
|
||||
mock_conn.send.reset_mock()
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
mock_conn,
|
||||
"client-1",
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": chat_id,
|
||||
"content": f"@{target_identity.name} hello",
|
||||
"webui": True,
|
||||
"turn_id": "turn-self-mention-new-chat",
|
||||
"session_handles": [{
|
||||
**target_identity.public_payload(),
|
||||
"session_key": target_key,
|
||||
}],
|
||||
},
|
||||
)
|
||||
|
||||
channel._handle_message.assert_awaited_once()
|
||||
assert channel._handle_message.call_args.kwargs["content"] == (
|
||||
f"@{target_identity.name} hello"
|
||||
)
|
||||
assert channel._handle_message.call_args.kwargs["metadata"]["session_handles"] == [{
|
||||
**target_identity.public_payload(),
|
||||
"session_key": target_key,
|
||||
}]
|
||||
assert read_transcript_lines(target_key)[-1]["text"] == (
|
||||
f"@{target_identity.name} hello"
|
||||
)
|
||||
assert json.loads(mock_conn.send.await_args.args[0]) == {
|
||||
"event": "message_accepted",
|
||||
"chat_id": chat_id,
|
||||
"turn_id": "turn-self-mention-new-chat",
|
||||
"starts_turn": True,
|
||||
"active_turn_id": "turn-self-mention-new-chat",
|
||||
"started_at": wth.websocket_turn_wall_started_at(chat_id),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_backed_webui_chat_preserves_its_visible_identity_mention(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
manager = SessionManager(tmp_path / "sessions")
|
||||
target_key = "websocket:transcript-only"
|
||||
append_transcript_object(
|
||||
target_key,
|
||||
{"event": "message", "chat_id": "transcript-only", "text": "Earlier reply"},
|
||||
)
|
||||
target_identity = SessionHandleDirectory(manager).ensure_snapshot_many([
|
||||
SessionHandleSnapshot(
|
||||
session_key=target_key,
|
||||
workspace=Path.cwd().resolve(),
|
||||
)
|
||||
])[target_key]
|
||||
channel = _make_channel(manager)
|
||||
mock_conn = AsyncMock()
|
||||
channel._webui_connections.add(mock_conn)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
mock_conn,
|
||||
"client-1",
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": "transcript-only",
|
||||
"content": f"@{target_identity.name} hello",
|
||||
"webui": True,
|
||||
"turn_id": "turn-self-mention-transcript",
|
||||
"session_handles": [{
|
||||
**target_identity.public_payload(),
|
||||
"session_key": target_key,
|
||||
}],
|
||||
},
|
||||
)
|
||||
|
||||
channel._handle_message.assert_awaited_once()
|
||||
assert channel._handle_message.call_args.kwargs["content"] == (
|
||||
f"@{target_identity.name} hello"
|
||||
)
|
||||
assert json.loads(mock_conn.send.await_args.args[0]) == {
|
||||
"event": "message_accepted",
|
||||
"chat_id": "transcript-only",
|
||||
"turn_id": "turn-self-mention-transcript",
|
||||
"starts_turn": True,
|
||||
"active_turn_id": "turn-self-mention-transcript",
|
||||
"started_at": wth.websocket_turn_wall_started_at("transcript-only"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -4,6 +4,7 @@ import asyncio
|
||||
import json
|
||||
import random
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
@@ -23,6 +24,10 @@ from nanobot.optional_features import InstallResult
|
||||
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
|
||||
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.session.session_handles import (
|
||||
SessionHandleDirectory,
|
||||
SessionHandleSnapshot,
|
||||
)
|
||||
from nanobot.triggers.local_store import LocalTriggerStore
|
||||
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
|
||||
|
||||
@@ -158,6 +163,8 @@ def bus() -> MagicMock:
|
||||
def _seed_session(workspace: Path, key: str = "websocket:test") -> SessionManager:
|
||||
sm = SessionManager(workspace)
|
||||
s = Session(key=key)
|
||||
if key.startswith("websocket:"):
|
||||
s.metadata["webui"] = True
|
||||
s.add_message("user", "hi")
|
||||
s.add_message("assistant", "hello back")
|
||||
sm.save(s)
|
||||
@@ -168,6 +175,8 @@ def _seed_many(workspace: Path, keys: list[str]) -> SessionManager:
|
||||
sm = SessionManager(workspace)
|
||||
for k in keys:
|
||||
s = Session(key=k)
|
||||
if k.startswith("websocket:"):
|
||||
s.metadata["webui"] = True
|
||||
s.add_message("user", f"hi from {k}")
|
||||
sm.save(s)
|
||||
return sm
|
||||
@@ -307,6 +316,11 @@ async def test_sessions_list_and_thread_restore_transcript_without_canonical_fil
|
||||
{"event": "message", "chat_id": "restored-history", "text": "original answer"},
|
||||
)
|
||||
assert not sm._get_session_path(key).exists()
|
||||
directory = SessionHandleDirectory(sm)
|
||||
directory.ensure_snapshot_many([
|
||||
SessionHandleSnapshot(session_key=key, workspace=sm.workspace)
|
||||
])
|
||||
assert directory.store_path.exists()
|
||||
|
||||
port = _free_port()
|
||||
channel = _ch(bus, session_manager=sm, port=port)
|
||||
@@ -323,8 +337,12 @@ async def test_sessions_list_and_thread_restore_transcript_without_canonical_fil
|
||||
)
|
||||
|
||||
assert listing.status_code == 200
|
||||
assert [row["key"] for row in listing.json()["sessions"]] == [key]
|
||||
assert listing.json()["sessions"][0]["preview"] == "original question"
|
||||
[row] = listing.json()["sessions"]
|
||||
assert row["key"] == key
|
||||
assert row["preview"] == "original question"
|
||||
assert "handle" not in row
|
||||
stored = json.loads(directory.store_path.read_text(encoding="utf-8"))
|
||||
assert all(handle["session_key"] != key for handle in stored["handles"])
|
||||
assert thread.status_code == 200
|
||||
assert [message["content"] for message in thread.json()["messages"]] == [
|
||||
"original question",
|
||||
@@ -2237,6 +2255,24 @@ async def test_sessions_list_only_returns_websocket_sessions_by_default(
|
||||
)
|
||||
assert rows["websocket:beta"]["workspace_scope"]["access_mode"] == "restricted"
|
||||
assert all(not any(key.startswith("_") for key in row) for row in sessions)
|
||||
assert all(set(row["handle"]) == {
|
||||
"id",
|
||||
"name",
|
||||
"color_slot",
|
||||
} for row in sessions)
|
||||
|
||||
refreshed = await _http_get(
|
||||
"http://127.0.0.1:29906/api/sessions", headers=auth
|
||||
)
|
||||
assert refreshed.status_code == 200
|
||||
refreshed_handles = {
|
||||
row["key"]: row["handle"]
|
||||
for row in refreshed.json()["sessions"]
|
||||
}
|
||||
assert refreshed_handles == {
|
||||
row["key"]: row["handle"]
|
||||
for row in sessions
|
||||
}
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
@@ -2297,6 +2333,8 @@ async def test_session_delete_removes_file(
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
sm = _seed_session(tmp_path, key="websocket:doomed")
|
||||
directory = SessionHandleDirectory(sm)
|
||||
identity = directory.ensure_many(["websocket:doomed"])["websocket:doomed"]
|
||||
from nanobot.webui.transcript import append_transcript_object
|
||||
|
||||
append_transcript_object("websocket:doomed", {"event": "user", "chat_id": "doomed", "text": "x"})
|
||||
@@ -2307,6 +2345,7 @@ async def test_session_delete_removes_file(
|
||||
assert path.exists()
|
||||
webui_path = tmp_path / "webui" / f"{SessionManager.safe_key('websocket:doomed')}.jsonl"
|
||||
assert webui_path.is_file()
|
||||
|
||||
resp = await _webui_mutate(
|
||||
channel,
|
||||
"session.delete",
|
||||
@@ -2316,6 +2355,11 @@ async def test_session_delete_removes_file(
|
||||
assert resp.json()["deleted"] is True
|
||||
assert not path.exists()
|
||||
assert not webui_path.exists()
|
||||
stored = json.loads(directory.store_path.read_text(encoding="utf-8"))
|
||||
assert all(
|
||||
row["id"] != identity.id
|
||||
for row in stored["handles"]
|
||||
)
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
@@ -2337,6 +2381,10 @@ async def test_session_delete_removes_transcript_without_canonical_file(
|
||||
assert not sm._get_session_path(key).exists()
|
||||
webui_path = tmp_path / "webui" / f"{SessionManager.safe_key(key)}.jsonl"
|
||||
assert webui_path.is_file()
|
||||
directory = SessionHandleDirectory(sm)
|
||||
identity = directory.ensure_snapshot_many([
|
||||
SessionHandleSnapshot(session_key=key, workspace=sm.workspace)
|
||||
])[key]
|
||||
|
||||
channel = _ch(bus, session_manager=sm, port=_free_port())
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
@@ -2350,6 +2398,77 @@ async def test_session_delete_removes_transcript_without_canonical_file(
|
||||
assert response.status_code == 200
|
||||
assert response.json()["deleted"] is True
|
||||
assert not webui_path.exists()
|
||||
stored = json.loads(directory.store_path.read_text(encoding="utf-8"))
|
||||
assert all(row["id"] != identity.id for row in stored["handles"])
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_delete_cannot_remove_recreated_session_identity(
|
||||
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from nanobot.webui import ws_http as ws_http_module
|
||||
from nanobot.webui.transcript import append_transcript_object
|
||||
|
||||
key = "websocket:delete-recreate"
|
||||
sm = _seed_session(tmp_path / "workspace", key=key)
|
||||
directory = SessionHandleDirectory(sm)
|
||||
directory.ensure_many([key])
|
||||
append_transcript_object(
|
||||
key,
|
||||
{"event": "user", "chat_id": "delete-recreate", "text": "old transcript"},
|
||||
)
|
||||
original_delete_webui_thread = ws_http_module.delete_webui_thread
|
||||
recreate_started = threading.Event()
|
||||
recreate_finished = threading.Event()
|
||||
recreated_identities = []
|
||||
recreate_errors: list[BaseException] = []
|
||||
recreate_threads: list[threading.Thread] = []
|
||||
|
||||
def recreate() -> None:
|
||||
recreate_started.set()
|
||||
try:
|
||||
with sm.locked_session_files():
|
||||
replacement = Session(key=key)
|
||||
replacement.metadata["webui"] = True
|
||||
replacement.add_message("user", "replacement")
|
||||
sm.save(replacement)
|
||||
recreated_identities.append(directory.ensure_many([key])[key])
|
||||
except BaseException as exc:
|
||||
recreate_errors.append(exc)
|
||||
finally:
|
||||
recreate_finished.set()
|
||||
|
||||
def delete_transcript_while_recreate_waits(session_key: str) -> bool:
|
||||
thread = threading.Thread(target=recreate, daemon=True)
|
||||
recreate_threads.append(thread)
|
||||
thread.start()
|
||||
assert recreate_started.wait(timeout=1)
|
||||
time.sleep(0.05)
|
||||
assert not recreate_finished.is_set()
|
||||
return original_delete_webui_thread(session_key)
|
||||
|
||||
monkeypatch.setattr(
|
||||
ws_http_module,
|
||||
"delete_webui_thread",
|
||||
delete_transcript_while_recreate_waits,
|
||||
)
|
||||
channel = _ch(bus, session_manager=sm, port=_free_port())
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
try:
|
||||
response = await _webui_mutate(channel, "session.delete", {"key": key})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["deleted"] is True
|
||||
assert recreate_threads
|
||||
await asyncio.to_thread(recreate_threads[0].join, 1)
|
||||
assert not recreate_threads[0].is_alive()
|
||||
assert recreate_errors == []
|
||||
[recreated] = recreated_identities
|
||||
assert directory.handle_for_session(key) == recreated
|
||||
assert sm._get_session_path(key).is_file()
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
Reference in New Issue
Block a user