chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,661 @@
|
||||
"""Tests for LangGraphAGUIAgent — CopilotKit's extension layer on top of AG-UI's base agent.
|
||||
|
||||
Covers:
|
||||
1. Custom event handling (_dispatch_event with CopilotKit-specific custom events)
|
||||
2. copilotkit state namespace (langgraph_default_merge_state)
|
||||
3. Unknown custom events pass through without crashing
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import MagicMock, patch
|
||||
from ag_ui.core import (
|
||||
EventType,
|
||||
CustomEvent,
|
||||
TextMessageStartEvent,
|
||||
TextMessageContentEvent,
|
||||
TextMessageEndEvent,
|
||||
ToolCallStartEvent,
|
||||
ToolCallArgsEvent,
|
||||
ToolCallEndEvent,
|
||||
StateSnapshotEvent,
|
||||
)
|
||||
from ag_ui_langgraph import LangGraphAgent as AGUIBase
|
||||
from copilotkit import CopilotKitRemoteEndpoint
|
||||
from copilotkit.langgraph_agui_agent import (
|
||||
LangGraphAGUIAgent,
|
||||
CustomEventNames,
|
||||
)
|
||||
|
||||
|
||||
# The source code at langgraph_agui_agent.py:134 checks for the literal string
|
||||
# "copilotkit_exit" (not via CustomEventNames enum — exit was never added to it).
|
||||
# We intentionally match the source's literal here.
|
||||
COPILOTKIT_EXIT_EVENT_NAME = "copilotkit_exit"
|
||||
|
||||
# The output event name "Exit" is a hardcoded downstream value in the source code
|
||||
# (langgraph_agui_agent.py:138), distinct from any CustomEventNames constant.
|
||||
EXIT_OUTPUT_NAME = "Exit"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent():
|
||||
"""Create a LangGraphAGUIAgent with a mocked graph."""
|
||||
mock_graph = MagicMock()
|
||||
mock_graph.get_state = MagicMock()
|
||||
a = LangGraphAGUIAgent(name="test", graph=mock_graph)
|
||||
a.active_run = {"id": "run-1", "thread_id": "t-1"}
|
||||
return a
|
||||
|
||||
|
||||
@contextmanager
|
||||
def track_parent_dispatches(agent):
|
||||
"""Patch AGUIBase._dispatch_event to record all dispatched events.
|
||||
|
||||
Yields the list of captured events. The original dispatch is still called
|
||||
so that event serialisation behaves normally.
|
||||
|
||||
Usage::
|
||||
|
||||
with track_parent_dispatches(agent) as dispatched:
|
||||
agent._dispatch_event(some_event)
|
||||
assert EventType.TEXT_MESSAGE_START in [e.type for e in dispatched]
|
||||
"""
|
||||
dispatched = []
|
||||
original = AGUIBase._dispatch_event
|
||||
|
||||
def _tracking(self_inner, event):
|
||||
dispatched.append(event)
|
||||
return original(self_inner, event)
|
||||
|
||||
with patch.object(AGUIBase, "_dispatch_event", new=_tracking):
|
||||
yield dispatched
|
||||
|
||||
|
||||
# ---------- Custom event: ManuallyEmitMessage ----------
|
||||
|
||||
|
||||
class TestManuallyEmitMessage:
|
||||
"""copilotkit_manually_emit_message → TEXT_MESSAGE_START/CONTENT/END sequence."""
|
||||
|
||||
def test_emits_text_message_sequence(self, agent):
|
||||
"""Should call super()._dispatch_event for start, content, end, and the custom event."""
|
||||
with track_parent_dispatches(agent) as dispatched:
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitMessage.value,
|
||||
value={
|
||||
"message_id": "msg-123",
|
||||
"message": "Hello world",
|
||||
"role": "assistant",
|
||||
},
|
||||
)
|
||||
agent._dispatch_event(event)
|
||||
|
||||
types = [getattr(e, "type", None) for e in dispatched]
|
||||
assert EventType.TEXT_MESSAGE_START in types
|
||||
assert EventType.TEXT_MESSAGE_CONTENT in types
|
||||
assert EventType.TEXT_MESSAGE_END in types
|
||||
|
||||
def test_message_ids_match(self, agent):
|
||||
"""All emitted events should carry the same message_id from the custom event."""
|
||||
with track_parent_dispatches(agent) as dispatched:
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitMessage.value,
|
||||
value={
|
||||
"message_id": "msg-456",
|
||||
"message": "test",
|
||||
"role": "assistant",
|
||||
},
|
||||
)
|
||||
agent._dispatch_event(event)
|
||||
|
||||
message_events = [e for e in dispatched if hasattr(e, "message_id")]
|
||||
for e in message_events:
|
||||
assert e.message_id == "msg-456"
|
||||
|
||||
def test_content_delta_matches(self, agent):
|
||||
"""TextMessageContentEvent should carry the message text as delta."""
|
||||
with track_parent_dispatches(agent) as dispatched:
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitMessage.value,
|
||||
value={
|
||||
"message_id": "msg-789",
|
||||
"message": "specific content",
|
||||
"role": "assistant",
|
||||
},
|
||||
)
|
||||
agent._dispatch_event(event)
|
||||
|
||||
content_events = [
|
||||
e for e in dispatched if e.type == EventType.TEXT_MESSAGE_CONTENT
|
||||
]
|
||||
assert len(content_events) == 1
|
||||
assert content_events[0].delta == "specific content"
|
||||
|
||||
|
||||
# ---------- Custom event: ManuallyEmitToolCall ----------
|
||||
|
||||
|
||||
class TestManuallyEmitToolCall:
|
||||
"""copilotkit_manually_emit_tool_call → TOOL_CALL_START/ARGS/END sequence."""
|
||||
|
||||
def test_emits_tool_call_sequence(self, agent):
|
||||
"""Should dispatch start, args, end for a tool call."""
|
||||
with track_parent_dispatches(agent) as dispatched:
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value={
|
||||
"id": "tc-123",
|
||||
"name": "SearchTool",
|
||||
"args": {"query": "test"},
|
||||
},
|
||||
)
|
||||
agent._dispatch_event(event)
|
||||
|
||||
types = [getattr(e, "type", None) for e in dispatched]
|
||||
assert EventType.TOOL_CALL_START in types
|
||||
assert EventType.TOOL_CALL_ARGS in types
|
||||
assert EventType.TOOL_CALL_END in types
|
||||
|
||||
def test_tool_call_ids_match(self, agent):
|
||||
"""All tool call events should carry the same tool_call_id."""
|
||||
with track_parent_dispatches(agent) as dispatched:
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value={
|
||||
"id": "tc-456",
|
||||
"name": "MyTool",
|
||||
"args": {"key": "val"},
|
||||
},
|
||||
)
|
||||
agent._dispatch_event(event)
|
||||
|
||||
tool_events = [e for e in dispatched if hasattr(e, "tool_call_id")]
|
||||
for e in tool_events:
|
||||
assert e.tool_call_id == "tc-456"
|
||||
|
||||
def test_args_serialized_as_json_when_dict(self, agent):
|
||||
"""When args is a dict, it should be JSON-serialized in the TOOL_CALL_ARGS event."""
|
||||
with track_parent_dispatches(agent) as dispatched:
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value={
|
||||
"id": "tc-789",
|
||||
"name": "MyTool",
|
||||
"args": {"key": "value"},
|
||||
},
|
||||
)
|
||||
agent._dispatch_event(event)
|
||||
|
||||
args_events = [e for e in dispatched if e.type == EventType.TOOL_CALL_ARGS]
|
||||
assert len(args_events) == 1
|
||||
assert args_events[0].delta == json.dumps({"key": "value"})
|
||||
|
||||
def test_args_passed_as_string_when_string(self, agent):
|
||||
"""When args is already a string, it should be passed through as-is."""
|
||||
with track_parent_dispatches(agent) as dispatched:
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value={
|
||||
"id": "tc-str",
|
||||
"name": "MyTool",
|
||||
"args": '{"already": "serialized"}',
|
||||
},
|
||||
)
|
||||
agent._dispatch_event(event)
|
||||
|
||||
args_events = [e for e in dispatched if e.type == EventType.TOOL_CALL_ARGS]
|
||||
assert len(args_events) == 1
|
||||
assert args_events[0].delta == '{"already": "serialized"}'
|
||||
|
||||
|
||||
# ---------- Custom event: ManuallyEmitState ----------
|
||||
|
||||
|
||||
class TestManuallyEmitState:
|
||||
"""copilotkit_manually_emit_intermediate_state → StateSnapshotEvent."""
|
||||
|
||||
def test_emits_state_snapshot(self, agent):
|
||||
"""Should set active_run['manually_emitted_state'] and dispatch a STATE_SNAPSHOT."""
|
||||
agent.get_state_snapshot = MagicMock(return_value={"progress": 50})
|
||||
|
||||
with track_parent_dispatches(agent) as dispatched:
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitState.value,
|
||||
value={"progress": 50},
|
||||
)
|
||||
agent._dispatch_event(event)
|
||||
|
||||
assert agent.active_run["manually_emitted_state"] == {"progress": 50}
|
||||
types = [getattr(e, "type", None) for e in dispatched]
|
||||
assert EventType.STATE_SNAPSHOT in types
|
||||
|
||||
|
||||
# ---------- Custom event: copilotkit_exit ----------
|
||||
|
||||
|
||||
class TestCopilotKitExit:
|
||||
"""copilotkit_exit → CustomEvent with name='Exit'."""
|
||||
|
||||
def test_emits_exit_event(self, agent):
|
||||
"""Should dispatch a CustomEvent with name 'Exit'."""
|
||||
with track_parent_dispatches(agent) as dispatched:
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=COPILOTKIT_EXIT_EVENT_NAME,
|
||||
value={},
|
||||
)
|
||||
agent._dispatch_event(event)
|
||||
|
||||
exit_events = [
|
||||
e
|
||||
for e in dispatched
|
||||
if e.type == EventType.CUSTOM
|
||||
and getattr(e, "name", None) == EXIT_OUTPUT_NAME
|
||||
]
|
||||
assert len(exit_events) == 1
|
||||
|
||||
|
||||
# ---------- Unknown custom events ----------
|
||||
|
||||
|
||||
class TestUnknownCustomEvent:
|
||||
"""Unknown custom event names should pass through to super() without crashing."""
|
||||
|
||||
def test_unknown_custom_event_passes_through(self, agent):
|
||||
"""An unrecognized custom event name should not raise and should call super()."""
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name="some_unknown_event",
|
||||
value={"data": "test"},
|
||||
)
|
||||
result = agent._dispatch_event(event)
|
||||
assert result is not None
|
||||
|
||||
|
||||
# ---------- Emit filtering (independent of tool calls) ----------
|
||||
|
||||
|
||||
class TestEmitFilteringIndependent:
|
||||
"""Test that both emit-messages and emit-tool-calls can be set independently."""
|
||||
|
||||
def test_emit_messages_false_tool_calls_true(self, agent):
|
||||
"""emit-messages=False should filter text events but not tool events."""
|
||||
metadata = {
|
||||
"copilotkit:emit-messages": False,
|
||||
"copilotkit:emit-tool-calls": True,
|
||||
}
|
||||
text_event = TextMessageContentEvent(
|
||||
messageId="msg-1",
|
||||
delta="hello",
|
||||
rawEvent={"metadata": metadata},
|
||||
)
|
||||
tool_event = ToolCallStartEvent(
|
||||
toolCallId="tc-1",
|
||||
toolCallName="tool",
|
||||
rawEvent={"metadata": metadata},
|
||||
)
|
||||
assert agent._dispatch_event(text_event) is None
|
||||
assert agent._dispatch_event(tool_event) is not None
|
||||
|
||||
def test_emit_tool_calls_false_messages_true(self, agent):
|
||||
"""emit-tool-calls=False should filter tool events but not text events."""
|
||||
metadata = {
|
||||
"copilotkit:emit-messages": True,
|
||||
"copilotkit:emit-tool-calls": False,
|
||||
}
|
||||
text_event = TextMessageContentEvent(
|
||||
messageId="msg-1",
|
||||
delta="hello",
|
||||
rawEvent={"metadata": metadata},
|
||||
)
|
||||
tool_event = ToolCallStartEvent(
|
||||
toolCallId="tc-1",
|
||||
toolCallName="tool",
|
||||
rawEvent={"metadata": metadata},
|
||||
)
|
||||
assert agent._dispatch_event(text_event) is not None
|
||||
assert agent._dispatch_event(tool_event) is None
|
||||
|
||||
def test_both_false_filters_both(self, agent):
|
||||
"""Both flags false should filter both types."""
|
||||
metadata = {
|
||||
"copilotkit:emit-messages": False,
|
||||
"copilotkit:emit-tool-calls": False,
|
||||
}
|
||||
text_event = TextMessageContentEvent(
|
||||
messageId="msg-1",
|
||||
delta="hello",
|
||||
rawEvent={"metadata": metadata},
|
||||
)
|
||||
tool_event = ToolCallStartEvent(
|
||||
toolCallId="tc-1",
|
||||
toolCallName="tool",
|
||||
rawEvent={"metadata": metadata},
|
||||
)
|
||||
assert agent._dispatch_event(text_event) is None
|
||||
assert agent._dispatch_event(tool_event) is None
|
||||
|
||||
|
||||
# ---------- copilotkit state namespace ----------
|
||||
|
||||
|
||||
class TestLanggraphDefaultMergeState:
|
||||
"""langgraph_default_merge_state adds copilotkit namespace with actions and context."""
|
||||
|
||||
def test_copilotkit_actions_from_agui_tools(self, agent):
|
||||
"""Tools from ag-ui should appear under copilotkit.actions."""
|
||||
tools = [{"name": "tool1"}, {"name": "tool2"}]
|
||||
with patch.object(
|
||||
AGUIBase,
|
||||
"langgraph_default_merge_state",
|
||||
return_value={
|
||||
"ag-ui": {"tools": tools, "context": []},
|
||||
"messages": [],
|
||||
},
|
||||
):
|
||||
result = agent.langgraph_default_merge_state({}, [], MagicMock())
|
||||
|
||||
assert "copilotkit" in result
|
||||
assert result["copilotkit"]["actions"] == tools
|
||||
|
||||
def test_copilotkit_context_from_agui(self, agent):
|
||||
"""Context from ag-ui should appear under copilotkit.context."""
|
||||
context = [{"description": "user info", "value": "test"}]
|
||||
with patch.object(
|
||||
AGUIBase,
|
||||
"langgraph_default_merge_state",
|
||||
return_value={
|
||||
"ag-ui": {"tools": [], "context": context},
|
||||
"messages": [],
|
||||
},
|
||||
):
|
||||
result = agent.langgraph_default_merge_state({}, [], MagicMock())
|
||||
|
||||
assert result["copilotkit"]["context"] == context
|
||||
|
||||
def test_no_agui_key_no_crash(self, agent):
|
||||
"""If no ag-ui key in state, should use merged_state as fallback without crashing."""
|
||||
with patch.object(
|
||||
AGUIBase,
|
||||
"langgraph_default_merge_state",
|
||||
return_value={"messages": [], "tools": [{"name": "fallback"}]},
|
||||
):
|
||||
result = agent.langgraph_default_merge_state({}, [], MagicMock())
|
||||
|
||||
assert "copilotkit" in result
|
||||
assert result["copilotkit"]["actions"] == [{"name": "fallback"}]
|
||||
|
||||
def test_empty_state_no_crash(self, agent):
|
||||
"""Completely empty state should not crash."""
|
||||
with patch.object(
|
||||
AGUIBase,
|
||||
"langgraph_default_merge_state",
|
||||
return_value={},
|
||||
):
|
||||
result = agent.langgraph_default_merge_state({}, [], MagicMock())
|
||||
|
||||
assert "copilotkit" in result
|
||||
assert result["copilotkit"]["actions"] == []
|
||||
assert result["copilotkit"]["context"] == []
|
||||
|
||||
def test_preserves_original_state_keys(self, agent):
|
||||
"""Original keys from super() should be preserved alongside copilotkit."""
|
||||
with patch.object(
|
||||
AGUIBase,
|
||||
"langgraph_default_merge_state",
|
||||
return_value={
|
||||
"ag-ui": {"tools": [], "context": []},
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"custom_key": "custom_value",
|
||||
},
|
||||
):
|
||||
result = agent.langgraph_default_merge_state({}, [], MagicMock())
|
||||
|
||||
assert result["custom_key"] == "custom_value"
|
||||
assert result["messages"] == [{"role": "user", "content": "hi"}]
|
||||
|
||||
def test_duplicate_tools_not_in_copilotkit_actions(self, agent):
|
||||
"""Tools should not be duplicated in copilotkit.actions when same name appears in input and state."""
|
||||
tools = [{"name": "tool_a"}, {"name": "tool_b"}]
|
||||
with patch.object(
|
||||
AGUIBase,
|
||||
"langgraph_default_merge_state",
|
||||
return_value={
|
||||
"ag-ui": {"tools": tools, "context": []},
|
||||
"messages": [],
|
||||
},
|
||||
):
|
||||
result = agent.langgraph_default_merge_state({}, [], MagicMock())
|
||||
|
||||
action_names = [a.get("name") for a in result["copilotkit"]["actions"]]
|
||||
assert action_names.count("tool_a") == 1, (
|
||||
"Duplicate tool names should not appear in copilotkit.actions"
|
||||
)
|
||||
assert action_names.count("tool_b") == 1
|
||||
|
||||
def test_copilotkit_actions_ordering_matches_tools(self, agent):
|
||||
"""copilotkit.actions should have the same ordering as the merged tools list."""
|
||||
tools = [{"name": "first"}, {"name": "second"}, {"name": "third"}]
|
||||
with patch.object(
|
||||
AGUIBase,
|
||||
"langgraph_default_merge_state",
|
||||
return_value={
|
||||
"ag-ui": {"tools": tools, "context": []},
|
||||
"messages": [],
|
||||
},
|
||||
):
|
||||
result = agent.langgraph_default_merge_state({}, [], MagicMock())
|
||||
|
||||
action_names = [a.get("name") for a in result["copilotkit"]["actions"]]
|
||||
assert action_names == ["first", "second", "third"]
|
||||
|
||||
|
||||
# ---------- Reasoning content preservation ----------
|
||||
|
||||
|
||||
class TestReasoningContentPreservation:
|
||||
"""Verify that LangGraphAGUIAgent does not drop or mutate reasoning events."""
|
||||
|
||||
def test_unknown_custom_event_does_not_suppress_reasoning(self, agent):
|
||||
"""An unrecognized custom event should pass through (not suppress subsequent reasoning events)."""
|
||||
# Dispatch a non-CopilotKit custom event — should pass through without crash
|
||||
unknown_event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name="some_unknown_custom_event",
|
||||
value={"reasoning": "chain-of-thought text"},
|
||||
)
|
||||
result = agent._dispatch_event(unknown_event)
|
||||
# Should pass through to super() and return something (not None)
|
||||
assert result is not None
|
||||
|
||||
def test_manually_emit_tool_call_with_empty_args(self, agent):
|
||||
"""ManuallyEmitToolCall with empty args dict should still emit the tool call sequence."""
|
||||
with track_parent_dispatches(agent) as dispatched:
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value={
|
||||
"id": "tc-empty",
|
||||
"name": "MyTool",
|
||||
"args": {},
|
||||
},
|
||||
)
|
||||
agent._dispatch_event(event)
|
||||
|
||||
types = [getattr(e, "type", None) for e in dispatched]
|
||||
assert EventType.TOOL_CALL_START in types
|
||||
assert EventType.TOOL_CALL_ARGS in types
|
||||
assert EventType.TOOL_CALL_END in types
|
||||
|
||||
|
||||
# ---------- AG-UI-style (unprefixed) event integration ----------
|
||||
|
||||
|
||||
class TestAGUIStyleEventIntegration:
|
||||
"""Verify that AG-UI-native (unprefixed) events flow correctly through CopilotKit.
|
||||
|
||||
CopilotKit's _dispatch_event only intercepts copilotkit_-prefixed CUSTOM events;
|
||||
unprefixed AG-UI events (manually_emit_message, manually_emit_tool_call, exit) are
|
||||
delegated to the AG-UI base class — never suppressed or double-converted by CopilotKit.
|
||||
"""
|
||||
|
||||
def _make_agent(self):
|
||||
mock_graph = MagicMock()
|
||||
mock_graph.get_state = MagicMock()
|
||||
agent = LangGraphAGUIAgent(name="test", graph=mock_graph)
|
||||
agent.active_run = {
|
||||
"id": "run-1",
|
||||
"thread_id": "t1",
|
||||
"reasoning_process": None,
|
||||
"node_name": "agent",
|
||||
"has_function_streaming": False,
|
||||
"model_made_tool_call": False,
|
||||
"state_reliable": True,
|
||||
"streamed_messages": [],
|
||||
"manually_emitted_state": None,
|
||||
"schema_keys": {
|
||||
"input": ["messages", "tools"],
|
||||
"output": ["messages", "tools"],
|
||||
"config": [],
|
||||
"context": [],
|
||||
},
|
||||
}
|
||||
return agent
|
||||
|
||||
def test_agui_manually_emit_message_produces_text_events(self):
|
||||
"""on_custom_event/"manually_emit_message" through CopilotKit should produce
|
||||
TEXT_MESSAGE_START/CONTENT/END via the AG-UI base handler — not suppressed."""
|
||||
import asyncio
|
||||
|
||||
agent = self._make_agent()
|
||||
lg_event = {
|
||||
"event": "on_custom_event",
|
||||
"name": "manually_emit_message",
|
||||
"data": {"message_id": "msg-1", "message": "Hello", "role": "assistant"},
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
async def _run():
|
||||
async for _ in agent._handle_single_event(lg_event, {}):
|
||||
pass
|
||||
|
||||
with track_parent_dispatches(agent) as dispatched:
|
||||
asyncio.run(_run())
|
||||
|
||||
types = [e.type for e in dispatched]
|
||||
assert EventType.TEXT_MESSAGE_START in types
|
||||
assert EventType.TEXT_MESSAGE_CONTENT in types
|
||||
assert EventType.TEXT_MESSAGE_END in types
|
||||
|
||||
def test_agui_manually_emit_tool_call_produces_tool_events(self):
|
||||
"""on_custom_event/"manually_emit_tool_call" through CopilotKit should produce
|
||||
TOOL_CALL_START/ARGS/END via the AG-UI base handler."""
|
||||
import asyncio
|
||||
|
||||
agent = self._make_agent()
|
||||
lg_event = {
|
||||
"event": "on_custom_event",
|
||||
"name": "manually_emit_tool_call",
|
||||
"data": {"id": "tc-1", "name": "search", "args": {"q": "weather"}},
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
async def _run():
|
||||
async for _ in agent._handle_single_event(lg_event, {}):
|
||||
pass
|
||||
|
||||
with track_parent_dispatches(agent) as dispatched:
|
||||
asyncio.run(_run())
|
||||
|
||||
types = [e.type for e in dispatched]
|
||||
assert EventType.TOOL_CALL_START in types
|
||||
assert EventType.TOOL_CALL_ARGS in types
|
||||
assert EventType.TOOL_CALL_END in types
|
||||
|
||||
def test_agui_exit_produces_custom_event(self):
|
||||
"""on_custom_event/"exit" through CopilotKit should emit a CUSTOM event —
|
||||
the exit signal is not suppressed by the CopilotKit layer."""
|
||||
import asyncio
|
||||
|
||||
agent = self._make_agent()
|
||||
lg_event = {
|
||||
"event": "on_custom_event",
|
||||
"name": "exit",
|
||||
"data": {},
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
async def _run():
|
||||
async for _ in agent._handle_single_event(lg_event, {}):
|
||||
pass
|
||||
|
||||
with track_parent_dispatches(agent) as dispatched:
|
||||
asyncio.run(_run())
|
||||
|
||||
types = [e.type for e in dispatched]
|
||||
assert EventType.CUSTOM in types
|
||||
|
||||
def test_agui_style_event_not_suppressed_by_dispatch(self):
|
||||
"""A CUSTOM event with an AG-UI-style unprefixed name passed to CopilotKit's
|
||||
_dispatch_event should be forwarded to the base class unchanged.
|
||||
|
||||
CopilotKit only converts copilotkit_-prefixed events (e.g. copilotkit_manually_emit_message
|
||||
→ TEXT_MESSAGE_*). An unprefixed "manually_emit_message" CustomEvent must not trigger
|
||||
that conversion path — no TEXT_MESSAGE_START should be injected, and the original
|
||||
event object must be forwarded as-is."""
|
||||
agent = self._make_agent()
|
||||
original = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name="manually_emit_message",
|
||||
value={"message_id": "msg-2", "message": "test", "role": "assistant"},
|
||||
)
|
||||
with track_parent_dispatches(agent) as dispatched:
|
||||
agent._dispatch_event(original)
|
||||
|
||||
types = [e.type for e in dispatched]
|
||||
assert EventType.CUSTOM in types
|
||||
assert EventType.TEXT_MESSAGE_START not in types
|
||||
assert dispatched[0] is original
|
||||
|
||||
|
||||
class TestAgentMetadata:
|
||||
"""Regression coverage for info() serializing LangGraphAGUIAgent metadata."""
|
||||
|
||||
def test_dict_repr_includes_type_without_parent_support(self, agent):
|
||||
"""dict_repr should not depend on AG-UI's base class implementing dict_repr."""
|
||||
assert agent.dict_repr() == {
|
||||
"name": "test",
|
||||
"description": "",
|
||||
"type": "langgraph_agui",
|
||||
}
|
||||
|
||||
def test_remote_endpoint_info_serializes_langgraph_agui_agent(self):
|
||||
"""sdk.info should include LangGraphAGUIAgent metadata without raising."""
|
||||
mock_graph = MagicMock()
|
||||
mock_graph.get_state = MagicMock()
|
||||
agent = LangGraphAGUIAgent(
|
||||
name="demo",
|
||||
graph=mock_graph,
|
||||
description="demo agent",
|
||||
)
|
||||
sdk = CopilotKitRemoteEndpoint(agents=[agent], actions=[])
|
||||
|
||||
info = sdk.info(context={"properties": {}, "frontend_url": None, "headers": {}})
|
||||
|
||||
assert info["agents"] == [
|
||||
{
|
||||
"name": "demo",
|
||||
"description": "demo agent",
|
||||
"type": "langgraph_agui",
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Tests for #3690: LangGraphAGUIAgent stores Context as Pydantic objects instead of dicts."""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
from ag_ui.core import Context
|
||||
|
||||
|
||||
class TestAGUIContextSerialization:
|
||||
"""Verify that context items stored in state are JSON-serializable dicts, not Pydantic objects."""
|
||||
|
||||
def test_context_items_are_dicts_not_pydantic(self):
|
||||
"""Context from ag-ui properties should be model_dump'd before storage."""
|
||||
from copilotkit.langgraph_agui_agent import LangGraphAGUIAgent
|
||||
|
||||
mock_graph = MagicMock()
|
||||
mock_graph.get_state = MagicMock()
|
||||
agent = LangGraphAGUIAgent(name="test", graph=mock_graph)
|
||||
|
||||
# Pydantic Context objects as they arrive from AG-UI
|
||||
ctx1 = Context(name="user_info", description="User details", value="John")
|
||||
ctx2 = Context(name="session", description="Session data", value="abc123")
|
||||
|
||||
merged_state = {
|
||||
"ag-ui": {
|
||||
"tools": [],
|
||||
"context": [ctx1, ctx2],
|
||||
},
|
||||
"messages": [],
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
type(agent).__mro__[1], # LangGraphAgent (parent class)
|
||||
"langgraph_default_merge_state",
|
||||
return_value=merged_state,
|
||||
):
|
||||
result = agent.langgraph_default_merge_state({}, [], None)
|
||||
|
||||
# Context items must be plain dicts, not Pydantic objects
|
||||
for item in result["copilotkit"]["context"]:
|
||||
assert isinstance(item, dict), f"Expected dict, got {type(item)}"
|
||||
json.dumps(item) # Must be JSON-serializable
|
||||
|
||||
def test_context_with_mixed_types(self):
|
||||
"""If context contains both Pydantic objects and plain dicts, handle both."""
|
||||
from copilotkit.langgraph_agui_agent import LangGraphAGUIAgent
|
||||
|
||||
mock_graph = MagicMock()
|
||||
mock_graph.get_state = MagicMock()
|
||||
agent = LangGraphAGUIAgent(name="test", graph=mock_graph)
|
||||
|
||||
ctx_pydantic = Context(name="key1", description="desc", value="val1")
|
||||
ctx_dict = {"name": "key2", "description": "desc2", "value": "val2"}
|
||||
|
||||
merged_state = {
|
||||
"ag-ui": {
|
||||
"tools": [],
|
||||
"context": [ctx_pydantic, ctx_dict],
|
||||
},
|
||||
"messages": [],
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
type(agent).__mro__[1],
|
||||
"langgraph_default_merge_state",
|
||||
return_value=merged_state,
|
||||
):
|
||||
result = agent.langgraph_default_merge_state({}, [], None)
|
||||
|
||||
for item in result["copilotkit"]["context"]:
|
||||
assert isinstance(item, dict), f"Expected dict, got {type(item)}"
|
||||
json.dumps(item)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,246 @@
|
||||
"""Tests for crewai_flow_messages_to_copilotkit assistant message emission.
|
||||
|
||||
Covers the parentMessageId orphan bug where the elif chain in the message
|
||||
conversion skipped emitting the assistant message for tool-call messages.
|
||||
Tool call entries reference their parent assistant message via parentMessageId,
|
||||
so the assistant message must always be emitted — even when content is empty.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# crewai_sdk.py imports litellm/crewai at module level. Stub them out
|
||||
# so the function under test (which needs none of these) can be imported.
|
||||
# We load crewai_sdk.py directly to bypass copilotkit/crewai/__init__.py
|
||||
# which pulls in crewai_agent.py and its heavy transitive dependencies.
|
||||
_STUBS = [
|
||||
"litellm",
|
||||
"litellm.types",
|
||||
"litellm.types.utils",
|
||||
"litellm.litellm_core_utils",
|
||||
"litellm.litellm_core_utils.streaming_handler",
|
||||
"crewai",
|
||||
"crewai.flow",
|
||||
"crewai.flow.flow",
|
||||
"crewai.utilities",
|
||||
"crewai.utilities.events",
|
||||
"crewai.utilities.events.flow_events",
|
||||
"copilotkit.runloop",
|
||||
"copilotkit.protocol",
|
||||
]
|
||||
_originals = {}
|
||||
for _name in _STUBS:
|
||||
if _name in sys.modules:
|
||||
_originals[_name] = sys.modules[_name]
|
||||
else:
|
||||
sys.modules[_name] = MagicMock()
|
||||
|
||||
_pkg_path = importlib.util.find_spec("copilotkit").submodule_search_locations[0] # type: ignore[union-attr,index]
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"copilotkit.crewai.crewai_sdk",
|
||||
f"{_pkg_path}/crewai/crewai_sdk.py",
|
||||
)
|
||||
_mod = importlib.util.module_from_spec(_spec) # type: ignore[arg-type]
|
||||
_spec.loader.exec_module(_mod) # type: ignore[union-attr]
|
||||
crewai_flow_messages_to_copilotkit = _mod.crewai_flow_messages_to_copilotkit
|
||||
|
||||
# Restore original modules
|
||||
for _name in _STUBS:
|
||||
if _name in _originals:
|
||||
sys.modules[_name] = _originals[_name]
|
||||
else:
|
||||
sys.modules.pop(_name, None)
|
||||
|
||||
|
||||
def _convert_and_split(messages):
|
||||
"""Convert messages and split result into assistant vs tool-call entries."""
|
||||
result = crewai_flow_messages_to_copilotkit(messages)
|
||||
assistant_msgs = [m for m in result if m.get("role") == "assistant"]
|
||||
tool_call_msgs = [m for m in result if "parentMessageId" in m]
|
||||
return result, assistant_msgs, tool_call_msgs
|
||||
|
||||
|
||||
class TestCrewAIAssistantMessageAlwaysEmitted:
|
||||
"""The assistant message must always be present so tool call entries can
|
||||
reference it via parentMessageId. Without it, tool calls are orphaned
|
||||
and the frontend cannot reconstruct tool call rendering on reconnect."""
|
||||
|
||||
def test_function_style_tool_calls_with_content(self):
|
||||
"""Message with content and function-style tool_calls emits assistant + tool calls."""
|
||||
messages = [
|
||||
{
|
||||
"id": "ai-1",
|
||||
"role": "assistant",
|
||||
"content": "Let me help.",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "tc-1",
|
||||
"function": {
|
||||
"name": "get_help",
|
||||
"arguments": json.dumps({"topic": "billing"}),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
_, assistant_msgs, tool_call_msgs = _convert_and_split(messages)
|
||||
|
||||
assert len(assistant_msgs) == 1
|
||||
assert assistant_msgs[0]["id"] == "ai-1"
|
||||
assert assistant_msgs[0]["content"] == "Let me help."
|
||||
|
||||
assert len(tool_call_msgs) == 1
|
||||
assert tool_call_msgs[0]["parentMessageId"] == "ai-1"
|
||||
|
||||
def test_function_style_tool_calls_with_empty_content(self):
|
||||
"""Message with empty content (OpenAI-style) still emits the assistant message."""
|
||||
messages = [
|
||||
{
|
||||
"id": "ai-1",
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "tc-1",
|
||||
"function": {
|
||||
"name": "get_help",
|
||||
"arguments": json.dumps({"topic": "billing"}),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
_, assistant_msgs, tool_call_msgs = _convert_and_split(messages)
|
||||
|
||||
assert len(assistant_msgs) == 1, (
|
||||
"Assistant message must be emitted even with empty content"
|
||||
)
|
||||
assert assistant_msgs[0]["id"] == "ai-1"
|
||||
assert assistant_msgs[0]["content"] == ""
|
||||
|
||||
assert len(tool_call_msgs) == 1
|
||||
assert tool_call_msgs[0]["parentMessageId"] == "ai-1"
|
||||
|
||||
def test_function_style_tool_calls_without_content_key(self):
|
||||
"""Message with no content key still emits the assistant message."""
|
||||
messages = [
|
||||
{
|
||||
"id": "ai-1",
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "tc-1",
|
||||
"function": {"name": "get_help", "arguments": json.dumps({})},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
_, assistant_msgs, tool_call_msgs = _convert_and_split(messages)
|
||||
|
||||
assert len(assistant_msgs) == 1, (
|
||||
"Assistant message must be emitted even without content key"
|
||||
)
|
||||
assert assistant_msgs[0]["content"] == ""
|
||||
|
||||
assert len(tool_call_msgs) == 1
|
||||
assert tool_call_msgs[0]["parentMessageId"] == "ai-1"
|
||||
|
||||
def test_direct_style_tool_calls_with_empty_content(self):
|
||||
"""Message with direct-style tool_calls (no function wrapper) still emits assistant."""
|
||||
messages = [
|
||||
{
|
||||
"id": "ai-1",
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "tc-1",
|
||||
"name": "get_help",
|
||||
"arguments": {"topic": "billing"},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
_, assistant_msgs, tool_call_msgs = _convert_and_split(messages)
|
||||
|
||||
assert len(assistant_msgs) == 1, (
|
||||
"Assistant message must be emitted for direct-style tool calls"
|
||||
)
|
||||
assert assistant_msgs[0]["content"] == ""
|
||||
|
||||
assert len(tool_call_msgs) == 1
|
||||
assert tool_call_msgs[0]["parentMessageId"] == "ai-1"
|
||||
|
||||
def test_tool_call_without_id_is_skipped(self):
|
||||
"""Tool calls missing an id should be silently skipped."""
|
||||
messages = [
|
||||
{
|
||||
"id": "ai-1",
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"function": {"name": "no_id_tool", "arguments": json.dumps({})}},
|
||||
{
|
||||
"id": "tc-1",
|
||||
"function": {
|
||||
"name": "search",
|
||||
"arguments": json.dumps({"q": "x"}),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
_, assistant_msgs, tool_call_msgs = _convert_and_split(messages)
|
||||
|
||||
assert len(assistant_msgs) == 1
|
||||
assert len(tool_call_msgs) == 1, "Only tool calls with an id should be emitted"
|
||||
assert tool_call_msgs[0]["id"] == "tc-1"
|
||||
|
||||
def test_no_orphaned_parent_message_ids(self):
|
||||
"""Every parentMessageId must reference an existing assistant message."""
|
||||
messages = [
|
||||
{"id": "h-1", "role": "user", "content": "help me"},
|
||||
{
|
||||
"id": "ai-1",
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "tc-1",
|
||||
"function": {
|
||||
"name": "get_help",
|
||||
"arguments": json.dumps({"topic": "billing"}),
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "tc-2",
|
||||
"function": {
|
||||
"name": "search",
|
||||
"arguments": json.dumps({"query": "docs"}),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{"id": "tm-1", "role": "tool", "tool_call_id": "tc-1", "content": "done"},
|
||||
{"id": "tm-2", "role": "tool", "tool_call_id": "tc-2", "content": "found"},
|
||||
]
|
||||
result, _, tool_call_msgs = _convert_and_split(messages)
|
||||
|
||||
message_ids = {m["id"] for m in result if "role" in m}
|
||||
|
||||
for tc in tool_call_msgs:
|
||||
assert tc["parentMessageId"] in message_ids, (
|
||||
f"Tool call {tc['id']} has orphaned parentMessageId {tc['parentMessageId']}"
|
||||
)
|
||||
|
||||
def test_plain_assistant_message_without_tool_calls(self):
|
||||
"""Plain assistant message (no tool calls) emits just the assistant message."""
|
||||
messages = [{"id": "ai-1", "role": "assistant", "content": "Hello!"}]
|
||||
result, _, _ = _convert_and_split(messages)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["role"] == "assistant"
|
||||
assert result[0]["content"] == "Hello!"
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Tests for CrewAI import compatibility (#3268).
|
||||
|
||||
When crewai>=0.177.0 is installed, the import path for flow events changed
|
||||
from crewai.utilities.events.flow_events to crewai.events.types.flow_events.
|
||||
The fix uses try/except to support both import paths.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def _make_fake_module(attrs: dict) -> types.ModuleType:
|
||||
"""Create a fake module with given attributes."""
|
||||
mod = types.ModuleType("fake")
|
||||
for k, v in attrs.items():
|
||||
setattr(mod, k, v)
|
||||
return mod
|
||||
|
||||
|
||||
class TestCrewAIImportCompat:
|
||||
"""Test that crewai_sdk.py can import flow events from either path."""
|
||||
|
||||
def test_old_import_path_works(self):
|
||||
"""When old crewai (<0.177.0) is installed, old import path should work."""
|
||||
# The old path: crewai.utilities.events.flow_events
|
||||
# If it exists, the import should succeed
|
||||
fake_events = _make_fake_module(
|
||||
{
|
||||
"FlowEvent": type("FlowEvent", (), {}),
|
||||
"FlowStartedEvent": type("FlowStartedEvent", (), {}),
|
||||
"MethodExecutionStartedEvent": type(
|
||||
"MethodExecutionStartedEvent", (), {}
|
||||
),
|
||||
"MethodExecutionFinishedEvent": type(
|
||||
"MethodExecutionFinishedEvent", (), {}
|
||||
),
|
||||
"FlowFinishedEvent": type("FlowFinishedEvent", (), {}),
|
||||
}
|
||||
)
|
||||
|
||||
with patch.dict(
|
||||
sys.modules,
|
||||
{
|
||||
"crewai.utilities.events.flow_events": fake_events,
|
||||
},
|
||||
):
|
||||
# Simulate what our try/except does
|
||||
try:
|
||||
from crewai.utilities.events.flow_events import FlowEvent
|
||||
|
||||
old_path_works = True
|
||||
except ImportError:
|
||||
old_path_works = False
|
||||
|
||||
assert old_path_works, "Old import path should work with old crewai"
|
||||
|
||||
def test_new_import_path_fallback(self):
|
||||
"""When new crewai (>=0.177.0) is installed, new import path should work."""
|
||||
# The new path: crewai.events.types.flow_events
|
||||
fake_events = _make_fake_module(
|
||||
{
|
||||
"FlowEvent": type("FlowEvent", (), {}),
|
||||
"FlowStartedEvent": type("FlowStartedEvent", (), {}),
|
||||
"MethodExecutionStartedEvent": type(
|
||||
"MethodExecutionStartedEvent", (), {}
|
||||
),
|
||||
"MethodExecutionFinishedEvent": type(
|
||||
"MethodExecutionFinishedEvent", (), {}
|
||||
),
|
||||
"FlowFinishedEvent": type("FlowFinishedEvent", (), {}),
|
||||
}
|
||||
)
|
||||
|
||||
# Remove old path, add new path
|
||||
old_mod_key = "crewai.utilities.events.flow_events"
|
||||
saved = sys.modules.get(old_mod_key)
|
||||
|
||||
try:
|
||||
# Make old path fail
|
||||
sys.modules[old_mod_key] = None # type: ignore
|
||||
|
||||
with patch.dict(
|
||||
sys.modules,
|
||||
{
|
||||
"crewai.events.types.flow_events": fake_events,
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
# Simulate fallback logic
|
||||
try:
|
||||
from crewai.utilities.events.flow_events import FlowEvent # type: ignore
|
||||
|
||||
new_path_needed = False
|
||||
except ImportError:
|
||||
new_path_needed = True
|
||||
|
||||
assert new_path_needed, "Old path should fail, triggering fallback"
|
||||
|
||||
from crewai.events.types.flow_events import FlowEvent # type: ignore
|
||||
|
||||
assert FlowEvent is not None, "New path should work"
|
||||
finally:
|
||||
if saved is not None:
|
||||
sys.modules[old_mod_key] = saved
|
||||
elif old_mod_key in sys.modules:
|
||||
del sys.modules[old_mod_key]
|
||||
|
||||
def test_both_paths_fail_raises_import_error(self):
|
||||
"""When neither path works, ImportError should propagate."""
|
||||
# This simulates crewai not having flow events at all
|
||||
old_key = "crewai.utilities.events.flow_events"
|
||||
new_key = "crewai.events.types.flow_events"
|
||||
|
||||
saved_old = sys.modules.get(old_key)
|
||||
saved_new = sys.modules.get(new_key)
|
||||
|
||||
try:
|
||||
sys.modules[old_key] = None # type: ignore
|
||||
sys.modules[new_key] = None # type: ignore
|
||||
|
||||
with pytest.raises(ImportError):
|
||||
# Old path
|
||||
try:
|
||||
from crewai.utilities.events.flow_events import FlowEvent # type: ignore
|
||||
except ImportError:
|
||||
pass
|
||||
# New path
|
||||
from crewai.events.types.flow_events import FlowEvent # type: ignore
|
||||
finally:
|
||||
if saved_old is not None:
|
||||
sys.modules[old_key] = saved_old
|
||||
elif old_key in sys.modules:
|
||||
del sys.modules[old_key]
|
||||
if saved_new is not None:
|
||||
sys.modules[new_key] = saved_new
|
||||
elif new_key in sys.modules:
|
||||
del sys.modules[new_key]
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Tests for emit_messages/emit_tool_calls filtering in LangGraphAGUIAgent.
|
||||
|
||||
Covers the two bugs fixed in https://github.com/CopilotKit/CopilotKit/issues/2066:
|
||||
1. raw_event is a dict, so metadata must be read with .get() not getattr()
|
||||
2. Filtered events must return None (not ""), and run() must strip them
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
from ag_ui.core import (
|
||||
EventType,
|
||||
TextMessageStartEvent,
|
||||
TextMessageContentEvent,
|
||||
ToolCallStartEvent,
|
||||
)
|
||||
from copilotkit.langgraph_agui_agent import LangGraphAGUIAgent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent():
|
||||
"""Create a LangGraphAGUIAgent with a mocked graph."""
|
||||
mock_graph = MagicMock()
|
||||
mock_graph.get_state = MagicMock()
|
||||
a = LangGraphAGUIAgent(name="test", graph=mock_graph)
|
||||
a.active_run = {"id": "run-1", "thread_id": "t-1"}
|
||||
return a
|
||||
|
||||
|
||||
def _make_text_event(metadata: dict) -> TextMessageContentEvent:
|
||||
"""Create a TEXT_MESSAGE_CONTENT event with a dict raw_event carrying metadata."""
|
||||
return TextMessageContentEvent(
|
||||
messageId="msg-1",
|
||||
delta="hello",
|
||||
rawEvent={"metadata": metadata},
|
||||
)
|
||||
|
||||
|
||||
def _make_tool_event(metadata: dict) -> ToolCallStartEvent:
|
||||
"""Create a TOOL_CALL_START event with a dict raw_event carrying metadata."""
|
||||
return ToolCallStartEvent(
|
||||
toolCallId="tc-1",
|
||||
toolCallName="some_tool",
|
||||
rawEvent={"metadata": metadata},
|
||||
)
|
||||
|
||||
|
||||
# ---------- Bug 1: dict metadata reading via .get() ----------
|
||||
|
||||
|
||||
class TestDictMetadataReading:
|
||||
"""raw_event is a dict — metadata must be read with .get(), not getattr()."""
|
||||
|
||||
def test_emit_messages_false_filters_text_event(self, agent):
|
||||
"""emit-messages=False should suppress text message events."""
|
||||
event = _make_text_event({"copilotkit:emit-messages": False})
|
||||
result = agent._dispatch_event(event)
|
||||
assert result is None
|
||||
|
||||
def test_emit_tool_calls_false_filters_tool_event(self, agent):
|
||||
"""emit-tool-calls=False should suppress tool call events."""
|
||||
event = _make_tool_event({"copilotkit:emit-tool-calls": False})
|
||||
result = agent._dispatch_event(event)
|
||||
assert result is None
|
||||
|
||||
def test_emit_messages_true_passes_through(self, agent):
|
||||
"""emit-messages=True should NOT filter — event passes to super()."""
|
||||
event = _make_text_event({"copilotkit:emit-messages": True})
|
||||
result = agent._dispatch_event(event)
|
||||
# super()._dispatch_event returns the event object (not None or "")
|
||||
assert result is not None
|
||||
|
||||
def test_no_metadata_key_passes_through(self, agent):
|
||||
"""No emit- keys in metadata — event should pass through."""
|
||||
event = _make_text_event({"some-other-key": True})
|
||||
result = agent._dispatch_event(event)
|
||||
assert result is not None
|
||||
|
||||
def test_empty_metadata_passes_through(self, agent):
|
||||
"""Empty metadata dict — event should pass through."""
|
||||
event = _make_text_event({})
|
||||
result = agent._dispatch_event(event)
|
||||
assert result is not None
|
||||
|
||||
|
||||
# ---------- Bug 2: filtered returns None, not "" ----------
|
||||
|
||||
|
||||
class TestFilteredReturnValue:
|
||||
"""Filtered events must return None (not empty string) to avoid encoder crash."""
|
||||
|
||||
def test_filtered_message_returns_none_not_empty_string(self, agent):
|
||||
event = _make_text_event({"copilotkit:emit-messages": False})
|
||||
result = agent._dispatch_event(event)
|
||||
assert result is None, f"Expected None, got {result!r}"
|
||||
assert result != "", "Must not return empty string — crashes the encoder"
|
||||
|
||||
def test_filtered_tool_call_returns_none_not_empty_string(self, agent):
|
||||
event = _make_tool_event({"copilotkit:emit-tool-calls": False})
|
||||
result = agent._dispatch_event(event)
|
||||
assert result is None, f"Expected None, got {result!r}"
|
||||
assert result != "", "Must not return empty string — crashes the encoder"
|
||||
|
||||
|
||||
# ---------- run() filters out None values ----------
|
||||
|
||||
|
||||
class TestRunFiltersNone:
|
||||
"""The run() override must strip None values from the event stream."""
|
||||
|
||||
def test_run_filters_none_events(self, agent):
|
||||
"""None values from _dispatch_event should not appear in run() output."""
|
||||
|
||||
# Mock super().run() to yield a mix of real events and None
|
||||
real_event = "data: {}\n\n"
|
||||
|
||||
async def mock_super_run(self, input):
|
||||
yield real_event
|
||||
yield None
|
||||
yield real_event
|
||||
|
||||
with patch.object(
|
||||
LangGraphAGUIAgent.__bases__[0],
|
||||
"run",
|
||||
new=mock_super_run,
|
||||
):
|
||||
results = asyncio.run(_collect_async_gen(agent.run(MagicMock())))
|
||||
|
||||
assert results == [real_event, real_event]
|
||||
assert None not in results
|
||||
|
||||
|
||||
async def _collect_async_gen(agen):
|
||||
"""Collect all items from an async generator into a list."""
|
||||
items = []
|
||||
async for item in agen:
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
# ---------- Edge cases: missing / None rawEvent ----------
|
||||
|
||||
|
||||
class TestMissingOrNoneRawEvent:
|
||||
"""Events where rawEvent is absent or None should pass through to super() without crashing."""
|
||||
|
||||
def test_none_raw_event_passes_through(self, agent):
|
||||
"""Event with raw_event=None should not crash and should pass through."""
|
||||
event = TextMessageContentEvent(
|
||||
messageId="msg-1",
|
||||
delta="hello",
|
||||
rawEvent=None,
|
||||
)
|
||||
# Should not raise and should call super() (returning non-None)
|
||||
result = agent._dispatch_event(event)
|
||||
assert result is not None
|
||||
|
||||
def test_event_without_raw_event_attr_passes_through(self, agent):
|
||||
"""If raw_event attribute is missing entirely, should pass through."""
|
||||
# TextMessageStartEvent has rawEvent as optional, passing None simulates missing
|
||||
event = TextMessageStartEvent(
|
||||
messageId="msg-1",
|
||||
role="assistant",
|
||||
rawEvent=None,
|
||||
)
|
||||
result = agent._dispatch_event(event)
|
||||
assert result is not None
|
||||
|
||||
|
||||
# ---------- Edge cases: None values for emit metadata keys ----------
|
||||
|
||||
|
||||
class TestNoneEmitMetadataValues:
|
||||
"""None values for emit metadata keys should NOT filter events (only False filters)."""
|
||||
|
||||
def test_none_emit_messages_does_not_filter(self, agent):
|
||||
"""copilotkit:emit-messages=None should not suppress text events (only False does)."""
|
||||
event = _make_text_event({"copilotkit:emit-messages": None})
|
||||
result = agent._dispatch_event(event)
|
||||
assert result is not None, (
|
||||
"None value should not filter — only False suppresses"
|
||||
)
|
||||
|
||||
def test_none_emit_tool_calls_does_not_filter(self, agent):
|
||||
"""copilotkit:emit-tool-calls=None should not suppress tool events."""
|
||||
event = _make_tool_event({"copilotkit:emit-tool-calls": None})
|
||||
result = agent._dispatch_event(event)
|
||||
assert result is not None
|
||||
|
||||
def test_zero_emit_messages_does_not_filter(self, agent):
|
||||
"""0 (falsy but not False) should not suppress text events (only False does)."""
|
||||
event = _make_text_event({"copilotkit:emit-messages": 0})
|
||||
result = agent._dispatch_event(event)
|
||||
assert result is not None
|
||||
@@ -0,0 +1,807 @@
|
||||
"""Tests for the optional `id` parameter on copilotkit_emit_tool_call.
|
||||
|
||||
Covers:
|
||||
1. LangGraph variant: default UUID generation, custom ID passthrough, return value
|
||||
2. CrewAI variant: default UUID generation, custom ID passthrough, return value
|
||||
3. AG-UI agent dispatch: custom ID propagates to all three TOOL_CALL events
|
||||
4. AG-UI dispatch validation: defensive CopilotKitMisuseError paths for
|
||||
missing/invalid id, name, args, non-serializable args, and non-dict value
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
from ag_ui.core import (
|
||||
EventType,
|
||||
CustomEvent,
|
||||
)
|
||||
from ag_ui_langgraph import LangGraphAgent as AGUIBase
|
||||
from copilotkit.langgraph_agui_agent import (
|
||||
LangGraphAGUIAgent,
|
||||
CustomEventNames,
|
||||
)
|
||||
from copilotkit.exc import CopilotKitMisuseError
|
||||
|
||||
|
||||
# ---- Fixtures ----
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent():
|
||||
"""Create a LangGraphAGUIAgent with a mocked graph."""
|
||||
mock_graph = MagicMock()
|
||||
mock_graph.get_state = MagicMock()
|
||||
a = LangGraphAGUIAgent(name="test", graph=mock_graph)
|
||||
a.active_run = {"id": "run-1", "thread_id": "t-1"}
|
||||
return a
|
||||
|
||||
|
||||
def _track_parent_dispatches(agent):
|
||||
"""Collect events dispatched to the AG-UI base class."""
|
||||
from contextlib import contextmanager
|
||||
|
||||
@contextmanager
|
||||
def _ctx():
|
||||
dispatched = []
|
||||
original = AGUIBase._dispatch_event
|
||||
|
||||
def _tracking(self_inner, event):
|
||||
dispatched.append(event)
|
||||
return original(self_inner, event)
|
||||
|
||||
with patch.object(AGUIBase, "_dispatch_event", new=_tracking):
|
||||
yield dispatched
|
||||
|
||||
return _ctx()
|
||||
|
||||
|
||||
# ---- LangGraph variant tests ----
|
||||
|
||||
|
||||
class TestLangGraphEmitToolCallOptionalId:
|
||||
"""copilotkit_emit_tool_call (langgraph) with optional id parameter."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_generates_uuid(self):
|
||||
"""When no id is provided, a UUID v4 string should be generated and returned."""
|
||||
with patch(
|
||||
"copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock
|
||||
) as mock_dispatch:
|
||||
from copilotkit.langgraph import copilotkit_emit_tool_call
|
||||
|
||||
config = {"metadata": {}}
|
||||
result = await copilotkit_emit_tool_call(
|
||||
config, name="MyTool", args={"key": "val"}
|
||||
)
|
||||
|
||||
assert isinstance(result, str)
|
||||
uuid.UUID(result)
|
||||
|
||||
payload = mock_dispatch.call_args[0][1]
|
||||
assert payload["id"] == result
|
||||
assert payload["name"] == "MyTool"
|
||||
assert payload["args"] == {"key": "val"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_id_passthrough(self):
|
||||
"""When a custom id is provided, it should be used as-is."""
|
||||
with patch(
|
||||
"copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock
|
||||
) as mock_dispatch:
|
||||
from copilotkit.langgraph import copilotkit_emit_tool_call
|
||||
|
||||
config = {"metadata": {}}
|
||||
result = await copilotkit_emit_tool_call(
|
||||
config, name="MyTool", args={"key": "val"}, tool_call_id="custom-id-123"
|
||||
)
|
||||
|
||||
assert result == "custom-id-123"
|
||||
|
||||
payload = mock_dispatch.call_args[0][1]
|
||||
assert payload["id"] == "custom-id-123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_generated_id(self):
|
||||
"""The return value should be the tool call ID (generated or custom)."""
|
||||
with patch(
|
||||
"copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock
|
||||
):
|
||||
from copilotkit.langgraph import copilotkit_emit_tool_call
|
||||
|
||||
config = {"metadata": {}}
|
||||
|
||||
result_auto = await copilotkit_emit_tool_call(config, name="Tool", args={})
|
||||
assert isinstance(result_auto, str)
|
||||
assert len(result_auto) > 0
|
||||
|
||||
result_custom = await copilotkit_emit_tool_call(
|
||||
config, name="Tool", args={}, tool_call_id="my-id"
|
||||
)
|
||||
assert result_custom == "my-id"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_none_id_generates_uuid(self):
|
||||
"""Explicitly passing tool_call_id=None should behave the same as omitting it."""
|
||||
with patch(
|
||||
"copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock
|
||||
) as mock_dispatch:
|
||||
from copilotkit.langgraph import copilotkit_emit_tool_call
|
||||
|
||||
config = {"metadata": {}}
|
||||
result = await copilotkit_emit_tool_call(
|
||||
config, name="Tool", args={}, tool_call_id=None
|
||||
)
|
||||
|
||||
assert isinstance(result, str)
|
||||
uuid.UUID(result)
|
||||
assert mock_dispatch.call_args[0][1]["id"] == result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_string_id_raises(self):
|
||||
"""Passing an empty string should raise ValueError."""
|
||||
with patch(
|
||||
"copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock
|
||||
):
|
||||
from copilotkit.langgraph import copilotkit_emit_tool_call
|
||||
|
||||
config = {"metadata": {}}
|
||||
with pytest.raises(ValueError, match="non-empty string"):
|
||||
await copilotkit_emit_tool_call(
|
||||
config, name="Tool", args={}, tool_call_id=""
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_whitespace_only_id_raises(self):
|
||||
"""Passing a whitespace-only string should raise ValueError."""
|
||||
with patch(
|
||||
"copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock
|
||||
):
|
||||
from copilotkit.langgraph import copilotkit_emit_tool_call
|
||||
|
||||
config = {"metadata": {}}
|
||||
with pytest.raises(ValueError, match="non-empty string"):
|
||||
await copilotkit_emit_tool_call(
|
||||
config, name="Tool", args={}, tool_call_id=" "
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_whitespace_only_name_raises(self):
|
||||
"""Passing a whitespace-only name should raise CopilotKitMisuseError."""
|
||||
with patch(
|
||||
"copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock
|
||||
):
|
||||
from copilotkit.langgraph import copilotkit_emit_tool_call
|
||||
|
||||
config = {"metadata": {}}
|
||||
with pytest.raises(CopilotKitMisuseError, match="non-empty string"):
|
||||
await copilotkit_emit_tool_call(config, name=" ", args={})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_name_raises(self):
|
||||
"""Passing an empty name should raise CopilotKitMisuseError."""
|
||||
with patch(
|
||||
"copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock
|
||||
):
|
||||
from copilotkit.langgraph import copilotkit_emit_tool_call
|
||||
|
||||
config = {"metadata": {}}
|
||||
with pytest.raises(CopilotKitMisuseError, match="non-empty string"):
|
||||
await copilotkit_emit_tool_call(config, name="", args={})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_serializable_args_raises(self):
|
||||
"""Passing non-JSON-serializable args should raise CopilotKitMisuseError."""
|
||||
with patch(
|
||||
"copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock
|
||||
):
|
||||
from copilotkit.langgraph import copilotkit_emit_tool_call
|
||||
|
||||
config = {"metadata": {}}
|
||||
with pytest.raises(CopilotKitMisuseError, match="not JSON-serializable"):
|
||||
await copilotkit_emit_tool_call(
|
||||
config, name="Tool", args={"bad": {1, 2, 3}}
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_error_propagates_from_post_dispatch_sleep(self):
|
||||
"""CancelledError during the shielded post-dispatch sleep must propagate."""
|
||||
with patch(
|
||||
"copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock
|
||||
) as mock_dispatch:
|
||||
from copilotkit.langgraph import copilotkit_emit_tool_call
|
||||
|
||||
config = {"metadata": {}}
|
||||
|
||||
async def _run_and_cancel():
|
||||
task = asyncio.current_task()
|
||||
# Schedule cancellation after dispatch completes but during sleep
|
||||
original_sleep = asyncio.sleep
|
||||
|
||||
async def _cancel_during_sleep(delay):
|
||||
task.cancel()
|
||||
await original_sleep(0)
|
||||
|
||||
with patch(
|
||||
"copilotkit.langgraph.asyncio.sleep",
|
||||
side_effect=_cancel_during_sleep,
|
||||
):
|
||||
with patch(
|
||||
"copilotkit.langgraph.asyncio.shield",
|
||||
side_effect=lambda coro: coro,
|
||||
):
|
||||
return await copilotkit_emit_tool_call(
|
||||
config,
|
||||
name="CancelTool",
|
||||
args={},
|
||||
tool_call_id="cancel-test-id",
|
||||
)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await _run_and_cancel()
|
||||
|
||||
mock_dispatch.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_error_logs_warning(self, caplog):
|
||||
"""CancelledError during post-dispatch sleep should log with the tool_call_id."""
|
||||
with patch(
|
||||
"copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock
|
||||
):
|
||||
from copilotkit.langgraph import copilotkit_emit_tool_call
|
||||
|
||||
config = {"metadata": {}}
|
||||
|
||||
async def _cancel_sleep(delay):
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="copilotkit.langgraph"):
|
||||
with patch(
|
||||
"copilotkit.langgraph.asyncio.sleep", side_effect=_cancel_sleep
|
||||
):
|
||||
with patch(
|
||||
"copilotkit.langgraph.asyncio.shield",
|
||||
side_effect=lambda coro: coro,
|
||||
):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await copilotkit_emit_tool_call(
|
||||
config,
|
||||
name="Tool",
|
||||
args={},
|
||||
tool_call_id="log-cancel-id",
|
||||
)
|
||||
|
||||
assert any("log-cancel-id" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
# ---- CrewAI variant tests ----
|
||||
|
||||
try:
|
||||
import crewai # noqa: F401
|
||||
|
||||
_has_crewai = True
|
||||
except ImportError:
|
||||
_has_crewai = False
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_crewai, reason="crewai not installed")
|
||||
class TestCrewAIEmitToolCallOptionalId:
|
||||
"""copilotkit_emit_tool_call (crewai) with optional id parameter."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_generates_uuid(self):
|
||||
"""When no id is provided, a UUID v4 string should be generated and returned."""
|
||||
with patch(
|
||||
"copilotkit.crewai.crewai_sdk.queue_put", new_callable=AsyncMock
|
||||
) as mock_queue:
|
||||
from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call
|
||||
|
||||
result = await copilotkit_emit_tool_call(name="MyTool", args={"key": "val"})
|
||||
|
||||
assert isinstance(result, str)
|
||||
uuid.UUID(result)
|
||||
|
||||
start_ev, args_ev, end_ev = mock_queue.call_args[0]
|
||||
assert start_ev["actionExecutionId"] == result
|
||||
assert start_ev["parentMessageId"] == result
|
||||
assert start_ev["actionName"] == "MyTool"
|
||||
assert args_ev["actionExecutionId"] == result
|
||||
assert json.loads(args_ev["args"]) == {"key": "val"}
|
||||
assert end_ev["actionExecutionId"] == result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_id_passthrough(self):
|
||||
"""When a custom id is provided, it should be used as the message_id."""
|
||||
with patch(
|
||||
"copilotkit.crewai.crewai_sdk.queue_put", new_callable=AsyncMock
|
||||
) as mock_queue:
|
||||
from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call
|
||||
|
||||
result = await copilotkit_emit_tool_call(
|
||||
name="MyTool", args={"key": "val"}, tool_call_id="crew-custom-id"
|
||||
)
|
||||
|
||||
assert result == "crew-custom-id"
|
||||
|
||||
start_ev, args_ev, end_ev = mock_queue.call_args[0]
|
||||
assert start_ev["actionExecutionId"] == "crew-custom-id"
|
||||
assert start_ev["parentMessageId"] == "crew-custom-id"
|
||||
assert args_ev["actionExecutionId"] == "crew-custom-id"
|
||||
assert end_ev["actionExecutionId"] == "crew-custom-id"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_id(self):
|
||||
"""Should return the tool call ID regardless of whether it was auto or custom."""
|
||||
with patch("copilotkit.crewai.crewai_sdk.queue_put", new_callable=AsyncMock):
|
||||
from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call
|
||||
|
||||
result_auto = await copilotkit_emit_tool_call(name="T", args={})
|
||||
assert isinstance(result_auto, str)
|
||||
assert len(result_auto) > 0
|
||||
|
||||
result_custom = await copilotkit_emit_tool_call(
|
||||
name="T", args={}, tool_call_id="explicit"
|
||||
)
|
||||
assert result_custom == "explicit"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_string_id_raises(self):
|
||||
"""Passing an empty string should raise ValueError."""
|
||||
with patch("copilotkit.crewai.crewai_sdk.queue_put", new_callable=AsyncMock):
|
||||
from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call
|
||||
|
||||
with pytest.raises(ValueError, match="non-empty string"):
|
||||
await copilotkit_emit_tool_call(name="Tool", args={}, tool_call_id="")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_whitespace_only_id_raises(self):
|
||||
"""Passing a whitespace-only string should raise ValueError."""
|
||||
with patch("copilotkit.crewai.crewai_sdk.queue_put", new_callable=AsyncMock):
|
||||
from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call
|
||||
|
||||
with pytest.raises(ValueError, match="non-empty string"):
|
||||
await copilotkit_emit_tool_call(
|
||||
name="Tool", args={}, tool_call_id=" "
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_none_id_generates_uuid(self):
|
||||
"""Explicitly passing tool_call_id=None should behave the same as omitting it."""
|
||||
with patch("copilotkit.crewai.crewai_sdk.queue_put", new_callable=AsyncMock):
|
||||
from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call
|
||||
|
||||
result = await copilotkit_emit_tool_call(
|
||||
name="Tool", args={}, tool_call_id=None
|
||||
)
|
||||
assert isinstance(result, str)
|
||||
uuid.UUID(result)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_whitespace_only_name_raises(self):
|
||||
"""Passing a whitespace-only name should raise CopilotKitMisuseError."""
|
||||
with patch("copilotkit.crewai.crewai_sdk.queue_put", new_callable=AsyncMock):
|
||||
from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call
|
||||
|
||||
with pytest.raises(CopilotKitMisuseError, match="non-empty string"):
|
||||
await copilotkit_emit_tool_call(name=" ", args={})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_serializable_args_raises(self):
|
||||
"""Passing non-JSON-serializable args should raise CopilotKitMisuseError."""
|
||||
with patch("copilotkit.crewai.crewai_sdk.queue_put", new_callable=AsyncMock):
|
||||
from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call
|
||||
|
||||
with pytest.raises(CopilotKitMisuseError, match="not JSON-serializable"):
|
||||
await copilotkit_emit_tool_call(name="Tool", args={"bad": {1, 2, 3}})
|
||||
|
||||
|
||||
# ---- CrewAI variant: compensating action_execution_end tests ----
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_crewai, reason="crewai not installed")
|
||||
class TestCrewAICompensatingEnd:
|
||||
"""Tests for the compensating action_execution_end when dispatch fails mid-stream.
|
||||
|
||||
queue_put is called once with all three events (start, args, end) in a single
|
||||
batch for atomicity. If the batch fails, a compensating end is always attempted
|
||||
as a best-effort measure — an orphaned END is harmless, but an orphaned START
|
||||
hangs the client UI.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_failure_emits_compensating_end(self):
|
||||
"""If the batched queue_put fails, a compensating end is emitted."""
|
||||
call_count = 0
|
||||
|
||||
async def _failing_queue_put(*events):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
raise RuntimeError("batch failed")
|
||||
|
||||
with patch("copilotkit.crewai.crewai_sdk.queue_put", new=_failing_queue_put):
|
||||
from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call
|
||||
|
||||
with pytest.raises(RuntimeError, match="batch failed"):
|
||||
await copilotkit_emit_tool_call(
|
||||
name="FailTool", args={"x": 1}, tool_call_id="comp-crew-1"
|
||||
)
|
||||
|
||||
assert call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compensating_end_failure_reraises_original(self):
|
||||
"""If the compensating end also fails, the original error still propagates."""
|
||||
call_count = 0
|
||||
|
||||
async def _failing_queue_put(*events):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise RuntimeError(f"queue failure #{call_count}")
|
||||
|
||||
with patch("copilotkit.crewai.crewai_sdk.queue_put", new=_failing_queue_put):
|
||||
from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call
|
||||
|
||||
with pytest.raises(RuntimeError, match="queue failure #1"):
|
||||
await copilotkit_emit_tool_call(
|
||||
name="FailTool", args={}, tool_call_id="comp-crew-3"
|
||||
)
|
||||
|
||||
assert call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compensating_end_failure_emits_log(self, caplog):
|
||||
"""The logger.error call includes the message_id when compensating end fails."""
|
||||
call_count = 0
|
||||
|
||||
async def _failing_queue_put(*events):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise RuntimeError(f"queue failure #{call_count}")
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="copilotkit.crewai.crewai_sdk"):
|
||||
with patch(
|
||||
"copilotkit.crewai.crewai_sdk.queue_put", new=_failing_queue_put
|
||||
):
|
||||
from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await copilotkit_emit_tool_call(
|
||||
name="FailTool", args={}, tool_call_id="log-crew-id"
|
||||
)
|
||||
|
||||
assert any("log-crew-id" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
# ---- AG-UI dispatch: custom ID propagates through all events ----
|
||||
|
||||
|
||||
class TestCustomIdPropagatesThroughAGUI:
|
||||
"""When a custom id is used, the downstream AG-UI events carry that exact ID."""
|
||||
|
||||
def test_custom_id_in_all_tool_call_events(self, agent):
|
||||
"""TOOL_CALL_START, TOOL_CALL_ARGS, and TOOL_CALL_END should all carry the custom id."""
|
||||
with _track_parent_dispatches(agent) as dispatched:
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value={
|
||||
"id": "user-provided-id-42",
|
||||
"name": "CustomTool",
|
||||
"args": {"x": 1},
|
||||
},
|
||||
)
|
||||
agent._dispatch_event(event)
|
||||
|
||||
tool_events = [e for e in dispatched if hasattr(e, "tool_call_id")]
|
||||
assert len(tool_events) == 3
|
||||
for e in tool_events:
|
||||
assert e.tool_call_id == "user-provided-id-42"
|
||||
|
||||
def test_custom_id_in_parent_message_id(self, agent):
|
||||
"""ToolCallStartEvent.parent_message_id should match the custom id."""
|
||||
with _track_parent_dispatches(agent) as dispatched:
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value={
|
||||
"id": "parent-test-id",
|
||||
"name": "ParentTool",
|
||||
"args": {},
|
||||
},
|
||||
)
|
||||
agent._dispatch_event(event)
|
||||
|
||||
start_events = [e for e in dispatched if e.type == EventType.TOOL_CALL_START]
|
||||
assert len(start_events) == 1
|
||||
assert start_events[0].parent_message_id == "parent-test-id"
|
||||
|
||||
def test_custom_id_with_dict_args_serialized(self, agent):
|
||||
"""Custom id + dict args should both work: args JSON-serialized, id preserved."""
|
||||
with _track_parent_dispatches(agent) as dispatched:
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value={
|
||||
"id": "combo-test",
|
||||
"name": "ComboTool",
|
||||
"args": {"nested": {"deep": True}},
|
||||
},
|
||||
)
|
||||
agent._dispatch_event(event)
|
||||
|
||||
args_events = [e for e in dispatched if e.type == EventType.TOOL_CALL_ARGS]
|
||||
assert len(args_events) == 1
|
||||
assert args_events[0].tool_call_id == "combo-test"
|
||||
assert json.loads(args_events[0].delta) == {"nested": {"deep": True}}
|
||||
|
||||
def test_string_args_passed_through_unchanged(self, agent):
|
||||
"""When args is already a JSON string, it should be passed through without re-serializing."""
|
||||
with _track_parent_dispatches(agent) as dispatched:
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value={
|
||||
"id": "string-args-test",
|
||||
"name": "StringArgsTool",
|
||||
"args": '{"x": 1}',
|
||||
},
|
||||
)
|
||||
agent._dispatch_event(event)
|
||||
|
||||
args_events = [e for e in dispatched if e.type == EventType.TOOL_CALL_ARGS]
|
||||
assert len(args_events) == 1
|
||||
assert args_events[0].delta == '{"x": 1}'
|
||||
|
||||
def test_empty_dict_args_does_not_raise(self, agent):
|
||||
"""An empty dict for args is valid and should not raise."""
|
||||
with _track_parent_dispatches(agent) as dispatched:
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value={
|
||||
"id": "empty-args-test",
|
||||
"name": "EmptyArgsTool",
|
||||
"args": {},
|
||||
},
|
||||
)
|
||||
agent._dispatch_event(event)
|
||||
|
||||
tool_events = [e for e in dispatched if hasattr(e, "tool_call_id")]
|
||||
assert len(tool_events) == 3
|
||||
|
||||
|
||||
# ---- AG-UI dispatch: validation negative tests ----
|
||||
|
||||
|
||||
class TestAGUIDispatchValidation:
|
||||
"""Negative tests for defensive validation in _dispatch_event."""
|
||||
|
||||
def test_missing_id_raises(self, agent):
|
||||
"""Event with no 'id' field should raise CopilotKitMisuseError."""
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value={"name": "Tool", "args": {}},
|
||||
)
|
||||
with pytest.raises(CopilotKitMisuseError, match="valid 'id'"):
|
||||
agent._dispatch_event(event)
|
||||
|
||||
def test_non_string_id_raises(self, agent):
|
||||
"""Event with non-string 'id' should raise CopilotKitMisuseError."""
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value={"id": 42, "name": "Tool", "args": {}},
|
||||
)
|
||||
with pytest.raises(CopilotKitMisuseError, match="valid 'id'"):
|
||||
agent._dispatch_event(event)
|
||||
|
||||
def test_empty_string_id_raises(self, agent):
|
||||
"""Event with empty string 'id' should raise CopilotKitMisuseError."""
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value={"id": "", "name": "Tool", "args": {}},
|
||||
)
|
||||
with pytest.raises(CopilotKitMisuseError, match="valid 'id'"):
|
||||
agent._dispatch_event(event)
|
||||
|
||||
def test_whitespace_only_id_raises(self, agent):
|
||||
"""Event with whitespace-only 'id' should raise CopilotKitMisuseError."""
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value={"id": " ", "name": "Tool", "args": {}},
|
||||
)
|
||||
with pytest.raises(CopilotKitMisuseError, match="valid 'id'"):
|
||||
agent._dispatch_event(event)
|
||||
|
||||
def test_missing_name_raises(self, agent):
|
||||
"""Event with no 'name' field should raise CopilotKitMisuseError."""
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value={"id": "valid-id", "args": {}},
|
||||
)
|
||||
with pytest.raises(CopilotKitMisuseError, match="valid 'name'"):
|
||||
agent._dispatch_event(event)
|
||||
|
||||
def test_whitespace_only_name_raises(self, agent):
|
||||
"""Event with whitespace-only 'name' should raise CopilotKitMisuseError."""
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value={"id": "valid-id", "name": " ", "args": {}},
|
||||
)
|
||||
with pytest.raises(CopilotKitMisuseError, match="valid 'name'"):
|
||||
agent._dispatch_event(event)
|
||||
|
||||
def test_missing_args_raises(self, agent):
|
||||
"""Event with no 'args' field should raise CopilotKitMisuseError."""
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value={"id": "valid-id", "name": "Tool"},
|
||||
)
|
||||
with pytest.raises(CopilotKitMisuseError, match="missing 'args'"):
|
||||
agent._dispatch_event(event)
|
||||
|
||||
def test_non_serializable_args_raises(self, agent):
|
||||
"""Event with non-JSON-serializable args (set) should raise CopilotKitMisuseError."""
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value={"id": "valid-id", "name": "Tool", "args": {1, 2, 3}},
|
||||
)
|
||||
with pytest.raises(CopilotKitMisuseError, match="not JSON-serializable"):
|
||||
agent._dispatch_event(event)
|
||||
|
||||
def test_non_dict_value_raises(self, agent):
|
||||
"""Event with non-dict value should raise CopilotKitMisuseError."""
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value=None,
|
||||
)
|
||||
with pytest.raises(CopilotKitMisuseError, match="must be a dict"):
|
||||
agent._dispatch_event(event)
|
||||
|
||||
def test_list_args_accepted(self, agent):
|
||||
"""Event with list args should be accepted (JSON-serializable)."""
|
||||
with _track_parent_dispatches(agent) as dispatched:
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value={"id": "list-args-id", "name": "Tool", "args": [1, 2, 3]},
|
||||
)
|
||||
agent._dispatch_event(event)
|
||||
|
||||
args_events = [e for e in dispatched if e.type == EventType.TOOL_CALL_ARGS]
|
||||
assert len(args_events) == 1
|
||||
assert args_events[0].delta == "[1, 2, 3]"
|
||||
|
||||
def test_int_args_accepted(self, agent):
|
||||
"""Event with int args should be accepted (JSON-serializable)."""
|
||||
with _track_parent_dispatches(agent) as dispatched:
|
||||
event = CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value={"id": "int-args-id", "name": "Tool", "args": 42},
|
||||
)
|
||||
agent._dispatch_event(event)
|
||||
|
||||
args_events = [e for e in dispatched if e.type == EventType.TOOL_CALL_ARGS]
|
||||
assert len(args_events) == 1
|
||||
assert args_events[0].delta == "42"
|
||||
|
||||
|
||||
# ---- AG-UI dispatch: compensating TOOL_CALL_END on mid-stream failure ----
|
||||
|
||||
|
||||
class TestAGUICompensatingEnd:
|
||||
"""Tests for the compensating TOOL_CALL_END when dispatch fails mid-stream."""
|
||||
|
||||
def _make_event(self, tool_call_id="comp-test-id"):
|
||||
return CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name=CustomEventNames.ManuallyEmitToolCall.value,
|
||||
value={
|
||||
"id": tool_call_id,
|
||||
"name": "FailTool",
|
||||
"args": {"x": 1},
|
||||
},
|
||||
)
|
||||
|
||||
def test_failure_after_start_emits_compensating_end(self, agent):
|
||||
"""If TOOL_CALL_ARGS fails after START was sent, a compensating END is dispatched."""
|
||||
call_count = 0
|
||||
original = AGUIBase._dispatch_event
|
||||
|
||||
def _fail_on_args(self_inner, evt):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 2:
|
||||
raise RuntimeError("args dispatch failed")
|
||||
return original(self_inner, evt)
|
||||
|
||||
with patch.object(AGUIBase, "_dispatch_event", new=_fail_on_args):
|
||||
with pytest.raises(RuntimeError, match="args dispatch failed"):
|
||||
agent._dispatch_event(self._make_event())
|
||||
|
||||
assert call_count == 3
|
||||
|
||||
def test_failure_on_start_does_not_emit_compensating_end(self, agent):
|
||||
"""If TOOL_CALL_START itself fails, no compensating END is dispatched."""
|
||||
call_count = 0
|
||||
original = AGUIBase._dispatch_event
|
||||
|
||||
def _fail_on_start(self_inner, evt):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
raise RuntimeError("start dispatch failed")
|
||||
return original(self_inner, evt)
|
||||
|
||||
with patch.object(AGUIBase, "_dispatch_event", new=_fail_on_start):
|
||||
with pytest.raises(RuntimeError, match="start dispatch failed"):
|
||||
agent._dispatch_event(self._make_event())
|
||||
|
||||
assert call_count == 1
|
||||
|
||||
def test_compensating_end_failure_reraises_original(self, agent):
|
||||
"""If the compensating END also fails, the original error propagates."""
|
||||
call_count = 0
|
||||
original = AGUIBase._dispatch_event
|
||||
|
||||
def _fail_on_args_and_end(self_inner, evt):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return original(self_inner, evt)
|
||||
raise RuntimeError(f"dispatch failure #{call_count}")
|
||||
|
||||
with patch.object(AGUIBase, "_dispatch_event", new=_fail_on_args_and_end):
|
||||
with pytest.raises(RuntimeError, match="dispatch failure #2"):
|
||||
agent._dispatch_event(self._make_event())
|
||||
|
||||
assert call_count == 3
|
||||
|
||||
def test_compensating_end_failure_emits_log(self, agent, caplog):
|
||||
"""The logger.error call includes the tool_call_id when compensating END fails."""
|
||||
call_count = 0
|
||||
original = AGUIBase._dispatch_event
|
||||
|
||||
def _fail_on_args_and_end(self_inner, evt):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return original(self_inner, evt)
|
||||
raise RuntimeError(f"dispatch failure #{call_count}")
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="copilotkit.langgraph_agui_agent"):
|
||||
with patch.object(AGUIBase, "_dispatch_event", new=_fail_on_args_and_end):
|
||||
with pytest.raises(RuntimeError):
|
||||
agent._dispatch_event(self._make_event("log-test-id"))
|
||||
|
||||
assert any("log-test-id" in record.message for record in caplog.records)
|
||||
|
||||
def test_failure_on_end_emits_compensating_end(self, agent):
|
||||
"""If TOOL_CALL_END itself fails, a compensating END is still attempted."""
|
||||
call_count = 0
|
||||
original = AGUIBase._dispatch_event
|
||||
|
||||
def _fail_on_end(self_inner, evt):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 3:
|
||||
raise RuntimeError("end dispatch failed")
|
||||
return original(self_inner, evt)
|
||||
|
||||
with patch.object(AGUIBase, "_dispatch_event", new=_fail_on_end):
|
||||
with pytest.raises(RuntimeError, match="end dispatch failed"):
|
||||
agent._dispatch_event(self._make_event("end-fail-id"))
|
||||
|
||||
assert call_count == 4
|
||||
@@ -0,0 +1,464 @@
|
||||
"""Tests for header propagation (x-* prefixed headers to outgoing LLM calls)."""
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import inspect
|
||||
import warnings
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from copilotkit.header_propagation import (
|
||||
get_forwarded_headers,
|
||||
install_httpx_hook,
|
||||
set_forwarded_headers,
|
||||
)
|
||||
|
||||
|
||||
class TestSetForwardedHeaders:
|
||||
"""set_forwarded_headers filters to x-* prefixed headers only."""
|
||||
|
||||
def test_filters_to_x_prefixed_headers(self):
|
||||
set_forwarded_headers(
|
||||
{
|
||||
"x-aimock-strict": "true",
|
||||
"x-aimock-session": "abc123",
|
||||
"x-request-id": "req-456",
|
||||
"x-custom-trace": "xyz",
|
||||
"authorization": "Bearer token",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
)
|
||||
result = get_forwarded_headers()
|
||||
assert result == {
|
||||
"x-aimock-strict": "true",
|
||||
"x-aimock-session": "abc123",
|
||||
"x-request-id": "req-456",
|
||||
"x-custom-trace": "xyz",
|
||||
}
|
||||
|
||||
def test_case_insensitive_prefix_match(self):
|
||||
set_forwarded_headers(
|
||||
{
|
||||
"X-AIMock-Strict": "true",
|
||||
"X-AIMOCK-SESSION": "xyz",
|
||||
}
|
||||
)
|
||||
result = get_forwarded_headers()
|
||||
assert result == {
|
||||
"x-aimock-strict": "true",
|
||||
"x-aimock-session": "xyz",
|
||||
}
|
||||
|
||||
def test_empty_when_no_x_headers(self):
|
||||
set_forwarded_headers(
|
||||
{
|
||||
"authorization": "Bearer token",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
)
|
||||
result = get_forwarded_headers()
|
||||
assert result == {}
|
||||
|
||||
def test_empty_input(self):
|
||||
set_forwarded_headers({})
|
||||
result = get_forwarded_headers()
|
||||
assert result == {}
|
||||
|
||||
|
||||
class TestGetForwardedHeaders:
|
||||
"""get_forwarded_headers returns empty dict by default."""
|
||||
|
||||
def test_default_is_empty_dict(self):
|
||||
# Reset to default by running in a fresh context
|
||||
ctx = contextvars.copy_context()
|
||||
result = ctx.run(get_forwarded_headers)
|
||||
assert result == {}
|
||||
|
||||
|
||||
class TestRoundTrip:
|
||||
"""set + get round-trip."""
|
||||
|
||||
def test_round_trip(self):
|
||||
headers = {"x-aimock-strict": "true", "x-aimock-foo": "bar"}
|
||||
set_forwarded_headers(headers)
|
||||
assert get_forwarded_headers() == headers
|
||||
|
||||
def test_overwrite(self):
|
||||
set_forwarded_headers({"x-aimock-a": "1"})
|
||||
set_forwarded_headers({"x-aimock-b": "2"})
|
||||
assert get_forwarded_headers() == {"x-aimock-b": "2"}
|
||||
|
||||
|
||||
class TestInstallHttpxHook:
|
||||
"""install_httpx_hook appends to event hooks."""
|
||||
|
||||
def test_appends_to_raw_httpx_client(self):
|
||||
"""Mock a raw httpx client with event_hooks dict."""
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self):
|
||||
self.event_hooks = {"request": []}
|
||||
|
||||
client = FakeClient()
|
||||
install_httpx_hook(client)
|
||||
assert len(client.event_hooks["request"]) == 1
|
||||
|
||||
def test_appends_to_sdk_wrapped_client(self):
|
||||
"""Mock an OpenAI/Anthropic SDK client with _client attribute."""
|
||||
|
||||
class FakeTransport:
|
||||
def __init__(self):
|
||||
self.event_hooks = {"request": []}
|
||||
|
||||
class FakeSDKClient:
|
||||
def __init__(self):
|
||||
self._client = FakeTransport()
|
||||
|
||||
client = FakeSDKClient()
|
||||
install_httpx_hook(client)
|
||||
assert len(client._client.event_hooks["request"]) == 1
|
||||
|
||||
def test_hook_injects_headers(self):
|
||||
"""The installed hook reads from ContextVar and injects headers."""
|
||||
|
||||
class FakeHeaders(dict):
|
||||
"""Dict that also supports item assignment like httpx Headers."""
|
||||
|
||||
pass
|
||||
|
||||
class FakeRequest:
|
||||
def __init__(self):
|
||||
self.headers = FakeHeaders()
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self):
|
||||
self.event_hooks = {"request": []}
|
||||
|
||||
client = FakeClient()
|
||||
install_httpx_hook(client)
|
||||
|
||||
# Set headers in ContextVar
|
||||
set_forwarded_headers({"x-aimock-strict": "true"})
|
||||
|
||||
# Simulate httpx calling the hook
|
||||
request = FakeRequest()
|
||||
client.event_hooks["request"][0](request)
|
||||
|
||||
assert request.headers["x-aimock-strict"] == "true"
|
||||
|
||||
def test_hook_noop_when_no_headers(self):
|
||||
"""Hook is a no-op when ContextVar is empty (demo traffic)."""
|
||||
|
||||
class FakeRequest:
|
||||
def __init__(self):
|
||||
self.headers = {}
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self):
|
||||
self.event_hooks = {"request": []}
|
||||
|
||||
# Reset ContextVar to simulate a fresh request with no aimock headers
|
||||
set_forwarded_headers({})
|
||||
|
||||
client = FakeClient()
|
||||
install_httpx_hook(client)
|
||||
request = FakeRequest()
|
||||
client.event_hooks["request"][0](request)
|
||||
assert request.headers == {}
|
||||
|
||||
def test_no_event_hooks_warns(self):
|
||||
"""Client without event_hooks emits a warning."""
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
install_httpx_hook(object())
|
||||
assert len(w) == 1
|
||||
assert "event_hooks" in str(w[0].message)
|
||||
|
||||
|
||||
class TestInstallHttpxHookNestedChain:
|
||||
"""install_httpx_hook walks the ``._client`` chain (modern OpenAI SDK shape)."""
|
||||
|
||||
def test_walks_chain_to_find_event_hooks(self):
|
||||
"""Modern OpenAI SDK: ChatOpenAI.client -> Resource -> openai.OpenAI
|
||||
-> ._client = httpx wrapper (event_hooks here). The hook installer
|
||||
must walk past intermediate ``._client`` hops that do NOT expose
|
||||
event_hooks, and attach to the first one that does.
|
||||
"""
|
||||
|
||||
class HttpxWrapper:
|
||||
def __init__(self):
|
||||
self.event_hooks = {"request": []}
|
||||
|
||||
class OpenAIClient:
|
||||
"""Mirrors openai.OpenAI / openai.AsyncOpenAI: holds an httpx
|
||||
wrapper at ``._client`` but exposes no event_hooks itself."""
|
||||
|
||||
def __init__(self):
|
||||
self._client = HttpxWrapper()
|
||||
|
||||
class Resource:
|
||||
"""Mirrors openai resources (Completions, AsyncCompletions, etc):
|
||||
holds the OpenAI client at ``._client``."""
|
||||
|
||||
def __init__(self):
|
||||
self._client = OpenAIClient()
|
||||
|
||||
class LangChainWrapper:
|
||||
"""Mirrors langchain_openai.ChatOpenAI.client: the resource."""
|
||||
|
||||
def __init__(self):
|
||||
self.client = Resource()
|
||||
|
||||
outer = LangChainWrapper()
|
||||
install_httpx_hook(outer.client)
|
||||
|
||||
# The hook MUST land on the deepest object that has event_hooks
|
||||
hooks = outer.client._client._client.event_hooks["request"]
|
||||
assert len(hooks) == 1, (
|
||||
f"expected hook installed on deepest httpx wrapper, got "
|
||||
f"{len(hooks)} hook(s) (chain walk likely stopped too shallow)"
|
||||
)
|
||||
|
||||
def test_idempotent_double_install(self):
|
||||
"""Calling install_httpx_hook twice must NOT register the hook twice."""
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self):
|
||||
self.event_hooks = {"request": []}
|
||||
|
||||
client = FakeClient()
|
||||
install_httpx_hook(client)
|
||||
install_httpx_hook(client)
|
||||
assert len(client.event_hooks["request"]) == 1, (
|
||||
"install_httpx_hook must be idempotent — double install detected"
|
||||
)
|
||||
|
||||
def test_header_agnostic_injection(self):
|
||||
"""Hook must forward whatever headers are in the ContextVar, not just x-aimock-*."""
|
||||
|
||||
class FakeRequest:
|
||||
def __init__(self):
|
||||
self.headers = {}
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self):
|
||||
self.event_hooks = {"request": []}
|
||||
|
||||
client = FakeClient()
|
||||
install_httpx_hook(client)
|
||||
|
||||
set_forwarded_headers(
|
||||
{
|
||||
"x-trace-id": "abc",
|
||||
"x-team-id": "ck",
|
||||
"x-anything-custom": "v",
|
||||
}
|
||||
)
|
||||
|
||||
request = FakeRequest()
|
||||
client.event_hooks["request"][0](request)
|
||||
|
||||
assert request.headers["x-trace-id"] == "abc"
|
||||
assert request.headers["x-team-id"] == "ck"
|
||||
assert request.headers["x-anything-custom"] == "v"
|
||||
|
||||
|
||||
class TestInstallHttpxHookAsync:
|
||||
"""For httpx.AsyncClient instances the installed hook MUST be an
|
||||
async callable; httpx awaits async-client request hooks."""
|
||||
|
||||
def test_async_client_gets_async_hook(self):
|
||||
"""httpx.AsyncClient -> the installed hook is a coroutine function."""
|
||||
|
||||
async def _run():
|
||||
client = httpx.AsyncClient()
|
||||
try:
|
||||
install_httpx_hook(client)
|
||||
hooks = client.event_hooks["request"]
|
||||
assert len(hooks) == 1
|
||||
hook = hooks[0]
|
||||
assert inspect.iscoroutinefunction(hook), (
|
||||
f"AsyncClient must receive an async hook, got sync "
|
||||
f"callable {hook!r}"
|
||||
)
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_async_hook_is_awaitable_and_injects_headers(self):
|
||||
"""Awaiting the installed async hook must inject forwarded headers."""
|
||||
|
||||
class FakeRequest:
|
||||
def __init__(self):
|
||||
self.headers = {}
|
||||
|
||||
async def _run():
|
||||
client = httpx.AsyncClient()
|
||||
try:
|
||||
install_httpx_hook(client)
|
||||
set_forwarded_headers({"x-aimock-strict": "true"})
|
||||
hook = client.event_hooks["request"][0]
|
||||
request = FakeRequest()
|
||||
# Must be awaitable without TypeError
|
||||
result = hook(request)
|
||||
assert inspect.isawaitable(result), (
|
||||
"async-client hook must return an awaitable"
|
||||
)
|
||||
await result
|
||||
assert request.headers["x-aimock-strict"] == "true"
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_sync_client_gets_sync_hook(self):
|
||||
"""httpx.Client -> the installed hook is a plain sync callable
|
||||
(httpx calls request hooks synchronously on a sync client)."""
|
||||
client = httpx.Client()
|
||||
try:
|
||||
install_httpx_hook(client)
|
||||
hooks = client.event_hooks["request"]
|
||||
assert len(hooks) == 1
|
||||
hook = hooks[0]
|
||||
assert not inspect.iscoroutinefunction(hook), (
|
||||
f"sync Client must receive a sync hook, got coroutine function {hook!r}"
|
||||
)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
class TestInstallHttpxHookRegressions:
|
||||
"""Regression tests for chain-depth, async/sync MRO heuristic, and
|
||||
foreign-hook preservation."""
|
||||
|
||||
def test_chain_depth_exhausted_warns(self):
|
||||
"""A ``._client`` chain LONGER than the walker's max depth where NO
|
||||
node exposes event_hooks must emit a warning (loud-via-warning) and
|
||||
not silently no-op."""
|
||||
|
||||
class ChainNode:
|
||||
def __init__(self):
|
||||
self._client: object | None = None # filled in below
|
||||
|
||||
# Build a chain of depth well beyond _MAX_CHAIN_DEPTH (5 hops).
|
||||
# 10 nodes total, none of which carries event_hooks.
|
||||
nodes = [ChainNode() for _ in range(10)]
|
||||
for i in range(len(nodes) - 1):
|
||||
nodes[i]._client = nodes[i + 1]
|
||||
# Terminate the chain at the last node with a non-None, non-event_hooks
|
||||
# object so the walker doesn't short-circuit on None.
|
||||
nodes[-1]._client = object()
|
||||
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
install_httpx_hook(nodes[0])
|
||||
assert len(w) >= 1
|
||||
assert any("event_hooks" in str(item.message) for item in w), (
|
||||
f"expected a warning mentioning event_hooks, got {[str(i.message) for i in w]}"
|
||||
)
|
||||
|
||||
def test_sync_client_with_async_named_mro_base_is_sync(self):
|
||||
"""A SYNC duck-typed client whose MRO includes a base class named
|
||||
``Async*`` (but whose own class name is neither ``AsyncClient`` nor
|
||||
starts with ``Async``) must be classified as SYNC. An overbroad
|
||||
``startswith("Async")`` MRO heuristic would misclassify it as async
|
||||
and install an async hook that httpx calls synchronously -> the
|
||||
coroutine is never awaited and headers are silently dropped."""
|
||||
|
||||
class AsyncMixin:
|
||||
"""A base class whose NAME starts with ``Async`` but which does
|
||||
not represent an async client (mirrors e.g. AsyncContextManager
|
||||
appearing in MRO)."""
|
||||
|
||||
pass
|
||||
|
||||
class FakeSyncClient(AsyncMixin):
|
||||
def __init__(self):
|
||||
self.event_hooks = {"request": []}
|
||||
|
||||
client = FakeSyncClient()
|
||||
install_httpx_hook(client)
|
||||
hooks = client.event_hooks["request"]
|
||||
assert len(hooks) == 1
|
||||
hook = hooks[0]
|
||||
assert not inspect.iscoroutinefunction(hook), (
|
||||
f"sync duck-typed client (MRO contains Async*-named base) must "
|
||||
f"receive a sync hook, got coroutine function {hook!r}"
|
||||
)
|
||||
|
||||
def test_preexisting_foreign_hook_is_preserved(self):
|
||||
"""If event_hooks['request'] already contains an unrelated callable
|
||||
(not carrying our idempotency marker), install_httpx_hook must
|
||||
APPEND ours alongside it — not skip installation, not replace the
|
||||
foreign hook."""
|
||||
|
||||
def foreign_hook(request):
|
||||
# Unrelated pre-existing hook; carries no marker.
|
||||
return None
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self):
|
||||
self.event_hooks = {"request": [foreign_hook]}
|
||||
|
||||
client = FakeClient()
|
||||
install_httpx_hook(client)
|
||||
|
||||
hooks = client.event_hooks["request"]
|
||||
assert len(hooks) == 2, (
|
||||
f"expected foreign hook + ours = 2 hooks, got {len(hooks)}: {hooks!r}"
|
||||
)
|
||||
# Foreign hook still present and unchanged.
|
||||
assert foreign_hook in hooks
|
||||
# Exactly one of the two hooks is ours (carries the marker).
|
||||
from copilotkit.header_propagation import _HOOK_MARKER
|
||||
|
||||
marked = [h for h in hooks if getattr(h, _HOOK_MARKER, False)]
|
||||
assert len(marked) == 1, (
|
||||
f"expected exactly one hook to carry our marker, got {len(marked)}"
|
||||
)
|
||||
|
||||
|
||||
class TestContextVarIsolation:
|
||||
"""ContextVar provides proper isolation across contexts."""
|
||||
|
||||
def test_context_isolation(self):
|
||||
"""Headers set in one context don't leak to another."""
|
||||
results = {}
|
||||
|
||||
def task_a():
|
||||
set_forwarded_headers({"x-aimock-task": "a"})
|
||||
results["a"] = get_forwarded_headers()
|
||||
|
||||
def task_b():
|
||||
set_forwarded_headers({"x-aimock-task": "b"})
|
||||
results["b"] = get_forwarded_headers()
|
||||
|
||||
# Run in separate contexts to verify isolation
|
||||
ctx_a = contextvars.copy_context()
|
||||
ctx_b = contextvars.copy_context()
|
||||
|
||||
ctx_a.run(task_a)
|
||||
ctx_b.run(task_b)
|
||||
|
||||
assert results["a"] == {"x-aimock-task": "a"}
|
||||
assert results["b"] == {"x-aimock-task": "b"}
|
||||
|
||||
def test_child_context_does_not_pollute_parent(self):
|
||||
"""Setting headers in a child context does not affect the parent."""
|
||||
|
||||
# Ensure clean state in a fresh context
|
||||
def _run():
|
||||
parent_before = get_forwarded_headers()
|
||||
|
||||
def child():
|
||||
set_forwarded_headers({"x-aimock-child": "yes"})
|
||||
|
||||
ctx = contextvars.copy_context()
|
||||
ctx.run(child)
|
||||
|
||||
parent_after = get_forwarded_headers()
|
||||
assert parent_before == parent_after
|
||||
|
||||
contextvars.copy_context().run(_run)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Tests for #3096: copilotkit_interrupt crashes on non-list resume values."""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
class TestInterruptNonListResume:
|
||||
"""Verify copilotkit_interrupt handles string/dict resume values without crashing."""
|
||||
|
||||
@patch("copilotkit.langgraph.interrupt")
|
||||
def test_string_resume_value(self, mock_interrupt):
|
||||
"""When LangGraph 1.x returns a string resume value, should not crash."""
|
||||
mock_interrupt.return_value = "user approved"
|
||||
|
||||
from copilotkit.langgraph import copilotkit_interrupt
|
||||
|
||||
answer, response = copilotkit_interrupt(message="Do you approve?")
|
||||
|
||||
assert answer == "user approved"
|
||||
assert response == "user approved"
|
||||
|
||||
@patch("copilotkit.langgraph.interrupt")
|
||||
def test_dict_resume_value(self, mock_interrupt):
|
||||
"""When LangGraph 1.x returns a dict resume value, should return JSON string."""
|
||||
mock_interrupt.return_value = {"approved": True, "reason": "looks good"}
|
||||
|
||||
from copilotkit.langgraph import copilotkit_interrupt
|
||||
|
||||
answer, response = copilotkit_interrupt(message="Do you approve?")
|
||||
|
||||
parsed = json.loads(answer)
|
||||
assert parsed["approved"] is True
|
||||
assert parsed["reason"] == "looks good"
|
||||
|
||||
@patch("copilotkit.langgraph.interrupt")
|
||||
def test_list_resume_value_still_works(self, mock_interrupt):
|
||||
"""Existing behavior: list resume values should still use [-1].content."""
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.content = "yes"
|
||||
mock_interrupt.return_value = [mock_msg]
|
||||
|
||||
from copilotkit.langgraph import copilotkit_interrupt
|
||||
|
||||
answer, response = copilotkit_interrupt(message="Do you approve?")
|
||||
|
||||
assert answer == "yes"
|
||||
assert response == [mock_msg]
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Tests for multi-part content handling in langchain_messages_to_copilotkit.
|
||||
|
||||
Covers the fix in PR #3844 / issue #1748: when AIMessage.content is a list
|
||||
of content blocks (e.g. Anthropic models), all text parts must be extracted
|
||||
and concatenated — not just the first element.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
||||
|
||||
from copilotkit.langgraph import langchain_messages_to_copilotkit
|
||||
|
||||
|
||||
class TestMultiPartContentList:
|
||||
"""AIMessage.content as a list should concatenate all text parts."""
|
||||
|
||||
def test_list_of_text_dicts(self):
|
||||
"""Multiple {"type": "text", "text": "..."} dicts are all concatenated."""
|
||||
msg = AIMessage(
|
||||
id="ai-1",
|
||||
content=[
|
||||
{"type": "text", "text": "Hello "},
|
||||
{"type": "text", "text": "world"},
|
||||
],
|
||||
)
|
||||
result = langchain_messages_to_copilotkit([msg])
|
||||
assert len(result) == 1
|
||||
assert result[0]["content"] == "Hello world"
|
||||
assert result[0]["role"] == "assistant"
|
||||
|
||||
def test_list_of_strings(self):
|
||||
"""Content list of plain strings should be concatenated."""
|
||||
msg = AIMessage(
|
||||
id="ai-2",
|
||||
content=["Part A", " Part B"],
|
||||
)
|
||||
result = langchain_messages_to_copilotkit([msg])
|
||||
assert len(result) == 1
|
||||
assert result[0]["content"] == "Part A Part B"
|
||||
|
||||
def test_mixed_strings_and_text_dicts(self):
|
||||
"""Mix of plain strings and text dicts should all be concatenated."""
|
||||
msg = AIMessage(
|
||||
id="ai-3",
|
||||
content=[
|
||||
"Start ",
|
||||
{"type": "text", "text": "middle "},
|
||||
{"text": "end"},
|
||||
],
|
||||
)
|
||||
result = langchain_messages_to_copilotkit([msg])
|
||||
assert len(result) == 1
|
||||
assert result[0]["content"] == "Start middle end"
|
||||
|
||||
def test_non_text_parts_are_skipped(self):
|
||||
"""Non-text content blocks (e.g. images) should be ignored."""
|
||||
msg = AIMessage(
|
||||
id="ai-4",
|
||||
content=[
|
||||
{"type": "text", "text": "Sample png file"},
|
||||
{
|
||||
"type": "image",
|
||||
"image_data": {"data": "base64data", "format": "image/png"},
|
||||
},
|
||||
],
|
||||
)
|
||||
result = langchain_messages_to_copilotkit([msg])
|
||||
assert len(result) == 1
|
||||
assert result[0]["content"] == "Sample png file"
|
||||
|
||||
def test_empty_list_returns_empty_content(self):
|
||||
"""Empty content list should produce assistant message with empty string."""
|
||||
msg = AIMessage(
|
||||
id="ai-5",
|
||||
content=[],
|
||||
)
|
||||
result = langchain_messages_to_copilotkit([msg])
|
||||
# Assistant messages are always emitted (even with empty content)
|
||||
# so that tool call entries can reference them via parentMessageId.
|
||||
assert len(result) == 1
|
||||
assert result[0]["content"] == ""
|
||||
assert result[0]["role"] == "assistant"
|
||||
|
||||
def test_single_text_dict_in_list(self):
|
||||
"""Single text dict in a list should still be extracted."""
|
||||
msg = AIMessage(
|
||||
id="ai-6",
|
||||
content=[{"type": "text", "text": "Only one part"}],
|
||||
)
|
||||
result = langchain_messages_to_copilotkit([msg])
|
||||
assert len(result) == 1
|
||||
assert result[0]["content"] == "Only one part"
|
||||
|
||||
def test_dict_without_type_but_with_text_key(self):
|
||||
"""A dict with "text" key but no "type" should still have text extracted."""
|
||||
msg = AIMessage(
|
||||
id="ai-7",
|
||||
content=[{"text": "no type field"}],
|
||||
)
|
||||
result = langchain_messages_to_copilotkit([msg])
|
||||
assert len(result) == 1
|
||||
assert result[0]["content"] == "no type field"
|
||||
|
||||
|
||||
class TestSingleDictContent:
|
||||
"""AIMessage.content as a single dict (Anthropic style) should extract text.
|
||||
|
||||
Note: langchain_core.messages.AIMessage validates content as str | list,
|
||||
so a raw dict cannot be passed directly. We use a mock to exercise the
|
||||
dict-handling code path in langchain_messages_to_copilotkit, which exists
|
||||
to handle edge cases from deserialized or non-standard message objects.
|
||||
"""
|
||||
|
||||
def test_dict_with_text_key(self):
|
||||
"""A content dict with "text" key should have its text extracted."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
msg = MagicMock(spec=AIMessage)
|
||||
msg.content = {"text": "dict content"}
|
||||
msg.id = "ai-8"
|
||||
msg.tool_calls = []
|
||||
result = langchain_messages_to_copilotkit([msg])
|
||||
assert len(result) == 1
|
||||
assert result[0]["content"] == "dict content"
|
||||
|
||||
|
||||
class TestPlainStringContent:
|
||||
"""Standard string content should still work as before."""
|
||||
|
||||
def test_plain_string_content(self):
|
||||
"""Normal string content passes through unchanged."""
|
||||
msg = AIMessage(
|
||||
id="ai-9",
|
||||
content="Just a string",
|
||||
)
|
||||
result = langchain_messages_to_copilotkit([msg])
|
||||
assert len(result) == 1
|
||||
assert result[0]["content"] == "Just a string"
|
||||
|
||||
def test_human_message_string(self):
|
||||
"""HumanMessage with string content still works."""
|
||||
msg = HumanMessage(id="human-1", content="Hello")
|
||||
result = langchain_messages_to_copilotkit([msg])
|
||||
assert len(result) == 1
|
||||
assert result[0]["role"] == "user"
|
||||
assert result[0]["content"] == "Hello"
|
||||
|
||||
def test_system_message_string(self):
|
||||
"""SystemMessage with string content still works."""
|
||||
msg = SystemMessage(id="sys-1", content="System prompt")
|
||||
result = langchain_messages_to_copilotkit([msg])
|
||||
assert len(result) == 1
|
||||
assert result[0]["role"] == "system"
|
||||
assert result[0]["content"] == "System prompt"
|
||||
|
||||
|
||||
class TestIssue1748Reproduction:
|
||||
"""Directly reproduces the scenario from issue #1748.
|
||||
|
||||
The original bug: when content is a list of dicts including an image block,
|
||||
only the first element was taken via `content[0]`, which was the dict itself,
|
||||
not a string. This caused the message to be silently dropped or mangled.
|
||||
"""
|
||||
|
||||
def test_text_and_image_content_preserves_text(self):
|
||||
"""The exact scenario from issue #1748: text + image content blocks."""
|
||||
msg = AIMessage(
|
||||
id="ai-repro",
|
||||
content=[
|
||||
{"type": "text", "text": "Sample png file"},
|
||||
{
|
||||
"type": "image",
|
||||
"image_data": {"data": "aW1hZ2VfZGF0YQ==", "format": "image/png"},
|
||||
},
|
||||
],
|
||||
)
|
||||
result = langchain_messages_to_copilotkit([msg])
|
||||
assert len(result) == 1
|
||||
assert result[0]["content"] == "Sample png file"
|
||||
assert result[0]["role"] == "assistant"
|
||||
|
||||
def test_multiple_text_parts_are_not_truncated(self):
|
||||
"""The core bug: only the first element was kept. All text must survive."""
|
||||
msg = AIMessage(
|
||||
id="ai-trunc",
|
||||
content=[
|
||||
{"type": "text", "text": "First part. "},
|
||||
{"type": "text", "text": "Second part. "},
|
||||
{"type": "text", "text": "Third part."},
|
||||
],
|
||||
)
|
||||
result = langchain_messages_to_copilotkit([msg])
|
||||
assert len(result) == 1
|
||||
assert result[0]["content"] == "First part. Second part. Third part."
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Tests for langchain_messages_to_copilotkit assistant message emission.
|
||||
|
||||
Covers the parentMessageId orphan bug where an `if content:` guard skipped
|
||||
emitting the assistant message when content was empty. Tool call entries
|
||||
reference their parent assistant message via parentMessageId, so the
|
||||
assistant message must always be emitted — even when content is empty
|
||||
(standard OpenAI behavior for tool-call-only responses).
|
||||
"""
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
|
||||
from copilotkit.langgraph import langchain_messages_to_copilotkit
|
||||
|
||||
|
||||
def _convert_and_split(messages):
|
||||
"""Convert messages and split result into assistant vs tool-call entries."""
|
||||
result = langchain_messages_to_copilotkit(messages)
|
||||
assistant_msgs = [m for m in result if m.get("role") == "assistant"]
|
||||
tool_call_msgs = [m for m in result if "parentMessageId" in m]
|
||||
return result, assistant_msgs, tool_call_msgs
|
||||
|
||||
|
||||
class TestAssistantMessageAlwaysEmitted:
|
||||
"""The assistant message must always be present so tool call entries can
|
||||
reference it via parentMessageId. Without it, tool calls are orphaned
|
||||
and the frontend cannot reconstruct tool call rendering on reconnect."""
|
||||
|
||||
def test_ai_message_with_content_and_tool_calls(self):
|
||||
"""AIMessage with both content and tool_calls emits assistant + tool calls."""
|
||||
messages = [
|
||||
AIMessage(
|
||||
id="ai-1",
|
||||
content="Let me help with that.",
|
||||
tool_calls=[
|
||||
{"id": "tc-1", "name": "get_help", "args": {"topic": "billing"}}
|
||||
],
|
||||
),
|
||||
]
|
||||
_, assistant_msgs, tool_call_msgs = _convert_and_split(messages)
|
||||
|
||||
assert len(assistant_msgs) == 1
|
||||
assert assistant_msgs[0]["id"] == "ai-1"
|
||||
assert assistant_msgs[0]["content"] == "Let me help with that."
|
||||
|
||||
assert len(tool_call_msgs) == 1
|
||||
assert tool_call_msgs[0]["parentMessageId"] == "ai-1"
|
||||
|
||||
def test_ai_message_with_empty_content_and_tool_calls(self):
|
||||
"""AIMessage with empty content (OpenAI-style) still emits the assistant message."""
|
||||
messages = [
|
||||
AIMessage(
|
||||
id="ai-1",
|
||||
content="",
|
||||
tool_calls=[
|
||||
{"id": "tc-1", "name": "get_help", "args": {"topic": "billing"}}
|
||||
],
|
||||
),
|
||||
]
|
||||
_, assistant_msgs, tool_call_msgs = _convert_and_split(messages)
|
||||
|
||||
assert len(assistant_msgs) == 1, (
|
||||
"Assistant message must be emitted even with empty content"
|
||||
)
|
||||
assert assistant_msgs[0]["id"] == "ai-1"
|
||||
assert assistant_msgs[0]["content"] == ""
|
||||
|
||||
assert len(tool_call_msgs) == 1
|
||||
assert tool_call_msgs[0]["parentMessageId"] == "ai-1"
|
||||
|
||||
def test_ai_message_with_none_content_and_tool_calls(self):
|
||||
"""AIMessage with None content still emits the assistant message."""
|
||||
msg = AIMessage(
|
||||
id="ai-1",
|
||||
content="",
|
||||
tool_calls=[{"id": "tc-1", "name": "get_help", "args": {}}],
|
||||
)
|
||||
# Simulate None content (some models/edge cases)
|
||||
msg.content = None # type: ignore[assignment]
|
||||
|
||||
_, assistant_msgs, _ = _convert_and_split([msg])
|
||||
|
||||
assert len(assistant_msgs) == 1, (
|
||||
"Assistant message must be emitted even with None content"
|
||||
)
|
||||
assert assistant_msgs[0]["content"] == ""
|
||||
|
||||
def test_no_orphaned_parent_message_ids(self):
|
||||
"""Every parentMessageId must reference an existing assistant message."""
|
||||
messages = [
|
||||
HumanMessage(id="h-1", content="help me"),
|
||||
AIMessage(
|
||||
id="ai-1",
|
||||
content="",
|
||||
tool_calls=[
|
||||
{"id": "tc-1", "name": "get_help", "args": {"topic": "billing"}},
|
||||
{"id": "tc-2", "name": "search", "args": {"query": "docs"}},
|
||||
],
|
||||
),
|
||||
ToolMessage(id="tm-1", content="done", tool_call_id="tc-1"),
|
||||
ToolMessage(id="tm-2", content="found", tool_call_id="tc-2"),
|
||||
]
|
||||
result, _, tool_call_msgs = _convert_and_split(messages)
|
||||
|
||||
message_ids = {m["id"] for m in result if "role" in m}
|
||||
|
||||
for tc in tool_call_msgs:
|
||||
assert tc["parentMessageId"] in message_ids, (
|
||||
f"Tool call {tc['id']} has orphaned parentMessageId {tc['parentMessageId']}"
|
||||
)
|
||||
|
||||
def test_ai_message_with_list_content_and_tool_calls(self):
|
||||
"""AIMessage with empty list content (Anthropic-style) still emits the assistant message."""
|
||||
msg = AIMessage(
|
||||
id="ai-1",
|
||||
content="",
|
||||
tool_calls=[{"id": "tc-1", "name": "get_help", "args": {}}],
|
||||
)
|
||||
# Anthropic models can return content as a list; empty list is falsy
|
||||
msg.content = [] # type: ignore[assignment]
|
||||
|
||||
_, assistant_msgs, tool_call_msgs = _convert_and_split([msg])
|
||||
|
||||
assert len(assistant_msgs) == 1, (
|
||||
"Assistant message must be emitted even with empty list content"
|
||||
)
|
||||
assert assistant_msgs[0]["content"] == ""
|
||||
|
||||
assert len(tool_call_msgs) == 1
|
||||
assert tool_call_msgs[0]["parentMessageId"] == "ai-1"
|
||||
|
||||
def test_ai_message_without_tool_calls(self):
|
||||
"""Plain AIMessage (no tool calls) emits just the assistant message."""
|
||||
messages = [AIMessage(id="ai-1", content="Hello!")]
|
||||
result, _, _ = _convert_and_split(messages)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["role"] == "assistant"
|
||||
assert result[0]["content"] == "Hello!"
|
||||
Reference in New Issue
Block a user