refactor: simplify cross-session messaging

This commit is contained in:
chengyongru
2026-08-19 01:15:56 +08:00
committed by chengyongru
parent 0e184965e8
commit 251a1ccd40
78 changed files with 1578 additions and 7569 deletions
+17 -34
View File
@@ -33,10 +33,10 @@ from nanobot.bus.outbound_events import (
GoalStatusEvent,
ProgressEvent,
RuntimeModelUpdatedEvent,
SessionMessageInputEvent,
SessionUpdatedEvent,
TurnEndEvent,
TurnModelUpdatedEvent,
UserInputEvent,
outbound_event_from_message,
)
from nanobot.bus.queue import MessageBus
@@ -87,7 +87,6 @@ from nanobot.webui.metadata import (
WEBUI_TURN_METADATA_KEY,
)
from nanobot.webui.session_access import (
SessionHandleMention,
SessionMention,
WebuiSessionAccess,
session_mentions_runtime_context,
@@ -1197,11 +1196,9 @@ 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,
@@ -1210,15 +1207,6 @@ 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
@@ -1244,7 +1232,6 @@ 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] = []
@@ -1253,9 +1240,9 @@ class WebSocketChannel(BaseChannel):
})
if quote is not None:
context_blocks.append(quote)
reference_context = session_mentions_runtime_context(session_mentions)
if reference_context is not None:
context_blocks.append(reference_context)
session_context = session_mentions_runtime_context(session_mentions)
if session_context is not None:
context_blocks.append(session_context)
if context_blocks:
metadata[RUNTIME_CONTEXT_INPUT_META] = context_blocks
await self._handle_message(
@@ -1273,7 +1260,7 @@ class WebSocketChannel(BaseChannel):
require_existing_session=(
temporary_policy.require_existing_session
if temporary_policy is not None
else is_webui
else False
),
)
accepted = True
@@ -1682,7 +1669,7 @@ class WebSocketChannel(BaseChannel):
if isinstance(
event,
ProgressEvent
| SessionMessageInputEvent
| UserInputEvent
| TurnEndEvent
| SessionUpdatedEvent
| GoalStatusEvent
@@ -1700,14 +1687,13 @@ class WebSocketChannel(BaseChannel):
context_window_tokens=event.context_window_tokens,
)
return
if isinstance(event, SessionMessageInputEvent):
if isinstance(event, UserInputEvent):
if conns:
await self.send_session_message_input(
await self.send_user_input(
msg.chat_id,
content=event.content,
created_at_ms=event.created_at_ms,
session_message=event.session_message,
metadata=msg.metadata,
provenance=event.provenance,
)
return
if isinstance(event, GoalStateSyncEvent):
@@ -2064,33 +2050,30 @@ class WebSocketChannel(BaseChannel):
for connection in conns:
await self._safe_send_to(connection, raw, label=" session_updated ")
async def send_session_message_input(
async def send_user_input(
self,
chat_id: str,
*,
content: str,
created_at_ms: int,
session_message: dict[str, Any],
metadata: dict[str, Any] | None = None,
provenance: dict[str, Any],
) -> None:
"""Project a session message before the target model starts responding."""
"""Project user input produced outside a WebSocket connection."""
conns = list(self._subs.get(chat_id, ()))
if not conns:
return
body: dict[str, Any] = {
"event": "session_message",
"event": "user_message",
"chat_id": chat_id,
"text": content,
"created_at_ms": created_at_ms,
"session_message": session_message,
"turn_phase": "user",
"starts_turn": False,
}
turn_id = (metadata or {}).get(WEBUI_TURN_METADATA_KEY)
if isinstance(turn_id, str) and turn_id:
body["turn_id"] = turn_id
if provenance:
body["provenance"] = provenance
raw = json.dumps(body, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" session_message ")
await self._safe_send_to(connection, raw, label=" user_message ")
async def send_runtime_model_updated(
self,
@@ -28,10 +28,10 @@ from nanobot.bus.outbound_events import (
GoalStatusEvent,
ProgressEvent,
RuntimeModelUpdatedEvent,
SessionMessageInputEvent,
SessionUpdatedEvent,
TurnEndEvent,
TurnModelUpdatedEvent,
UserInputEvent,
)
from nanobot.bus.queue import MessageBus
from nanobot.channels.websocket.runtime import (
@@ -48,6 +48,7 @@ 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.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
from nanobot.session.session_handles import session_handle_for_key
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
from nanobot.webui.http_utils import (
http_error as _http_error,
@@ -540,7 +541,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 True
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
@@ -2007,6 +2008,41 @@ async def test_send_broadcasts_runtime_model_updates() -> None:
assert payload["model_preset"] == "fast"
@pytest.mark.asyncio
async def test_send_projects_external_user_input_to_existing_wire_event() -> None:
bus = MessageBus()
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(bus),
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel.send(
OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
event=UserInputEvent(
content="hello from another session",
created_at_ms=1234,
provenance={"name": "mira-deadbeef00"},
),
)
)
payload = json.loads(mock_ws.send.call_args.args[0])
assert payload == {
"event": "user_message",
"chat_id": "chat-1",
"text": "hello from another session",
"created_at_ms": 1234,
"starts_turn": False,
"provenance": {"name": "mira-deadbeef00"},
}
@pytest.mark.asyncio
async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None:
bus = MessageBus()
@@ -2066,57 +2102,6 @@ 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()
@@ -4971,7 +4956,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, tmp_path: Path) -> None:
def test_sessions_list_includes_active_run_started_at(monkeypatch) -> None:
from websockets.datastructures import Headers
from websockets.http11 import Request
@@ -4979,7 +4964,7 @@ def test_sessions_list_includes_active_run_started_at(monkeypatch, tmp_path: Pat
from nanobot.webui import ws_http as ws_http_module
bus = MagicMock()
session_manager = SessionManager(tmp_path / "sessions")
session_manager = MagicMock()
sessions = [
{
"key": "websocket:chat-1",
@@ -4988,7 +4973,6 @@ def test_sessions_list_includes_active_run_started_at(monkeypatch, tmp_path: Pat
"title": "Running",
"preview": "work",
"model_preset": "fast",
"_persisted_webui": True,
"path": "/private/path",
},
{
@@ -5016,13 +5000,8 @@ def test_sessions_list_includes_active_run_started_at(monkeypatch, tmp_path: Pat
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",
@@ -5032,6 +5011,7 @@ def test_sessions_list_includes_active_run_started_at(monkeypatch, tmp_path: Pat
"preview": "work",
"model_preset": "fast",
"run_started_at": 1_700_000_000.0,
"handle": session_handle_for_key("websocket:chat-1").public_payload(),
}
]
@@ -19,12 +19,11 @@ from nanobot.channels.websocket.runtime import (
WebSocketChannel,
WebSocketConfig,
)
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
from nanobot.runtime_context import RUNTIME_CONTEXT_INPUT_META
from nanobot.session import webui_turns as wth
from nanobot.session.manager import SessionManager
from nanobot.session.session_handles import SessionHandleDirectory, SessionHandleSnapshot
from nanobot.session.session_handles import session_handle_for_key
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:
@@ -234,176 +233,39 @@ async def test_message_forwards_normalized_cli_app_attachments() -> None:
@pytest.mark.asyncio
async def test_webui_message_preserves_verified_session_handles_in_focused_chat(tmp_path) -> None:
async def test_webui_message_forwards_verified_session_mentions(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,
"webui": True,
WORKSPACE_SCOPE_METADATA_KEY: {
"project_path": str(Path.cwd().resolve()),
"access_mode": "full",
},
})
target.metadata.update({"title": "Pricing", "title_user_edited": True})
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": f"@{target_identity.name} review the launch plan",
"content": "Use @pricing",
"webui": True,
"session_handles": [{
"id": target_identity.id,
"name": target_identity.name,
"session_mentions": [{
"name": "pricing",
"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_handles"] == [{
"id": target_identity.id,
"name": target_identity.name,
assert metadata["session_mentions"] == [{
**session_handle_for_key("websocket:pricing").public_payload(),
"session_key": "websocket:pricing",
"color_slot": target_identity.color_slot,
"title": "Pricing",
}]
@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"),
}
[block] = metadata[RUNTIME_CONTEXT_INPUT_META]
assert block.source == "session_mentions"
assert "websocket:pricing" in block.content
@pytest.mark.asyncio
@@ -4,7 +4,6 @@ import asyncio
import json
import random
import socket
import threading
import time
from contextlib import suppress
from pathlib import Path
@@ -24,10 +23,7 @@ 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.session.session_handles import session_handle_for_key
from nanobot.triggers.local_store import LocalTriggerStore
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
@@ -163,8 +159,6 @@ 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)
@@ -175,8 +169,6 @@ 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
@@ -316,11 +308,6 @@ 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)
@@ -337,12 +324,8 @@ async def test_sessions_list_and_thread_restore_transcript_without_canonical_fil
)
assert listing.status_code == 200
[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 [row["key"] for row in listing.json()["sessions"]] == [key]
assert listing.json()["sessions"][0]["preview"] == "original question"
assert thread.status_code == 200
assert [message["content"] for message in thread.json()["messages"]] == [
"original question",
@@ -2250,29 +2233,17 @@ async def test_sessions_list_only_returns_websocket_sessions_by_default(
# Slack / Lark rows would be non-resumable from the browser.
assert keys == {"websocket:alpha", "websocket:beta"}
rows = {row["key"]: row for row in sessions}
assert rows["websocket:alpha"]["handle"] == session_handle_for_key(
"websocket:alpha"
).public_payload()
assert rows["websocket:beta"]["handle"] == session_handle_for_key(
"websocket:beta"
).public_payload()
assert rows["websocket:beta"]["workspace_scope"]["project_path"] == str(
project.resolve()
)
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
@@ -2333,8 +2304,6 @@ 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"})
@@ -2345,7 +2314,6 @@ 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",
@@ -2355,11 +2323,6 @@ 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
@@ -2381,10 +2344,6 @@ 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())
@@ -2398,77 +2357,6 @@ 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