feat(webui): add lightweight session messaging via mentions

This commit is contained in:
chengyongru
2026-08-19 01:15:56 +08:00
committed by chengyongru
parent 2bdb11eeba
commit 0e184965e8
76 changed files with 8297 additions and 658 deletions
+57 -4
View File
@@ -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