deer-flow/backend/tests/test_branch_history_seed.py
Aari 68797c5759
fix(gateway): scope branch history seed run ids per inherited turn (#4459)
Branch creation seeds the new thread's run-event feed from its checkpoint
so inherited history survives the first run (#4380). Every seeded row
carried one shared run id, but run_id is a *turn* identity to the feed's
consumers, not a provenance tag: regenerating the inherited answer
resolves that row's run id as the superseded source, and
GET /messages/page then drops every row carrying it. One shared id for
the whole seed therefore deleted the complete inherited history on a
branch's first regenerate, leaving only the regenerated turn.

Group seeded rows into one synthetic run per inherited turn
(branch-seed-{thread_id}-{n}), a new turn opening at each persisted human
message — the same boundary a real run has, including the allowlisted
hidden ask_clarification reply, which resumes as its own run. Supersession
is then confined to the turn actually regenerated.

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-26 08:00:48 +08:00

236 lines
9.4 KiB
Python

"""Unit tests for branch-history run-event seeding (#4380 problem 2).
``build_branch_history_seed_events`` must mirror RunJournal's message-event
contract exactly: same event types, ``category="message"``,
``content=message.model_dump()``, the same hidden-message rules, and the same
original-user-text restoration — so the thread feed cannot tell a seeded row
from a journaled one.
"""
import asyncio
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from deerflow.runtime.events.store.memory import MemoryRunEventStore
from deerflow.runtime.journal import build_branch_history_seed_events
def _seed(messages):
return build_branch_history_seed_events(
messages,
thread_id="branch-thread",
run_id_prefix="branch-seed-branch-thread",
parent_thread_id="parent-thread",
)
def test_seed_serializes_visible_history_in_order() -> None:
events = _seed(
[
HumanMessage(id="h1", content="question"),
AIMessage(id="a1", content="answer"),
ToolMessage(id="t1", content="tool output", tool_call_id="call-1"),
]
)
assert [event["event_type"] for event in events] == ["llm.human.input", "llm.ai.response", "llm.tool.result"]
assert all(event["category"] == "message" for event in events)
assert all(event["thread_id"] == "branch-thread" for event in events)
assert all(event["run_id"] == "branch-seed-branch-thread-1" for event in events)
assert [event["content"]["id"] for event in events] == ["h1", "a1", "t1"]
# Human/AI rows carry the journal's caller tag; every row carries the
# seed provenance marker.
assert events[0]["metadata"]["caller"] == "lead_agent"
assert events[1]["metadata"]["caller"] == "lead_agent"
assert "caller" not in events[2]["metadata"]
assert all(event["metadata"]["branch_seed"] is True for event in events)
assert all(event["metadata"]["branch_parent_thread_id"] == "parent-thread" for event in events)
def test_seed_skips_system_summary_and_nonmessage() -> None:
"""System prompts, summary-named/hidden human turns, and non-messages never
enter the thread feed — same as RunJournal's message path."""
events = _seed(
[
SystemMessage(id="s1", content="system prompt"),
HumanMessage(id="h-hidden", content="internal", additional_kwargs={"hide_from_ui": True}),
HumanMessage(id="h-summary", content="compacted", name="summary"),
HumanMessage(id="h1", content="question"),
AIMessage(id="a1", content="answer"),
"not-a-message",
]
)
assert [event["content"]["id"] for event in events] == ["h1", "a1"]
def test_seed_persists_hidden_ai_and_tool_rows_like_runjournal() -> None:
"""RunJournal's on_llm_end / _persist_tool_result_message write hide_from_ui
AI and tool rows unconditionally (the frontend hides them client-side), so
the seed must too — otherwise seeded rows diverge from journaled ones and a
hidden turn disappears from a forked feed."""
events = _seed(
[
HumanMessage(id="h1", content="question"),
AIMessage(id="a-hidden", content="internal", additional_kwargs={"hide_from_ui": True}),
AIMessage(id="a1", content="answer"),
ToolMessage(id="t-hidden", content="internal", tool_call_id="call-h", additional_kwargs={"hide_from_ui": True}),
]
)
assert [event["content"]["id"] for event in events] == ["h1", "a-hidden", "a1", "t-hidden"]
assert [event["event_type"] for event in events] == [
"llm.human.input",
"llm.ai.response",
"llm.ai.response",
"llm.tool.result",
]
# The hidden marker is preserved in content so the frontend still hides them.
assert events[1]["content"]["additional_kwargs"]["hide_from_ui"] is True
assert events[3]["content"]["additional_kwargs"]["hide_from_ui"] is True
def test_seed_deserializes_dict_shaped_checkpoint_messages() -> None:
"""Checkpoint messages can arrive as model_dump()-shaped dicts (the
branch-matching helpers in threads.py already handle both); the seed must
deserialize them instead of silently producing an empty batch."""
dict_messages = [
HumanMessage(id="h1", content="question").model_dump(),
AIMessage(
id="a1",
content="answer",
tool_calls=[{"name": "search", "args": {"q": "x"}, "id": "call-1", "type": "tool_call"}],
).model_dump(),
ToolMessage(id="t1", content="tool output", tool_call_id="call-1").model_dump(),
]
events = _seed(dict_messages)
assert [event["event_type"] for event in events] == ["llm.human.input", "llm.ai.response", "llm.tool.result"]
assert [event["content"]["id"] for event in events] == ["h1", "a1", "t1"]
# Faithful reconstruction: AI tool_calls and the tool's tool_call_id survive.
assert events[1]["content"]["tool_calls"][0]["id"] == "call-1"
assert events[2]["content"]["tool_call_id"] == "call-1"
def test_seed_drops_unparseable_dict_message() -> None:
"""A dict with no usable ``type`` can't be reconstructed; it is dropped
rather than crashing the seed (best-effort — the branch stays usable)."""
events = _seed([{"content": "orphan", "no_type": True}, HumanMessage(id="h1", content="q")])
assert [event["content"]["id"] for event in events] == ["h1"]
def test_seed_persists_allowlisted_hidden_human_input_response() -> None:
"""Mirrors RunJournal: answered clarification replies stay recoverable."""
response = {
"version": 1,
"kind": "human_input_response",
"source": "ask_clarification",
"request_id": "req-1",
"value": "yes",
"response_kind": "text",
}
events = _seed(
[
HumanMessage(
id="h-response",
content="yes",
additional_kwargs={"hide_from_ui": True, "human_input_response": response},
)
]
)
assert [event["content"]["id"] for event in events] == ["h-response"]
assert events[0]["event_type"] == "llm.human.input"
def test_seed_restores_original_user_content() -> None:
"""The model-facing wrapper must not leak into the seeded feed."""
message = HumanMessage(
id="h1",
content="<wrapped>question with injected context</wrapped>",
additional_kwargs={"original_user_content": "question"},
)
events = _seed([message])
assert events[0]["content"]["content"] == "question"
assert "original_user_content" not in events[0]["content"]["additional_kwargs"]
def test_seed_scopes_one_synthetic_run_per_inherited_turn() -> None:
"""``run_id`` is a turn identity to the feed, not a provenance tag: the
regenerate path supersedes a whole ``run_id``, so packing every inherited
turn into one id deleted the complete history on the first regenerate
(#4458). Each human message opens the next synthetic run."""
events = _seed(
[
HumanMessage(id="h1", content="turn one"),
AIMessage(id="a1", content="answer one"),
HumanMessage(id="h2", content="turn two"),
AIMessage(id="a2", content="calling a tool", tool_calls=[{"name": "search", "args": {}, "id": "call-1", "type": "tool_call"}]),
ToolMessage(id="t2", content="tool output", tool_call_id="call-1"),
AIMessage(id="a2b", content="answer two"),
]
)
assert [(event["content"]["id"], event["run_id"]) for event in events] == [
("h1", "branch-seed-branch-thread-1"),
("a1", "branch-seed-branch-thread-1"),
("h2", "branch-seed-branch-thread-2"),
("a2", "branch-seed-branch-thread-2"),
("t2", "branch-seed-branch-thread-2"),
("a2b", "branch-seed-branch-thread-2"),
]
def test_seed_opens_a_new_turn_at_a_hidden_clarification_reply() -> None:
"""An answered clarification resumes as its own run, so the reply is a turn
boundary exactly like a visible user message."""
response = {
"version": 1,
"kind": "human_input_response",
"source": "ask_clarification",
"request_id": "req-1",
"value": "yes",
"response_kind": "text",
}
events = _seed(
[
HumanMessage(id="h1", content="question"),
AIMessage(id="a1", content="which one?"),
HumanMessage(id="h-response", content="yes", additional_kwargs={"hide_from_ui": True, "human_input_response": response}),
AIMessage(id="a2", content="answer"),
]
)
assert [event["run_id"] for event in events] == [
"branch-seed-branch-thread-1",
"branch-seed-branch-thread-1",
"branch-seed-branch-thread-2",
"branch-seed-branch-thread-2",
]
def test_seed_roundtrips_through_memory_store_feed() -> None:
"""put_batch preserves order and list_messages returns the seeded feed."""
store = MemoryRunEventStore()
events = _seed(
[
HumanMessage(id="h1", content="question"),
AIMessage(id="a1", content="answer"),
]
)
async def roundtrip():
await store.put_batch(events)
return await store.list_messages("branch-thread", user_id=None)
rows = asyncio.run(roundtrip())
assert [row["content"]["id"] for row in rows] == ["h1", "a1"]
seqs = [row["seq"] for row in rows]
assert seqs == sorted(seqs)
assert all(row["category"] == "message" for row in rows)