feat(webui): persist fork boundary metadata

This commit is contained in:
Xubin Ren
2026-06-10 04:26:06 +08:00
parent 03bca4c0a9
commit 73d4b1cb2f
6 changed files with 182 additions and 8 deletions
@@ -454,6 +454,34 @@ def test_fork_session_before_user_index_copies_only_prefix(tmp_path):
assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"]
def test_fork_session_from_middle_assistant_reply_keeps_selected_turn(tmp_path):
manager = SessionManager(tmp_path)
source = manager.get_or_create("websocket:source")
source.add_message("user", "round1")
source.add_message("assistant", "answer1")
source.add_message("user", "round2")
source.add_message("assistant", "answer2")
source.add_message("user", "round3 must not appear")
source.add_message("assistant", "answer3 must not appear")
manager.save(source)
forked = manager.fork_session_before_user_index(
"websocket:source",
"websocket:fork",
2,
)
assert forked is not None
assert [m["content"] for m in forked.messages] == [
"round1",
"answer1",
"round2",
"answer2",
]
saved = manager.read_session_file("websocket:fork")
assert "round3 must not appear" not in str(saved)
def test_fork_session_rejects_negative_missing_and_out_of_range(tmp_path):
manager = SessionManager(tmp_path)
source = manager.get_or_create("websocket:source")
+13 -4
View File
@@ -2422,7 +2422,12 @@ async def test_fork_chat_copies_only_prefix_session_and_transcript(
await channel._dispatch_envelope(
conn,
"webui-client",
{"type": "fork_chat", "source_chat_id": "source", "before_user_index": 1},
{
"type": "fork_chat",
"source_chat_id": "source",
"before_user_index": 1,
"title": "Fork: Old title",
},
)
sent = [json.loads(call.args[0]) for call in conn.send.await_args_list]
@@ -2430,8 +2435,10 @@ async def test_fork_chat_copies_only_prefix_session_and_transcript(
fork_id = attached["chat_id"]
saved = sessions.read_session_file(f"websocket:{fork_id}")
assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"]
assert saved["metadata"]["title"] == "Fork: Old title"
fork_lines = read_transcript_lines(f"websocket:{fork_id}")
assert [line.get("text") for line in fork_lines] == ["round1", "answer1", None]
assert [line.get("text") for line in fork_lines] == ["round1", "answer1", None, None]
assert fork_lines[-1]["event"] == "fork_marker"
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()
@@ -2477,7 +2484,8 @@ async def test_fork_chat_falls_back_to_session_prefix_when_transcript_lacks_user
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 [line.get("text") for line in fork_lines] == ["round1", "answer1", None]
assert fork_lines[-1]["event"] == "fork_marker"
assert "round3 must not appear" not in json.dumps(fork_lines, ensure_ascii=False)
bus.publish_inbound.assert_not_awaited()
@@ -2520,7 +2528,8 @@ async def test_fork_chat_allows_index_equal_to_user_count(
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 [line.get("text") for line in fork_lines] == ["round1", "answer1", None]
assert fork_lines[-1]["event"] == "fork_marker"
bus.publish_inbound.assert_not_awaited()
+80
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from nanobot.webui.transcript import (
WEBUI_TRANSCRIPT_SCHEMA_VERSION,
append_fork_marker,
append_transcript_object,
build_webui_thread_response,
fork_transcript_before_user_index,
@@ -45,6 +46,33 @@ def test_fork_transcript_before_user_index_copies_only_prefix(tmp_path, monkeypa
assert "round3 must not appear" not in "\n".join(str(line.get("text")) for line in lines)
def test_fork_transcript_from_middle_assistant_reply_keeps_selected_turn(
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": "user", "chat_id": "source", "text": "round2"},
{"event": "message", "chat_id": "source", "text": "answer2"},
{"event": "user", "chat_id": "source", "text": "round3 must not appear"},
{"event": "message", "chat_id": "source", "text": "answer3 must not appear"},
):
append_transcript_object(source, ev)
ok = fork_transcript_before_user_index(source, "websocket:fork", 2)
assert ok is True
assert [line.get("text") for line in read_transcript_lines("websocket:fork")] == [
"round1",
"answer1",
"round2",
"answer2",
]
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"
@@ -72,6 +100,58 @@ def test_fork_transcript_allows_index_equal_to_user_count(tmp_path, monkeypatch)
]
def test_build_response_reports_fork_boundary_from_marker(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:fork"
for ev in (
{"event": "user", "chat_id": "fork", "text": "round1"},
{"event": "message", "chat_id": "fork", "text": "answer1"},
):
append_transcript_object(key, ev)
append_fork_marker(key)
append_transcript_object(key, {"event": "user", "chat_id": "fork", "text": "new branch"})
out = build_webui_thread_response(key)
assert out is not None
assert [m["content"] for m in out["messages"]] == ["round1", "answer1", "new branch"]
assert out["fork_boundary_message_count"] == 2
def test_nested_fork_drops_inherited_fork_marker(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)
append_fork_marker(source)
for ev in (
{"event": "user", "chat_id": "source", "text": "round2"},
{"event": "message", "chat_id": "source", "text": "answer2"},
):
append_transcript_object(source, ev)
ok = fork_transcript_before_user_index(source, "websocket:nested", 2)
append_fork_marker("websocket:nested")
lines = read_transcript_lines("websocket:nested")
out = build_webui_thread_response("websocket:nested")
assert ok is True
assert [line.get("event") for line in lines] == [
"user",
"message",
"user",
"message",
"fork_marker",
]
assert out is not None
assert [m["content"] for m in out["messages"]] == ["round1", "answer1", "round2", "answer2"]
assert out["fork_boundary_message_count"] == 4
def test_write_session_messages_as_transcript_builds_canonical_prefix(
tmp_path,
monkeypatch,