fix(webui): persist user messages for refresh

This commit is contained in:
chengyongru
2026-06-05 16:13:51 +08:00
committed by Xubin Ren
parent 3da68ac7fe
commit 710d00a179
5 changed files with 427 additions and 2 deletions
+125
View File
@@ -294,6 +294,87 @@ async def test_webui_message_envelope_marks_inbound_metadata(bus: MagicMock) ->
assert msg.metadata["_wants_stream"] is True
@pytest.mark.asyncio
async def test_webui_message_envelope_persists_user_transcript_for_refresh(
bus: MagicMock,
tmp_path,
monkeypatch,
) -> None:
from nanobot.webui.transcript import build_webui_thread_response, read_transcript_lines
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
channel = _ch(bus)
conn = AsyncMock()
conn.remote_address = ("127.0.0.1", 50123)
async def answer_during_publish(_msg: Any) -> None:
await channel.send(OutboundMessage(channel="websocket", chat_id="chat-1", content="hi back"))
bus.publish_inbound.side_effect = answer_during_publish
await channel._dispatch_envelope(
conn,
"webui-client",
{"type": "message", "chat_id": "chat-1", "content": "hello", "webui": True},
)
lines = read_transcript_lines("websocket:chat-1")
assert [line["event"] for line in lines] == ["user", "message"]
body = build_webui_thread_response("websocket:chat-1")
assert body is not None
assert [message["role"] for message in body["messages"]] == ["user", "assistant"]
assert [message["content"] for message in body["messages"]] == ["hello", "hi back"]
@pytest.mark.asyncio
async def test_webui_stop_control_message_is_not_persisted_as_user_bubble(
bus: MagicMock,
tmp_path,
monkeypatch,
) -> None:
from nanobot.webui.transcript import read_transcript_lines
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
channel = _ch(bus)
conn = AsyncMock()
conn.remote_address = ("127.0.0.1", 50123)
await channel._dispatch_envelope(
conn,
"webui-client",
{"type": "message", "chat_id": "chat-1", "content": "/stop", "webui": True},
)
msg = bus.publish_inbound.await_args.args[0]
assert msg.content == "/stop"
assert read_transcript_lines("websocket:chat-1") == []
@pytest.mark.asyncio
async def test_webui_user_transcript_append_failure_does_not_block_inbound(
bus: MagicMock,
monkeypatch,
) -> None:
def fail_append(_session_key: str, _obj: dict[str, Any]) -> None:
raise OSError("disk full")
monkeypatch.setattr("nanobot.channels.websocket.append_transcript_object", fail_append)
channel = _ch(bus)
conn = AsyncMock()
conn.remote_address = ("127.0.0.1", 50123)
await channel._dispatch_envelope(
conn,
"webui-client",
{"type": "message", "chat_id": "chat-1", "content": "hello", "webui": True},
)
msg = bus.publish_inbound.await_args.args[0]
assert msg.chat_id == "chat-1"
assert msg.content == "hello"
@pytest.mark.asyncio
async def test_plain_websocket_message_does_not_mark_webui(bus: MagicMock) -> None:
channel = _ch(bus)
@@ -2411,3 +2492,47 @@ def test_handle_webui_thread_get_returns_json(tmp_path, monkeypatch) -> None:
assert len(body["messages"]) == 1
assert body["messages"][0]["role"] == "user"
assert body["messages"][0]["content"] == "hi"
def test_handle_webui_thread_get_backfills_legacy_missing_user_rows(
tmp_path,
monkeypatch,
) -> None:
from urllib.parse import quote
from websockets.datastructures import Headers
from websockets.http11 import Request
from nanobot.webui.transcript import append_transcript_object
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
workspace = tmp_path / "workspace"
sessions = SessionManager(workspace)
key = "websocket:c-legacy"
session = sessions.get_or_create(key)
session.add_message("user", "legacy question")
session.add_message("assistant", "legacy answer")
sessions.save(session)
append_transcript_object(
key,
{"event": "message", "chat_id": "c-legacy", "text": "legacy answer"},
)
bus = MagicMock()
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=workspace),
)
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0
enc = quote(key, safe="")
req = Request(f"/api/sessions/{enc}/webui-thread", Headers([("Authorization", "Bearer tok")]))
resp = channel.gateway.http._handle_webui_thread_get(req, enc)
assert resp.status_code == 200
body = json.loads(resp.body.decode())
assert [message["role"] for message in body["messages"]] == ["user", "assistant"]
assert [message["content"] for message in body["messages"]] == [
"legacy question",
"legacy answer",
]
+93
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from nanobot.webui.transcript import (
WEBUI_TRANSCRIPT_SCHEMA_VERSION,
append_transcript_object,
build_webui_thread_response,
read_transcript_lines,
replay_transcript_to_ui_messages,
)
@@ -66,6 +67,98 @@ def test_replay_uses_stream_end_final_text() -> None:
assert msgs[1]["content"] == "![Diagram](/api/media/sig/payload)"
def test_build_response_backfills_legacy_sse_only_transcripts(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-legacy"
for ev in (
{"event": "delta", "chat_id": "t-legacy", "text": "first answer"},
{"event": "stream_end", "chat_id": "t-legacy"},
{"event": "turn_end", "chat_id": "t-legacy"},
{"event": "message", "chat_id": "t-legacy", "text": "second answer"},
{"event": "turn_end", "chat_id": "t-legacy"},
):
append_transcript_object(key, ev)
out = build_webui_thread_response(
key,
session_messages=[
{"role": "user", "content": "first question"},
{"role": "assistant", "content": "first answer"},
{"role": "user", "content": "second question"},
{"role": "assistant", "content": "second answer"},
],
)
assert out is not None
assert [message["role"] for message in out["messages"]] == [
"user",
"assistant",
"user",
"assistant",
]
assert [message["content"] for message in out["messages"]] == [
"first question",
"first answer",
"second question",
"second answer",
]
def test_backfill_does_not_duplicate_existing_user_transcript(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-current"
for ev in (
{"event": "user", "chat_id": "t-current", "text": "already stored"},
{"event": "message", "chat_id": "t-current", "text": "answer"},
{"event": "turn_end", "chat_id": "t-current"},
):
append_transcript_object(key, ev)
out = build_webui_thread_response(
key,
session_messages=[{"role": "user", "content": "already stored"}],
)
assert out is not None
assert [message["role"] for message in out["messages"]] == ["user", "assistant"]
assert out["messages"][0]["content"] == "already stored"
def test_backfill_does_not_misalign_when_session_only_has_transcript_tail(
tmp_path,
monkeypatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-tail"
for ev in (
{"event": "message", "chat_id": "t-tail", "text": "old answer"},
{"event": "turn_end", "chat_id": "t-tail"},
{"event": "message", "chat_id": "t-tail", "text": "tail answer"},
{"event": "turn_end", "chat_id": "t-tail"},
):
append_transcript_object(key, ev)
out = build_webui_thread_response(
key,
session_messages=[
{"role": "user", "content": "tail question"},
{"role": "assistant", "content": "tail answer"},
],
)
assert out is not None
assert [message["role"] for message in out["messages"]] == [
"assistant",
"user",
"assistant",
]
assert [message["content"] for message in out["messages"]] == [
"old answer",
"tail question",
"tail answer",
]
def test_replay_infers_video_media_from_attachment_name() -> None:
msgs = replay_transcript_to_ui_messages(
[