chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
View File
+661
View File
@@ -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]
+194
View File
@@ -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
+464
View File
@@ -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)
+48
View File
@@ -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!"