feat(webui): add assistant reply fork-from-here

This commit is contained in:
Bayern4ever-dot
2026-06-10 04:26:06 +08:00
committed by Xubin Ren
parent 4a58b83acc
commit 03bca4c0a9
30 changed files with 1358 additions and 36 deletions
@@ -426,6 +426,87 @@ def test_get_history_synthesizes_cli_app_attachment_breadcrumb():
}]
def test_fork_session_before_user_index_copies_only_prefix(tmp_path):
manager = SessionManager(tmp_path)
source = manager.get_or_create("websocket:source")
source.metadata["webui"] = True
source.metadata["title"] = "Old title"
source.metadata["goal_state"] = {"status": "active", "objective": "do not inherit"}
source.add_message("user", "round1")
source.add_message("assistant", "answer1")
source.add_message("user", "round2 fork me")
source.add_message("assistant", "answer2")
source.add_message("user", "round3 must not appear")
manager.save(source)
forked = manager.fork_session_before_user_index(
"websocket:source",
"websocket:fork",
1,
)
assert forked is not None
assert [m["content"] for m in forked.messages] == ["round1", "answer1"]
assert forked.metadata["webui"] is True
assert "title" not in forked.metadata
assert "goal_state" not in forked.metadata
saved = manager.read_session_file("websocket:fork")
assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"]
def test_fork_session_rejects_negative_missing_and_out_of_range(tmp_path):
manager = SessionManager(tmp_path)
source = manager.get_or_create("websocket:source")
source.add_message("user", "round1")
manager.save(source)
assert manager.fork_session_before_user_index("websocket:source", "websocket:x", -1) is None
assert manager.fork_session_before_user_index("websocket:missing", "websocket:x", 0) is None
assert manager.fork_session_before_user_index("websocket:source", "websocket:x", 2) is None
def test_fork_session_allows_index_equal_to_user_count(tmp_path):
manager = SessionManager(tmp_path)
source = manager.get_or_create("websocket:source")
source.add_message("user", "round1")
source.add_message("assistant", "answer1")
manager.save(source)
forked = manager.fork_session_before_user_index(
"websocket:source",
"websocket:fork",
1,
)
assert forked is not None
assert [m["content"] for m in forked.messages] == ["round1", "answer1"]
def test_fork_session_drops_summary_when_fork_point_is_inside_consolidated_prefix(tmp_path):
manager = SessionManager(tmp_path)
source = manager.get_or_create("websocket:source")
source.messages = [
{"role": "user", "content": "round1"},
{"role": "assistant", "content": "answer1"},
{"role": "user", "content": "round2 fork me"},
{"role": "assistant", "content": "answer2"},
]
source.last_consolidated = 4
source.metadata["_last_summary"] = {"text": "round2 fork me and answer2"}
manager.save(source)
forked = manager.fork_session_before_user_index(
"websocket:source",
"websocket:fork",
1,
)
assert forked is not None
assert [m["content"] for m in forked.messages] == ["round1", "answer1"]
assert forked.last_consolidated == 0
assert "_last_summary" not in forked.metadata
def test_get_history_ignores_media_kwarg_on_non_user_rows():
"""``media`` only ever appears on user entries in practice, but the
synthesizer must be defensive: assistants / tools with list content
+211
View File
@@ -45,6 +45,7 @@ from nanobot.webui.http_utils import (
parse_request_path as _parse_request_path,
)
from nanobot.webui.settings_api import settings_payload, update_provider_settings
from nanobot.webui.transcript import append_transcript_object, read_transcript_lines
# -- Shared helpers (aligned with test_websocket_integration.py) ---------------
@@ -2385,6 +2386,216 @@ async def test_multiplex_new_chat_roundtrip(bus: MagicMock) -> None:
await server_task
@pytest.mark.asyncio
async def test_fork_chat_copies_only_prefix_session_and_transcript(
bus: MagicMock,
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
sessions = SessionManager(tmp_path / "sessions")
source = sessions.get_or_create("websocket:source")
source.metadata["webui"] = True
source.add_message("user", "round1")
source.add_message("assistant", "answer1")
source.add_message("user", "round2 fork me")
source.add_message("assistant", "answer2")
source.add_message("user", "round3 must not appear")
sessions.save(source)
for ev in (
{"event": "user", "chat_id": "source", "text": "round1"},
{"event": "message", "chat_id": "source", "text": "answer1"},
{"event": "turn_end", "chat_id": "source"},
{"event": "user", "chat_id": "source", "text": "round2 fork me"},
{"event": "message", "chat_id": "source", "text": "answer2"},
{"event": "user", "chat_id": "source", "text": "round3 must not appear"},
):
append_transcript_object("websocket:source", ev)
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
)
conn = AsyncMock()
await channel._dispatch_envelope(
conn,
"webui-client",
{"type": "fork_chat", "source_chat_id": "source", "before_user_index": 1},
)
sent = [json.loads(call.args[0]) for call in conn.send.await_args_list]
attached = next(item for item in sent if item["event"] == "attached")
fork_id = attached["chat_id"]
saved = sessions.read_session_file(f"websocket:{fork_id}")
assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"]
fork_lines = read_transcript_lines(f"websocket:{fork_id}")
assert [line.get("text") for line in fork_lines] == ["round1", "answer1", None]
assert all(line.get("chat_id") == fork_id for line in fork_lines)
assert "round3 must not appear" not in json.dumps(saved, ensure_ascii=False)
bus.publish_inbound.assert_not_awaited()
@pytest.mark.asyncio
async def test_fork_chat_falls_back_to_session_prefix_when_transcript_lacks_user_rows(
bus: MagicMock,
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
sessions = SessionManager(tmp_path / "sessions")
source = sessions.get_or_create("websocket:source")
source.metadata["webui"] = True
source.add_message("user", "round1")
source.add_message("assistant", "answer1")
source.add_message("user", "round2 fork me")
source.add_message("assistant", "answer2")
source.add_message("user", "round3 must not appear")
sessions.save(source)
append_transcript_object(
"websocket:source",
{"event": "message", "chat_id": "source", "text": "answer1"},
)
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
)
conn = AsyncMock()
await channel._dispatch_envelope(
conn,
"webui-client",
{"type": "fork_chat", "source_chat_id": "source", "before_user_index": 1},
)
sent = [json.loads(call.args[0]) for call in conn.send.await_args_list]
attached = next(item for item in sent if item["event"] == "attached")
fork_id = attached["chat_id"]
saved = sessions.read_session_file(f"websocket:{fork_id}")
assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"]
fork_lines = read_transcript_lines(f"websocket:{fork_id}")
assert [line.get("text") for line in fork_lines] == ["round1", "answer1"]
assert "round3 must not appear" not in json.dumps(fork_lines, ensure_ascii=False)
bus.publish_inbound.assert_not_awaited()
@pytest.mark.asyncio
async def test_fork_chat_allows_index_equal_to_user_count(
bus: MagicMock,
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
sessions = SessionManager(tmp_path / "sessions")
source = sessions.get_or_create("websocket:source")
source.metadata["webui"] = True
source.add_message("user", "round1")
source.add_message("assistant", "answer1")
sessions.save(source)
append_transcript_object("websocket:source", {"event": "user", "chat_id": "source", "text": "round1"})
append_transcript_object(
"websocket:source",
{"event": "message", "chat_id": "source", "text": "answer1"},
)
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
)
conn = AsyncMock()
await channel._dispatch_envelope(
conn,
"webui-client",
{"type": "fork_chat", "source_chat_id": "source", "before_user_index": 1},
)
sent = [json.loads(call.args[0]) for call in conn.send.await_args_list]
attached = next(item for item in sent if item["event"] == "attached")
fork_id = attached["chat_id"]
saved = sessions.read_session_file(f"websocket:{fork_id}")
assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"]
fork_lines = read_transcript_lines(f"websocket:{fork_id}")
assert [line.get("text") for line in fork_lines] == ["round1", "answer1"]
bus.publish_inbound.assert_not_awaited()
@pytest.mark.asyncio
async def test_fork_chat_rejects_invalid_source_and_index(bus: MagicMock, tmp_path) -> None:
sessions = SessionManager(tmp_path / "sessions")
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
)
conn = AsyncMock()
await channel._dispatch_envelope(
conn,
"webui-client",
{"type": "fork_chat", "source_chat_id": "bad/source", "before_user_index": 0},
)
payload = json.loads(conn.send.await_args.args[0])
assert payload["event"] == "error"
assert payload["detail"] == "invalid source_chat_id"
conn.reset_mock()
await channel._dispatch_envelope(
conn,
"webui-client",
{"type": "fork_chat", "source_chat_id": "missing", "before_user_index": -1},
)
payload = json.loads(conn.send.await_args.args[0])
assert payload["event"] == "error"
assert payload["detail"] == "invalid before_user_index"
bus.publish_inbound.assert_not_awaited()
@pytest.mark.asyncio
async def test_webui_message_envelope_appends_user_transcript(
bus: MagicMock,
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
sessions = SessionManager(tmp_path / "sessions")
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
)
conn = AsyncMock()
conn.remote_address = ("127.0.0.1", 50123)
await channel._dispatch_envelope(
conn,
"webui-client",
{
"type": "message",
"chat_id": "source",
"content": "round1",
"webui": True,
},
)
[line] = read_transcript_lines("websocket:source")
assert {
"event": line.get("event"),
"chat_id": line.get("chat_id"),
"text": line.get("text"),
} == {"event": "user", "chat_id": "source", "text": "round1"}
assert isinstance(line.get("turn_id"), str)
assert line.get("turn_phase") == "user"
assert line.get("turn_seq") == 1
inbound = bus.publish_inbound.await_args.args[0]
assert inbound.chat_id == "source"
assert inbound.content == "round1"
@pytest.mark.asyncio
async def test_multiplex_two_chats_isolated(bus: MagicMock) -> None:
port = 29932
+75
View File
@@ -6,8 +6,10 @@ from nanobot.webui.transcript import (
WEBUI_TRANSCRIPT_SCHEMA_VERSION,
append_transcript_object,
build_webui_thread_response,
fork_transcript_before_user_index,
read_transcript_lines,
replay_transcript_to_ui_messages,
write_session_messages_as_transcript,
)
@@ -20,6 +22,79 @@ def test_append_and_read_roundtrip(tmp_path, monkeypatch) -> None:
assert lines[0]["text"] == "hello"
def test_fork_transcript_before_user_index_copies_only_prefix(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
source = "websocket:source"
for ev in (
{"event": "user", "chat_id": "source", "text": "round1"},
{"event": "message", "chat_id": "source", "text": "answer1"},
{"event": "turn_end", "chat_id": "source"},
{"event": "user", "chat_id": "source", "text": "round2 fork me"},
{"event": "message", "chat_id": "source", "text": "answer2"},
{"event": "user", "chat_id": "source", "text": "round3 must not appear"},
):
append_transcript_object(source, ev)
ok = fork_transcript_before_user_index(source, "websocket:fork", 1)
assert ok is True
lines = read_transcript_lines("websocket:fork")
assert [line.get("text") for line in lines] == ["round1", "answer1", None]
assert all(line.get("chat_id") == "fork" for line in lines)
assert "round2 fork me" not in "\n".join(str(line.get("text")) for line in lines)
assert "round3 must not appear" not in "\n".join(str(line.get("text")) for line in lines)
def test_fork_transcript_rejects_out_of_range_user_index(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
source = "websocket:source"
append_transcript_object(source, {"event": "user", "chat_id": "source", "text": "round1"})
assert fork_transcript_before_user_index(source, "websocket:fork", 2) is False
assert read_transcript_lines("websocket:fork") == []
def test_fork_transcript_allows_index_equal_to_user_count(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
source = "websocket:source"
for ev in (
{"event": "user", "chat_id": "source", "text": "round1"},
{"event": "message", "chat_id": "source", "text": "answer1"},
):
append_transcript_object(source, ev)
ok = fork_transcript_before_user_index(source, "websocket:fork", 1)
assert ok is True
assert [line.get("text") for line in read_transcript_lines("websocket:fork")] == [
"round1",
"answer1",
]
def test_write_session_messages_as_transcript_builds_canonical_prefix(
tmp_path,
monkeypatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
write_session_messages_as_transcript(
"websocket:fork",
[
{"role": "user", "content": "round1"},
{"role": "assistant", "content": "answer1"},
],
)
lines = read_transcript_lines("websocket:fork")
assert lines == [
{"event": "user", "chat_id": "fork", "text": "round1"},
{"event": "message", "chat_id": "fork", "text": "answer1"},
]
msgs = replay_transcript_to_ui_messages(lines)
assert [m["content"] for m in msgs] == ["round1", "answer1"]
def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t2"