mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-28 08:56:13 +00:00
3039 lines
111 KiB
Python
3039 lines
111 KiB
Python
import asyncio
|
|
import copy
|
|
from contextlib import suppress
|
|
from types import SimpleNamespace
|
|
from typing import Annotated, Any, NotRequired, TypedDict
|
|
from unittest.mock import AsyncMock, MagicMock, call, patch
|
|
|
|
import pytest
|
|
from langchain_core.messages import AIMessage, AIMessageChunk, AnyMessage, HumanMessage
|
|
from langgraph.channels.delta import DeltaChannel
|
|
from langgraph.checkpoint.base import empty_checkpoint
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
|
from langgraph.graph import StateGraph
|
|
from langgraph.graph.message import add_messages
|
|
from langgraph.types import Overwrite
|
|
|
|
from deerflow.agents.thread_state import merge_artifacts, merge_message_writes
|
|
from deerflow.runtime.checkpoint_state import CheckpointStateAccessor
|
|
from deerflow.runtime.context_keys import CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY
|
|
from deerflow.runtime.runs.manager import CancelOutcome, ConflictError, RunManager
|
|
from deerflow.runtime.runs.schemas import RunStatus
|
|
from deerflow.runtime.runs.worker import (
|
|
RollbackPoint,
|
|
RunContext,
|
|
_agent_factory_supports_app_config,
|
|
_build_runtime_context,
|
|
_bump_channel_version,
|
|
_capture_rollback_point,
|
|
_collect_pre_existing_message_ids,
|
|
_ensure_interrupted_title,
|
|
_extract_llm_error_fallback_message,
|
|
_install_runtime_context,
|
|
_LargeFileToolChunkBatcher,
|
|
_rollback_to_pre_run_checkpoint,
|
|
_try_extract_from_message,
|
|
run_agent,
|
|
)
|
|
|
|
|
|
class FakeCheckpointer:
|
|
def __init__(self):
|
|
self.adelete_thread = AsyncMock()
|
|
self.aget_tuple = AsyncMock(return_value=None)
|
|
self.aput_writes = AsyncMock()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_pending_cancel_stops_waiting_for_prior_finalization():
|
|
run_manager = RunManager()
|
|
prior = await run_manager.create("thread-cancel-while-waiting")
|
|
prior.status = RunStatus.interrupted
|
|
prior.finalizing = True
|
|
record = await run_manager.create("thread-cancel-while-waiting")
|
|
bridge = SimpleNamespace(
|
|
publish=AsyncMock(),
|
|
publish_end=AsyncMock(),
|
|
cleanup=AsyncMock(),
|
|
)
|
|
factory_called = False
|
|
|
|
def agent_factory(**_kwargs):
|
|
nonlocal factory_called
|
|
factory_called = True
|
|
raise AssertionError("cancelled pending run must not build an agent")
|
|
|
|
record.task = asyncio.create_task(
|
|
run_agent(
|
|
bridge,
|
|
run_manager,
|
|
record,
|
|
ctx=RunContext(checkpointer=None),
|
|
agent_factory=agent_factory,
|
|
graph_input={"messages": []},
|
|
config={},
|
|
)
|
|
)
|
|
await asyncio.sleep(0)
|
|
|
|
outcome = await run_manager.cancel(record.run_id)
|
|
await asyncio.wait_for(record.task, timeout=0.2)
|
|
|
|
assert outcome == CancelOutcome.cancelled
|
|
assert prior.finalizing is True
|
|
assert factory_called is False
|
|
assert record.status == RunStatus.interrupted
|
|
bridge.publish_end.assert_awaited_once_with(record.run_id)
|
|
|
|
|
|
def _make_rollback_point(*, checkpoint_id="ckpt-1", messages=("before",), pending_writes=()):
|
|
materialized_messages = tuple(messages)
|
|
return RollbackPoint(
|
|
config={
|
|
"configurable": {
|
|
"thread_id": "thread-1",
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": checkpoint_id,
|
|
}
|
|
},
|
|
state_values={},
|
|
messages=materialized_messages,
|
|
metadata={"source": "input"},
|
|
pending_writes=tuple(pending_writes),
|
|
)
|
|
|
|
|
|
def _stub_mutation_graph(monkeypatch, *, restored_config):
|
|
"""Replace the rollback mutation graph with a stub returning ``restored_config``."""
|
|
mock_graph = SimpleNamespace()
|
|
mock_graph.aupdate_state = AsyncMock(return_value=restored_config)
|
|
monkeypatch.setattr(
|
|
"deerflow.runtime.runs.worker.build_state_mutation_graph",
|
|
lambda *args, **kwargs: mock_graph,
|
|
)
|
|
return mock_graph
|
|
|
|
|
|
def _rollback_accessor(checkpointer):
|
|
return CheckpointStateAccessor(graph=SimpleNamespace(), checkpointer=checkpointer, mode="full")
|
|
|
|
|
|
class _DeltaChannelState(TypedDict):
|
|
messages: Annotated[list[AnyMessage], DeltaChannel(merge_message_writes, snapshot_frequency=1000)]
|
|
|
|
|
|
class _FullChannelState(TypedDict):
|
|
messages: Annotated[list[AnyMessage], add_messages]
|
|
|
|
|
|
def _build_message_append_graph(state_schema: type, checkpointer: Any):
|
|
async def _append_message(state: dict[str, Any]) -> dict[str, Any]:
|
|
return {"messages": [HumanMessage(content=f"turn-{len(state.get('messages') or [])}")]}
|
|
|
|
builder = StateGraph(state_schema)
|
|
builder.add_node("append_message", _append_message)
|
|
builder.set_entry_point("append_message")
|
|
builder.set_finish_point("append_message")
|
|
return builder.compile(checkpointer=checkpointer)
|
|
|
|
|
|
@pytest.mark.parametrize("tool_name", ["write_file", "str_replace"])
|
|
def test_large_file_tool_chunk_batcher_streams_bounded_batches(tool_name: str):
|
|
batcher = _LargeFileToolChunkBatcher(batch_size=2)
|
|
first = AIMessageChunk(
|
|
content="",
|
|
id="ai-1",
|
|
tool_call_chunks=[
|
|
{
|
|
"id": "call-1",
|
|
"index": 0,
|
|
"name": tool_name,
|
|
"args": '{"path":"/mnt/user-data/outputs/report.md","content":"Hel',
|
|
}
|
|
],
|
|
)
|
|
continuation = AIMessageChunk(
|
|
content="",
|
|
id="ai-1",
|
|
tool_call_chunks=[{"index": 0, "name": None, "args": 'lo"}'}],
|
|
)
|
|
|
|
assert batcher.push((first, {})) == []
|
|
published = batcher.push((continuation, {}))
|
|
assert len(published) == 1
|
|
message, metadata = published[0]
|
|
assert metadata == {}
|
|
assert message.tool_calls[0]["args"]["content"] == "Hello"
|
|
assert batcher.flush() == []
|
|
|
|
|
|
def test_large_file_tool_chunk_batcher_preserves_visible_and_non_file_chunks():
|
|
batcher = _LargeFileToolChunkBatcher()
|
|
visible_text = AIMessageChunk(content="Writing the report now.", id="ai-1")
|
|
search_tool = AIMessageChunk(
|
|
content="",
|
|
id="ai-2",
|
|
tool_call_chunks=[
|
|
{
|
|
"id": "call-2",
|
|
"index": 0,
|
|
"name": "web_search",
|
|
"args": '{"query":"vector databases"}',
|
|
}
|
|
],
|
|
)
|
|
write_with_reasoning = AIMessageChunk(
|
|
content="",
|
|
id="ai-3",
|
|
additional_kwargs={"reasoning_content": "Choosing a filename."},
|
|
tool_call_chunks=[
|
|
{
|
|
"id": "call-3",
|
|
"index": 0,
|
|
"name": "write_file",
|
|
"args": '{"path":"/mnt/user-data/outputs/report.md"}',
|
|
}
|
|
],
|
|
)
|
|
|
|
assert batcher.push((visible_text, {})) == [(visible_text, {})]
|
|
assert batcher.push((search_tool, {})) == [(search_tool, {})]
|
|
visible_reasoning = batcher.push((write_with_reasoning, {}))
|
|
assert len(visible_reasoning) == 1
|
|
filtered_message, filtered_metadata = visible_reasoning[0]
|
|
assert filtered_metadata == {}
|
|
assert filtered_message.additional_kwargs == {"reasoning_content": "Choosing a filename."}
|
|
assert filtered_message.tool_call_chunks == []
|
|
pending_file_chunks = batcher.flush()
|
|
assert len(pending_file_chunks) == 1
|
|
assert pending_file_chunks[0][0].tool_call_chunks[0]["name"] == "write_file"
|
|
|
|
|
|
def test_large_file_tool_chunk_batcher_separates_subgraph_namespaces():
|
|
batcher = _LargeFileToolChunkBatcher()
|
|
first = AIMessageChunk(
|
|
content="",
|
|
id="shared-ai-id",
|
|
tool_call_chunks=[{"id": "call-a", "index": 0, "name": "write_file", "args": '{"path":"a.md","content":"A'}],
|
|
)
|
|
second = AIMessageChunk(
|
|
content="",
|
|
id="shared-ai-id",
|
|
tool_call_chunks=[{"id": "call-b", "index": 0, "name": "write_file", "args": '{"path":"b.md","content":"B'}],
|
|
)
|
|
|
|
assert batcher.push((first, {"langgraph_checkpoint_ns": "task-a"})) == []
|
|
published = batcher.push((second, {"langgraph_checkpoint_ns": "task-b"}))
|
|
|
|
assert len(published) == 1
|
|
assert published[0][1]["langgraph_checkpoint_ns"] == "task-a"
|
|
assert batcher.flush()[0][1]["langgraph_checkpoint_ns"] == "task-b"
|
|
|
|
|
|
@pytest.mark.parametrize("metadata", [None, "not-a-dict"])
|
|
def test_large_file_tool_chunk_batcher_accepts_non_dict_metadata(metadata: Any):
|
|
batcher = _LargeFileToolChunkBatcher(batch_size=1)
|
|
message = AIMessageChunk(
|
|
content="",
|
|
id="ai-file",
|
|
tool_call_chunks=[
|
|
{
|
|
"id": "call-file",
|
|
"index": 0,
|
|
"name": "write_file",
|
|
"args": '{"path":"report.md","content":"draft"}',
|
|
}
|
|
],
|
|
)
|
|
|
|
published = batcher.push((message, metadata))
|
|
|
|
assert len(published) == 1
|
|
assert published[0][0].tool_call_chunks[0]["args"] == '{"path":"report.md","content":"draft"}'
|
|
assert published[0][1] == {}
|
|
|
|
|
|
def test_large_file_tool_chunk_batcher_does_not_retain_non_file_names():
|
|
batcher = _LargeFileToolChunkBatcher()
|
|
|
|
for index in range(100):
|
|
message = AIMessageChunk(
|
|
content="",
|
|
id=f"ai-{index}",
|
|
tool_call_chunks=[
|
|
{
|
|
"id": f"call-{index}",
|
|
"index": 0,
|
|
"name": "web_search",
|
|
"args": '{"query":"deerflow"}',
|
|
}
|
|
],
|
|
)
|
|
|
|
assert batcher.push((message, {})) == [(message, {})]
|
|
|
|
assert batcher.tool_names == {}
|
|
|
|
|
|
def test_large_file_tool_chunk_batcher_starts_batching_after_split_name_matches():
|
|
batcher = _LargeFileToolChunkBatcher(batch_size=1)
|
|
name_prefix = AIMessageChunk(
|
|
content="",
|
|
id="ai-file",
|
|
tool_call_chunks=[{"id": "call-file", "index": 0, "name": "write_", "args": ""}],
|
|
)
|
|
name_suffix = AIMessageChunk(
|
|
content="",
|
|
id="ai-file",
|
|
tool_call_chunks=[
|
|
{
|
|
"index": 0,
|
|
"name": "file",
|
|
"args": '{"path":"report.md","content":"draft"}',
|
|
}
|
|
],
|
|
)
|
|
|
|
assert batcher.push((name_prefix, {})) == [(name_prefix, {})]
|
|
assert set(batcher.tool_names.values()) == {"write_"}
|
|
assert len(batcher.push((name_suffix, {}))) == 1
|
|
assert set(batcher.tool_names.values()) == {"write_file"}
|
|
|
|
|
|
def test_large_file_tool_chunk_batcher_keeps_identity_across_batches_then_releases_it():
|
|
batcher = _LargeFileToolChunkBatcher(batch_size=1)
|
|
first = AIMessageChunk(
|
|
content="",
|
|
id="ai-file",
|
|
tool_call_chunks=[
|
|
{
|
|
"id": "call-file",
|
|
"index": 0,
|
|
"name": "write_file",
|
|
"args": '{"path":"report.md","content":"Hel',
|
|
}
|
|
],
|
|
)
|
|
continuation = AIMessageChunk(
|
|
content="",
|
|
id="ai-file",
|
|
tool_call_chunks=[{"index": 0, "name": None, "args": 'lo"}'}],
|
|
)
|
|
|
|
assert len(batcher.push((first, {}))) == 1
|
|
assert len(batcher.push((continuation, {}))) == 1
|
|
assert set(batcher.tool_names.values()) == {"write_file"}
|
|
|
|
assert batcher.finish() == []
|
|
assert batcher.tool_names == {}
|
|
|
|
|
|
def test_build_runtime_context_includes_app_config_when_present():
|
|
app_config = object()
|
|
|
|
context = _build_runtime_context("thread-1", "run-1", None, app_config)
|
|
|
|
assert context["thread_id"] == "thread-1"
|
|
assert context["run_id"] == "run-1"
|
|
assert context["app_config"] is app_config
|
|
|
|
|
|
def test_install_runtime_context_preserves_existing_thread_id_and_threads_app_config():
|
|
app_config = object()
|
|
config = {"context": {"thread_id": "caller-thread"}}
|
|
|
|
_install_runtime_context(
|
|
config,
|
|
{
|
|
"thread_id": "record-thread",
|
|
"run_id": "run-1",
|
|
"app_config": app_config,
|
|
},
|
|
)
|
|
|
|
assert config["context"]["thread_id"] == "caller-thread"
|
|
assert config["context"]["run_id"] == "run-1"
|
|
assert config["context"]["app_config"] is app_config
|
|
|
|
|
|
def test_install_runtime_context_overrides_internal_pre_existing_message_ids():
|
|
config = {"context": {CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY: {"spoofed"}}}
|
|
|
|
_install_runtime_context(
|
|
config,
|
|
{
|
|
"thread_id": "record-thread",
|
|
"run_id": "run-1",
|
|
CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY: frozenset({"old-ai"}),
|
|
},
|
|
)
|
|
|
|
assert config["context"][CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY] == frozenset({"old-ai"})
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_run_agent_batches_incremental_file_args_and_keeps_complete_values():
|
|
run_manager = RunManager()
|
|
record = await run_manager.create("thread-file-stream")
|
|
bridge = SimpleNamespace(
|
|
publish=AsyncMock(),
|
|
publish_end=AsyncMock(),
|
|
cleanup=AsyncMock(),
|
|
)
|
|
complete_message = AIMessage(
|
|
content="",
|
|
id="ai-file",
|
|
tool_calls=[
|
|
{
|
|
"id": "call-file",
|
|
"name": "write_file",
|
|
"args": {
|
|
"path": "/mnt/user-data/outputs/report.md",
|
|
"content": "Hello world",
|
|
},
|
|
"type": "tool_call",
|
|
}
|
|
],
|
|
)
|
|
|
|
class DummyAgent:
|
|
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
|
|
del graph_input, config, stream_mode, subgraphs
|
|
yield (
|
|
"messages",
|
|
(
|
|
AIMessageChunk(
|
|
content="",
|
|
id="ai-file",
|
|
tool_call_chunks=[
|
|
{
|
|
"id": "call-file",
|
|
"index": 0,
|
|
"name": "write_file",
|
|
"args": '{"path":"/mnt/user-data/outputs/report.md","content":"Hello',
|
|
}
|
|
],
|
|
),
|
|
{},
|
|
),
|
|
)
|
|
yield (
|
|
"messages",
|
|
(
|
|
AIMessageChunk(
|
|
content="",
|
|
id="ai-file",
|
|
tool_call_chunks=[{"index": 0, "name": None, "args": ' world"}'}],
|
|
),
|
|
{},
|
|
),
|
|
)
|
|
yield ("values", {"messages": [complete_message]})
|
|
|
|
await run_agent(
|
|
bridge,
|
|
run_manager,
|
|
record,
|
|
ctx=RunContext(checkpointer=None),
|
|
agent_factory=lambda **_kwargs: DummyAgent(),
|
|
graph_input={},
|
|
config={},
|
|
stream_modes=["messages-tuple", "values"],
|
|
)
|
|
|
|
message_events = [call.args for call in bridge.publish.await_args_list if call.args[1] == "messages"]
|
|
assert len(message_events) == 1
|
|
assert message_events[0][2][0]["tool_calls"][0]["args"]["content"] == "Hello world"
|
|
values_events = [call.args[2] for call in bridge.publish.await_args_list if call.args[1] == "values"]
|
|
assert any(event["messages"][0]["tool_calls"][0]["args"]["content"] == "Hello world" for event in values_events)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("stream_error", "flush_publish_error", "expected_error"),
|
|
[
|
|
(True, False, "stream failed"),
|
|
(True, True, "stream failed"),
|
|
(False, True, "flush publish failed"),
|
|
],
|
|
)
|
|
@pytest.mark.anyio
|
|
async def test_run_agent_handles_pending_file_args_when_stream_or_flush_raises(stream_error: bool, flush_publish_error: bool, expected_error: str):
|
|
run_manager = RunManager()
|
|
record = await run_manager.create("thread-file-stream-error")
|
|
|
|
async def publish(_run_id: str, event: str, _data: Any):
|
|
if flush_publish_error and event == "messages":
|
|
raise RuntimeError("flush publish failed")
|
|
|
|
bridge = SimpleNamespace(
|
|
publish=AsyncMock(side_effect=publish),
|
|
publish_end=AsyncMock(),
|
|
cleanup=AsyncMock(),
|
|
)
|
|
|
|
class DummyAgent:
|
|
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
|
|
del graph_input, config, stream_mode, subgraphs
|
|
yield (
|
|
"messages",
|
|
(
|
|
AIMessageChunk(
|
|
content="",
|
|
id="ai-file",
|
|
tool_call_chunks=[
|
|
{
|
|
"id": "call-file",
|
|
"index": 0,
|
|
"name": "write_file",
|
|
"args": '{"path":"report.md","content":"partial',
|
|
}
|
|
],
|
|
),
|
|
{},
|
|
),
|
|
)
|
|
if stream_error:
|
|
raise RuntimeError("stream failed")
|
|
|
|
await run_agent(
|
|
bridge,
|
|
run_manager,
|
|
record,
|
|
ctx=RunContext(checkpointer=None),
|
|
agent_factory=lambda **_kwargs: DummyAgent(),
|
|
graph_input={},
|
|
config={},
|
|
stream_modes=["messages-tuple", "values"],
|
|
)
|
|
|
|
message_events = [call.args for call in bridge.publish.await_args_list if call.args[1] == "messages"]
|
|
assert len(message_events) == 1
|
|
assert message_events[0][2][0]["tool_call_chunks"][0]["args"].endswith('"content":"partial')
|
|
error_events = [call.args for call in bridge.publish.await_args_list if call.args[1] == "error"]
|
|
assert error_events[0][2]["message"] == expected_error
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_run_agent_keeps_file_chunks_unbatched_without_values_mode():
|
|
run_manager = RunManager()
|
|
record = await run_manager.create("thread-file-messages-only")
|
|
bridge = SimpleNamespace(
|
|
publish=AsyncMock(),
|
|
publish_end=AsyncMock(),
|
|
cleanup=AsyncMock(),
|
|
)
|
|
chunks = [
|
|
AIMessageChunk(
|
|
content="",
|
|
id="ai-file",
|
|
tool_call_chunks=[
|
|
{
|
|
"id": "call-file",
|
|
"index": 0,
|
|
"name": "write_file",
|
|
"args": '{"path":"report.md","content":"Hel',
|
|
}
|
|
],
|
|
),
|
|
AIMessageChunk(
|
|
content="",
|
|
id="ai-file",
|
|
tool_call_chunks=[{"index": 0, "name": None, "args": 'lo"}'}],
|
|
),
|
|
]
|
|
|
|
class DummyAgent:
|
|
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
|
|
del graph_input, config, stream_mode, subgraphs
|
|
for chunk in chunks:
|
|
yield (chunk, {})
|
|
|
|
await run_agent(
|
|
bridge,
|
|
run_manager,
|
|
record,
|
|
ctx=RunContext(checkpointer=None),
|
|
agent_factory=lambda **_kwargs: DummyAgent(),
|
|
graph_input={},
|
|
config={},
|
|
stream_modes=["messages-tuple"],
|
|
)
|
|
|
|
message_events = [call.args[2] for call in bridge.publish.await_args_list if call.args[1] == "messages"]
|
|
assert len(message_events) == 2
|
|
assert message_events[0][0]["tool_call_chunks"][0]["args"].endswith('"content":"Hel')
|
|
assert message_events[1][0]["tool_call_chunks"][0]["args"] == 'lo"}'
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_run_agent_threads_explicit_app_config_into_config_only_factory():
|
|
run_manager = RunManager()
|
|
record = await run_manager.create("thread-1")
|
|
bridge = SimpleNamespace(
|
|
publish=AsyncMock(),
|
|
publish_end=AsyncMock(),
|
|
cleanup=AsyncMock(),
|
|
)
|
|
app_config = object()
|
|
captured: dict[str, object] = {}
|
|
|
|
class DummyAgent:
|
|
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
|
|
captured["astream_context"] = config["context"]
|
|
yield {"messages": []}
|
|
|
|
def factory(*, config):
|
|
captured["factory_context"] = config["context"]
|
|
return DummyAgent()
|
|
|
|
await run_agent(
|
|
bridge,
|
|
run_manager,
|
|
record,
|
|
ctx=RunContext(checkpointer=None, app_config=app_config),
|
|
agent_factory=factory,
|
|
graph_input={},
|
|
config={},
|
|
)
|
|
await asyncio.sleep(0)
|
|
|
|
assert captured["factory_context"]["app_config"] is app_config
|
|
assert captured["astream_context"]["app_config"] is app_config
|
|
fetched = await run_manager.get(record.run_id)
|
|
assert fetched is not None
|
|
assert fetched.status == RunStatus.success
|
|
bridge.publish_end.assert_awaited_once_with(record.run_id)
|
|
bridge.cleanup.assert_awaited_once_with(record.run_id, delay=60)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_run_agent_threads_pre_existing_message_ids_into_runtime_context():
|
|
run_manager = RunManager()
|
|
record = await run_manager.create("thread-1")
|
|
bridge = SimpleNamespace(
|
|
publish=AsyncMock(),
|
|
publish_end=AsyncMock(),
|
|
cleanup=AsyncMock(),
|
|
)
|
|
captured: dict[str, object] = {}
|
|
|
|
class DummyCheckpointer:
|
|
async def aget_tuple(self, _config):
|
|
return SimpleNamespace(
|
|
config={"configurable": {"checkpoint_id": "checkpoint-1"}},
|
|
checkpoint={"channel_values": {}},
|
|
metadata={},
|
|
pending_writes=[],
|
|
)
|
|
|
|
class DummyAgent:
|
|
async def aget_state(self, _config):
|
|
return SimpleNamespace(
|
|
values={
|
|
"messages": [
|
|
HumanMessage(id="h1", content="question"),
|
|
AIMessage(id="a1", content="answer"),
|
|
]
|
|
},
|
|
config={
|
|
"configurable": {
|
|
"thread_id": "thread-1",
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": "checkpoint-1",
|
|
}
|
|
},
|
|
parent_config=None,
|
|
metadata={},
|
|
next=(),
|
|
tasks=(),
|
|
created_at=None,
|
|
)
|
|
|
|
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
|
|
captured["context"] = config["context"]
|
|
yield {"messages": []}
|
|
|
|
def factory(*, config):
|
|
return DummyAgent()
|
|
|
|
await run_agent(
|
|
bridge,
|
|
run_manager,
|
|
record,
|
|
ctx=RunContext(checkpointer=DummyCheckpointer()),
|
|
agent_factory=factory,
|
|
graph_input={},
|
|
config={},
|
|
)
|
|
|
|
context = captured["context"]
|
|
assert context[CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY] == frozenset({"h1", "a1"})
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_run_agent_marks_rollback_unusable_when_capture_fails():
|
|
"""A failed pre-run capture must disable rollback entirely.
|
|
|
|
Rollback now restores messages by forking the pre-run checkpoint through
|
|
the graph (raw checkpoint blobs cannot reconstruct Delta-channel history),
|
|
so there is no safe fallback when materialization fails: the worker must
|
|
report ``snapshot_capture_failed=True`` with no rollback point rather
|
|
than restoring an empty or partial message history.
|
|
"""
|
|
run_manager = RunManager()
|
|
record = await run_manager.create("thread-1")
|
|
bridge = SimpleNamespace(
|
|
publish=AsyncMock(),
|
|
publish_end=AsyncMock(),
|
|
cleanup=AsyncMock(),
|
|
)
|
|
|
|
class DummyCheckpointer:
|
|
async def aget_tuple(self, _config):
|
|
return SimpleNamespace(
|
|
config={
|
|
"configurable": {
|
|
"thread_id": "thread-1",
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": "checkpoint-1",
|
|
}
|
|
},
|
|
checkpoint={"id": "checkpoint-1", "channel_values": {}},
|
|
metadata={"source": "loop"},
|
|
pending_writes=[],
|
|
)
|
|
|
|
class DummyAgent:
|
|
async def aget_state(self, _config):
|
|
# Cancel after the worker crosses its startup barrier so this test
|
|
# exercises the running rollback path, not pending cancellation.
|
|
record.abort_action = "rollback"
|
|
record.abort_event.set()
|
|
raise RuntimeError("materialization failed")
|
|
|
|
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
|
|
yield {"messages": []}
|
|
|
|
with patch(
|
|
"deerflow.runtime.runs.worker._rollback_to_pre_run_checkpoint",
|
|
new_callable=AsyncMock,
|
|
) as rollback:
|
|
await run_agent(
|
|
bridge,
|
|
run_manager,
|
|
record,
|
|
ctx=RunContext(checkpointer=DummyCheckpointer()),
|
|
agent_factory=lambda *, config: DummyAgent(),
|
|
graph_input={},
|
|
config={},
|
|
)
|
|
|
|
rollback.assert_awaited_once()
|
|
rollback_kwargs = rollback.await_args.kwargs
|
|
assert rollback_kwargs["snapshot_capture_failed"] is True
|
|
assert rollback_kwargs["rollback_point"] is None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_run_agent_overrides_spoofed_pre_existing_message_ids_without_snapshot():
|
|
run_manager = RunManager()
|
|
record = await run_manager.create("thread-1")
|
|
bridge = SimpleNamespace(
|
|
publish=AsyncMock(),
|
|
publish_end=AsyncMock(),
|
|
cleanup=AsyncMock(),
|
|
)
|
|
captured: dict[str, object] = {}
|
|
|
|
class DummyAgent:
|
|
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
|
|
captured["context"] = config["context"]
|
|
yield {"messages": []}
|
|
|
|
def factory(*, config):
|
|
return DummyAgent()
|
|
|
|
await run_agent(
|
|
bridge,
|
|
run_manager,
|
|
record,
|
|
ctx=RunContext(checkpointer=None),
|
|
agent_factory=factory,
|
|
graph_input={},
|
|
config={"context": {CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY: {"spoofed"}}},
|
|
)
|
|
|
|
context = captured["context"]
|
|
assert context[CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY] == frozenset()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_run_agent_marks_llm_error_fallback_as_error_status():
|
|
run_manager = RunManager()
|
|
record = await run_manager.create("thread-1")
|
|
bridge = SimpleNamespace(
|
|
publish=AsyncMock(),
|
|
publish_end=AsyncMock(),
|
|
cleanup=AsyncMock(),
|
|
)
|
|
|
|
class DummyAgent:
|
|
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
|
|
yield {
|
|
"messages": [
|
|
AIMessage(
|
|
content="The configured LLM provider is temporarily unavailable after multiple retries.",
|
|
additional_kwargs={
|
|
"deerflow_error_fallback": True,
|
|
"error_type": "APIConnectionError",
|
|
"error_reason": "transient",
|
|
"error_detail": "Connection error.",
|
|
},
|
|
)
|
|
]
|
|
}
|
|
|
|
def factory(*, config):
|
|
return DummyAgent()
|
|
|
|
await run_agent(
|
|
bridge,
|
|
run_manager,
|
|
record,
|
|
ctx=RunContext(checkpointer=None),
|
|
agent_factory=factory,
|
|
graph_input={},
|
|
config={},
|
|
)
|
|
|
|
fetched = await run_manager.get(record.run_id)
|
|
assert fetched is not None
|
|
assert fetched.status == RunStatus.error
|
|
assert fetched.error == "Connection error."
|
|
bridge.publish_end.assert_awaited_once_with(record.run_id)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_run_agent_rolls_back_failed_edit_replay_and_publishes_restored_values():
|
|
run_manager = RunManager()
|
|
record = await run_manager.create(
|
|
"thread-1",
|
|
metadata={"replay_kind": "edit", "regenerate_from_run_id": "source-run"},
|
|
)
|
|
bridge = SimpleNamespace(
|
|
publish=AsyncMock(),
|
|
publish_end=AsyncMock(),
|
|
cleanup=AsyncMock(),
|
|
)
|
|
before_messages = [HumanMessage(id="h1", content="original"), AIMessage(id="a1", content="answer")]
|
|
|
|
class DummyCheckpointer:
|
|
async def aget_tuple(self, _config):
|
|
return SimpleNamespace(
|
|
config={
|
|
"configurable": {
|
|
"thread_id": "thread-1",
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": "checkpoint-1",
|
|
}
|
|
},
|
|
checkpoint={"id": "checkpoint-1", "channel_values": {}},
|
|
metadata={"source": "loop"},
|
|
pending_writes=[],
|
|
)
|
|
|
|
class DummyAgent:
|
|
async def aget_state(self, _config):
|
|
return SimpleNamespace(
|
|
values={"messages": before_messages},
|
|
config={
|
|
"configurable": {
|
|
"thread_id": "thread-1",
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": "checkpoint-1",
|
|
}
|
|
},
|
|
parent_config=None,
|
|
metadata={},
|
|
next=(),
|
|
tasks=(),
|
|
created_at=None,
|
|
)
|
|
|
|
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
|
|
raise RuntimeError("edit replay failed")
|
|
if False:
|
|
yield # pragma: no cover - keep this an async generator
|
|
|
|
error_status_finalizing_states: list[bool] = []
|
|
original_set_status = run_manager.set_status
|
|
|
|
async def _set_status(run_id, status, **kwargs):
|
|
if run_id == record.run_id and status == RunStatus.error:
|
|
error_status_finalizing_states.append(record.finalizing)
|
|
return await original_set_status(run_id, status, **kwargs)
|
|
|
|
run_manager.set_status = _set_status # type: ignore[method-assign]
|
|
with patch(
|
|
"deerflow.runtime.runs.worker._rollback_to_pre_run_checkpoint",
|
|
new_callable=AsyncMock,
|
|
) as rollback:
|
|
rollback.return_value = True
|
|
await run_agent(
|
|
bridge,
|
|
run_manager,
|
|
record,
|
|
ctx=RunContext(checkpointer=DummyCheckpointer()),
|
|
agent_factory=lambda *, config: DummyAgent(),
|
|
graph_input={},
|
|
config={},
|
|
)
|
|
|
|
fetched = await run_manager.get(record.run_id)
|
|
assert fetched is not None
|
|
assert fetched.status == RunStatus.error
|
|
assert error_status_finalizing_states == [True]
|
|
rollback.assert_awaited_once()
|
|
publish_events = [call_args.args[1] for call_args in bridge.publish.await_args_list]
|
|
assert "error" in publish_events
|
|
assert "values" in publish_events
|
|
assert publish_events.index("values") > publish_events.index("error")
|
|
bridge.publish_end.assert_awaited_once_with(record.run_id)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_failed_edit_replay_does_not_publish_restored_values_when_snapshot_capture_failed():
|
|
run_manager = RunManager()
|
|
record = await run_manager.create(
|
|
"thread-1",
|
|
metadata={"replay_kind": "edit", "regenerate_from_run_id": "source-run"},
|
|
)
|
|
bridge = SimpleNamespace(
|
|
publish=AsyncMock(),
|
|
publish_end=AsyncMock(),
|
|
cleanup=AsyncMock(),
|
|
)
|
|
|
|
class DummyCheckpointer:
|
|
async def aget_tuple(self, _config):
|
|
return SimpleNamespace(
|
|
config={
|
|
"configurable": {
|
|
"thread_id": "thread-1",
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": "checkpoint-1",
|
|
}
|
|
},
|
|
checkpoint={"id": "checkpoint-1", "channel_values": {}},
|
|
metadata={"source": "loop"},
|
|
pending_writes=[],
|
|
)
|
|
|
|
class DummyAgent:
|
|
async def aget_state(self, _config):
|
|
raise RuntimeError("snapshot capture failed")
|
|
|
|
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
|
|
raise RuntimeError("edit replay failed")
|
|
if False:
|
|
yield # pragma: no cover - keep this an async generator
|
|
|
|
await run_agent(
|
|
bridge,
|
|
run_manager,
|
|
record,
|
|
ctx=RunContext(checkpointer=DummyCheckpointer()),
|
|
agent_factory=lambda *, config: DummyAgent(),
|
|
graph_input={},
|
|
config={},
|
|
)
|
|
|
|
fetched = await run_manager.get(record.run_id)
|
|
assert fetched is not None
|
|
assert fetched.status == RunStatus.error
|
|
publish_events = [call_args.args[1] for call_args in bridge.publish.await_args_list]
|
|
assert "error" in publish_events
|
|
assert "values" not in publish_events
|
|
bridge.publish_end.assert_awaited_once_with(record.run_id)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_run_agent_defaults_root_run_name_from_assistant_id():
|
|
run_manager = RunManager()
|
|
record = await run_manager.create("thread-1", assistant_id="lead_agent")
|
|
bridge = SimpleNamespace(
|
|
publish=AsyncMock(),
|
|
publish_end=AsyncMock(),
|
|
cleanup=AsyncMock(),
|
|
)
|
|
captured: dict[str, object] = {}
|
|
|
|
class DummyAgent:
|
|
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
|
|
captured["astream_run_name"] = config["run_name"]
|
|
yield {"messages": []}
|
|
|
|
def factory(*, config):
|
|
captured["factory_run_name"] = config["run_name"]
|
|
return DummyAgent()
|
|
|
|
await run_agent(
|
|
bridge,
|
|
run_manager,
|
|
record,
|
|
ctx=RunContext(checkpointer=None),
|
|
agent_factory=factory,
|
|
graph_input={},
|
|
config={},
|
|
)
|
|
|
|
assert captured["factory_run_name"] == "lead_agent"
|
|
assert captured["astream_run_name"] == "lead_agent"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_run_agent_defaults_root_run_name_from_context_agent_name():
|
|
run_manager = RunManager()
|
|
record = await run_manager.create("thread-1", assistant_id="lead_agent")
|
|
bridge = SimpleNamespace(
|
|
publish=AsyncMock(),
|
|
publish_end=AsyncMock(),
|
|
cleanup=AsyncMock(),
|
|
)
|
|
captured: dict[str, object] = {}
|
|
|
|
class DummyAgent:
|
|
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
|
|
captured["astream_run_name"] = config["run_name"]
|
|
yield {"messages": []}
|
|
|
|
def factory(*, config):
|
|
captured["factory_run_name"] = config["run_name"]
|
|
return DummyAgent()
|
|
|
|
await run_agent(
|
|
bridge,
|
|
run_manager,
|
|
record,
|
|
ctx=RunContext(checkpointer=None),
|
|
agent_factory=factory,
|
|
graph_input={},
|
|
config={"context": {"agent_name": "finalis"}},
|
|
)
|
|
|
|
assert captured["factory_run_name"] == "finalis"
|
|
assert captured["astream_run_name"] == "finalis"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_run_agent_defaults_root_run_name_from_configurable_agent_name():
|
|
run_manager = RunManager()
|
|
record = await run_manager.create("thread-1", assistant_id="lead_agent")
|
|
bridge = SimpleNamespace(
|
|
publish=AsyncMock(),
|
|
publish_end=AsyncMock(),
|
|
cleanup=AsyncMock(),
|
|
)
|
|
captured: dict[str, object] = {}
|
|
|
|
class DummyAgent:
|
|
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
|
|
captured["astream_run_name"] = config["run_name"]
|
|
yield {"messages": []}
|
|
|
|
def factory(*, config):
|
|
captured["factory_run_name"] = config["run_name"]
|
|
return DummyAgent()
|
|
|
|
await run_agent(
|
|
bridge,
|
|
run_manager,
|
|
record,
|
|
ctx=RunContext(checkpointer=None),
|
|
agent_factory=factory,
|
|
graph_input={},
|
|
config={"configurable": {"agent_name": "finalis"}},
|
|
)
|
|
|
|
assert captured["factory_run_name"] == "finalis"
|
|
assert captured["astream_run_name"] == "finalis"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_rollback_forks_pre_run_checkpoint_without_deleting_thread(monkeypatch):
|
|
checkpointer = FakeCheckpointer()
|
|
mock_graph = _stub_mutation_graph(
|
|
monkeypatch,
|
|
restored_config={"configurable": {"thread_id": "thread-1", "checkpoint_ns": "", "checkpoint_id": "restored-1"}},
|
|
)
|
|
rollback_point = _make_rollback_point(
|
|
pending_writes=[
|
|
("task-a", "messages", {"content": "first"}),
|
|
("task-a", "status", "done"),
|
|
("task-b", "events", {"type": "tool"}),
|
|
],
|
|
)
|
|
|
|
await _rollback_to_pre_run_checkpoint(
|
|
accessor=_rollback_accessor(checkpointer),
|
|
checkpointer=checkpointer,
|
|
thread_id="thread-1",
|
|
run_id="run-1",
|
|
rollback_point=rollback_point,
|
|
snapshot_capture_failed=False,
|
|
)
|
|
|
|
checkpointer.adelete_thread.assert_not_awaited()
|
|
mock_graph.aupdate_state.assert_awaited_once()
|
|
update_config, update_values = mock_graph.aupdate_state.await_args.args[:2]
|
|
assert update_config["configurable"]["checkpoint_id"] == "ckpt-1"
|
|
overwrite = update_values["messages"]
|
|
assert isinstance(overwrite, Overwrite)
|
|
assert overwrite.value == ["before"]
|
|
assert mock_graph.aupdate_state.await_args.kwargs["as_node"] == "rollback_restore"
|
|
assert checkpointer.aput_writes.await_args_list == [
|
|
call(
|
|
{"configurable": {"thread_id": "thread-1", "checkpoint_ns": "", "checkpoint_id": "restored-1"}},
|
|
[("messages", {"content": "first"}), ("status", "done")],
|
|
task_id="task-a",
|
|
),
|
|
call(
|
|
{"configurable": {"thread_id": "thread-1", "checkpoint_ns": "", "checkpoint_id": "restored-1"}},
|
|
[("events", {"type": "tool"})],
|
|
task_id="task-b",
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_rollback_skips_when_rollback_point_has_no_checkpoint_id(monkeypatch):
|
|
"""A rollback point without a checkpoint id cannot anchor a fork - skip safely."""
|
|
checkpointer = FakeCheckpointer()
|
|
mock_graph = _stub_mutation_graph(
|
|
monkeypatch,
|
|
restored_config={"configurable": {"thread_id": "thread-1", "checkpoint_ns": "", "checkpoint_id": "restored-1"}},
|
|
)
|
|
|
|
await _rollback_to_pre_run_checkpoint(
|
|
accessor=_rollback_accessor(checkpointer),
|
|
checkpointer=checkpointer,
|
|
thread_id="thread-1",
|
|
run_id="run-1",
|
|
rollback_point=_make_rollback_point(checkpoint_id=None),
|
|
snapshot_capture_failed=False,
|
|
)
|
|
|
|
checkpointer.adelete_thread.assert_not_awaited()
|
|
mock_graph.aupdate_state.assert_not_awaited()
|
|
checkpointer.aput_writes.assert_not_awaited()
|
|
|
|
|
|
class _ArtifactChannelState(TypedDict):
|
|
messages: Annotated[list[AnyMessage], add_messages]
|
|
artifacts: Annotated[list[str], merge_artifacts]
|
|
|
|
|
|
def _build_message_and_artifact_graph(checkpointer: Any):
|
|
async def _append(state: dict[str, Any]) -> dict[str, Any]:
|
|
n = len(state.get("messages") or [])
|
|
return {"messages": [HumanMessage(content=f"turn-{n}")], "artifacts": [f"a{n}"]}
|
|
|
|
builder = StateGraph(_ArtifactChannelState)
|
|
builder.add_node("append", _append)
|
|
builder.set_entry_point("append")
|
|
builder.set_finish_point("append")
|
|
return builder.compile(checkpointer=checkpointer)
|
|
|
|
|
|
class _ExtensionFullState(TypedDict):
|
|
"""Full-mode schema with a channel base ThreadState does not know about.
|
|
|
|
Mirrors a custom ``AgentMiddleware.state_schema`` contribution (memory,
|
|
todos, uploads, …): absent from the base schema the mutation graph falls
|
|
back to, so a base-schema restore silently drops it.
|
|
"""
|
|
|
|
messages: Annotated[list[AnyMessage], add_messages]
|
|
memory_notes: NotRequired[str]
|
|
|
|
|
|
class _ExtensionDeltaState(TypedDict):
|
|
messages: Annotated[list[AnyMessage], DeltaChannel(merge_message_writes, snapshot_frequency=1000)]
|
|
memory_notes: NotRequired[str]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize(
|
|
"mode,state_schema",
|
|
[("full", _ExtensionFullState), ("delta", _ExtensionDeltaState)],
|
|
)
|
|
async def test_rollback_preserves_middleware_contributed_channels(mode, state_schema):
|
|
"""The rollback mutation graph must compile with the thread's effective schema.
|
|
|
|
Regression: ``_rollback_to_pre_run_checkpoint`` used to build the mutation
|
|
graph from the base ThreadState fallback, silently dropping channels
|
|
contributed by custom middleware from the restored checkpoint.
|
|
"""
|
|
checkpointer = InMemorySaver()
|
|
|
|
async def _step(state: dict[str, Any]) -> dict[str, Any]:
|
|
n = len(state.get("messages") or [])
|
|
return {"messages": [HumanMessage(content=f"turn-{n}")], "memory_notes": f"note-{n}"}
|
|
|
|
builder = StateGraph(state_schema)
|
|
builder.add_node("step", _step)
|
|
builder.set_entry_point("step")
|
|
builder.set_finish_point("step")
|
|
graph = builder.compile(checkpointer=checkpointer)
|
|
|
|
accessor = CheckpointStateAccessor.bind(graph, checkpointer, mode=mode)
|
|
thread_config = {"configurable": {"thread_id": "thread-1"}}
|
|
|
|
await graph.ainvoke({}, thread_config)
|
|
rollback_point = await _capture_rollback_point(accessor, checkpointer, thread_config)
|
|
assert rollback_point is not None
|
|
|
|
await graph.ainvoke({}, thread_config)
|
|
|
|
await _rollback_to_pre_run_checkpoint(
|
|
accessor=accessor,
|
|
checkpointer=checkpointer,
|
|
thread_id="thread-1",
|
|
run_id="run-1",
|
|
rollback_point=rollback_point,
|
|
snapshot_capture_failed=False,
|
|
)
|
|
|
|
latest_snapshot = await accessor.aget(thread_config)
|
|
assert [message.content for message in latest_snapshot.values["messages"]] == ["turn-0"]
|
|
assert latest_snapshot.values["memory_notes"] == "note-0"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_rollback_restores_non_message_channels_via_fork_inheritance():
|
|
"""Rollback overwrites only messages; sibling reducer channels must be
|
|
restored to their pre-run values by forking the pre-run checkpoint
|
|
(non-updated channels inherit from the parent). Locks in the load-bearing
|
|
assumption of the rollback design."""
|
|
checkpointer = InMemorySaver()
|
|
graph = _build_message_and_artifact_graph(checkpointer)
|
|
accessor = CheckpointStateAccessor.bind(graph, checkpointer, mode="full")
|
|
thread_config = {"configurable": {"thread_id": "thread-1"}}
|
|
|
|
await graph.ainvoke({}, thread_config)
|
|
rollback_point = await _capture_rollback_point(accessor, checkpointer, thread_config)
|
|
assert rollback_point is not None
|
|
|
|
await graph.ainvoke({}, thread_config)
|
|
cancelled_snapshot = await accessor.aget(thread_config)
|
|
assert cancelled_snapshot.values["artifacts"] == ["a0", "a1"]
|
|
assert [message.content for message in cancelled_snapshot.values["messages"]] == ["turn-0", "turn-1"]
|
|
|
|
await _rollback_to_pre_run_checkpoint(
|
|
accessor=accessor,
|
|
checkpointer=checkpointer,
|
|
thread_id="thread-1",
|
|
run_id="run-1",
|
|
rollback_point=rollback_point,
|
|
snapshot_capture_failed=False,
|
|
)
|
|
|
|
latest_snapshot = await accessor.aget(thread_config)
|
|
assert [message.content for message in latest_snapshot.values["messages"]] == ["turn-0"]
|
|
assert latest_snapshot.values["artifacts"] == ["a0"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_rollback_restored_checkpoint_becomes_latest_with_real_checkpointer():
|
|
"""Full-mode: the restored fork becomes latest, keeps pre-run values and
|
|
pre-run pending writes, and drops writes attached to the cancelled run."""
|
|
checkpointer = InMemorySaver()
|
|
graph = _build_message_append_graph(_FullChannelState, checkpointer)
|
|
accessor = CheckpointStateAccessor.bind(graph, checkpointer, mode="full")
|
|
thread_config = {"configurable": {"thread_id": "thread-1"}}
|
|
|
|
await graph.ainvoke({}, thread_config)
|
|
pre_run_snapshot = await accessor.aget(thread_config)
|
|
pre_run_checkpoint_id = pre_run_snapshot.config["configurable"]["checkpoint_id"]
|
|
pre_run_config = {
|
|
"configurable": {
|
|
"thread_id": "thread-1",
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": pre_run_checkpoint_id,
|
|
}
|
|
}
|
|
checkpointer.put_writes(pre_run_config, [("title", "pending-before")], task_id="task-before")
|
|
|
|
rollback_point = await _capture_rollback_point(accessor, checkpointer, thread_config)
|
|
assert rollback_point is not None
|
|
assert rollback_point.pending_writes == (("task-before", "title", "pending-before"),)
|
|
|
|
await graph.ainvoke({}, thread_config)
|
|
cancelled_snapshot = await accessor.aget(thread_config)
|
|
cancelled_checkpoint_id = cancelled_snapshot.config["configurable"]["checkpoint_id"]
|
|
checkpointer.put_writes(
|
|
{"configurable": {"thread_id": "thread-1", "checkpoint_ns": "", "checkpoint_id": cancelled_checkpoint_id}},
|
|
[("title", "pending-after")],
|
|
task_id="task-after",
|
|
)
|
|
|
|
await _rollback_to_pre_run_checkpoint(
|
|
accessor=accessor,
|
|
checkpointer=checkpointer,
|
|
thread_id="thread-1",
|
|
run_id="run-1",
|
|
rollback_point=rollback_point,
|
|
snapshot_capture_failed=False,
|
|
)
|
|
|
|
latest_snapshot = await accessor.aget(thread_config)
|
|
restored_checkpoint_id = latest_snapshot.config["configurable"]["checkpoint_id"]
|
|
assert restored_checkpoint_id not in (pre_run_checkpoint_id, cancelled_checkpoint_id)
|
|
assert [message.content for message in latest_snapshot.values["messages"]] == ["turn-0"]
|
|
|
|
latest_tuple = await checkpointer.aget_tuple(thread_config)
|
|
assert latest_tuple.pending_writes == [("task-before", "title", "pending-before")]
|
|
restored_tuple = await checkpointer.aget_tuple({"configurable": {"thread_id": "thread-1", "checkpoint_ns": "", "checkpoint_id": restored_checkpoint_id}})
|
|
assert restored_tuple.parent_config["configurable"]["checkpoint_id"] == pre_run_checkpoint_id
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_rollback_deletes_thread_when_no_snapshot_exists():
|
|
checkpointer = FakeCheckpointer()
|
|
|
|
await _rollback_to_pre_run_checkpoint(
|
|
accessor=_rollback_accessor(checkpointer),
|
|
checkpointer=checkpointer,
|
|
thread_id="thread-1",
|
|
run_id="run-1",
|
|
rollback_point=None,
|
|
snapshot_capture_failed=False,
|
|
)
|
|
|
|
checkpointer.adelete_thread.assert_awaited_once_with("thread-1")
|
|
checkpointer.aput_writes.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_rollback_raises_when_restore_config_has_no_checkpoint_id(monkeypatch):
|
|
checkpointer = FakeCheckpointer()
|
|
_stub_mutation_graph(
|
|
monkeypatch,
|
|
restored_config={"configurable": {"thread_id": "thread-1", "checkpoint_ns": ""}},
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="did not return checkpoint_id"):
|
|
await _rollback_to_pre_run_checkpoint(
|
|
accessor=_rollback_accessor(checkpointer),
|
|
checkpointer=checkpointer,
|
|
thread_id="thread-1",
|
|
run_id="run-1",
|
|
rollback_point=_make_rollback_point(pending_writes=[("task-a", "messages", "value")]),
|
|
snapshot_capture_failed=False,
|
|
)
|
|
|
|
checkpointer.adelete_thread.assert_not_awaited()
|
|
checkpointer.aput_writes.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_capture_rollback_point_normalizes_missing_checkpoint_ns():
|
|
"""Snapshots whose config omits ``checkpoint_ns`` must anchor the root namespace."""
|
|
|
|
class _TupleCheckpointer:
|
|
async def aget_tuple(self, _config):
|
|
return SimpleNamespace(pending_writes=[])
|
|
|
|
class _SnapshotAgent:
|
|
async def aget_state(self, _config):
|
|
return SimpleNamespace(
|
|
values={"messages": [HumanMessage(id="h1", content="before")]},
|
|
config={"configurable": {"thread_id": "thread-1", "checkpoint_id": "ckpt-1"}},
|
|
metadata={"source": "loop"},
|
|
)
|
|
|
|
checkpointer = _TupleCheckpointer()
|
|
accessor = CheckpointStateAccessor(graph=_SnapshotAgent(), checkpointer=checkpointer, mode="full")
|
|
|
|
rollback_point = await _capture_rollback_point(accessor, checkpointer, {"configurable": {"thread_id": "thread-1"}})
|
|
|
|
assert rollback_point is not None
|
|
assert rollback_point.config["configurable"]["checkpoint_ns"] == ""
|
|
assert rollback_point.config["configurable"]["checkpoint_id"] == "ckpt-1"
|
|
assert [message.content for message in rollback_point.messages] == ["before"]
|
|
assert rollback_point.metadata == {"source": "loop"}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_capture_rollback_point_returns_none_without_checkpoint():
|
|
"""A thread with no checkpoints has no rollback point (delete/reset contract)."""
|
|
|
|
class _EmptyAgent:
|
|
async def aget_state(self, _config):
|
|
return SimpleNamespace(
|
|
values={},
|
|
config={"configurable": {"thread_id": "thread-1", "checkpoint_ns": ""}},
|
|
metadata={},
|
|
)
|
|
|
|
checkpointer = SimpleNamespace(aget_tuple=AsyncMock(return_value=None))
|
|
accessor = CheckpointStateAccessor(graph=_EmptyAgent(), checkpointer=checkpointer, mode="full")
|
|
|
|
assert await _capture_rollback_point(accessor, checkpointer, {"configurable": {"thread_id": "thread-1"}}) is None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_rollback_raises_on_malformed_pending_write_not_a_tuple(monkeypatch):
|
|
"""pending_writes containing a non-3-tuple item should raise RuntimeError."""
|
|
checkpointer = FakeCheckpointer()
|
|
_stub_mutation_graph(
|
|
monkeypatch,
|
|
restored_config={"configurable": {"thread_id": "thread-1", "checkpoint_ns": "", "checkpoint_id": "restored-1"}},
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="rollback failed: pending_write is not a 3-tuple"):
|
|
await _rollback_to_pre_run_checkpoint(
|
|
accessor=_rollback_accessor(checkpointer),
|
|
checkpointer=checkpointer,
|
|
thread_id="thread-1",
|
|
run_id="run-1",
|
|
rollback_point=_make_rollback_point(
|
|
pending_writes=[
|
|
("task-a", "messages", "valid"), # valid
|
|
["only", "two"], # malformed: only 2 elements
|
|
],
|
|
),
|
|
snapshot_capture_failed=False,
|
|
)
|
|
|
|
checkpointer.aput_writes.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_rollback_raises_on_malformed_pending_write_non_string_channel(monkeypatch):
|
|
"""pending_writes containing a non-string channel should raise RuntimeError."""
|
|
checkpointer = FakeCheckpointer()
|
|
_stub_mutation_graph(
|
|
monkeypatch,
|
|
restored_config={"configurable": {"thread_id": "thread-1", "checkpoint_ns": "", "checkpoint_id": "restored-1"}},
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="rollback failed: pending_write has non-string channel"):
|
|
await _rollback_to_pre_run_checkpoint(
|
|
accessor=_rollback_accessor(checkpointer),
|
|
checkpointer=checkpointer,
|
|
thread_id="thread-1",
|
|
run_id="run-1",
|
|
rollback_point=_make_rollback_point(
|
|
pending_writes=[
|
|
("task-a", 123, "value"), # malformed: channel is not a string
|
|
],
|
|
),
|
|
snapshot_capture_failed=False,
|
|
)
|
|
|
|
checkpointer.aput_writes.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_rollback_propagates_aput_writes_failure(monkeypatch):
|
|
"""If aput_writes fails, the exception should propagate (not be swallowed)."""
|
|
checkpointer = FakeCheckpointer()
|
|
checkpointer.aput_writes.side_effect = RuntimeError("Database connection lost")
|
|
_stub_mutation_graph(
|
|
monkeypatch,
|
|
restored_config={"configurable": {"thread_id": "thread-1", "checkpoint_ns": "", "checkpoint_id": "restored-1"}},
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="Database connection lost"):
|
|
await _rollback_to_pre_run_checkpoint(
|
|
accessor=_rollback_accessor(checkpointer),
|
|
checkpointer=checkpointer,
|
|
thread_id="thread-1",
|
|
run_id="run-1",
|
|
rollback_point=_make_rollback_point(
|
|
pending_writes=[
|
|
("task-a", "messages", "value"),
|
|
],
|
|
),
|
|
snapshot_capture_failed=False,
|
|
)
|
|
|
|
checkpointer.aput_writes.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_rollback_linearizes_delta_restore_onto_cancelled_head():
|
|
"""Delta rollback replaces state on the head instead of creating a fork.
|
|
|
|
The cancelled path has already attached writes to the pre-run checkpoint,
|
|
so forking that checkpoint would replay sibling writes. The restored
|
|
checkpoint must instead descend from the cancelled head and replace the
|
|
captured messages there.
|
|
"""
|
|
checkpointer = InMemorySaver()
|
|
graph = _build_message_append_graph(_DeltaChannelState, checkpointer)
|
|
accessor = CheckpointStateAccessor.bind(graph, checkpointer, mode="delta")
|
|
thread_config = {"configurable": {"thread_id": "thread-1"}}
|
|
|
|
await graph.ainvoke({}, thread_config)
|
|
await graph.ainvoke({}, thread_config)
|
|
rollback_point = await _capture_rollback_point(accessor, checkpointer, thread_config)
|
|
assert rollback_point is not None
|
|
assert [message.content for message in rollback_point.messages] == ["turn-0", "turn-1"]
|
|
pre_run_checkpoint_id = rollback_point.config["configurable"]["checkpoint_id"]
|
|
|
|
await graph.ainvoke({}, thread_config) # the run that gets cancelled
|
|
cancelled_snapshot = await accessor.aget(thread_config)
|
|
cancelled_checkpoint_id = cancelled_snapshot.config["configurable"]["checkpoint_id"]
|
|
assert len(cancelled_snapshot.values["messages"]) == 3
|
|
|
|
await _rollback_to_pre_run_checkpoint(
|
|
accessor=accessor,
|
|
checkpointer=checkpointer,
|
|
thread_id="thread-1",
|
|
run_id="run-1",
|
|
rollback_point=rollback_point,
|
|
snapshot_capture_failed=False,
|
|
)
|
|
|
|
latest_snapshot = await accessor.aget(thread_config)
|
|
restored_checkpoint_id = latest_snapshot.config["configurable"]["checkpoint_id"]
|
|
assert restored_checkpoint_id not in (pre_run_checkpoint_id, cancelled_checkpoint_id)
|
|
assert [message.content for message in latest_snapshot.values["messages"]] == ["turn-0", "turn-1"]
|
|
|
|
restored_tuple = await checkpointer.aget_tuple({"configurable": {"thread_id": "thread-1", "checkpoint_ns": "", "checkpoint_id": restored_checkpoint_id}})
|
|
assert restored_tuple.parent_config["configurable"]["checkpoint_id"] == cancelled_checkpoint_id
|
|
assert restored_tuple.metadata.get("source") == "update"
|
|
# A non-snapshot Delta checkpoint must not persist the full message list.
|
|
raw_messages = restored_tuple.checkpoint.get("channel_values", {}).get("messages")
|
|
assert not isinstance(raw_messages, list)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_rollback_restores_pre_run_pending_writes_for_delta_checkpoints():
|
|
"""Pre-run pending writes are re-attached to the restored checkpoint; writes
|
|
attached to the cancelled run are not."""
|
|
checkpointer = InMemorySaver()
|
|
graph = _build_message_append_graph(_DeltaChannelState, checkpointer)
|
|
accessor = CheckpointStateAccessor.bind(graph, checkpointer, mode="delta")
|
|
thread_config = {"configurable": {"thread_id": "thread-1"}}
|
|
|
|
await graph.ainvoke({}, thread_config)
|
|
pre_run_snapshot = await accessor.aget(thread_config)
|
|
pre_run_checkpoint_id = pre_run_snapshot.config["configurable"]["checkpoint_id"]
|
|
checkpointer.put_writes(
|
|
{"configurable": {"thread_id": "thread-1", "checkpoint_ns": "", "checkpoint_id": pre_run_checkpoint_id}},
|
|
[("title", "in-flight-title")],
|
|
task_id="task-in-flight",
|
|
)
|
|
|
|
rollback_point = await _capture_rollback_point(accessor, checkpointer, thread_config)
|
|
assert rollback_point is not None
|
|
assert rollback_point.pending_writes == (("task-in-flight", "title", "in-flight-title"),)
|
|
|
|
await graph.ainvoke({}, thread_config) # cancelled run
|
|
cancelled_snapshot = await accessor.aget(thread_config)
|
|
cancelled_checkpoint_id = cancelled_snapshot.config["configurable"]["checkpoint_id"]
|
|
checkpointer.put_writes(
|
|
{"configurable": {"thread_id": "thread-1", "checkpoint_ns": "", "checkpoint_id": cancelled_checkpoint_id}},
|
|
[("title", "cancelled-title")],
|
|
task_id="task-cancelled",
|
|
)
|
|
|
|
await _rollback_to_pre_run_checkpoint(
|
|
accessor=accessor,
|
|
checkpointer=checkpointer,
|
|
thread_id="thread-1",
|
|
run_id="run-1",
|
|
rollback_point=rollback_point,
|
|
snapshot_capture_failed=False,
|
|
)
|
|
|
|
latest_tuple = await checkpointer.aget_tuple(thread_config)
|
|
assert latest_tuple.pending_writes == [("task-in-flight", "title", "in-flight-title")]
|
|
assert [message.content for message in (await accessor.aget(thread_config)).values["messages"]] == ["turn-0"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_rollback_linearizes_delta_restore_sqlite_reopen(tmp_path):
|
|
"""Same linear restore contract against a disk-backed saver, verified after a
|
|
close/reopen so only persisted bytes can satisfy the assertions."""
|
|
db_path = tmp_path / "rollback.sqlite3"
|
|
|
|
async with AsyncSqliteSaver.from_conn_string(str(db_path)) as checkpointer:
|
|
await checkpointer.setup()
|
|
graph = _build_message_append_graph(_DeltaChannelState, checkpointer)
|
|
accessor = CheckpointStateAccessor.bind(graph, checkpointer, mode="delta")
|
|
thread_config = {"configurable": {"thread_id": "thread-1"}}
|
|
|
|
await graph.ainvoke({}, thread_config)
|
|
await graph.ainvoke({}, thread_config)
|
|
rollback_point = await _capture_rollback_point(accessor, checkpointer, thread_config)
|
|
assert rollback_point is not None
|
|
pre_run_checkpoint_id = rollback_point.config["configurable"]["checkpoint_id"]
|
|
|
|
await graph.ainvoke({}, thread_config) # cancelled run
|
|
cancelled_snapshot = await accessor.aget(thread_config)
|
|
cancelled_checkpoint_id = cancelled_snapshot.config["configurable"]["checkpoint_id"]
|
|
|
|
await _rollback_to_pre_run_checkpoint(
|
|
accessor=accessor,
|
|
checkpointer=checkpointer,
|
|
thread_id="thread-1",
|
|
run_id="run-1",
|
|
rollback_point=rollback_point,
|
|
snapshot_capture_failed=False,
|
|
)
|
|
|
|
restored_snapshot = await accessor.aget(thread_config)
|
|
restored_checkpoint_id = restored_snapshot.config["configurable"]["checkpoint_id"]
|
|
assert restored_checkpoint_id not in (pre_run_checkpoint_id, cancelled_checkpoint_id)
|
|
assert [message.content for message in restored_snapshot.values["messages"]] == ["turn-0", "turn-1"]
|
|
restored_tuple = await checkpointer.aget_tuple({"configurable": {"thread_id": "thread-1", "checkpoint_ns": "", "checkpoint_id": restored_checkpoint_id}})
|
|
assert restored_tuple.parent_config["configurable"]["checkpoint_id"] == cancelled_checkpoint_id
|
|
|
|
async with AsyncSqliteSaver.from_conn_string(str(db_path)) as checkpointer:
|
|
graph = _build_message_append_graph(_DeltaChannelState, checkpointer)
|
|
accessor = CheckpointStateAccessor.bind(graph, checkpointer, mode="delta")
|
|
thread_config = {"configurable": {"thread_id": "thread-1"}}
|
|
|
|
latest_snapshot = await accessor.aget(thread_config)
|
|
assert latest_snapshot.config["configurable"]["checkpoint_id"] == restored_checkpoint_id
|
|
assert [message.content for message in latest_snapshot.values["messages"]] == ["turn-0", "turn-1"]
|
|
|
|
restored_tuple = await checkpointer.aget_tuple({"configurable": {"thread_id": "thread-1", "checkpoint_ns": "", "checkpoint_id": restored_checkpoint_id}})
|
|
assert restored_tuple.parent_config["configurable"]["checkpoint_id"] == cancelled_checkpoint_id
|
|
raw_messages = restored_tuple.checkpoint.get("channel_values", {}).get("messages")
|
|
assert not isinstance(raw_messages, list)
|
|
|
|
|
|
def test_agent_factory_supports_app_config_detects_supported_signature():
|
|
def factory(*, config, app_config=None):
|
|
return (config, app_config)
|
|
|
|
assert _agent_factory_supports_app_config(factory) is True
|
|
|
|
|
|
def test_build_runtime_context_defaults_to_thread_and_run_id():
|
|
ctx = _build_runtime_context("thread-1", "run-1", None)
|
|
assert ctx == {"thread_id": "thread-1", "run_id": "run-1"}
|
|
|
|
|
|
def test_build_runtime_context_merges_caller_context():
|
|
"""Regression for issue #2677: keys from ``config['context']`` (e.g. ``agent_name``)
|
|
must be merged into the Runtime's context so that ``ToolRuntime.context`` — which
|
|
is what ``setup_agent`` reads — can see them."""
|
|
caller_context = {"agent_name": "my-agent", "is_bootstrap": True, "model_name": "gpt-4"}
|
|
|
|
ctx = _build_runtime_context("thread-1", "run-1", caller_context)
|
|
|
|
assert ctx["thread_id"] == "thread-1"
|
|
assert ctx["run_id"] == "run-1"
|
|
assert ctx["agent_name"] == "my-agent"
|
|
assert ctx["is_bootstrap"] is True
|
|
assert ctx["model_name"] == "gpt-4"
|
|
|
|
|
|
def test_build_runtime_context_caller_cannot_override_thread_id_or_run_id():
|
|
"""A malicious or buggy caller must not be able to overwrite the worker-assigned
|
|
``thread_id`` / ``run_id`` by stuffing them into ``config['context']``."""
|
|
caller_context = {"thread_id": "spoofed", "run_id": "spoofed", "agent_name": "ok"}
|
|
|
|
ctx = _build_runtime_context("real-thread", "real-run", caller_context)
|
|
|
|
assert ctx["thread_id"] == "real-thread"
|
|
assert ctx["run_id"] == "real-run"
|
|
assert ctx["agent_name"] == "ok"
|
|
|
|
|
|
def test_build_runtime_context_ignores_caller_pre_existing_message_ids():
|
|
caller_context = {CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY: {"spoofed"}}
|
|
|
|
ctx = _build_runtime_context("thread-1", "run-1", caller_context)
|
|
|
|
assert CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY not in ctx
|
|
|
|
|
|
def test_build_runtime_context_ignores_non_dict_caller_context():
|
|
ctx = _build_runtime_context("thread-1", "run-1", "not-a-dict")
|
|
assert ctx == {"thread_id": "thread-1", "run_id": "run-1"}
|
|
|
|
|
|
def test_agent_factory_supports_app_config_returns_false_when_signature_lookup_fails(monkeypatch):
|
|
class BrokenCallable:
|
|
def __call__(self, **kwargs):
|
|
return kwargs
|
|
|
|
monkeypatch.setattr("deerflow.runtime.runs.worker.inspect.signature", lambda _obj: (_ for _ in ()).throw(ValueError("boom")))
|
|
|
|
assert _agent_factory_supports_app_config(BrokenCallable()) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _extract_llm_error_fallback_message coverage
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_try_extract_from_message_finds_fallback_on_message_object():
|
|
msg = AIMessage(
|
|
content="fallback",
|
|
additional_kwargs={
|
|
"deerflow_error_fallback": True,
|
|
"error_detail": "Connection error.",
|
|
"error_reason": "transient",
|
|
},
|
|
)
|
|
assert _try_extract_from_message(msg) == "Connection error."
|
|
|
|
|
|
def test_try_extract_from_message_finds_fallback_on_dict():
|
|
msg = {
|
|
"content": "fallback",
|
|
"additional_kwargs": {
|
|
"deerflow_error_fallback": True,
|
|
"error_detail": "Quota exceeded.",
|
|
},
|
|
}
|
|
assert _try_extract_from_message(msg) == "Quota exceeded."
|
|
|
|
|
|
def test_try_extract_from_message_returns_none_for_normal_message():
|
|
msg = AIMessage(content="hello")
|
|
assert _try_extract_from_message(msg) is None
|
|
|
|
|
|
def test_extract_llm_error_fallback_message_large_state_chunk_no_fallback():
|
|
"""Normal-size state dict without fallback markers must not raise and should return None."""
|
|
large_state = {
|
|
"messages": [
|
|
AIMessage(content="Hello!"),
|
|
{"role": "user", "content": "Hi there"},
|
|
],
|
|
"foo": "x" * 10_000,
|
|
"bar": {"nested": {"deep": {"data": list(range(1000))}}},
|
|
"baz": [{"id": i, "payload": "y" * 1000} for i in range(500)],
|
|
}
|
|
assert _extract_llm_error_fallback_message(large_state) is None
|
|
|
|
|
|
def test_extract_llm_error_fallback_message_finds_fallback_in_messages_list():
|
|
state = {
|
|
"messages": [
|
|
AIMessage(content="Hello!"),
|
|
AIMessage(
|
|
content="Unavailable.",
|
|
additional_kwargs={
|
|
"deerflow_error_fallback": True,
|
|
"error_detail": "Connection error.",
|
|
},
|
|
),
|
|
],
|
|
"other_state": "large_value" * 1000,
|
|
}
|
|
assert _extract_llm_error_fallback_message(state) == "Connection error."
|
|
|
|
|
|
def test_extract_llm_error_fallback_message_finds_fallback_in_raw_message():
|
|
msg = AIMessage(
|
|
content="Unavailable.",
|
|
additional_kwargs={
|
|
"deerflow_error_fallback": True,
|
|
"error_reason": "quota",
|
|
},
|
|
)
|
|
assert _extract_llm_error_fallback_message(msg) == "quota"
|
|
|
|
|
|
def test_extract_llm_error_fallback_message_finds_fallback_in_tuple():
|
|
item = (
|
|
"messages",
|
|
AIMessage(
|
|
content="Unavailable.",
|
|
additional_kwargs={
|
|
"deerflow_error_fallback": True,
|
|
"error_detail": "Circuit open.",
|
|
},
|
|
),
|
|
)
|
|
assert _extract_llm_error_fallback_message(item) == "Circuit open."
|
|
|
|
|
|
def test_extract_llm_error_fallback_message_returns_none_for_empty_values():
|
|
assert _extract_llm_error_fallback_message({}) is None
|
|
assert _extract_llm_error_fallback_message([]) is None
|
|
assert _extract_llm_error_fallback_message(None) is None
|
|
assert _extract_llm_error_fallback_message("string") is None
|
|
|
|
|
|
def test_extract_llm_error_fallback_message_finds_fallback_in_updates_mode():
|
|
"""stream_mode='updates' yields dicts keyed by node name (e.g. {'call_model': {...}}).
|
|
Fallback marker is nested inside the node's state update, not at the top level."""
|
|
update_chunk = {
|
|
"call_model": {
|
|
"messages": [
|
|
AIMessage(
|
|
content="Unavailable.",
|
|
additional_kwargs={
|
|
"deerflow_error_fallback": True,
|
|
"error_detail": "Connection error.",
|
|
},
|
|
)
|
|
]
|
|
}
|
|
}
|
|
assert _extract_llm_error_fallback_message(update_chunk) == "Connection error."
|
|
|
|
|
|
def test_extract_llm_error_fallback_message_updates_mode_no_fallback():
|
|
"""Normal updates chunk without any fallback should return None safely."""
|
|
update_chunk = {
|
|
"__interrupt__": [
|
|
{
|
|
"value": "ask_human",
|
|
"resumable": True,
|
|
"ns": ["agent"],
|
|
"when": "during",
|
|
}
|
|
]
|
|
}
|
|
assert _extract_llm_error_fallback_message(update_chunk) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# pre_existing_ids filtering — stale fallback markers from prior runs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_try_extract_skips_message_with_pre_existing_id():
|
|
"""Fallback marker on a message whose id is in pre_existing_ids must be ignored."""
|
|
msg = AIMessage(
|
|
id="stale-1",
|
|
content="Unavailable.",
|
|
additional_kwargs={
|
|
"deerflow_error_fallback": True,
|
|
"error_detail": "Connection error.",
|
|
},
|
|
)
|
|
assert _try_extract_from_message(msg, {"stale-1"}) is None
|
|
# Without the filter, the same message would still surface the marker.
|
|
assert _try_extract_from_message(msg) == "Connection error."
|
|
|
|
|
|
def test_try_extract_still_finds_fresh_message_when_others_are_stale():
|
|
"""A non-stale message with a fallback marker must still match."""
|
|
msg = AIMessage(
|
|
id="fresh-1",
|
|
content="Unavailable.",
|
|
additional_kwargs={
|
|
"deerflow_error_fallback": True,
|
|
"error_detail": "Connection error.",
|
|
},
|
|
)
|
|
assert _try_extract_from_message(msg, {"stale-1", "stale-2"}) == "Connection error."
|
|
|
|
|
|
def test_try_extract_skips_dict_message_with_pre_existing_id():
|
|
msg = {
|
|
"id": "stale-2",
|
|
"content": "Unavailable.",
|
|
"additional_kwargs": {
|
|
"deerflow_error_fallback": True,
|
|
"error_detail": "Quota exceeded.",
|
|
},
|
|
}
|
|
assert _try_extract_from_message(msg, {"stale-2"}) is None
|
|
assert _try_extract_from_message(msg) == "Quota exceeded."
|
|
|
|
|
|
def test_extract_llm_error_fallback_message_skips_stale_history():
|
|
"""A state chunk replaying a stale fallback marker from a prior run must return None."""
|
|
state = {
|
|
"messages": [
|
|
AIMessage(id="stale-1", content="Hi"),
|
|
AIMessage(
|
|
id="stale-fallback",
|
|
content="Unavailable.",
|
|
additional_kwargs={
|
|
"deerflow_error_fallback": True,
|
|
"error_detail": "Connection error.",
|
|
},
|
|
),
|
|
]
|
|
}
|
|
assert _extract_llm_error_fallback_message(state, {"stale-1", "stale-fallback"}) is None
|
|
|
|
|
|
def test_extract_llm_error_fallback_message_returns_fresh_marker_alongside_stale_history():
|
|
"""Stale history is ignored, but a brand-new fallback in the same chunk is reported."""
|
|
state = {
|
|
"messages": [
|
|
AIMessage(id="stale-1", content="Hi"),
|
|
AIMessage(
|
|
id="stale-fallback",
|
|
content="Old failure.",
|
|
additional_kwargs={
|
|
"deerflow_error_fallback": True,
|
|
"error_detail": "Old error.",
|
|
},
|
|
),
|
|
AIMessage(
|
|
id="fresh-fallback",
|
|
content="New failure.",
|
|
additional_kwargs={
|
|
"deerflow_error_fallback": True,
|
|
"error_detail": "Fresh error.",
|
|
},
|
|
),
|
|
]
|
|
}
|
|
assert _extract_llm_error_fallback_message(state, {"stale-1", "stale-fallback"}) == "Fresh error."
|
|
|
|
|
|
def test_extract_llm_error_fallback_message_default_filter_is_empty():
|
|
"""Passing no pre_existing_ids must preserve the original (pre-fix) behavior."""
|
|
state = {
|
|
"messages": [
|
|
AIMessage(
|
|
id="any",
|
|
content="Unavailable.",
|
|
additional_kwargs={
|
|
"deerflow_error_fallback": True,
|
|
"error_detail": "Connection error.",
|
|
},
|
|
)
|
|
]
|
|
}
|
|
assert _extract_llm_error_fallback_message(state) == "Connection error."
|
|
|
|
|
|
def test_collect_pre_existing_message_ids_pulls_ids_from_values():
|
|
values = {
|
|
"messages": [
|
|
AIMessage(id="a", content="x"),
|
|
AIMessage(id="b", content="y"),
|
|
AIMessage(content="no-id-here"), # ignored
|
|
]
|
|
}
|
|
assert _collect_pre_existing_message_ids(values) == {"a", "b"}
|
|
|
|
|
|
def test_collect_pre_existing_message_ids_handles_missing_pieces():
|
|
assert _collect_pre_existing_message_ids(None) == set()
|
|
assert _collect_pre_existing_message_ids({}) == set()
|
|
assert _collect_pre_existing_message_ids({"messages": None}) == set()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_run_agent_ignores_stale_llm_error_fallback_from_prior_run():
|
|
"""A stale fallback marker checkpointed by an earlier run on the same thread
|
|
must NOT cause a successful current run to be reported as ``error``.
|
|
|
|
This guards against the regression where one IndexError-driven failure (now
|
|
classified transient and surfaced as a ``deerflow_error_fallback`` AIMessage)
|
|
persisted in thread history and tripped ``RunStatus.error`` on every
|
|
subsequent run that re-played the messages channel via ``stream_mode="values"``.
|
|
"""
|
|
run_manager = RunManager()
|
|
record = await run_manager.create("thread-1")
|
|
bridge = SimpleNamespace(
|
|
publish=AsyncMock(),
|
|
publish_end=AsyncMock(),
|
|
cleanup=AsyncMock(),
|
|
)
|
|
|
|
stale_fallback = AIMessage(
|
|
id="stale-fallback",
|
|
content="Old failure.",
|
|
additional_kwargs={
|
|
"deerflow_error_fallback": True,
|
|
"error_type": "IndexError",
|
|
"error_reason": "transient",
|
|
"error_detail": "list index out of range",
|
|
},
|
|
)
|
|
|
|
class StaleHistoryCheckpointer:
|
|
async def aget_tuple(self, config):
|
|
checkpoint = empty_checkpoint()
|
|
checkpoint["id"] = "ckpt-stale"
|
|
checkpoint["channel_values"] = {"messages": [stale_fallback]}
|
|
return SimpleNamespace(
|
|
config={"configurable": {"thread_id": "thread-1", "checkpoint_ns": "", "checkpoint_id": "ckpt-stale"}},
|
|
checkpoint=checkpoint,
|
|
metadata={},
|
|
pending_writes=[],
|
|
)
|
|
|
|
class DummyAgent:
|
|
async def aget_state(self, _config):
|
|
return SimpleNamespace(
|
|
values={"messages": [stale_fallback]},
|
|
config={
|
|
"configurable": {
|
|
"thread_id": "thread-1",
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": "ckpt-stale",
|
|
}
|
|
},
|
|
parent_config=None,
|
|
metadata={},
|
|
next=(),
|
|
tasks=(),
|
|
created_at=None,
|
|
)
|
|
|
|
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
|
|
# Replay the prior fallback message (as LangGraph would when using
|
|
# stream_mode="values") and then yield a fresh successful AIMessage.
|
|
yield {
|
|
"messages": [
|
|
stale_fallback,
|
|
AIMessage(id="fresh-ok", content="Hello — the run succeeded."),
|
|
]
|
|
}
|
|
|
|
def factory(*, config):
|
|
return DummyAgent()
|
|
|
|
await run_agent(
|
|
bridge,
|
|
run_manager,
|
|
record,
|
|
ctx=RunContext(checkpointer=StaleHistoryCheckpointer()),
|
|
agent_factory=factory,
|
|
graph_input={},
|
|
config={},
|
|
)
|
|
|
|
fetched = await run_manager.get(record.run_id)
|
|
assert fetched is not None
|
|
assert fetched.status == RunStatus.success, f"Stale fallback marker from prior run should not flip current run to error, got status={fetched.status} error={fetched.error!r}"
|
|
bridge.publish_end.assert_awaited_once_with(record.run_id)
|
|
|
|
|
|
class _FakeCheckpointTuple:
|
|
"""Minimal stand-in for ``CheckpointTuple`` used by ``_ensure_interrupted_title``."""
|
|
|
|
def __init__(self, *, checkpoint: dict, metadata: dict, config: dict | None = None):
|
|
self.checkpoint = checkpoint
|
|
self.metadata = metadata
|
|
self.config = config or {"configurable": {"thread_id": "thread-1", "checkpoint_ns": ""}}
|
|
|
|
|
|
class _TitleCheckpointer:
|
|
"""Captures ``aput`` arguments and exposes ``get_next_version`` like DB savers."""
|
|
|
|
def __init__(self, *, tuple_value: _FakeCheckpointTuple | None, put_result: dict | None = None):
|
|
self.aget_tuple = AsyncMock(return_value=tuple_value)
|
|
self.aput = AsyncMock(return_value=put_result or {})
|
|
|
|
def get_next_version(self, current, _channel):
|
|
if current is None:
|
|
return 1
|
|
if isinstance(current, int):
|
|
return current + 1
|
|
if isinstance(current, str):
|
|
try:
|
|
return str(int(current) + 1)
|
|
except ValueError:
|
|
return f"{current}.1"
|
|
return 1
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_interrupted_title_finalization_blocks_new_same_thread_run(monkeypatch):
|
|
"""A cancelled run must remain active while its title-only checkpoint is finalizing."""
|
|
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
|
|
|
|
monkeypatch.setattr(
|
|
TitleMiddleware,
|
|
"_generate_title_result",
|
|
lambda self, state, allow_partial_exchange=False: {"title": "Old Prompt"},
|
|
)
|
|
|
|
initial_checkpoint = {
|
|
"id": "ckpt-old",
|
|
"ts": "2026-06-29T00:00:00Z",
|
|
"channel_values": {"messages": [{"type": "human", "content": "old prompt"}]},
|
|
"channel_versions": {"messages": 1},
|
|
}
|
|
|
|
class _BlockingTitleCheckpointer:
|
|
def __init__(self) -> None:
|
|
self.latest_checkpoint = copy.deepcopy(initial_checkpoint)
|
|
self.latest_metadata = {"source": "loop", "step": 1}
|
|
self.title_write_started = asyncio.Event()
|
|
self.release_title_write = asyncio.Event()
|
|
|
|
async def aget_tuple(self, config):
|
|
del config
|
|
return _FakeCheckpointTuple(
|
|
checkpoint=copy.deepcopy(self.latest_checkpoint),
|
|
metadata=dict(self.latest_metadata),
|
|
config={
|
|
"configurable": {
|
|
"thread_id": "thread-1",
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": self.latest_checkpoint["id"],
|
|
}
|
|
},
|
|
)
|
|
|
|
async def aput(self, config, checkpoint, metadata, new_versions):
|
|
del config, new_versions
|
|
self.title_write_started.set()
|
|
await self.release_title_write.wait()
|
|
self.latest_checkpoint = copy.deepcopy(checkpoint)
|
|
self.latest_metadata = dict(metadata)
|
|
return {
|
|
"configurable": {
|
|
"thread_id": "thread-1",
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": checkpoint["id"],
|
|
}
|
|
}
|
|
|
|
def get_next_version(self, current, _channel):
|
|
return (current or 0) + 1
|
|
|
|
run_manager = RunManager()
|
|
record = await run_manager.create("thread-1")
|
|
bridge = SimpleNamespace(
|
|
publish=AsyncMock(),
|
|
publish_end=AsyncMock(),
|
|
cleanup=AsyncMock(),
|
|
)
|
|
checkpointer = _BlockingTitleCheckpointer()
|
|
|
|
class _AbortingAgent:
|
|
metadata = {"model_name": "fake-test-model"}
|
|
checkpointer: Any | None = None
|
|
store: Any | None = None
|
|
interrupt_before_nodes = None
|
|
interrupt_after_nodes = None
|
|
|
|
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
|
|
del graph_input, config, stream_mode, subgraphs
|
|
record.abort_event.set()
|
|
if False:
|
|
yield # pragma: no cover
|
|
|
|
def factory(*, config):
|
|
del config
|
|
return _AbortingAgent()
|
|
|
|
task = asyncio.create_task(
|
|
run_agent(
|
|
bridge,
|
|
run_manager,
|
|
record,
|
|
ctx=RunContext(checkpointer=checkpointer),
|
|
agent_factory=factory,
|
|
graph_input={"messages": [{"role": "user", "content": "old prompt"}]},
|
|
config={},
|
|
)
|
|
)
|
|
record.task = task
|
|
|
|
try:
|
|
await asyncio.wait_for(checkpointer.title_write_started.wait(), timeout=1.0)
|
|
with pytest.raises(ConflictError, match="active run"):
|
|
await run_manager.create_or_reject("thread-1", multitask_strategy="reject")
|
|
finally:
|
|
checkpointer.release_title_write.set()
|
|
await task
|
|
|
|
records = await run_manager.list_by_thread("thread-1")
|
|
assert [item.run_id for item in records] == [record.run_id]
|
|
assert checkpointer.latest_checkpoint["channel_values"]["messages"] == [{"type": "human", "content": "old prompt"}]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_finalizing_run_only_blocks_reject_strategy():
|
|
"""A finalizing run must not break interrupt/rollback superseding semantics."""
|
|
|
|
async def _seed_finalizing_run():
|
|
run_manager = RunManager()
|
|
record = await run_manager.create("thread-1")
|
|
release_cleanup = asyncio.Event()
|
|
cleanup_cancelled = asyncio.Event()
|
|
|
|
async def _cleanup_task():
|
|
try:
|
|
await release_cleanup.wait()
|
|
except asyncio.CancelledError:
|
|
cleanup_cancelled.set()
|
|
raise
|
|
|
|
task = asyncio.create_task(_cleanup_task())
|
|
record.task = task
|
|
await run_manager.set_status(record.run_id, RunStatus.interrupted)
|
|
await run_manager.set_finalizing(record.run_id, True)
|
|
return run_manager, record, task, release_cleanup, cleanup_cancelled
|
|
|
|
for strategy in ("interrupt", "rollback"):
|
|
run_manager, record, task, release_cleanup, cleanup_cancelled = await _seed_finalizing_run()
|
|
try:
|
|
replacement = await run_manager.create_or_reject("thread-1", multitask_strategy=strategy)
|
|
await asyncio.sleep(0)
|
|
|
|
assert replacement.run_id != record.run_id
|
|
assert record.status == RunStatus.interrupted
|
|
assert record.finalizing is True
|
|
assert not cleanup_cancelled.is_set()
|
|
assert not task.done()
|
|
finally:
|
|
release_cleanup.set()
|
|
with suppress(asyncio.CancelledError):
|
|
await task
|
|
|
|
run_manager, _record, task, release_cleanup, _cleanup_cancelled = await _seed_finalizing_run()
|
|
try:
|
|
with pytest.raises(ConflictError, match="active run"):
|
|
await run_manager.create_or_reject("thread-1", multitask_strategy="reject")
|
|
finally:
|
|
release_cleanup.set()
|
|
with suppress(asyncio.CancelledError):
|
|
await task
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_admitted_pending_replacement_does_not_steal_interrupted_title_recovery(monkeypatch):
|
|
"""The old run must still write the fallback title before releasing a serialized replacement."""
|
|
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
|
|
|
|
monkeypatch.setattr(
|
|
TitleMiddleware,
|
|
"_generate_title_result",
|
|
lambda self, state, allow_partial_exchange=False: {"title": "Old Prompt"},
|
|
)
|
|
|
|
initial_checkpoint = {
|
|
"id": "ckpt-old",
|
|
"ts": "2026-06-29T00:00:00Z",
|
|
"channel_values": {"messages": [{"type": "human", "content": "Old prompt"}]},
|
|
"channel_versions": {"messages": 1},
|
|
}
|
|
|
|
run_manager = RunManager()
|
|
old_record = await run_manager.create("thread-1")
|
|
bridge = SimpleNamespace(
|
|
publish=AsyncMock(),
|
|
publish_end=AsyncMock(),
|
|
cleanup=AsyncMock(),
|
|
)
|
|
checkpointer = _TitleCheckpointer(
|
|
tuple_value=_FakeCheckpointTuple(
|
|
checkpoint=initial_checkpoint,
|
|
metadata={"source": "loop", "step": 1},
|
|
config={
|
|
"configurable": {
|
|
"thread_id": "thread-1",
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": "ckpt-old",
|
|
}
|
|
},
|
|
),
|
|
)
|
|
|
|
old_title_gate_entered = asyncio.Event()
|
|
release_old_title_gate = asyncio.Event()
|
|
original_wait_for_prior_finalizing = run_manager.wait_for_prior_finalizing
|
|
|
|
async def _wait_for_prior_finalizing(thread_id, run_id, **kwargs):
|
|
if run_id == old_record.run_id and old_record.status == RunStatus.interrupted and old_record.finalizing:
|
|
old_title_gate_entered.set()
|
|
await release_old_title_gate.wait()
|
|
return await original_wait_for_prior_finalizing(thread_id, run_id, **kwargs)
|
|
|
|
run_manager.wait_for_prior_finalizing = _wait_for_prior_finalizing # type: ignore[method-assign]
|
|
|
|
class _AbortingAgent:
|
|
metadata = {"model_name": "fake-test-model"}
|
|
checkpointer: Any | None = None
|
|
store: Any | None = None
|
|
interrupt_before_nodes = None
|
|
interrupt_after_nodes = None
|
|
|
|
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
|
|
del graph_input, config, stream_mode, subgraphs
|
|
old_record.abort_event.set()
|
|
if False:
|
|
yield # pragma: no cover
|
|
|
|
def factory(*, config):
|
|
del config
|
|
return _AbortingAgent()
|
|
|
|
old_task = asyncio.create_task(
|
|
run_agent(
|
|
bridge,
|
|
run_manager,
|
|
old_record,
|
|
ctx=RunContext(checkpointer=checkpointer),
|
|
agent_factory=factory,
|
|
graph_input={"messages": [{"role": "user", "content": "Old prompt"}]},
|
|
config={},
|
|
)
|
|
)
|
|
old_record.task = old_task
|
|
|
|
try:
|
|
await asyncio.wait_for(old_title_gate_entered.wait(), timeout=1.0)
|
|
replacement_record = await run_manager.create_or_reject("thread-1", multitask_strategy="interrupt")
|
|
assert replacement_record.status == RunStatus.pending
|
|
|
|
release_old_title_gate.set()
|
|
await old_task
|
|
finally:
|
|
release_old_title_gate.set()
|
|
if not old_task.done():
|
|
old_task.cancel()
|
|
with suppress(asyncio.CancelledError):
|
|
await old_task
|
|
|
|
checkpointer.aput.assert_awaited_once()
|
|
_, written_checkpoint, _, _ = checkpointer.aput.await_args.args
|
|
assert written_checkpoint["channel_values"]["title"] == "Old Prompt"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_interrupted_title_does_not_overwrite_checkpoint_from_admitted_replacement(monkeypatch):
|
|
"""A replacement run admitted by multitask interrupt must not lose its newer checkpoint."""
|
|
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
|
|
|
|
monkeypatch.setattr(
|
|
TitleMiddleware,
|
|
"_generate_title_result",
|
|
lambda self, state, allow_partial_exchange=False: {"title": "Old prompt"},
|
|
)
|
|
|
|
old_checkpoint = {
|
|
"id": "ckpt-old",
|
|
"ts": "2026-06-29T00:00:00Z",
|
|
"channel_values": {"messages": [{"type": "human", "content": "Old prompt"}]},
|
|
"channel_versions": {"messages": 1},
|
|
}
|
|
replacement_messages = [
|
|
{"type": "human", "content": "Old prompt"},
|
|
{"type": "human", "content": "Replacement prompt"},
|
|
]
|
|
replacement_checkpoint = {
|
|
"id": "ckpt-replacement",
|
|
"ts": "2026-06-29T00:00:01Z",
|
|
"channel_values": {"messages": replacement_messages},
|
|
"channel_versions": {"messages": 2},
|
|
}
|
|
|
|
class _ReplacementRaceCheckpointer:
|
|
def __init__(self) -> None:
|
|
self.latest_checkpoint = copy.deepcopy(old_checkpoint)
|
|
self.latest_metadata = {"source": "loop", "step": 1}
|
|
self.title_write_started = asyncio.Event()
|
|
self.replacement_checkpoint_written = asyncio.Event()
|
|
|
|
async def aget_tuple(self, config):
|
|
del config
|
|
return _FakeCheckpointTuple(
|
|
checkpoint=copy.deepcopy(self.latest_checkpoint),
|
|
metadata=dict(self.latest_metadata),
|
|
config={
|
|
"configurable": {
|
|
"thread_id": "thread-1",
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": self.latest_checkpoint["id"],
|
|
}
|
|
},
|
|
)
|
|
|
|
async def aput(self, config, checkpoint, metadata, new_versions):
|
|
del config, new_versions
|
|
self.title_write_started.set()
|
|
await self.replacement_checkpoint_written.wait()
|
|
self.latest_checkpoint = copy.deepcopy(checkpoint)
|
|
self.latest_metadata = dict(metadata)
|
|
return {
|
|
"configurable": {
|
|
"thread_id": "thread-1",
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": checkpoint["id"],
|
|
}
|
|
}
|
|
|
|
def get_next_version(self, current, _channel):
|
|
return (current or 0) + 1
|
|
|
|
run_manager = RunManager()
|
|
old_record = await run_manager.create("thread-1")
|
|
bridge = SimpleNamespace(
|
|
publish=AsyncMock(),
|
|
publish_end=AsyncMock(),
|
|
cleanup=AsyncMock(),
|
|
)
|
|
checkpointer = _ReplacementRaceCheckpointer()
|
|
old_agent_started = asyncio.Event()
|
|
|
|
class _BlockingAgent:
|
|
metadata = {"model_name": "fake-test-model"}
|
|
checkpointer: Any | None = None
|
|
store: Any | None = None
|
|
interrupt_before_nodes = None
|
|
interrupt_after_nodes = None
|
|
|
|
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
|
|
del graph_input, config, stream_mode, subgraphs
|
|
old_agent_started.set()
|
|
while True:
|
|
await asyncio.sleep(0.05)
|
|
if False:
|
|
yield # pragma: no cover
|
|
|
|
def factory(*, config):
|
|
del config
|
|
return _BlockingAgent()
|
|
|
|
old_task = asyncio.create_task(
|
|
run_agent(
|
|
bridge,
|
|
run_manager,
|
|
old_record,
|
|
ctx=RunContext(checkpointer=checkpointer),
|
|
agent_factory=factory,
|
|
graph_input={"messages": [{"role": "user", "content": "Old prompt"}]},
|
|
config={},
|
|
)
|
|
)
|
|
old_record.task = old_task
|
|
|
|
try:
|
|
await asyncio.wait_for(old_agent_started.wait(), timeout=1.0)
|
|
replacement_record = await run_manager.create_or_reject("thread-1", multitask_strategy="interrupt")
|
|
assert replacement_record.run_id != old_record.run_id
|
|
await run_manager.set_status(replacement_record.run_id, RunStatus.running)
|
|
|
|
with suppress(asyncio.TimeoutError):
|
|
await asyncio.wait_for(checkpointer.title_write_started.wait(), timeout=0.25)
|
|
|
|
checkpointer.latest_checkpoint = copy.deepcopy(replacement_checkpoint)
|
|
checkpointer.latest_metadata = {"source": "loop", "step": 2}
|
|
checkpointer.replacement_checkpoint_written.set()
|
|
await old_task
|
|
finally:
|
|
checkpointer.replacement_checkpoint_written.set()
|
|
if not old_task.done():
|
|
old_task.cancel()
|
|
with suppress(asyncio.CancelledError):
|
|
await old_task
|
|
|
|
assert checkpointer.latest_checkpoint["channel_values"]["messages"] == replacement_messages
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_replacement_run_waits_for_prior_finalizing_run():
|
|
"""Replacement workers must not enter the graph while an older run is finalizing."""
|
|
run_manager = RunManager()
|
|
old_record = await run_manager.create("thread-1")
|
|
replacement_record = await run_manager.create("thread-1")
|
|
await run_manager.set_finalizing(old_record.run_id, True)
|
|
|
|
bridge = SimpleNamespace(
|
|
publish=AsyncMock(),
|
|
publish_end=AsyncMock(),
|
|
cleanup=AsyncMock(),
|
|
)
|
|
replacement_started = asyncio.Event()
|
|
|
|
class _ReplacementAgent:
|
|
metadata = {"model_name": "fake-test-model"}
|
|
checkpointer: Any | None = None
|
|
store: Any | None = None
|
|
interrupt_before_nodes = None
|
|
interrupt_after_nodes = None
|
|
|
|
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
|
|
del graph_input, config, stream_mode, subgraphs
|
|
replacement_started.set()
|
|
if False:
|
|
yield # pragma: no cover
|
|
|
|
def factory(*, config):
|
|
del config
|
|
return _ReplacementAgent()
|
|
|
|
task = asyncio.create_task(
|
|
run_agent(
|
|
bridge,
|
|
run_manager,
|
|
replacement_record,
|
|
ctx=RunContext(checkpointer=None),
|
|
agent_factory=factory,
|
|
graph_input={"messages": [{"role": "user", "content": "Replacement prompt"}]},
|
|
config={},
|
|
)
|
|
)
|
|
replacement_record.task = task
|
|
|
|
try:
|
|
await asyncio.sleep(0.1)
|
|
assert not replacement_started.is_set()
|
|
|
|
await run_manager.set_finalizing(old_record.run_id, False)
|
|
await asyncio.wait_for(replacement_started.wait(), timeout=1.0)
|
|
await task
|
|
finally:
|
|
await run_manager.set_finalizing(old_record.run_id, False)
|
|
if not task.done():
|
|
task.cancel()
|
|
with suppress(asyncio.CancelledError):
|
|
await task
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_ensure_interrupted_title_reloads_latest_checkpoint_before_write():
|
|
"""If the checkpoint advances before the title write, preserve the newer messages."""
|
|
from deerflow.config.title_config import TitleConfig
|
|
|
|
old_checkpoint = {
|
|
"id": "ckpt-old",
|
|
"ts": "2026-06-29T00:00:00Z",
|
|
"channel_values": {"messages": [{"type": "human", "content": "Old prompt"}]},
|
|
"channel_versions": {"messages": 1},
|
|
}
|
|
new_messages = [{"type": "human", "content": "New prompt"}]
|
|
new_checkpoint = {
|
|
"id": "ckpt-new",
|
|
"ts": "2026-06-29T00:00:01Z",
|
|
"channel_values": {"messages": new_messages},
|
|
"channel_versions": {"messages": 2},
|
|
}
|
|
|
|
class _AdvancingTitleCheckpointer:
|
|
def __init__(self) -> None:
|
|
self.read_count = 0
|
|
self.aput = AsyncMock(return_value={})
|
|
|
|
async def aget_tuple(self, config):
|
|
del config
|
|
self.read_count += 1
|
|
checkpoint = old_checkpoint if self.read_count == 1 else new_checkpoint
|
|
return _FakeCheckpointTuple(
|
|
checkpoint=copy.deepcopy(checkpoint),
|
|
metadata={"source": "loop", "step": self.read_count},
|
|
config={
|
|
"configurable": {
|
|
"thread_id": "thread-1",
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": checkpoint["id"],
|
|
}
|
|
},
|
|
)
|
|
|
|
def get_next_version(self, current, _channel):
|
|
return (current or 0) + 1
|
|
|
|
checkpointer = _AdvancingTitleCheckpointer()
|
|
app_config = SimpleNamespace(title=TitleConfig(enabled=True, max_chars=40, max_words=20))
|
|
|
|
title = await _ensure_interrupted_title(checkpointer=checkpointer, thread_id="thread-1", app_config=app_config)
|
|
|
|
assert title == "New prompt"
|
|
_, written_checkpoint, _, _ = checkpointer.aput.await_args.args
|
|
assert written_checkpoint["channel_values"]["messages"] == new_messages
|
|
assert written_checkpoint["channel_values"]["title"] == "New prompt"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_ensure_interrupted_title_bumps_channel_version_and_declares_it_in_new_versions(monkeypatch):
|
|
"""Regression for #3859 review: DB-backed savers (Sqlite/Postgres) strip inline
|
|
``channel_values`` from ``put`` and only persist blobs for channels listed in
|
|
``new_versions``. The helper must therefore bump ``channel_versions["title"]``
|
|
and pass ``{"title": next_version}`` so the fallback title actually survives
|
|
a fresh ``aget_tuple`` after the worker's finally hook.
|
|
"""
|
|
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
|
|
|
|
monkeypatch.setattr(
|
|
TitleMiddleware,
|
|
"_generate_title_result",
|
|
lambda self, state, allow_partial_exchange=False: {"title": "Generated Title"},
|
|
)
|
|
|
|
initial_checkpoint = {
|
|
"id": "ckpt-1",
|
|
"ts": "2026-06-29T00:00:00Z",
|
|
"channel_values": {"messages": [{"type": "human", "content": "hi"}]},
|
|
"channel_versions": {"messages": 5},
|
|
}
|
|
checkpointer = _TitleCheckpointer(
|
|
tuple_value=_FakeCheckpointTuple(
|
|
checkpoint=initial_checkpoint,
|
|
metadata={"source": "loop", "step": 7},
|
|
),
|
|
)
|
|
|
|
title = await _ensure_interrupted_title(checkpointer=checkpointer, thread_id="thread-1", app_config=None)
|
|
|
|
assert title == "Generated Title"
|
|
checkpointer.aput.assert_awaited_once()
|
|
write_config, written_checkpoint, written_metadata, new_versions = checkpointer.aput.await_args.args
|
|
|
|
# The title channel must be declared in new_versions — without this, DB
|
|
# savers drop the inline channel_values["title"] from the persisted blob.
|
|
assert new_versions == {"title": 1}
|
|
# Channel versions on the checkpoint itself must also reflect the bump,
|
|
# so a subsequent aget_tuple reconstructs channel_values with the title.
|
|
assert written_checkpoint["channel_versions"]["title"] == 1
|
|
# Pre-existing channel versions must be preserved.
|
|
assert written_checkpoint["channel_versions"]["messages"] == 5
|
|
# The fallback title rides into channel_values for the (legacy / single-table)
|
|
# savers that inline the snapshot.
|
|
assert written_checkpoint["channel_values"]["title"] == "Generated Title"
|
|
assert written_metadata["source"] == "update"
|
|
assert written_metadata["step"] == 8
|
|
assert written_metadata["writes"] == {"runtime_interrupt_title": {"title": "Generated Title"}}
|
|
# The write is parented to the checkpoint it was derived from - without
|
|
# the parent pointer the saver stores a root checkpoint, severing
|
|
# Delta-channel replay ancestry and full-mode history walks.
|
|
assert write_config == {"configurable": {"thread_id": "thread-1", "checkpoint_ns": "", "checkpoint_id": "ckpt-1"}}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_ensure_interrupted_title_writes_graph_input_fallback_without_checkpoint(monkeypatch):
|
|
"""When no checkpoint exists, graph_input should still seed the fallback title write."""
|
|
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
|
|
|
|
captured_state: dict[str, Any] = {}
|
|
|
|
def _generate(self, state, allow_partial_exchange=False):
|
|
del self
|
|
captured_state.update(state)
|
|
assert allow_partial_exchange is True
|
|
return {"title": "Graph Input Title"}
|
|
|
|
monkeypatch.setattr(TitleMiddleware, "_generate_title_result", _generate)
|
|
|
|
checkpointer = _TitleCheckpointer(tuple_value=None)
|
|
|
|
title = await _ensure_interrupted_title(
|
|
checkpointer=checkpointer,
|
|
thread_id="thread-1",
|
|
app_config=None,
|
|
graph_input={"messages": [{"role": "user", "content": "Please name this thread"}]},
|
|
)
|
|
|
|
assert title == "Graph Input Title"
|
|
assert captured_state["messages"] == [{"role": "user", "content": "Please name this thread"}]
|
|
checkpointer.aput.assert_awaited_once()
|
|
_, written_checkpoint, written_metadata, new_versions = checkpointer.aput.await_args.args
|
|
assert written_checkpoint["channel_values"]["title"] == "Graph Input Title"
|
|
assert written_metadata["writes"] == {"runtime_interrupt_title": {"title": "Graph Input Title"}}
|
|
assert set(new_versions.keys()) == {"title"}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_ensure_interrupted_title_bumps_existing_string_version(monkeypatch):
|
|
"""When the checkpointer lacks ``get_next_version`` and the prior title
|
|
version is a string (some savers use UUID-shaped versions), the helper must
|
|
still produce a strictly different value rather than overwriting in place.
|
|
"""
|
|
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
|
|
|
|
monkeypatch.setattr(
|
|
TitleMiddleware,
|
|
"_generate_title_result",
|
|
lambda self, state, allow_partial_exchange=False: {"title": "T"},
|
|
)
|
|
|
|
initial_checkpoint = {
|
|
"id": "ckpt-1",
|
|
"ts": "2026-06-29T00:00:00Z",
|
|
"channel_values": {"messages": [{"type": "human", "content": "hi"}]},
|
|
"channel_versions": {"title": "v3"},
|
|
}
|
|
|
|
class _NoGetNextVersion:
|
|
def __init__(self):
|
|
self.aget_tuple = AsyncMock(
|
|
return_value=_FakeCheckpointTuple(
|
|
checkpoint=initial_checkpoint,
|
|
metadata={},
|
|
),
|
|
)
|
|
self.aput = AsyncMock(return_value={})
|
|
|
|
checkpointer = _NoGetNextVersion()
|
|
await _ensure_interrupted_title(checkpointer=checkpointer, thread_id="thread-1", app_config=None)
|
|
|
|
_, written_checkpoint, _, new_versions = checkpointer.aput.await_args.args
|
|
bumped = written_checkpoint["channel_versions"]["title"]
|
|
assert bumped != "v3", "title version must change so DB savers persist the update"
|
|
assert new_versions == {"title": bumped}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_ensure_interrupted_title_skips_when_title_already_set():
|
|
"""If the checkpoint already carries a title, no new checkpoint is written."""
|
|
checkpointer = _TitleCheckpointer(
|
|
tuple_value=_FakeCheckpointTuple(
|
|
checkpoint={
|
|
"id": "ckpt-1",
|
|
"channel_values": {"messages": [], "title": "Already there"},
|
|
"channel_versions": {"title": 1},
|
|
},
|
|
metadata={},
|
|
),
|
|
)
|
|
|
|
title = await _ensure_interrupted_title(checkpointer=checkpointer, thread_id="thread-1", app_config=None)
|
|
|
|
assert title == "Already there"
|
|
checkpointer.aput.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_ensure_interrupted_title_round_trip_with_real_sqlite_checkpointer(tmp_path):
|
|
"""Full round-trip against a real ``AsyncSqliteSaver`` on a disk-backed DB.
|
|
|
|
Mirrors what Gateway constructs in production via ``make_checkpointer`` when
|
|
``database.backend == "sqlite"``, then closes and re-opens the saver to
|
|
simulate a fresh connection. The fallback title must survive that boundary —
|
|
this is the scenario the #3874 review flagged as broken before the
|
|
``new_versions={"title": ...}`` fix.
|
|
"""
|
|
from langchain_core.messages import HumanMessage
|
|
from langgraph.checkpoint.base import empty_checkpoint
|
|
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
|
|
|
from deerflow.config.title_config import TitleConfig
|
|
|
|
db_path = str(tmp_path / "ckpt.db")
|
|
thread_cfg = {"configurable": {"thread_id": "thread-1", "checkpoint_ns": ""}}
|
|
|
|
# 1. Seed a first-turn checkpoint that has a human message and NO title —
|
|
# the same shape the agent leaves behind when interrupted mid-stream.
|
|
async with AsyncSqliteSaver.from_conn_string(db_path) as writer:
|
|
await writer.setup()
|
|
ck = empty_checkpoint()
|
|
ck["channel_values"] = {
|
|
"messages": [HumanMessage(content="Why is the sky blue?").model_dump()],
|
|
}
|
|
ck["channel_versions"] = {"messages": 1}
|
|
await writer.aput(thread_cfg, ck, {"source": "loop", "step": 1, "writes": {}}, {"messages": 1})
|
|
|
|
# 2. Run the worker helper through a *fresh* saver instance — this is what
|
|
# the lifespan-owned checkpointer pool does for each request.
|
|
title_config = TitleConfig(enabled=True, max_chars=40, max_words=20)
|
|
app_config = SimpleNamespace(title=title_config)
|
|
async with AsyncSqliteSaver.from_conn_string(db_path) as worker_saver:
|
|
title = await _ensure_interrupted_title(
|
|
checkpointer=worker_saver,
|
|
thread_id="thread-1",
|
|
app_config=app_config,
|
|
)
|
|
assert title, "fallback title must be generated from the seeded user message"
|
|
|
|
# 3. Open ANOTHER fresh saver and confirm the title survives — this is the
|
|
# invariant the #3874 review was guarding: ``new_versions={}`` would
|
|
# cause DB savers to drop the title blob, so a fresh aget_tuple would
|
|
# read back without it.
|
|
async with AsyncSqliteSaver.from_conn_string(db_path) as reader:
|
|
tup = await reader.aget_tuple(thread_cfg)
|
|
assert tup is not None
|
|
persisted = tup.checkpoint.get("channel_values", {}).get("title")
|
|
assert persisted == title
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _bump_channel_version — invariant: the returned version MUST differ from
|
|
# the prior value, no matter the checkpointer's versioning scheme.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _CheckpointerWithIntVersion:
|
|
"""A checkpointer whose ``get_next_version`` increments integers (default LangGraph behavior)."""
|
|
|
|
@staticmethod
|
|
def get_next_version(current, _channel):
|
|
return (current or 0) + 1
|
|
|
|
|
|
class _CheckpointerWithBrokenGetNextVersion:
|
|
"""A checkpointer whose ``get_next_version`` raises — must fall back, not propagate."""
|
|
|
|
@staticmethod
|
|
def get_next_version(current, _channel):
|
|
raise RuntimeError("simulated saver bug")
|
|
|
|
|
|
def test_bump_channel_version_uses_checkpointer_get_next_version_when_available():
|
|
"""Happy path — saver's ``get_next_version`` result is preferred over our fallback."""
|
|
assert _bump_channel_version(_CheckpointerWithIntVersion(), 5) == 6
|
|
|
|
|
|
def test_bump_channel_version_falls_back_on_broken_get_next_version():
|
|
"""A raising ``get_next_version`` must not propagate; the defensive path bumps from prior.
|
|
|
|
Without this, a saver bug would leave ``new_versions={"title": v}`` no-op
|
|
on DB savers — the very class of bug the #3874 review flagged.
|
|
"""
|
|
bumped = _bump_channel_version(_CheckpointerWithBrokenGetNextVersion(), 7)
|
|
assert bumped == 8
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _ensure_interrupted_title — additional defensive boundaries
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_ensure_interrupted_title_handles_none_messages_channel(monkeypatch):
|
|
"""A partially-initialized checkpoint with ``messages=None`` must not crash."""
|
|
from deerflow.config.title_config import TitleConfig
|
|
|
|
initial_checkpoint = {
|
|
"id": "ckpt-1",
|
|
"channel_values": {"messages": None},
|
|
"channel_versions": {},
|
|
}
|
|
checkpointer = _TitleCheckpointer(
|
|
tuple_value=_FakeCheckpointTuple(checkpoint=initial_checkpoint, metadata={}),
|
|
)
|
|
app_config = SimpleNamespace(title=TitleConfig(enabled=True, max_chars=40, max_words=20))
|
|
|
|
assert await _ensure_interrupted_title(checkpointer=checkpointer, thread_id="thread-1", app_config=app_config) is None
|
|
checkpointer.aput.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_ensure_interrupted_title_propagates_aput_error_to_caller(monkeypatch):
|
|
"""Exceptions from ``aput`` propagate — the caller (worker.run_agent finally block) is responsible for swallowing them.
|
|
|
|
This test pins the contract: the helper itself does NOT silently eat saver errors,
|
|
so a structural saver regression remains visible in the logs at the call site.
|
|
"""
|
|
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
|
|
|
|
monkeypatch.setattr(
|
|
TitleMiddleware,
|
|
"_generate_title_result",
|
|
lambda self, state, allow_partial_exchange=False: {"title": "Generated"},
|
|
)
|
|
|
|
initial_checkpoint = {
|
|
"id": "ckpt-1",
|
|
"channel_values": {"messages": [{"type": "human", "content": "hi"}]},
|
|
"channel_versions": {"messages": 1},
|
|
}
|
|
checkpointer = _TitleCheckpointer(
|
|
tuple_value=_FakeCheckpointTuple(checkpoint=initial_checkpoint, metadata={}),
|
|
)
|
|
checkpointer.aput.side_effect = RuntimeError("simulated DB write failure")
|
|
|
|
with pytest.raises(RuntimeError, match="simulated DB write failure"):
|
|
await _ensure_interrupted_title(checkpointer=checkpointer, thread_id="thread-1", app_config=None)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_ensure_interrupted_title_idempotent_across_repeated_calls(monkeypatch):
|
|
"""Second invocation against the now-titled checkpoint must not re-write.
|
|
|
|
Regression anchor for the case where a brittle helper might re-trigger
|
|
on subsequent finally-hook runs (e.g. retries) and rewrite the title.
|
|
"""
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
|
|
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
|
|
|
|
monkeypatch.setattr(
|
|
TitleMiddleware,
|
|
"_generate_title_result",
|
|
lambda self, state, allow_partial_exchange=False: {"title": "First Title"},
|
|
)
|
|
|
|
checkpointer = InMemorySaver()
|
|
cfg = {"configurable": {"thread_id": "thread-1", "checkpoint_ns": ""}}
|
|
ck = empty_checkpoint()
|
|
ck["channel_values"] = {"messages": [{"type": "human", "content": "hi"}]}
|
|
ck["channel_versions"] = {"messages": 1}
|
|
await checkpointer.aput(cfg, ck, {"source": "loop", "step": 1, "writes": {}}, {"messages": 1})
|
|
|
|
first = await _ensure_interrupted_title(checkpointer=checkpointer, thread_id="thread-1", app_config=None)
|
|
assert first == "First Title"
|
|
|
|
# Second call: title is now present, so the helper short-circuits without
|
|
# rewriting — even if the middleware were to suggest a different title.
|
|
monkeypatch.setattr(
|
|
TitleMiddleware,
|
|
"_generate_title_result",
|
|
lambda self, state, allow_partial_exchange=False: {"title": "Different Title"},
|
|
)
|
|
second = await _ensure_interrupted_title(checkpointer=checkpointer, thread_id="thread-1", app_config=None)
|
|
assert second == "First Title"
|
|
|
|
tup = await checkpointer.aget_tuple(cfg)
|
|
assert tup.checkpoint["channel_values"]["title"] == "First Title"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_ensure_interrupted_title_preserves_non_title_channel_versions(monkeypatch):
|
|
"""Bumping ``channel_versions["title"]`` must not modify other channels' versions.
|
|
|
|
Regression anchor: an earlier draft built ``new_versions`` from
|
|
``dict(channel_versions)`` and would have erroneously declared every
|
|
channel as "needs new blob" on DB savers.
|
|
"""
|
|
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
|
|
|
|
monkeypatch.setattr(
|
|
TitleMiddleware,
|
|
"_generate_title_result",
|
|
lambda self, state, allow_partial_exchange=False: {"title": "Generated"},
|
|
)
|
|
|
|
initial_checkpoint = {
|
|
"id": "ckpt-1",
|
|
"channel_values": {
|
|
"messages": [{"type": "human", "content": "hi"}],
|
|
"artifacts": [],
|
|
"todos": None,
|
|
},
|
|
"channel_versions": {"messages": 5, "artifacts": 3, "todos": 1},
|
|
}
|
|
checkpointer = _TitleCheckpointer(
|
|
tuple_value=_FakeCheckpointTuple(checkpoint=initial_checkpoint, metadata={}),
|
|
)
|
|
|
|
await _ensure_interrupted_title(checkpointer=checkpointer, thread_id="thread-1", app_config=None)
|
|
|
|
_, written_checkpoint, _, new_versions = checkpointer.aput.await_args.args
|
|
# Only the title channel is declared in new_versions.
|
|
assert set(new_versions.keys()) == {"title"}
|
|
# Other channel versions are preserved verbatim on the written checkpoint.
|
|
assert written_checkpoint["channel_versions"]["messages"] == 5
|
|
assert written_checkpoint["channel_versions"]["artifacts"] == 3
|
|
assert written_checkpoint["channel_versions"]["todos"] == 1
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_worker_finally_block_swallows_helper_exceptions(monkeypatch):
|
|
"""The worker's interrupted-title hook must remain non-fatal — any exception
|
|
from the helper (DB saver bug, middleware bug, etc.) must not propagate past
|
|
the run boundary or prevent the subsequent threads_meta sync block from
|
|
running. This pins the integration of helper + finally try/except, not just
|
|
the helper itself.
|
|
"""
|
|
import deerflow.runtime.runs.worker as worker_module
|
|
|
|
helper_called = asyncio.Event()
|
|
|
|
async def _boom(*_args, **_kwargs):
|
|
helper_called.set()
|
|
raise RuntimeError("forced helper failure")
|
|
|
|
monkeypatch.setattr(worker_module, "_ensure_interrupted_title", _boom)
|
|
|
|
run_manager = RunManager()
|
|
record = await run_manager.create("thread-1")
|
|
|
|
bridge = SimpleNamespace(
|
|
publish=AsyncMock(),
|
|
publish_end=AsyncMock(),
|
|
cleanup=AsyncMock(),
|
|
)
|
|
|
|
class _MinimalCheckpointer:
|
|
async def aget_tuple(self, config):
|
|
return None
|
|
|
|
async def aput(self, *args, **kwargs):
|
|
return {}
|
|
|
|
captured_status: dict[str, Any] = {}
|
|
|
|
class _ThreadStore:
|
|
async def update_display_name(self, thread_id, title):
|
|
captured_status["display_name"] = (thread_id, title)
|
|
|
|
async def update_status(self, thread_id, status):
|
|
captured_status["status"] = (thread_id, status)
|
|
|
|
class _AbortingAgent:
|
|
def __init__(self) -> None:
|
|
self.metadata = {"model_name": "fake-test-model"}
|
|
self.checkpointer: Any | None = None
|
|
self.store: Any | None = None
|
|
self.interrupt_before_nodes = None
|
|
self.interrupt_after_nodes = None
|
|
|
|
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
|
|
# Abort immediately so the run lands in the interrupted branch.
|
|
record.abort_event.set()
|
|
if False:
|
|
yield # pragma: no cover — make this an async generator
|
|
return
|
|
|
|
def factory(*, config):
|
|
del config
|
|
return _AbortingAgent()
|
|
|
|
await run_agent(
|
|
bridge,
|
|
run_manager,
|
|
record,
|
|
ctx=RunContext(checkpointer=_MinimalCheckpointer(), thread_store=_ThreadStore()),
|
|
agent_factory=factory,
|
|
graph_input={"messages": []},
|
|
config={},
|
|
)
|
|
|
|
# The helper raised, but the run still reaches the threads_meta status sync
|
|
# and ``publish_end`` — i.e. the SSE stream is closed cleanly and the row
|
|
# reflects the run outcome.
|
|
assert helper_called.is_set()
|
|
assert captured_status.get("status") == ("thread-1", "interrupted")
|
|
bridge.publish_end.assert_awaited_once_with(record.run_id)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_worker_skips_execution_and_finalization_after_ownership_loss():
|
|
"""A fenced worker closes its stream without starting or finalizing work."""
|
|
run_manager = RunManager()
|
|
record = await run_manager.create("thread-lease-lost")
|
|
record.ownership_lost = True
|
|
record.abort_event.set()
|
|
record.status = RunStatus.error
|
|
|
|
bridge = SimpleNamespace(
|
|
publish=AsyncMock(),
|
|
publish_end=AsyncMock(),
|
|
cleanup=AsyncMock(),
|
|
)
|
|
thread_store = SimpleNamespace(
|
|
update_display_name=AsyncMock(),
|
|
update_status=AsyncMock(),
|
|
)
|
|
on_run_completed = AsyncMock()
|
|
agent_factory = MagicMock(side_effect=AssertionError("fenced worker started the agent"))
|
|
|
|
await run_agent(
|
|
bridge,
|
|
run_manager,
|
|
record,
|
|
ctx=RunContext(
|
|
checkpointer=None,
|
|
thread_store=thread_store,
|
|
on_run_completed=on_run_completed,
|
|
),
|
|
agent_factory=agent_factory,
|
|
graph_input={"messages": []},
|
|
config={},
|
|
)
|
|
|
|
agent_factory.assert_not_called()
|
|
thread_store.update_display_name.assert_not_awaited()
|
|
thread_store.update_status.assert_not_awaited()
|
|
on_run_completed.assert_not_awaited()
|
|
bridge.publish_end.assert_awaited_once_with(record.run_id)
|