deer-flow/backend/tests/test_null_tool_result_pairing.py
huyengiang101086-spec 8331afa759
fix(agents): pair null call IDs with empty result IDs (#7837)
* fix(agents): pair null call IDs with empty result IDs

* fix(agents): treat producer sentinel IDs as absent when pairing

Middleware producers spell a missing provider ID three ways: "" is canonical,
str(tool_call.get("id", "")) yields "None" once the key is present with a
null value, and the artifact/deferred/knowledge middlewares fall back to
"missing_tool_call_id". Only "" reached the pairing pass, so a result carrying
either sentinel was dropped as an orphan and the middleware's guidance was
replaced by the interrupted placeholder — the exact outcome this PR set out to
avoid, just through a different spelling.

Route every absent spelling through one canonicalisation used by the pairing
gate, the candidate comparison and the turn-completeness count.

---------

Co-authored-by: huyengiang101086-spec <270684046+huyengiang101086-spec@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-05 14:07:15 +08:00

95 lines
5.0 KiB
Python

"""Missing call IDs must not swallow middleware-generated tool guidance."""
import pytest
from langchain_core.messages import AIMessage, ToolMessage
from langchain_openai.chat_models.base import _convert_message_to_dict
from deerflow.agents.middlewares.dangling_tool_call_middleware import DanglingToolCallMiddleware
@pytest.mark.parametrize("call_id,result_id", [(None, ""), ("", None), (None, None), ("", "")])
def test_missing_ids_preserve_result_and_provider_pairing(call_id, result_id):
call = AIMessage(content="", tool_calls=[{"name": "write_file", "id": call_id, "args": {"path": "a.md"}}])
# Normal middleware replies use ""; model_construct also covers old None-ID history.
constructor = ToolMessage.model_construct if result_id is None else ToolMessage
result = constructor(content="Error: read the file before writing it", tool_call_id=result_id, name="write_file", status="error")
patched = DanglingToolCallMiddleware()._build_patched_messages([call, result])
assert patched is not None
assert len(patched) == 2
assert patched[1].content == result.content
assert patched[1].status == "error"
wire_call, wire_result = map(_convert_message_to_dict, patched)
assert wire_call["tool_calls"][0]["id"] == wire_result["tool_call_id"]
assert wire_result["tool_call_id"]
assert call.tool_calls[0]["id"] == call_id
assert result.tool_call_id == result_id
def test_mixed_missing_ids_keep_partial_same_name_results_ambiguous():
call = AIMessage(content="", tool_calls=[{"name": "bash", "id": call_id, "args": {}} for call_id in (None, "")])
result = ToolMessage(content="UNATTRIBUTABLE", tool_call_id="", name="bash")
patched = DanglingToolCallMiddleware()._build_patched_messages([call, result])
assert patched is not None
assert all(message.content != "UNATTRIBUTABLE" for message in patched[1:])
assert len(patched[1:]) == 2
assert all(message.status == "error" for message in patched[1:])
def test_missing_id_does_not_override_a_different_tool_name():
call = AIMessage(content="", tool_calls=[{"name": "write_file", "id": None, "args": {}}])
result = ToolMessage(content="UNRELATED", tool_call_id="", name="bash")
patched = DanglingToolCallMiddleware()._build_patched_messages([call, result])
assert patched is not None
assert patched[1].content != "UNRELATED"
assert patched[1].status == "error"
def test_mixed_missing_ids_complete_parallel_turn_keeps_input_order():
call = AIMessage(content="", tool_calls=[{"name": "bash", "id": call_id, "args": {}} for call_id in (None, "")])
replies = [ToolMessage(content=text, tool_call_id="", name="bash") for text in ("FIRST", "SECOND")]
patched = DanglingToolCallMiddleware()._build_patched_messages([call, *replies])
assert patched is not None
assert [message.content for message in patched[1:]] == ["FIRST", "SECOND"]
assert [message.tool_call_id for message in patched[1:]] == [tc["id"] for tc in patched[0].tool_calls]
@pytest.mark.parametrize("sentinel", ["None", "missing_tool_call_id"])
def test_producer_sentinel_ids_pair_with_a_null_call_id(sentinel):
"""Producers spell a missing ID as "None" or "missing_tool_call_id", not ""."""
call = AIMessage(content="", tool_calls=[{"name": "write_file", "id": None, "args": {"path": "a.md"}}])
result = ToolMessage(content="Error: read the file before writing it", tool_call_id=sentinel, name="write_file", status="error")
patched = DanglingToolCallMiddleware()._build_patched_messages([call, result])
assert patched is not None
assert len(patched) == 2
assert patched[1].content == result.content
assert patched[1].status == "error"
wire_call, wire_result = map(_convert_message_to_dict, patched)
assert wire_call["tool_calls"][0]["id"] == wire_result["tool_call_id"]
def test_producer_sentinel_pairing_does_not_invent_one_for_a_valid_id():
"""A sentinel result must not steal the slot of a call that does have an ID."""
call = AIMessage(content="", tool_calls=[{"name": "write_file", "id": "call_1", "args": {}}])
result = ToolMessage(content="UNRELATED", tool_call_id="missing_tool_call_id", name="write_file")
patched = DanglingToolCallMiddleware()._build_patched_messages([call, result])
assert patched is not None
assert patched[1].content != "UNRELATED"
assert patched[1].status == "error"
def test_producer_sentinels_complete_a_parallel_turn_in_input_order():
"""Mixed call spellings plus sentinel results: order still identifies the calls."""
call = AIMessage(content="", tool_calls=[{"name": "bash", "id": call_id, "args": {}} for call_id in (None, "")])
replies = [ToolMessage(content=text, tool_call_id="missing_tool_call_id", name="bash") for text in ("FIRST", "SECOND")]
patched = DanglingToolCallMiddleware()._build_patched_messages([call, *replies])
assert patched is not None
assert [message.content for message in patched[1:]] == ["FIRST", "SECOND"]
assert [message.tool_call_id for message in patched[1:]] == [tc["id"] for tc in patched[0].tool_calls]