* fix(middleware): drop orphan ToolMessages with no matching AIMessage tool_call
The rebuild loop only skipped ToolMessages whose tool_call_id matched a
known AIMessage tool_call (to be re-emitted after it). An orphan ToolMessage
whose tool_call_id has no matching AIMessage tool_calls fell through and was
kept, leaving a dangling tool result that strict providers reject. Drop
orphan ToolMessages as well, logging at debug.
* fix(dangling): demote orphan-drop logs, add tool_call_id=None test
- Update module/class docstrings to mention orphan ToolMessage handling
- Accumulate orphan drop_count and emit a single logger.warning
instead of per-message logger.debug calls
- Simplify early-return logic: return None only when no patching
AND no orphans were dropped
- Add test_tool_call_id_none_orphan_is_dropped — a ToolMessage
with tool_call_id=None is always an orphan and must be dropped
Closes#4080
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(test): use model_construct for None tool_call_id test to bypass pydantic validation
ToolMessage content='ghost' tool_call_id=None fails pydantic validation at
construction. Use model_construct to simulate a corrupt/edge-case payload
without tripping the string-only guard.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* test: add unit tests for DanglingToolCallMiddleware
Cover message patching logic for dangling tool calls:
- No-op when all tool calls have responses
- Synthetic ToolMessage insertion at correct positions
- Mixed responded/dangling scenarios
- wrap_model_call and awrap_model_call integration
* test: fix async tests and strengthen override assertions
- Use @pytest.mark.anyio + async def instead of deprecated
asyncio.get_event_loop().run_until_complete() (fixes Py3.12 CI failure)
- Assert that override() receives the correct patched messages kwarg
in both wrap_model_call and awrap_model_call tests
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>