From 865991b463dc328e00d95704ca88c9ead1dfa218 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 19 May 2026 18:09:34 +0200 Subject: [PATCH 01/32] feat(sdk): add optional `id` parameter to `copilotkit_emit_tool_call` Allow callers to supply a custom tool call ID for correlation, idempotency, and observability. Falls back to uuid4 when omitted. Applied consistently across Python LangGraph, Python CrewAI, and JS SDK. Also returns the tool call ID from all variants for downstream reference. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/sdk-js/src/langgraph/utils.ts | 14 +- sdk-python/copilotkit/crewai/crewai_sdk.py | 19 +- sdk-python/copilotkit/langgraph.py | 24 +- .../tests/test_emit_tool_call_optional_id.py | 265 ++++++++++++++++++ 4 files changed, 309 insertions(+), 13 deletions(-) create mode 100644 sdk-python/tests/test_emit_tool_call_optional_id.py diff --git a/packages/sdk-js/src/langgraph/utils.ts b/packages/sdk-js/src/langgraph/utils.ts index 2979a8c989..b0e309a742 100644 --- a/packages/sdk-js/src/langgraph/utils.ts +++ b/packages/sdk-js/src/langgraph/utils.ts @@ -317,7 +317,13 @@ export async function copilotkitEmitToolCall( * The arguments to emit. */ args: any, -) { + /** + * Optional tool call ID. If not provided, a random ID is generated. + * When provided, this ID is used as the toolCallId in AG-UI protocol events. + * The caller is responsible for ensuring uniqueness. + */ + id?: string, +): Promise { if (!config) { throw new CopilotKitMisuseError({ message: "LangGraph configuration is required for copilotkitEmitToolCall", @@ -337,10 +343,12 @@ export async function copilotkitEmitToolCall( }); } + const toolCallId = id ?? randomId(); + try { await dispatchCustomEvent( "copilotkit_manually_emit_tool_call", - { name, args, id: randomId() }, + { name, args, id: toolCallId }, config, ); } catch (error) { @@ -348,6 +356,8 @@ export async function copilotkitEmitToolCall( message: `Failed to emit tool call '${name}': ${error instanceof Error ? error.message : String(error)}`, }); } + + return toolCallId; } export function convertActionToDynamicStructuredTool( diff --git a/sdk-python/copilotkit/crewai/crewai_sdk.py b/sdk-python/copilotkit/crewai/crewai_sdk.py index 4dcb15f320..72241b8713 100644 --- a/sdk-python/copilotkit/crewai/crewai_sdk.py +++ b/sdk-python/copilotkit/crewai/crewai_sdk.py @@ -5,7 +5,7 @@ import uuid import json import asyncio -from typing_extensions import Any, Dict, List, Literal +from typing_extensions import Any, Dict, List, Literal, Optional from pydantic import BaseModel, Field from litellm.types.utils import ( ModelResponse, @@ -226,7 +226,9 @@ async def copilotkit_emit_message(message: str) -> str: return message_id -async def copilotkit_emit_tool_call(*, name: str, args: Dict[str, Any]) -> str: +async def copilotkit_emit_tool_call( + *, name: str, args: Dict[str, Any], id: Optional[str] = None +) -> str: """ Manually emits a tool call to CopilotKit. @@ -234,6 +236,9 @@ async def copilotkit_emit_tool_call(*, name: str, args: Dict[str, Any]) -> str: from copilotkit.crewai import copilotkit_emit_tool_call await copilotkit_emit_tool_call(name="SearchTool", args={"steps": 10}) + + # With a custom ID for correlation/idempotency: + await copilotkit_emit_tool_call(name="SearchTool", args={"steps": 10}, id="my-custom-id") ``` Parameters @@ -242,13 +247,17 @@ async def copilotkit_emit_tool_call(*, name: str, args: Dict[str, Any]) -> str: The name of the tool to emit. args : Dict[str, Any] The arguments to emit. + id : Optional[str] + Optional tool call ID. If not provided, a random UUID is generated. + When provided, this ID is used as the toolCallId in AG-UI protocol events. + The caller is responsible for ensuring uniqueness. Returns ------- - Awaitable[bool] - Always return True. + str + The tool call ID used for the emitted tool call. """ - message_id = str(uuid.uuid4()) + message_id = id if id is not None else str(uuid.uuid4()) await queue_put( action_execution_start( action_execution_id=message_id, diff --git a/sdk-python/copilotkit/langgraph.py b/sdk-python/copilotkit/langgraph.py index bb70c2eee6..c652b27100 100644 --- a/sdk-python/copilotkit/langgraph.py +++ b/sdk-python/copilotkit/langgraph.py @@ -374,8 +374,12 @@ async def copilotkit_emit_message(config: RunnableConfig, message: str): async def copilotkit_emit_tool_call( - config: RunnableConfig, *, name: str, args: Dict[str, Any] -): + config: RunnableConfig, + *, + name: str, + args: Dict[str, Any], + id: Optional[str] = None, +) -> str: """ Manually emits a tool call to CopilotKit. @@ -383,6 +387,9 @@ async def copilotkit_emit_tool_call( from copilotkit.langgraph import copilotkit_emit_tool_call await copilotkit_emit_tool_call(config, name="SearchTool", args={"steps": 10}) + + # With a custom ID for correlation/idempotency: + await copilotkit_emit_tool_call(config, name="SearchTool", args={"steps": 10}, id="my-custom-id") ``` Parameters @@ -393,21 +400,26 @@ async def copilotkit_emit_tool_call( The name of the tool to emit. args : Dict[str, Any] The arguments to emit. + id : Optional[str] + Optional tool call ID. If not provided, a random UUID is generated. + When provided, this ID is used as the toolCallId in AG-UI protocol events. + The caller is responsible for ensuring uniqueness. Returns ------- - Awaitable[bool] - Always return True. + str + The tool call ID used for the emitted tool call. """ + tool_call_id = id if id is not None else str(uuid.uuid4()) await adispatch_custom_event( "copilotkit_manually_emit_tool_call", - {"name": name, "args": args, "id": str(uuid.uuid4())}, + {"name": name, "args": args, "id": tool_call_id}, config=config, ) await asyncio.sleep(0.02) - return True + return tool_call_id def copilotkit_interrupt( diff --git a/sdk-python/tests/test_emit_tool_call_optional_id.py b/sdk-python/tests/test_emit_tool_call_optional_id.py new file mode 100644 index 0000000000..0674c2e10e --- /dev/null +++ b/sdk-python/tests/test_emit_tool_call_optional_id.py @@ -0,0 +1,265 @@ +"""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 +""" + +import json +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, +) + + +# ---- 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"}, 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={}, id="my-id" + ) + assert result_custom == "my-id" + + @pytest.mark.asyncio + async def test_none_id_generates_uuid(self): + """Explicitly passing 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={}, id=None + ) + + assert isinstance(result, str) + uuid.UUID(result) + assert mock_dispatch.call_args[0][1]["id"] == result + + +# ---- 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) + + first_call_arg = mock_queue.call_args[0][0] + assert result in str(first_call_arg) + + @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 + ): + from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call + + result = await copilotkit_emit_tool_call( + name="MyTool", args={"key": "val"}, id="crew-custom-id" + ) + + assert result == "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={}, id="explicit" + ) + assert result_custom == "explicit" + + +# ---- 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}} From 174be4f355451097be3c42f4bec0bd96c9bcc1f3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 16:10:52 +0000 Subject: [PATCH 02/32] style: auto-fix formatting --- .../tests/test_emit_tool_call_optional_id.py | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/sdk-python/tests/test_emit_tool_call_optional_id.py b/sdk-python/tests/test_emit_tool_call_optional_id.py index 0674c2e10e..469ec9add6 100644 --- a/sdk-python/tests/test_emit_tool_call_optional_id.py +++ b/sdk-python/tests/test_emit_tool_call_optional_id.py @@ -108,9 +108,7 @@ async def test_returns_generated_id(self): config = {"metadata": {}} - result_auto = await copilotkit_emit_tool_call( - config, name="Tool", args={} - ) + result_auto = await copilotkit_emit_tool_call(config, name="Tool", args={}) assert isinstance(result_auto, str) assert len(result_auto) > 0 @@ -141,6 +139,7 @@ async def test_none_id_generates_uuid(self): try: import crewai # noqa: F401 + _has_crewai = True except ImportError: _has_crewai = False @@ -158,9 +157,7 @@ async def test_default_generates_uuid(self): ) as mock_queue: from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call - result = await copilotkit_emit_tool_call( - name="MyTool", args={"key": "val"} - ) + result = await copilotkit_emit_tool_call(name="MyTool", args={"key": "val"}) assert isinstance(result, str) uuid.UUID(result) @@ -171,9 +168,7 @@ async def test_default_generates_uuid(self): @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 - ): + 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( @@ -185,9 +180,7 @@ async def test_custom_id_passthrough(self): @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 - ): + 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={}) @@ -239,9 +232,7 @@ def test_custom_id_in_parent_message_id(self, agent): ) agent._dispatch_event(event) - start_events = [ - e for e in dispatched if e.type == EventType.TOOL_CALL_START - ] + 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" From c7693e938e32e35665736b65c7c113d0f0f3218a Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 19 May 2026 18:40:31 +0200 Subject: [PATCH 03/32] fix(sdk): validate id param, rename to tool_call_id, use options bag in JS Address review feedback on copilotkit_emit_tool_call: - Add non-empty string validation for the tool call ID in all 3 SDKs - Rename Python `id` param to `tool_call_id` to avoid shadowing the builtin - Refactor JS 4th positional arg to options bag `{ id?: string }` for extensibility - Document that the ID is also used as parentMessageId in AG-UI events - Add JS tests for the new parameter (generated ID, custom ID, validation) - Add Python validation tests (empty string, whitespace rejection) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/langgraph/__tests__/utils.test.ts | 66 +++++++++++++++++++ packages/sdk-js/src/langgraph/utils.ts | 30 +++++++-- sdk-python/copilotkit/crewai/crewai_sdk.py | 16 +++-- sdk-python/copilotkit/langgraph.py | 19 ++++-- .../tests/test_emit_tool_call_optional_id.py | 51 ++++++++++++-- 5 files changed, 159 insertions(+), 23 deletions(-) diff --git a/packages/sdk-js/src/langgraph/__tests__/utils.test.ts b/packages/sdk-js/src/langgraph/__tests__/utils.test.ts index 06140c376b..3b627e034d 100644 --- a/packages/sdk-js/src/langgraph/__tests__/utils.test.ts +++ b/packages/sdk-js/src/langgraph/__tests__/utils.test.ts @@ -1,9 +1,16 @@ +import { vi } from "vitest"; +import { dispatchCustomEvent } from "@langchain/core/callbacks/dispatch"; import { copilotkitCustomizeConfig, + copilotkitEmitToolCall, convertActionsToDynamicStructuredTools, convertActionToDynamicStructuredTool, } from "../utils"; +vi.mock("@langchain/core/callbacks/dispatch", () => ({ + dispatchCustomEvent: vi.fn().mockResolvedValue(undefined), +})); + describe("copilotkitCustomizeConfig", () => { it("returns config unchanged when no options provided", () => { const baseConfig = { metadata: { existing: true } }; @@ -252,3 +259,62 @@ describe("convertActionsToDynamicStructuredTools", () => { ); }); }); + +describe("copilotkitEmitToolCall", () => { + const mockConfig = { metadata: {} } as any; + const mockedDispatch = vi.mocked(dispatchCustomEvent); + + beforeEach(() => { + mockedDispatch.mockClear(); + }); + + it("returns a generated id when no id is provided", async () => { + const result = await copilotkitEmitToolCall( + mockConfig, + "SearchTool", + { steps: 10 }, + ); + + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + expect(mockedDispatch).toHaveBeenCalledWith( + "copilotkit_manually_emit_tool_call", + { name: "SearchTool", args: { steps: 10 }, id: result }, + mockConfig, + ); + }); + + it("uses caller-provided id verbatim", async () => { + const result = await copilotkitEmitToolCall( + mockConfig, + "SearchTool", + { steps: 10 }, + { id: "custom-abc" }, + ); + + expect(result).toBe("custom-abc"); + expect(mockedDispatch).toHaveBeenCalledWith( + "copilotkit_manually_emit_tool_call", + { name: "SearchTool", args: { steps: 10 }, id: "custom-abc" }, + mockConfig, + ); + }); + + it("treats undefined options the same as omitted", async () => { + const result = await copilotkitEmitToolCall( + mockConfig, + "SearchTool", + {}, + undefined, + ); + + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }); + + it("throws on empty string id", async () => { + await expect( + copilotkitEmitToolCall(mockConfig, "SearchTool", {}, { id: "" }), + ).rejects.toThrow("non-empty string"); + }); +}); diff --git a/packages/sdk-js/src/langgraph/utils.ts b/packages/sdk-js/src/langgraph/utils.ts index b0e309a742..e682c22878 100644 --- a/packages/sdk-js/src/langgraph/utils.ts +++ b/packages/sdk-js/src/langgraph/utils.ts @@ -301,7 +301,10 @@ export async function copilotkitEmitMessage( * ```typescript * import { copilotkitEmitToolCall } from "@copilotkit/sdk-js"; * - * await copilotkitEmitToolCall(config, name="SearchTool", args={"steps": 10}) + * await copilotkitEmitToolCall(config, "SearchTool", { steps: 10 }); + * + * // With a custom ID for correlation/idempotency: + * await copilotkitEmitToolCall(config, "SearchTool", { steps: 10 }, { id: "my-custom-id" }); * ``` */ export async function copilotkitEmitToolCall( @@ -318,11 +321,16 @@ export async function copilotkitEmitToolCall( */ args: any, /** - * Optional tool call ID. If not provided, a random ID is generated. - * When provided, this ID is used as the toolCallId in AG-UI protocol events. - * The caller is responsible for ensuring uniqueness. + * Options for the tool call emission. */ - id?: string, + options?: { + /** + * Optional tool call ID. If not provided, a random ID is generated. + * When provided, this ID is used as the toolCallId and parentMessageId + * in AG-UI protocol events. The caller is responsible for ensuring uniqueness. + */ + id?: string; + }, ): Promise { if (!config) { throw new CopilotKitMisuseError({ @@ -343,7 +351,17 @@ export async function copilotkitEmitToolCall( }); } - const toolCallId = id ?? randomId(); + if ( + options?.id !== undefined && + (typeof options.id !== "string" || options.id.length === 0) + ) { + throw new CopilotKitMisuseError({ + message: + "Tool call id must be a non-empty string when provided for copilotkitEmitToolCall", + }); + } + + const toolCallId = options?.id ?? randomId(); try { await dispatchCustomEvent( diff --git a/sdk-python/copilotkit/crewai/crewai_sdk.py b/sdk-python/copilotkit/crewai/crewai_sdk.py index 72241b8713..fa172dc76a 100644 --- a/sdk-python/copilotkit/crewai/crewai_sdk.py +++ b/sdk-python/copilotkit/crewai/crewai_sdk.py @@ -227,7 +227,7 @@ async def copilotkit_emit_message(message: str) -> str: async def copilotkit_emit_tool_call( - *, name: str, args: Dict[str, Any], id: Optional[str] = None + *, name: str, args: Dict[str, Any], tool_call_id: Optional[str] = None ) -> str: """ Manually emits a tool call to CopilotKit. @@ -238,7 +238,7 @@ async def copilotkit_emit_tool_call( await copilotkit_emit_tool_call(name="SearchTool", args={"steps": 10}) # With a custom ID for correlation/idempotency: - await copilotkit_emit_tool_call(name="SearchTool", args={"steps": 10}, id="my-custom-id") + await copilotkit_emit_tool_call(name="SearchTool", args={"steps": 10}, tool_call_id="my-custom-id") ``` Parameters @@ -247,9 +247,10 @@ async def copilotkit_emit_tool_call( The name of the tool to emit. args : Dict[str, Any] The arguments to emit. - id : Optional[str] + tool_call_id : Optional[str] Optional tool call ID. If not provided, a random UUID is generated. - When provided, this ID is used as the toolCallId in AG-UI protocol events. + When provided, this ID is used as both the toolCallId and + parentMessageId in AG-UI protocol events. The caller is responsible for ensuring uniqueness. Returns @@ -257,7 +258,12 @@ async def copilotkit_emit_tool_call( str The tool call ID used for the emitted tool call. """ - message_id = id if id is not None else str(uuid.uuid4()) + if tool_call_id is not None: + if not isinstance(tool_call_id, str) or not tool_call_id.strip(): + raise ValueError( + "Tool call id must be a non-empty string when provided for copilotkit_emit_tool_call" + ) + message_id = tool_call_id if tool_call_id is not None else str(uuid.uuid4()) await queue_put( action_execution_start( action_execution_id=message_id, diff --git a/sdk-python/copilotkit/langgraph.py b/sdk-python/copilotkit/langgraph.py index c652b27100..82c50c8b91 100644 --- a/sdk-python/copilotkit/langgraph.py +++ b/sdk-python/copilotkit/langgraph.py @@ -378,7 +378,7 @@ async def copilotkit_emit_tool_call( *, name: str, args: Dict[str, Any], - id: Optional[str] = None, + tool_call_id: Optional[str] = None, ) -> str: """ Manually emits a tool call to CopilotKit. @@ -389,7 +389,7 @@ async def copilotkit_emit_tool_call( await copilotkit_emit_tool_call(config, name="SearchTool", args={"steps": 10}) # With a custom ID for correlation/idempotency: - await copilotkit_emit_tool_call(config, name="SearchTool", args={"steps": 10}, id="my-custom-id") + await copilotkit_emit_tool_call(config, name="SearchTool", args={"steps": 10}, tool_call_id="my-custom-id") ``` Parameters @@ -400,23 +400,30 @@ async def copilotkit_emit_tool_call( The name of the tool to emit. args : Dict[str, Any] The arguments to emit. - id : Optional[str] + tool_call_id : Optional[str] Optional tool call ID. If not provided, a random UUID is generated. - When provided, this ID is used as the toolCallId in AG-UI protocol events. - The caller is responsible for ensuring uniqueness. + When provided, this ID is used as the toolCallId and parentMessageId + in AG-UI protocol events. The caller is responsible for ensuring uniqueness. Returns ------- str The tool call ID used for the emitted tool call. """ - tool_call_id = id if id is not None else str(uuid.uuid4()) + if tool_call_id is not None: + if not isinstance(tool_call_id, str) or not tool_call_id.strip(): + raise ValueError( + "Tool call id must be a non-empty string when provided for copilotkit_emit_tool_call" + ) + else: + tool_call_id = str(uuid.uuid4()) await adispatch_custom_event( "copilotkit_manually_emit_tool_call", {"name": name, "args": args, "id": tool_call_id}, config=config, ) + # Allow the event to flush before the next event interleaves await asyncio.sleep(0.02) return tool_call_id diff --git a/sdk-python/tests/test_emit_tool_call_optional_id.py b/sdk-python/tests/test_emit_tool_call_optional_id.py index 469ec9add6..2d0561daa4 100644 --- a/sdk-python/tests/test_emit_tool_call_optional_id.py +++ b/sdk-python/tests/test_emit_tool_call_optional_id.py @@ -90,7 +90,7 @@ async def test_custom_id_passthrough(self): config = {"metadata": {}} result = await copilotkit_emit_tool_call( - config, name="MyTool", args={"key": "val"}, id="custom-id-123" + config, name="MyTool", args={"key": "val"}, tool_call_id="custom-id-123" ) assert result == "custom-id-123" @@ -113,13 +113,13 @@ async def test_returns_generated_id(self): assert len(result_auto) > 0 result_custom = await copilotkit_emit_tool_call( - config, name="Tool", args={}, id="my-id" + 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 id=None should behave the same as omitting it.""" + """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: @@ -127,13 +127,41 @@ async def test_none_id_generates_uuid(self): config = {"metadata": {}} result = await copilotkit_emit_tool_call( - config, name="Tool", args={}, id=None + 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=" " + ) + # ---- CrewAI variant tests ---- @@ -172,7 +200,7 @@ async def test_custom_id_passthrough(self): from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call result = await copilotkit_emit_tool_call( - name="MyTool", args={"key": "val"}, id="crew-custom-id" + name="MyTool", args={"key": "val"}, tool_call_id="crew-custom-id" ) assert result == "crew-custom-id" @@ -188,10 +216,21 @@ async def test_returns_id(self): assert len(result_auto) > 0 result_custom = await copilotkit_emit_tool_call( - name="T", args={}, id="explicit" + 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="" + ) + # ---- AG-UI dispatch: custom ID propagates through all events ---- From d2070b7b5f8078b5cf3cd55e697ca31c80637c5a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 16:41:40 +0000 Subject: [PATCH 04/32] style: auto-fix formatting --- packages/sdk-js/src/langgraph/__tests__/utils.test.ts | 8 +++----- sdk-python/tests/test_emit_tool_call_optional_id.py | 4 +--- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/sdk-js/src/langgraph/__tests__/utils.test.ts b/packages/sdk-js/src/langgraph/__tests__/utils.test.ts index 3b627e034d..79c5cc34e5 100644 --- a/packages/sdk-js/src/langgraph/__tests__/utils.test.ts +++ b/packages/sdk-js/src/langgraph/__tests__/utils.test.ts @@ -269,11 +269,9 @@ describe("copilotkitEmitToolCall", () => { }); it("returns a generated id when no id is provided", async () => { - const result = await copilotkitEmitToolCall( - mockConfig, - "SearchTool", - { steps: 10 }, - ); + const result = await copilotkitEmitToolCall(mockConfig, "SearchTool", { + steps: 10, + }); expect(typeof result).toBe("string"); expect(result.length).toBeGreaterThan(0); diff --git a/sdk-python/tests/test_emit_tool_call_optional_id.py b/sdk-python/tests/test_emit_tool_call_optional_id.py index 2d0561daa4..babcca7742 100644 --- a/sdk-python/tests/test_emit_tool_call_optional_id.py +++ b/sdk-python/tests/test_emit_tool_call_optional_id.py @@ -227,9 +227,7 @@ async def test_empty_string_id_raises(self): 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="" - ) + await copilotkit_emit_tool_call(name="Tool", args={}, tool_call_id="") # ---- AG-UI dispatch: custom ID propagates through all events ---- From 09ede29d58850354935c842e66d96ad4d2c80b5d Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 19 May 2026 18:56:43 +0200 Subject: [PATCH 05/32] fix(sdk): align whitespace validation, improve error handling and docs Align JS whitespace-only ID rejection with Python (.trim()), show returned ID in docstring examples, strengthen CrewAI test assertions to verify event payloads structurally, and stop miscategorizing dispatch errors as CopilotKitMisuseError (preserve original stack). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/langgraph/__tests__/utils.test.ts | 6 ++++++ packages/sdk-js/src/langgraph/utils.ts | 16 ++++++++++------ sdk-python/copilotkit/crewai/crewai_sdk.py | 4 ++-- sdk-python/copilotkit/langgraph.py | 4 ++-- .../tests/test_emit_tool_call_optional_id.py | 19 ++++++++++++++++--- 5 files changed, 36 insertions(+), 13 deletions(-) diff --git a/packages/sdk-js/src/langgraph/__tests__/utils.test.ts b/packages/sdk-js/src/langgraph/__tests__/utils.test.ts index 79c5cc34e5..5b4fcc9300 100644 --- a/packages/sdk-js/src/langgraph/__tests__/utils.test.ts +++ b/packages/sdk-js/src/langgraph/__tests__/utils.test.ts @@ -315,4 +315,10 @@ describe("copilotkitEmitToolCall", () => { copilotkitEmitToolCall(mockConfig, "SearchTool", {}, { id: "" }), ).rejects.toThrow("non-empty string"); }); + + it("throws on whitespace-only id", async () => { + await expect( + copilotkitEmitToolCall(mockConfig, "SearchTool", {}, { id: " " }), + ).rejects.toThrow("non-empty string"); + }); }); diff --git a/packages/sdk-js/src/langgraph/utils.ts b/packages/sdk-js/src/langgraph/utils.ts index e682c22878..0bd5f3b416 100644 --- a/packages/sdk-js/src/langgraph/utils.ts +++ b/packages/sdk-js/src/langgraph/utils.ts @@ -301,10 +301,10 @@ export async function copilotkitEmitMessage( * ```typescript * import { copilotkitEmitToolCall } from "@copilotkit/sdk-js"; * - * await copilotkitEmitToolCall(config, "SearchTool", { steps: 10 }); + * const id = await copilotkitEmitToolCall(config, "SearchTool", { steps: 10 }); * * // With a custom ID for correlation/idempotency: - * await copilotkitEmitToolCall(config, "SearchTool", { steps: 10 }, { id: "my-custom-id" }); + * const customId = await copilotkitEmitToolCall(config, "SearchTool", { steps: 10 }, { id: "my-custom-id" }); * ``` */ export async function copilotkitEmitToolCall( @@ -353,7 +353,7 @@ export async function copilotkitEmitToolCall( if ( options?.id !== undefined && - (typeof options.id !== "string" || options.id.length === 0) + (typeof options.id !== "string" || options.id.trim().length === 0) ) { throw new CopilotKitMisuseError({ message: @@ -370,9 +370,13 @@ export async function copilotkitEmitToolCall( config, ); } catch (error) { - throw new CopilotKitMisuseError({ - message: `Failed to emit tool call '${name}': ${error instanceof Error ? error.message : String(error)}`, - }); + const wrapped = new Error( + `Failed to emit tool call '${name}': ${error instanceof Error ? error.message : String(error)}`, + ); + if (error instanceof Error) { + wrapped.stack = error.stack; + } + throw wrapped; } return toolCallId; diff --git a/sdk-python/copilotkit/crewai/crewai_sdk.py b/sdk-python/copilotkit/crewai/crewai_sdk.py index fa172dc76a..551449481a 100644 --- a/sdk-python/copilotkit/crewai/crewai_sdk.py +++ b/sdk-python/copilotkit/crewai/crewai_sdk.py @@ -235,10 +235,10 @@ async def copilotkit_emit_tool_call( ```python from copilotkit.crewai import copilotkit_emit_tool_call - await copilotkit_emit_tool_call(name="SearchTool", args={"steps": 10}) + tool_call_id = await copilotkit_emit_tool_call(name="SearchTool", args={"steps": 10}) # With a custom ID for correlation/idempotency: - await copilotkit_emit_tool_call(name="SearchTool", args={"steps": 10}, tool_call_id="my-custom-id") + tool_call_id = await copilotkit_emit_tool_call(name="SearchTool", args={"steps": 10}, tool_call_id="my-custom-id") ``` Parameters diff --git a/sdk-python/copilotkit/langgraph.py b/sdk-python/copilotkit/langgraph.py index 82c50c8b91..80dd855137 100644 --- a/sdk-python/copilotkit/langgraph.py +++ b/sdk-python/copilotkit/langgraph.py @@ -386,10 +386,10 @@ async def copilotkit_emit_tool_call( ```python from copilotkit.langgraph import copilotkit_emit_tool_call - await copilotkit_emit_tool_call(config, name="SearchTool", args={"steps": 10}) + tool_call_id = await copilotkit_emit_tool_call(config, name="SearchTool", args={"steps": 10}) # With a custom ID for correlation/idempotency: - await copilotkit_emit_tool_call(config, name="SearchTool", args={"steps": 10}, tool_call_id="my-custom-id") + tool_call_id = await copilotkit_emit_tool_call(config, name="SearchTool", args={"steps": 10}, tool_call_id="my-custom-id") ``` Parameters diff --git a/sdk-python/tests/test_emit_tool_call_optional_id.py b/sdk-python/tests/test_emit_tool_call_optional_id.py index babcca7742..1777e4f240 100644 --- a/sdk-python/tests/test_emit_tool_call_optional_id.py +++ b/sdk-python/tests/test_emit_tool_call_optional_id.py @@ -190,13 +190,20 @@ async def test_default_generates_uuid(self): assert isinstance(result, str) uuid.UUID(result) - first_call_arg = mock_queue.call_args[0][0] - assert result in str(first_call_arg) + 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): + 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( @@ -205,6 +212,12 @@ async def test_custom_id_passthrough(self): 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.""" From 50301b7bf0f73dc38ef19c96ae38c5d49bb1dba9 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 19 May 2026 19:19:00 +0200 Subject: [PATCH 06/32] fix(sdk): restore error type, rename options.id, add validation parity - Restore CopilotKitMisuseError for dispatch failures in JS (was bare Error) - Rename JS options.id to options.toolCallId for cross-SDK naming parity - Add name/args validation to Python LangGraph and CrewAI variants - Add defensive field validation in AG-UI dispatch handler - Add missing CrewAI whitespace-only ID test - Add JS dispatch failure test Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/langgraph/__tests__/utils.test.ts | 19 ++++++---- packages/sdk-js/src/langgraph/utils.ts | 22 +++++------- sdk-python/copilotkit/crewai/crewai_sdk.py | 10 ++++++ sdk-python/copilotkit/langgraph.py | 10 ++++++ sdk-python/copilotkit/langgraph_agui_agent.py | 35 ++++++++++++++----- .../tests/test_emit_tool_call_optional_id.py | 9 +++++ 6 files changed, 77 insertions(+), 28 deletions(-) diff --git a/packages/sdk-js/src/langgraph/__tests__/utils.test.ts b/packages/sdk-js/src/langgraph/__tests__/utils.test.ts index 5b4fcc9300..9259027600 100644 --- a/packages/sdk-js/src/langgraph/__tests__/utils.test.ts +++ b/packages/sdk-js/src/langgraph/__tests__/utils.test.ts @@ -282,12 +282,12 @@ describe("copilotkitEmitToolCall", () => { ); }); - it("uses caller-provided id verbatim", async () => { + it("uses caller-provided toolCallId verbatim", async () => { const result = await copilotkitEmitToolCall( mockConfig, "SearchTool", { steps: 10 }, - { id: "custom-abc" }, + { toolCallId: "custom-abc" }, ); expect(result).toBe("custom-abc"); @@ -310,15 +310,22 @@ describe("copilotkitEmitToolCall", () => { expect(result.length).toBeGreaterThan(0); }); - it("throws on empty string id", async () => { + it("throws on empty string toolCallId", async () => { await expect( - copilotkitEmitToolCall(mockConfig, "SearchTool", {}, { id: "" }), + copilotkitEmitToolCall(mockConfig, "SearchTool", {}, { toolCallId: "" }), ).rejects.toThrow("non-empty string"); }); - it("throws on whitespace-only id", async () => { + it("throws on whitespace-only toolCallId", async () => { await expect( - copilotkitEmitToolCall(mockConfig, "SearchTool", {}, { id: " " }), + copilotkitEmitToolCall(mockConfig, "SearchTool", {}, { toolCallId: " " }), ).rejects.toThrow("non-empty string"); }); + + it("wraps dispatch failure as CopilotKitMisuseError", async () => { + mockedDispatch.mockRejectedValueOnce(new Error("transport closed")); + await expect( + copilotkitEmitToolCall(mockConfig, "SearchTool", {}), + ).rejects.toThrow(/Failed to emit tool call 'SearchTool': transport closed/); + }); }); diff --git a/packages/sdk-js/src/langgraph/utils.ts b/packages/sdk-js/src/langgraph/utils.ts index 0bd5f3b416..776b01725a 100644 --- a/packages/sdk-js/src/langgraph/utils.ts +++ b/packages/sdk-js/src/langgraph/utils.ts @@ -301,10 +301,10 @@ export async function copilotkitEmitMessage( * ```typescript * import { copilotkitEmitToolCall } from "@copilotkit/sdk-js"; * - * const id = await copilotkitEmitToolCall(config, "SearchTool", { steps: 10 }); + * const toolCallId = await copilotkitEmitToolCall(config, "SearchTool", { steps: 10 }); * * // With a custom ID for correlation/idempotency: - * const customId = await copilotkitEmitToolCall(config, "SearchTool", { steps: 10 }, { id: "my-custom-id" }); + * const toolCallId = await copilotkitEmitToolCall(config, "SearchTool", { steps: 10 }, { toolCallId: "my-custom-id" }); * ``` */ export async function copilotkitEmitToolCall( @@ -329,7 +329,7 @@ export async function copilotkitEmitToolCall( * When provided, this ID is used as the toolCallId and parentMessageId * in AG-UI protocol events. The caller is responsible for ensuring uniqueness. */ - id?: string; + toolCallId?: string; }, ): Promise { if (!config) { @@ -352,8 +352,8 @@ export async function copilotkitEmitToolCall( } if ( - options?.id !== undefined && - (typeof options.id !== "string" || options.id.trim().length === 0) + options?.toolCallId !== undefined && + (typeof options.toolCallId !== "string" || options.toolCallId.trim().length === 0) ) { throw new CopilotKitMisuseError({ message: @@ -361,7 +361,7 @@ export async function copilotkitEmitToolCall( }); } - const toolCallId = options?.id ?? randomId(); + const toolCallId = options?.toolCallId ?? randomId(); try { await dispatchCustomEvent( @@ -370,13 +370,9 @@ export async function copilotkitEmitToolCall( config, ); } catch (error) { - const wrapped = new Error( - `Failed to emit tool call '${name}': ${error instanceof Error ? error.message : String(error)}`, - ); - if (error instanceof Error) { - wrapped.stack = error.stack; - } - throw wrapped; + throw new CopilotKitMisuseError({ + message: `Failed to emit tool call '${name}': ${error instanceof Error ? error.message : String(error)}`, + }); } return toolCallId; diff --git a/sdk-python/copilotkit/crewai/crewai_sdk.py b/sdk-python/copilotkit/crewai/crewai_sdk.py index 551449481a..cfe8d55fcb 100644 --- a/sdk-python/copilotkit/crewai/crewai_sdk.py +++ b/sdk-python/copilotkit/crewai/crewai_sdk.py @@ -258,6 +258,16 @@ async def copilotkit_emit_tool_call( str The tool call ID used for the emitted tool call. """ + if not name or not isinstance(name, str): + raise ValueError( + "Tool name must be a non-empty string for copilotkit_emit_tool_call" + ) + + if args is None or not isinstance(args, dict): + raise ValueError( + "Tool arguments must be a dict for copilotkit_emit_tool_call" + ) + if tool_call_id is not None: if not isinstance(tool_call_id, str) or not tool_call_id.strip(): raise ValueError( diff --git a/sdk-python/copilotkit/langgraph.py b/sdk-python/copilotkit/langgraph.py index 80dd855137..d41d0a7221 100644 --- a/sdk-python/copilotkit/langgraph.py +++ b/sdk-python/copilotkit/langgraph.py @@ -410,6 +410,16 @@ async def copilotkit_emit_tool_call( str The tool call ID used for the emitted tool call. """ + if not name or not isinstance(name, str): + raise ValueError( + "Tool name must be a non-empty string for copilotkit_emit_tool_call" + ) + + if args is None or not isinstance(args, dict): + raise ValueError( + "Tool arguments must be a dict for copilotkit_emit_tool_call" + ) + if tool_call_id is not None: if not isinstance(tool_call_id, str) or not tool_call_id.strip(): raise ValueError( diff --git a/sdk-python/copilotkit/langgraph_agui_agent.py b/sdk-python/copilotkit/langgraph_agui_agent.py index 778118572a..7f506bb801 100644 --- a/sdk-python/copilotkit/langgraph_agui_agent.py +++ b/sdk-python/copilotkit/langgraph_agui_agent.py @@ -104,30 +104,47 @@ def _dispatch_event(self, event) -> str: return super()._dispatch_event(event) if custom_event.name == CustomEventNames.ManuallyEmitToolCall.value: - # Emit the tool call events + value = custom_event.value + tool_call_id = value.get("id") + tool_call_name = value.get("name") + tool_call_args = value.get("args") + + if not tool_call_id or not isinstance(tool_call_id, str): + raise ValueError( + f"ManuallyEmitToolCall event missing valid 'id': {value!r}" + ) + if not tool_call_name or not isinstance(tool_call_name, str): + raise ValueError( + f"ManuallyEmitToolCall event missing valid 'name': {value!r}" + ) + if tool_call_args is None: + raise ValueError( + f"ManuallyEmitToolCall event missing 'args': {value!r}" + ) + super()._dispatch_event( ToolCallStartEvent( type=EventType.TOOL_CALL_START, - tool_call_id=custom_event.value["id"], - tool_call_name=custom_event.value["name"], - parent_message_id=custom_event.value["id"], + tool_call_id=tool_call_id, + tool_call_name=tool_call_name, + parent_message_id=tool_call_id, raw_event=event, ) ) super()._dispatch_event( ToolCallArgsEvent( type=EventType.TOOL_CALL_ARGS, - tool_call_id=custom_event.value["id"], - delta=custom_event.value["args"] - if isinstance(custom_event.value["args"], str) - else json.dumps(custom_event.value["args"]), + tool_call_id=tool_call_id, + delta=tool_call_args + if isinstance(tool_call_args, str) + else json.dumps(tool_call_args), raw_event=event, ) ) super()._dispatch_event( ToolCallEndEvent( type=EventType.TOOL_CALL_END, - tool_call_id=custom_event.value["id"], + tool_call_id=tool_call_id, raw_event=event, ) ) diff --git a/sdk-python/tests/test_emit_tool_call_optional_id.py b/sdk-python/tests/test_emit_tool_call_optional_id.py index 1777e4f240..11fa800923 100644 --- a/sdk-python/tests/test_emit_tool_call_optional_id.py +++ b/sdk-python/tests/test_emit_tool_call_optional_id.py @@ -242,6 +242,15 @@ async def test_empty_string_id_raises(self): 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=" ") + # ---- AG-UI dispatch: custom ID propagates through all events ---- From 6fb417a90a64967a56a4bc83dfde89454cb8bbc6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 17:19:55 +0000 Subject: [PATCH 07/32] style: auto-fix formatting --- packages/sdk-js/src/langgraph/__tests__/utils.test.ts | 11 +++++++++-- packages/sdk-js/src/langgraph/utils.ts | 3 ++- sdk-python/copilotkit/crewai/crewai_sdk.py | 4 +--- sdk-python/copilotkit/langgraph.py | 4 +--- sdk-python/tests/test_emit_tool_call_optional_id.py | 4 +++- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/sdk-js/src/langgraph/__tests__/utils.test.ts b/packages/sdk-js/src/langgraph/__tests__/utils.test.ts index 9259027600..542d09d3ea 100644 --- a/packages/sdk-js/src/langgraph/__tests__/utils.test.ts +++ b/packages/sdk-js/src/langgraph/__tests__/utils.test.ts @@ -318,7 +318,12 @@ describe("copilotkitEmitToolCall", () => { it("throws on whitespace-only toolCallId", async () => { await expect( - copilotkitEmitToolCall(mockConfig, "SearchTool", {}, { toolCallId: " " }), + copilotkitEmitToolCall( + mockConfig, + "SearchTool", + {}, + { toolCallId: " " }, + ), ).rejects.toThrow("non-empty string"); }); @@ -326,6 +331,8 @@ describe("copilotkitEmitToolCall", () => { mockedDispatch.mockRejectedValueOnce(new Error("transport closed")); await expect( copilotkitEmitToolCall(mockConfig, "SearchTool", {}), - ).rejects.toThrow(/Failed to emit tool call 'SearchTool': transport closed/); + ).rejects.toThrow( + /Failed to emit tool call 'SearchTool': transport closed/, + ); }); }); diff --git a/packages/sdk-js/src/langgraph/utils.ts b/packages/sdk-js/src/langgraph/utils.ts index 776b01725a..7fd6f6b1a9 100644 --- a/packages/sdk-js/src/langgraph/utils.ts +++ b/packages/sdk-js/src/langgraph/utils.ts @@ -353,7 +353,8 @@ export async function copilotkitEmitToolCall( if ( options?.toolCallId !== undefined && - (typeof options.toolCallId !== "string" || options.toolCallId.trim().length === 0) + (typeof options.toolCallId !== "string" || + options.toolCallId.trim().length === 0) ) { throw new CopilotKitMisuseError({ message: diff --git a/sdk-python/copilotkit/crewai/crewai_sdk.py b/sdk-python/copilotkit/crewai/crewai_sdk.py index cfe8d55fcb..0cd9b0a76e 100644 --- a/sdk-python/copilotkit/crewai/crewai_sdk.py +++ b/sdk-python/copilotkit/crewai/crewai_sdk.py @@ -264,9 +264,7 @@ async def copilotkit_emit_tool_call( ) if args is None or not isinstance(args, dict): - raise ValueError( - "Tool arguments must be a dict for copilotkit_emit_tool_call" - ) + raise ValueError("Tool arguments must be a dict for copilotkit_emit_tool_call") if tool_call_id is not None: if not isinstance(tool_call_id, str) or not tool_call_id.strip(): diff --git a/sdk-python/copilotkit/langgraph.py b/sdk-python/copilotkit/langgraph.py index d41d0a7221..b3435dc437 100644 --- a/sdk-python/copilotkit/langgraph.py +++ b/sdk-python/copilotkit/langgraph.py @@ -416,9 +416,7 @@ async def copilotkit_emit_tool_call( ) if args is None or not isinstance(args, dict): - raise ValueError( - "Tool arguments must be a dict for copilotkit_emit_tool_call" - ) + raise ValueError("Tool arguments must be a dict for copilotkit_emit_tool_call") if tool_call_id is not None: if not isinstance(tool_call_id, str) or not tool_call_id.strip(): diff --git a/sdk-python/tests/test_emit_tool_call_optional_id.py b/sdk-python/tests/test_emit_tool_call_optional_id.py index 11fa800923..76280c4a61 100644 --- a/sdk-python/tests/test_emit_tool_call_optional_id.py +++ b/sdk-python/tests/test_emit_tool_call_optional_id.py @@ -249,7 +249,9 @@ async def test_whitespace_only_id_raises(self): 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=" ") + await copilotkit_emit_tool_call( + name="Tool", args={}, tool_call_id=" " + ) # ---- AG-UI dispatch: custom ID propagates through all events ---- From 9cb89960562b88e113004997a7cb97efa02e43a0 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 19 May 2026 19:41:17 +0200 Subject: [PATCH 08/32] fix(sdk): harden validation, error types, and dispatch safety across SDKs Address code review findings: stop mislabeling dispatch errors as CopilotKitMisuseError in JS (let them propagate naturally), add CopilotKitMisuseError(ValueError) to Python SDK, pre-serialize args in AG-UI handler to prevent partial event emission, align whitespace validation across all SDKs and the dispatch layer, tighten JS args type to Record, and add comprehensive negative tests for AG-UI dispatch validation. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/__tests__/error-handling.test.ts | 6 +- .../src/langgraph/__tests__/utils.test.ts | 31 ++++- packages/sdk-js/src/langgraph/utils.ts | 30 ++--- sdk-python/copilotkit/crewai/crewai_sdk.py | 13 +- sdk-python/copilotkit/exc.py | 9 ++ sdk-python/copilotkit/langgraph.py | 17 ++- sdk-python/copilotkit/langgraph_agui_agent.py | 28 ++-- .../tests/test_emit_tool_call_optional_id.py | 123 ++++++++++++++++++ 8 files changed, 213 insertions(+), 44 deletions(-) diff --git a/packages/sdk-js/src/__tests__/error-handling.test.ts b/packages/sdk-js/src/__tests__/error-handling.test.ts index 0252e10b94..a793e424d3 100644 --- a/packages/sdk-js/src/__tests__/error-handling.test.ts +++ b/packages/sdk-js/src/__tests__/error-handling.test.ts @@ -229,12 +229,12 @@ describe("SDK-JS Error Handling", () => { it("should throw CopilotKitMisuseError when args is undefined for copilotkitEmitToolCall", async () => { await expect( - copilotkitEmitToolCall(mockConfig, "testTool", undefined), + copilotkitEmitToolCall(mockConfig, "testTool", undefined as any), ).rejects.toThrow(CopilotKitMisuseError); await expect( - copilotkitEmitToolCall(mockConfig, "testTool", undefined), + copilotkitEmitToolCall(mockConfig, "testTool", undefined as any), ).rejects.toThrow( - "Tool arguments are required for copilotkitEmitToolCall", + "Tool arguments must be a plain object for copilotkitEmitToolCall", ); }); }); diff --git a/packages/sdk-js/src/langgraph/__tests__/utils.test.ts b/packages/sdk-js/src/langgraph/__tests__/utils.test.ts index 542d09d3ea..8e532a1dac 100644 --- a/packages/sdk-js/src/langgraph/__tests__/utils.test.ts +++ b/packages/sdk-js/src/langgraph/__tests__/utils.test.ts @@ -308,6 +308,11 @@ describe("copilotkitEmitToolCall", () => { expect(typeof result).toBe("string"); expect(result.length).toBeGreaterThan(0); + expect(mockedDispatch).toHaveBeenCalledWith( + "copilotkit_manually_emit_tool_call", + { name: "SearchTool", args: {}, id: result }, + mockConfig, + ); }); it("throws on empty string toolCallId", async () => { @@ -327,12 +332,30 @@ describe("copilotkitEmitToolCall", () => { ).rejects.toThrow("non-empty string"); }); - it("wraps dispatch failure as CopilotKitMisuseError", async () => { + it("throws on whitespace-only name", async () => { + await expect( + copilotkitEmitToolCall(mockConfig, " ", {}), + ).rejects.toThrow("non-empty string"); + }); + + it("throws on non-object args", async () => { + await expect( + copilotkitEmitToolCall(mockConfig, "SearchTool", 42 as any), + ).rejects.toThrow("plain object"); + + await expect( + copilotkitEmitToolCall(mockConfig, "SearchTool", null as any), + ).rejects.toThrow("plain object"); + + await expect( + copilotkitEmitToolCall(mockConfig, "SearchTool", [1, 2] as any), + ).rejects.toThrow("plain object"); + }); + + it("propagates dispatch errors without wrapping", async () => { mockedDispatch.mockRejectedValueOnce(new Error("transport closed")); await expect( copilotkitEmitToolCall(mockConfig, "SearchTool", {}), - ).rejects.toThrow( - /Failed to emit tool call 'SearchTool': transport closed/, - ); + ).rejects.toThrow("transport closed"); }); }); diff --git a/packages/sdk-js/src/langgraph/utils.ts b/packages/sdk-js/src/langgraph/utils.ts index 7fd6f6b1a9..a9073f7e15 100644 --- a/packages/sdk-js/src/langgraph/utils.ts +++ b/packages/sdk-js/src/langgraph/utils.ts @@ -319,7 +319,7 @@ export async function copilotkitEmitToolCall( /** * The arguments to emit. */ - args: any, + args: Record, /** * Options for the tool call emission. */ @@ -338,16 +338,22 @@ export async function copilotkitEmitToolCall( }); } - if (!name || typeof name !== "string") { + if (typeof name !== "string" || name.trim().length === 0) { throw new CopilotKitMisuseError({ message: "Tool name must be a non-empty string for copilotkitEmitToolCall", }); } - if (args === undefined) { + if ( + args === null || + args === undefined || + typeof args !== "object" || + Array.isArray(args) + ) { throw new CopilotKitMisuseError({ - message: "Tool arguments are required for copilotkitEmitToolCall", + message: + "Tool arguments must be a plain object for copilotkitEmitToolCall", }); } @@ -364,17 +370,11 @@ export async function copilotkitEmitToolCall( const toolCallId = options?.toolCallId ?? randomId(); - try { - await dispatchCustomEvent( - "copilotkit_manually_emit_tool_call", - { name, args, id: toolCallId }, - config, - ); - } catch (error) { - throw new CopilotKitMisuseError({ - message: `Failed to emit tool call '${name}': ${error instanceof Error ? error.message : String(error)}`, - }); - } + await dispatchCustomEvent( + "copilotkit_manually_emit_tool_call", + { name, args, id: toolCallId }, + config, + ); return toolCallId; } diff --git a/sdk-python/copilotkit/crewai/crewai_sdk.py b/sdk-python/copilotkit/crewai/crewai_sdk.py index 0cd9b0a76e..4594ae7c62 100644 --- a/sdk-python/copilotkit/crewai/crewai_sdk.py +++ b/sdk-python/copilotkit/crewai/crewai_sdk.py @@ -6,6 +6,7 @@ import json import asyncio from typing_extensions import Any, Dict, List, Literal, Optional +from copilotkit.exc import CopilotKitMisuseError from pydantic import BaseModel, Field from litellm.types.utils import ( ModelResponse, @@ -258,17 +259,19 @@ async def copilotkit_emit_tool_call( str The tool call ID used for the emitted tool call. """ - if not name or not isinstance(name, str): - raise ValueError( + if not isinstance(name, str) or not name.strip(): + raise CopilotKitMisuseError( "Tool name must be a non-empty string for copilotkit_emit_tool_call" ) - if args is None or not isinstance(args, dict): - raise ValueError("Tool arguments must be a dict for copilotkit_emit_tool_call") + if not isinstance(args, dict): + raise CopilotKitMisuseError( + "Tool arguments must be a dict for copilotkit_emit_tool_call" + ) if tool_call_id is not None: if not isinstance(tool_call_id, str) or not tool_call_id.strip(): - raise ValueError( + raise CopilotKitMisuseError( "Tool call id must be a non-empty string when provided for copilotkit_emit_tool_call" ) message_id = tool_call_id if tool_call_id is not None else str(uuid.uuid4()) diff --git a/sdk-python/copilotkit/exc.py b/sdk-python/copilotkit/exc.py index 45f444f18d..ef56828917 100644 --- a/sdk-python/copilotkit/exc.py +++ b/sdk-python/copilotkit/exc.py @@ -33,3 +33,12 @@ def __init__(self, name: str, error: Exception): self.name = name self.error = error super().__init__(f"Agent '{name}' failed to execute: {error}") + + +class CopilotKitMisuseError(ValueError): + """Exception raised when CopilotKit detects incorrect usage of its APIs. + + Subclasses ValueError for backward compatibility with existing handlers. + """ + + pass diff --git a/sdk-python/copilotkit/langgraph.py b/sdk-python/copilotkit/langgraph.py index b3435dc437..2ee31f7c0b 100644 --- a/sdk-python/copilotkit/langgraph.py +++ b/sdk-python/copilotkit/langgraph.py @@ -23,6 +23,7 @@ from langgraph.types import interrupt from .types import Message, IntermediateStateConfig +from .exc import CopilotKitMisuseError from .logging import get_logger logger = get_logger(__name__) @@ -410,17 +411,19 @@ async def copilotkit_emit_tool_call( str The tool call ID used for the emitted tool call. """ - if not name or not isinstance(name, str): - raise ValueError( + if not isinstance(name, str) or not name.strip(): + raise CopilotKitMisuseError( "Tool name must be a non-empty string for copilotkit_emit_tool_call" ) - if args is None or not isinstance(args, dict): - raise ValueError("Tool arguments must be a dict for copilotkit_emit_tool_call") + if not isinstance(args, dict): + raise CopilotKitMisuseError( + "Tool arguments must be a dict for copilotkit_emit_tool_call" + ) if tool_call_id is not None: if not isinstance(tool_call_id, str) or not tool_call_id.strip(): - raise ValueError( + raise CopilotKitMisuseError( "Tool call id must be a non-empty string when provided for copilotkit_emit_tool_call" ) else: @@ -431,7 +434,9 @@ async def copilotkit_emit_tool_call( {"name": name, "args": args, "id": tool_call_id}, config=config, ) - # Allow the event to flush before the next event interleaves + # LangGraph's adispatch_custom_event is async but does not guarantee the event + # has been flushed to the SSE stream before it returns. Without this sleep, + # a subsequent emit can interleave and corrupt event ordering on the client. await asyncio.sleep(0.02) return tool_call_id diff --git a/sdk-python/copilotkit/langgraph_agui_agent.py b/sdk-python/copilotkit/langgraph_agui_agent.py index 7f506bb801..6ef503d650 100644 --- a/sdk-python/copilotkit/langgraph_agui_agent.py +++ b/sdk-python/copilotkit/langgraph_agui_agent.py @@ -1,6 +1,7 @@ import json from typing import Dict, Any, List, Optional, Union, AsyncGenerator from enum import Enum +from .exc import CopilotKitMisuseError from ag_ui_langgraph import LangGraphAgent from ag_ui.core import ( EventType, @@ -109,19 +110,26 @@ def _dispatch_event(self, event) -> str: tool_call_name = value.get("name") tool_call_args = value.get("args") - if not tool_call_id or not isinstance(tool_call_id, str): - raise ValueError( - f"ManuallyEmitToolCall event missing valid 'id': {value!r}" + if not isinstance(tool_call_id, str) or not tool_call_id.strip(): + raise CopilotKitMisuseError( + f"ManuallyEmitToolCall event missing valid 'id': got {type(tool_call_id).__name__}" ) - if not tool_call_name or not isinstance(tool_call_name, str): - raise ValueError( - f"ManuallyEmitToolCall event missing valid 'name': {value!r}" + if not isinstance(tool_call_name, str) or not tool_call_name.strip(): + raise CopilotKitMisuseError( + f"ManuallyEmitToolCall event missing valid 'name': got {type(tool_call_name).__name__}" ) if tool_call_args is None: - raise ValueError( - f"ManuallyEmitToolCall event missing 'args': {value!r}" + raise CopilotKitMisuseError( + "ManuallyEmitToolCall event missing 'args'" ) + try: + delta = tool_call_args if isinstance(tool_call_args, str) else json.dumps(tool_call_args) + except (TypeError, ValueError) as e: + raise CopilotKitMisuseError( + f"ManuallyEmitToolCall 'args' is not JSON-serializable for tool_call_id={tool_call_id}: {e}" + ) from e + super()._dispatch_event( ToolCallStartEvent( type=EventType.TOOL_CALL_START, @@ -135,9 +143,7 @@ def _dispatch_event(self, event) -> str: ToolCallArgsEvent( type=EventType.TOOL_CALL_ARGS, tool_call_id=tool_call_id, - delta=tool_call_args - if isinstance(tool_call_args, str) - else json.dumps(tool_call_args), + delta=delta, raw_event=event, ) ) diff --git a/sdk-python/tests/test_emit_tool_call_optional_id.py b/sdk-python/tests/test_emit_tool_call_optional_id.py index 76280c4a61..30b4fdaf1f 100644 --- a/sdk-python/tests/test_emit_tool_call_optional_id.py +++ b/sdk-python/tests/test_emit_tool_call_optional_id.py @@ -19,6 +19,7 @@ LangGraphAGUIAgent, CustomEventNames, ) +from copilotkit.exc import CopilotKitMisuseError # ---- Fixtures ---- @@ -315,3 +316,125 @@ def test_custom_id_with_dict_args_serialized(self, agent): 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 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) From d5cc135c5c66cdcd5c1167817227a4684d0fc4b8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 17:43:08 +0000 Subject: [PATCH 09/32] style: auto-fix formatting --- packages/sdk-js/src/langgraph/__tests__/utils.test.ts | 6 +++--- sdk-python/copilotkit/langgraph_agui_agent.py | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/sdk-js/src/langgraph/__tests__/utils.test.ts b/packages/sdk-js/src/langgraph/__tests__/utils.test.ts index 8e532a1dac..7029e9d7d0 100644 --- a/packages/sdk-js/src/langgraph/__tests__/utils.test.ts +++ b/packages/sdk-js/src/langgraph/__tests__/utils.test.ts @@ -333,9 +333,9 @@ describe("copilotkitEmitToolCall", () => { }); it("throws on whitespace-only name", async () => { - await expect( - copilotkitEmitToolCall(mockConfig, " ", {}), - ).rejects.toThrow("non-empty string"); + await expect(copilotkitEmitToolCall(mockConfig, " ", {})).rejects.toThrow( + "non-empty string", + ); }); it("throws on non-object args", async () => { diff --git a/sdk-python/copilotkit/langgraph_agui_agent.py b/sdk-python/copilotkit/langgraph_agui_agent.py index 6ef503d650..e5aa0b2bee 100644 --- a/sdk-python/copilotkit/langgraph_agui_agent.py +++ b/sdk-python/copilotkit/langgraph_agui_agent.py @@ -124,7 +124,11 @@ def _dispatch_event(self, event) -> str: ) try: - delta = tool_call_args if isinstance(tool_call_args, str) else json.dumps(tool_call_args) + delta = ( + tool_call_args + if isinstance(tool_call_args, str) + else json.dumps(tool_call_args) + ) except (TypeError, ValueError) as e: raise CopilotKitMisuseError( f"ManuallyEmitToolCall 'args' is not JSON-serializable for tool_call_id={tool_call_id}: {e}" From be9b60c8a91d31c3d528d07d04d98bad2cf2a929 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 19 May 2026 20:24:29 +0200 Subject: [PATCH 10/32] fix(sdk): harden AG-UI dispatch, add exception hierarchy, fix docstrings Address code review findings: - Wrap AG-UI tool call dispatch in try/except with compensating TOOL_CALL_END to prevent clients hanging on partial emission - Reject non-dict/non-str args at the dispatch layer (lists, ints, None) - Guard against None event value before calling .get() - Fix docstring examples that reuse variable names (won't compile) - Introduce CopilotKitError base class; all exceptions now inherit from it; CopilotKitMisuseError inherits from both CopilotKitError and ValueError - Add missing validation tests for name and args across LangGraph and CrewAI Python variants, plus AG-UI dispatch edge cases Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/sdk-js/src/langgraph/utils.ts | 4 +- sdk-python/copilotkit/crewai/crewai_sdk.py | 4 +- sdk-python/copilotkit/exc.py | 22 +++- sdk-python/copilotkit/langgraph.py | 4 +- sdk-python/copilotkit/langgraph_agui_agent.py | 76 ++++++++---- sdk-python/copilotkit/sdk.py | 2 + .../tests/test_emit_tool_call_optional_id.py | 108 +++++++++++++++++- 7 files changed, 181 insertions(+), 39 deletions(-) diff --git a/packages/sdk-js/src/langgraph/utils.ts b/packages/sdk-js/src/langgraph/utils.ts index a9073f7e15..caf16d5e00 100644 --- a/packages/sdk-js/src/langgraph/utils.ts +++ b/packages/sdk-js/src/langgraph/utils.ts @@ -301,10 +301,10 @@ export async function copilotkitEmitMessage( * ```typescript * import { copilotkitEmitToolCall } from "@copilotkit/sdk-js"; * - * const toolCallId = await copilotkitEmitToolCall(config, "SearchTool", { steps: 10 }); + * const autoId = await copilotkitEmitToolCall(config, "SearchTool", { steps: 10 }); * * // With a custom ID for correlation/idempotency: - * const toolCallId = await copilotkitEmitToolCall(config, "SearchTool", { steps: 10 }, { toolCallId: "my-custom-id" }); + * const customId = await copilotkitEmitToolCall(config, "SearchTool", { steps: 10 }, { toolCallId: "my-custom-id" }); * ``` */ export async function copilotkitEmitToolCall( diff --git a/sdk-python/copilotkit/crewai/crewai_sdk.py b/sdk-python/copilotkit/crewai/crewai_sdk.py index 4594ae7c62..3c897f79f1 100644 --- a/sdk-python/copilotkit/crewai/crewai_sdk.py +++ b/sdk-python/copilotkit/crewai/crewai_sdk.py @@ -236,10 +236,10 @@ async def copilotkit_emit_tool_call( ```python from copilotkit.crewai import copilotkit_emit_tool_call - tool_call_id = await copilotkit_emit_tool_call(name="SearchTool", args={"steps": 10}) + auto_id = await copilotkit_emit_tool_call(name="SearchTool", args={"steps": 10}) # With a custom ID for correlation/idempotency: - tool_call_id = await copilotkit_emit_tool_call(name="SearchTool", args={"steps": 10}, tool_call_id="my-custom-id") + custom_id = await copilotkit_emit_tool_call(name="SearchTool", args={"steps": 10}, tool_call_id="my-custom-id") ``` Parameters diff --git a/sdk-python/copilotkit/exc.py b/sdk-python/copilotkit/exc.py index ef56828917..130521a383 100644 --- a/sdk-python/copilotkit/exc.py +++ b/sdk-python/copilotkit/exc.py @@ -1,7 +1,16 @@ """Exceptions for CopilotKit.""" -class ActionNotFoundException(Exception): +class CopilotKitError(Exception): + """Base exception for all CopilotKit errors. + + Catch this to handle any CopilotKit-specific exception. + """ + + pass + + +class ActionNotFoundException(CopilotKitError): """Exception raised when an action or agent is not found.""" def __init__(self, name: str): @@ -9,7 +18,7 @@ def __init__(self, name: str): super().__init__(f"Action '{name}' not found.") -class AgentNotFoundException(Exception): +class AgentNotFoundException(CopilotKitError): """Exception raised when an agent is not found.""" def __init__(self, name: str): @@ -17,7 +26,7 @@ def __init__(self, name: str): super().__init__(f"Agent '{name}' not found.") -class ActionExecutionException(Exception): +class ActionExecutionException(CopilotKitError): """Exception raised when an action fails to execute.""" def __init__(self, name: str, error: Exception): @@ -26,7 +35,7 @@ def __init__(self, name: str, error: Exception): super().__init__(f"Action '{name}' failed to execute: {error}") -class AgentExecutionException(Exception): +class AgentExecutionException(CopilotKitError): """Exception raised when an agent fails to execute.""" def __init__(self, name: str, error: Exception): @@ -35,10 +44,11 @@ def __init__(self, name: str, error: Exception): super().__init__(f"Agent '{name}' failed to execute: {error}") -class CopilotKitMisuseError(ValueError): +class CopilotKitMisuseError(CopilotKitError, ValueError): """Exception raised when CopilotKit detects incorrect usage of its APIs. - Subclasses ValueError for backward compatibility with existing handlers. + Inherits from both CopilotKitError (for ``except CopilotKitError``) and + ValueError (for backward compatibility with ``except ValueError`` handlers). """ pass diff --git a/sdk-python/copilotkit/langgraph.py b/sdk-python/copilotkit/langgraph.py index 2ee31f7c0b..9386cc5a18 100644 --- a/sdk-python/copilotkit/langgraph.py +++ b/sdk-python/copilotkit/langgraph.py @@ -387,10 +387,10 @@ async def copilotkit_emit_tool_call( ```python from copilotkit.langgraph import copilotkit_emit_tool_call - tool_call_id = await copilotkit_emit_tool_call(config, name="SearchTool", args={"steps": 10}) + auto_id = await copilotkit_emit_tool_call(config, name="SearchTool", args={"steps": 10}) # With a custom ID for correlation/idempotency: - tool_call_id = await copilotkit_emit_tool_call(config, name="SearchTool", args={"steps": 10}, tool_call_id="my-custom-id") + custom_id = await copilotkit_emit_tool_call(config, name="SearchTool", args={"steps": 10}, tool_call_id="my-custom-id") ``` Parameters diff --git a/sdk-python/copilotkit/langgraph_agui_agent.py b/sdk-python/copilotkit/langgraph_agui_agent.py index e5aa0b2bee..7e1fcda9b3 100644 --- a/sdk-python/copilotkit/langgraph_agui_agent.py +++ b/sdk-python/copilotkit/langgraph_agui_agent.py @@ -1,7 +1,10 @@ import json +import logging from typing import Dict, Any, List, Optional, Union, AsyncGenerator from enum import Enum from .exc import CopilotKitMisuseError + +logger = logging.getLogger(__name__) from ag_ui_langgraph import LangGraphAgent from ag_ui.core import ( EventType, @@ -106,6 +109,11 @@ def _dispatch_event(self, event) -> str: if custom_event.name == CustomEventNames.ManuallyEmitToolCall.value: value = custom_event.value + if not isinstance(value, dict): + raise CopilotKitMisuseError( + f"ManuallyEmitToolCall event 'value' must be a dict, got {type(value).__name__}" + ) + tool_call_id = value.get("id") tool_call_name = value.get("name") tool_call_args = value.get("args") @@ -118,9 +126,10 @@ def _dispatch_event(self, event) -> str: raise CopilotKitMisuseError( f"ManuallyEmitToolCall event missing valid 'name': got {type(tool_call_name).__name__}" ) - if tool_call_args is None: + if not isinstance(tool_call_args, (dict, str)): raise CopilotKitMisuseError( - "ManuallyEmitToolCall event missing 'args'" + f"ManuallyEmitToolCall 'args' must be a dict or pre-serialized JSON string, " + f"got {type(tool_call_args).__name__} for tool_call_id={tool_call_id}" ) try: @@ -129,35 +138,54 @@ def _dispatch_event(self, event) -> str: if isinstance(tool_call_args, str) else json.dumps(tool_call_args) ) - except (TypeError, ValueError) as e: + except Exception as e: raise CopilotKitMisuseError( f"ManuallyEmitToolCall 'args' is not JSON-serializable for tool_call_id={tool_call_id}: {e}" ) from e - super()._dispatch_event( - ToolCallStartEvent( - type=EventType.TOOL_CALL_START, - tool_call_id=tool_call_id, - tool_call_name=tool_call_name, - parent_message_id=tool_call_id, - raw_event=event, + dispatched_start = False + try: + super()._dispatch_event( + ToolCallStartEvent( + type=EventType.TOOL_CALL_START, + tool_call_id=tool_call_id, + tool_call_name=tool_call_name, + parent_message_id=tool_call_id, + raw_event=event, + ) ) - ) - super()._dispatch_event( - ToolCallArgsEvent( - type=EventType.TOOL_CALL_ARGS, - tool_call_id=tool_call_id, - delta=delta, - raw_event=event, + dispatched_start = True + super()._dispatch_event( + ToolCallArgsEvent( + type=EventType.TOOL_CALL_ARGS, + tool_call_id=tool_call_id, + delta=delta, + raw_event=event, + ) ) - ) - super()._dispatch_event( - ToolCallEndEvent( - type=EventType.TOOL_CALL_END, - tool_call_id=tool_call_id, - raw_event=event, + super()._dispatch_event( + ToolCallEndEvent( + type=EventType.TOOL_CALL_END, + tool_call_id=tool_call_id, + raw_event=event, + ) ) - ) + except Exception: + if dispatched_start: + try: + super()._dispatch_event( + ToolCallEndEvent( + type=EventType.TOOL_CALL_END, + tool_call_id=tool_call_id, + raw_event=event, + ) + ) + except Exception as close_err: + logger.error( + "Failed to emit compensating TOOL_CALL_END for %s: %s", + tool_call_id, close_err, + ) + raise return super()._dispatch_event(event) if custom_event.name == CustomEventNames.ManuallyEmitState.value: diff --git a/sdk-python/copilotkit/sdk.py b/sdk-python/copilotkit/sdk.py index 8eeb42ecae..4e8e3e5ece 100644 --- a/sdk-python/copilotkit/sdk.py +++ b/sdk-python/copilotkit/sdk.py @@ -10,6 +10,8 @@ from .action import Action, ActionDict, ActionResultDict from .types import Message, MetaEvent from .exc import ( + CopilotKitError, + CopilotKitMisuseError, ActionNotFoundException, AgentNotFoundException, ActionExecutionException, diff --git a/sdk-python/tests/test_emit_tool_call_optional_id.py b/sdk-python/tests/test_emit_tool_call_optional_id.py index 30b4fdaf1f..ba262c9616 100644 --- a/sdk-python/tests/test_emit_tool_call_optional_id.py +++ b/sdk-python/tests/test_emit_tool_call_optional_id.py @@ -4,6 +4,8 @@ 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 json @@ -163,6 +165,43 @@ async def test_whitespace_only_id_raises(self): 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_dict_args_raises(self): + """Passing non-dict args should raise CopilotKitMisuseError.""" + with patch( + "copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock + ): + from copilotkit.langgraph import copilotkit_emit_tool_call + + config = {"metadata": {}} + for bad_args in [None, [1, 2], "raw", 42]: + with pytest.raises(CopilotKitMisuseError, match="must be a dict"): + await copilotkit_emit_tool_call(config, name="Tool", args=bad_args) + # ---- CrewAI variant tests ---- @@ -254,6 +293,39 @@ async def test_whitespace_only_id_raises(self): 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_dict_args_raises(self): + """Passing non-dict 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 + + for bad_args in [None, [1, 2], "raw", 42]: + with pytest.raises(CopilotKitMisuseError, match="must be a dict"): + await copilotkit_emit_tool_call(name="Tool", args=bad_args) + # ---- AG-UI dispatch: custom ID propagates through all events ---- @@ -426,15 +498,45 @@ def test_missing_args_raises(self, agent): name=CustomEventNames.ManuallyEmitToolCall.value, value={"id": "valid-id", "name": "Tool"}, ) - with pytest.raises(CopilotKitMisuseError, match="missing 'args'"): + with pytest.raises(CopilotKitMisuseError, match="must be a dict or pre-serialized"): agent._dispatch_event(event) def test_non_serializable_args_raises(self, agent): - """Event with non-JSON-serializable args should raise CopilotKitMisuseError.""" + """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"): + with pytest.raises(CopilotKitMisuseError, match="must be a dict or pre-serialized"): + 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_raises(self, agent): + """Event with list args 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="must be a dict or pre-serialized"): + agent._dispatch_event(event) + + def test_int_args_raises(self, agent): + """Event with int args should raise CopilotKitMisuseError.""" + event = CustomEvent( + type=EventType.CUSTOM, + name=CustomEventNames.ManuallyEmitToolCall.value, + value={"id": "valid-id", "name": "Tool", "args": 42}, + ) + with pytest.raises(CopilotKitMisuseError, match="must be a dict or pre-serialized"): agent._dispatch_event(event) From 1df8a80e5873d48aa2f380f28f2e0a790fe7db14 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 18:26:34 +0000 Subject: [PATCH 11/32] style: auto-fix formatting --- sdk-python/copilotkit/langgraph_agui_agent.py | 3 ++- .../tests/test_emit_tool_call_optional_id.py | 20 ++++++++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/sdk-python/copilotkit/langgraph_agui_agent.py b/sdk-python/copilotkit/langgraph_agui_agent.py index 7e1fcda9b3..5a2bf8146d 100644 --- a/sdk-python/copilotkit/langgraph_agui_agent.py +++ b/sdk-python/copilotkit/langgraph_agui_agent.py @@ -183,7 +183,8 @@ def _dispatch_event(self, event) -> str: except Exception as close_err: logger.error( "Failed to emit compensating TOOL_CALL_END for %s: %s", - tool_call_id, close_err, + tool_call_id, + close_err, ) raise return super()._dispatch_event(event) diff --git a/sdk-python/tests/test_emit_tool_call_optional_id.py b/sdk-python/tests/test_emit_tool_call_optional_id.py index ba262c9616..c7e8fd0f48 100644 --- a/sdk-python/tests/test_emit_tool_call_optional_id.py +++ b/sdk-python/tests/test_emit_tool_call_optional_id.py @@ -296,9 +296,7 @@ async def test_whitespace_only_id_raises(self): @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 - ): + 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( @@ -498,7 +496,9 @@ def test_missing_args_raises(self, agent): name=CustomEventNames.ManuallyEmitToolCall.value, value={"id": "valid-id", "name": "Tool"}, ) - with pytest.raises(CopilotKitMisuseError, match="must be a dict or pre-serialized"): + with pytest.raises( + CopilotKitMisuseError, match="must be a dict or pre-serialized" + ): agent._dispatch_event(event) def test_non_serializable_args_raises(self, agent): @@ -508,7 +508,9 @@ def test_non_serializable_args_raises(self, agent): name=CustomEventNames.ManuallyEmitToolCall.value, value={"id": "valid-id", "name": "Tool", "args": {1, 2, 3}}, ) - with pytest.raises(CopilotKitMisuseError, match="must be a dict or pre-serialized"): + with pytest.raises( + CopilotKitMisuseError, match="must be a dict or pre-serialized" + ): agent._dispatch_event(event) def test_non_dict_value_raises(self, agent): @@ -528,7 +530,9 @@ def test_list_args_raises(self, agent): name=CustomEventNames.ManuallyEmitToolCall.value, value={"id": "valid-id", "name": "Tool", "args": [1, 2, 3]}, ) - with pytest.raises(CopilotKitMisuseError, match="must be a dict or pre-serialized"): + with pytest.raises( + CopilotKitMisuseError, match="must be a dict or pre-serialized" + ): agent._dispatch_event(event) def test_int_args_raises(self, agent): @@ -538,5 +542,7 @@ def test_int_args_raises(self, agent): name=CustomEventNames.ManuallyEmitToolCall.value, value={"id": "valid-id", "name": "Tool", "args": 42}, ) - with pytest.raises(CopilotKitMisuseError, match="must be a dict or pre-serialized"): + with pytest.raises( + CopilotKitMisuseError, match="must be a dict or pre-serialized" + ): agent._dispatch_event(event) From 62a3164aab246c0104be78d1a582a5b3df1085e4 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 19 May 2026 20:35:22 +0200 Subject: [PATCH 12/32] fix(sdk): add @returns JSDoc and compensating-END test coverage Add missing @returns tag to JS copilotkitEmitToolCall JSDoc, and add 4 tests for the AG-UI compensating TOOL_CALL_END error-recovery path. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/sdk-js/src/langgraph/utils.ts | 3 + .../tests/test_emit_tool_call_optional_id.py | 92 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/packages/sdk-js/src/langgraph/utils.ts b/packages/sdk-js/src/langgraph/utils.ts index caf16d5e00..b5ffb0fc53 100644 --- a/packages/sdk-js/src/langgraph/utils.ts +++ b/packages/sdk-js/src/langgraph/utils.ts @@ -306,6 +306,9 @@ export async function copilotkitEmitMessage( * // With a custom ID for correlation/idempotency: * const customId = await copilotkitEmitToolCall(config, "SearchTool", { steps: 10 }, { toolCallId: "my-custom-id" }); * ``` + * + * @returns The tool call ID used for the emitted call — equals `options.toolCallId` + * when provided, otherwise a randomly generated ID. */ export async function copilotkitEmitToolCall( /** diff --git a/sdk-python/tests/test_emit_tool_call_optional_id.py b/sdk-python/tests/test_emit_tool_call_optional_id.py index c7e8fd0f48..d12c274b00 100644 --- a/sdk-python/tests/test_emit_tool_call_optional_id.py +++ b/sdk-python/tests/test_emit_tool_call_optional_id.py @@ -9,6 +9,7 @@ """ import json +import logging import uuid import pytest from unittest.mock import MagicMock, AsyncMock, patch @@ -546,3 +547,94 @@ def test_int_args_raises(self, agent): CopilotKitMisuseError, match="must be a dict or pre-serialized" ): agent._dispatch_event(event) + + +# ---- 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) From 4a6d364e2c2b787790cf7197af7b9333c991c6d3 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 19 May 2026 20:58:50 +0200 Subject: [PATCH 13/32] fix(sdk): harden error handling, revert args strictness, shield sleep Address code review findings across all three SDK variants: - Shield asyncio.sleep(0.02) with asyncio.shield() so task cancellation doesn't prevent returning the tool_call_id after dispatch - Revert args validation to original permissiveness (JS: undefined-only check, Python: no isinstance check) to avoid breaking existing callers - Add upfront json.dumps() serializability check in Python variants - Fix compensating TOOL_CALL_END double-emit by tracking dispatched_end - Add compensating action_execution_end to CrewAI variant (queue_put is non-atomic) - Use exc_info=True in compensating-END error logging - Export all exception types from copilotkit package root (__init__.py) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/__tests__/error-handling.test.ts | 6 +-- .../src/langgraph/__tests__/utils.test.ts | 14 ++---- packages/sdk-js/src/langgraph/utils.ts | 12 ++--- sdk-python/copilotkit/__init__.py | 14 ++++++ sdk-python/copilotkit/crewai/crewai_sdk.py | 50 +++++++++++++------ sdk-python/copilotkit/langgraph.py | 18 +++++-- sdk-python/copilotkit/langgraph_agui_agent.py | 10 ++-- .../tests/test_emit_tool_call_optional_id.py | 20 ++++---- 8 files changed, 89 insertions(+), 55 deletions(-) diff --git a/packages/sdk-js/src/__tests__/error-handling.test.ts b/packages/sdk-js/src/__tests__/error-handling.test.ts index a793e424d3..0252e10b94 100644 --- a/packages/sdk-js/src/__tests__/error-handling.test.ts +++ b/packages/sdk-js/src/__tests__/error-handling.test.ts @@ -229,12 +229,12 @@ describe("SDK-JS Error Handling", () => { it("should throw CopilotKitMisuseError when args is undefined for copilotkitEmitToolCall", async () => { await expect( - copilotkitEmitToolCall(mockConfig, "testTool", undefined as any), + copilotkitEmitToolCall(mockConfig, "testTool", undefined), ).rejects.toThrow(CopilotKitMisuseError); await expect( - copilotkitEmitToolCall(mockConfig, "testTool", undefined as any), + copilotkitEmitToolCall(mockConfig, "testTool", undefined), ).rejects.toThrow( - "Tool arguments must be a plain object for copilotkitEmitToolCall", + "Tool arguments are required for copilotkitEmitToolCall", ); }); }); diff --git a/packages/sdk-js/src/langgraph/__tests__/utils.test.ts b/packages/sdk-js/src/langgraph/__tests__/utils.test.ts index 7029e9d7d0..a7d6d06e07 100644 --- a/packages/sdk-js/src/langgraph/__tests__/utils.test.ts +++ b/packages/sdk-js/src/langgraph/__tests__/utils.test.ts @@ -338,18 +338,10 @@ describe("copilotkitEmitToolCall", () => { ); }); - it("throws on non-object args", async () => { + it("throws on undefined args", async () => { await expect( - copilotkitEmitToolCall(mockConfig, "SearchTool", 42 as any), - ).rejects.toThrow("plain object"); - - await expect( - copilotkitEmitToolCall(mockConfig, "SearchTool", null as any), - ).rejects.toThrow("plain object"); - - await expect( - copilotkitEmitToolCall(mockConfig, "SearchTool", [1, 2] as any), - ).rejects.toThrow("plain object"); + copilotkitEmitToolCall(mockConfig, "SearchTool", undefined), + ).rejects.toThrow("required"); }); it("propagates dispatch errors without wrapping", async () => { diff --git a/packages/sdk-js/src/langgraph/utils.ts b/packages/sdk-js/src/langgraph/utils.ts index b5ffb0fc53..5f38c6304f 100644 --- a/packages/sdk-js/src/langgraph/utils.ts +++ b/packages/sdk-js/src/langgraph/utils.ts @@ -322,7 +322,7 @@ export async function copilotkitEmitToolCall( /** * The arguments to emit. */ - args: Record, + args: any, /** * Options for the tool call emission. */ @@ -348,15 +348,9 @@ export async function copilotkitEmitToolCall( }); } - if ( - args === null || - args === undefined || - typeof args !== "object" || - Array.isArray(args) - ) { + if (args === undefined) { throw new CopilotKitMisuseError({ - message: - "Tool arguments must be a plain object for copilotkitEmitToolCall", + message: "Tool arguments are required for copilotkitEmitToolCall", }); } diff --git a/sdk-python/copilotkit/__init__.py b/sdk-python/copilotkit/__init__.py index 2b741e5252..18ffbd0d3b 100644 --- a/sdk-python/copilotkit/__init__.py +++ b/sdk-python/copilotkit/__init__.py @@ -7,6 +7,14 @@ CopilotKitSDKContext, ) from .action import Action +from .exc import ( + CopilotKitError, + CopilotKitMisuseError, + ActionNotFoundException, + AgentNotFoundException, + ActionExecutionException, + AgentExecutionException, +) from .langgraph import CopilotKitState from .parameter import Parameter from .agent import Agent @@ -32,6 +40,12 @@ "Agent", "CopilotKitContext", "CopilotKitSDKContext", + "CopilotKitError", + "CopilotKitMisuseError", + "ActionNotFoundException", + "AgentNotFoundException", + "ActionExecutionException", + "AgentExecutionException", "CrewAIAgent", # pyright: ignore[reportUnsupportedDunderAll] pylint: disable=undefined-all-variable "LangGraphAGUIAgent", "CopilotKitMiddleware", diff --git a/sdk-python/copilotkit/crewai/crewai_sdk.py b/sdk-python/copilotkit/crewai/crewai_sdk.py index 3c897f79f1..2f64ac381f 100644 --- a/sdk-python/copilotkit/crewai/crewai_sdk.py +++ b/sdk-python/copilotkit/crewai/crewai_sdk.py @@ -264,26 +264,48 @@ async def copilotkit_emit_tool_call( "Tool name must be a non-empty string for copilotkit_emit_tool_call" ) - if not isinstance(args, dict): - raise CopilotKitMisuseError( - "Tool arguments must be a dict for copilotkit_emit_tool_call" - ) - if tool_call_id is not None: if not isinstance(tool_call_id, str) or not tool_call_id.strip(): raise CopilotKitMisuseError( "Tool call id must be a non-empty string when provided for copilotkit_emit_tool_call" ) + try: + args_json = json.dumps(args) + except (TypeError, ValueError) as e: + raise CopilotKitMisuseError( + f"Tool arguments for '{name}' are not JSON-serializable: {e}" + ) from e + message_id = tool_call_id if tool_call_id is not None else str(uuid.uuid4()) - await queue_put( - action_execution_start( - action_execution_id=message_id, - action_name=name, - parent_message_id=message_id, - ), - action_execution_args(action_execution_id=message_id, args=json.dumps(args)), - action_execution_end(action_execution_id=message_id), - ) + dispatched_start = False + try: + await queue_put( + action_execution_start( + action_execution_id=message_id, + action_name=name, + parent_message_id=message_id, + ), + ) + dispatched_start = True + await queue_put( + action_execution_args(action_execution_id=message_id, args=args_json), + ) + await queue_put( + action_execution_end(action_execution_id=message_id), + ) + except Exception: + if dispatched_start: + try: + await queue_put( + action_execution_end(action_execution_id=message_id), + ) + except Exception: + logger.error( + "Failed to emit compensating action_execution_end for %s", + message_id, + exc_info=True, + ) + raise return message_id diff --git a/sdk-python/copilotkit/langgraph.py b/sdk-python/copilotkit/langgraph.py index 9386cc5a18..1626dbc464 100644 --- a/sdk-python/copilotkit/langgraph.py +++ b/sdk-python/copilotkit/langgraph.py @@ -416,10 +416,12 @@ async def copilotkit_emit_tool_call( "Tool name must be a non-empty string for copilotkit_emit_tool_call" ) - if not isinstance(args, dict): + try: + json.dumps(args) + except (TypeError, ValueError) as e: raise CopilotKitMisuseError( - "Tool arguments must be a dict for copilotkit_emit_tool_call" - ) + f"Tool arguments for '{name}' are not JSON-serializable: {e}" + ) from e if tool_call_id is not None: if not isinstance(tool_call_id, str) or not tool_call_id.strip(): @@ -437,7 +439,15 @@ async def copilotkit_emit_tool_call( # LangGraph's adispatch_custom_event is async but does not guarantee the event # has been flushed to the SSE stream before it returns. Without this sleep, # a subsequent emit can interleave and corrupt event ordering on the client. - await asyncio.sleep(0.02) + # Shielded so that task cancellation doesn't prevent us from returning the ID. + try: + await asyncio.shield(asyncio.sleep(0.02)) + except asyncio.CancelledError: + logger.warning( + "copilotkit_emit_tool_call cancelled during post-dispatch flush for " + "tool_call_id=%s; event was already dispatched", + tool_call_id, + ) return tool_call_id diff --git a/sdk-python/copilotkit/langgraph_agui_agent.py b/sdk-python/copilotkit/langgraph_agui_agent.py index 5a2bf8146d..a720224bff 100644 --- a/sdk-python/copilotkit/langgraph_agui_agent.py +++ b/sdk-python/copilotkit/langgraph_agui_agent.py @@ -144,6 +144,7 @@ def _dispatch_event(self, event) -> str: ) from e dispatched_start = False + dispatched_end = False try: super()._dispatch_event( ToolCallStartEvent( @@ -163,6 +164,7 @@ def _dispatch_event(self, event) -> str: raw_event=event, ) ) + dispatched_end = True super()._dispatch_event( ToolCallEndEvent( type=EventType.TOOL_CALL_END, @@ -171,7 +173,7 @@ def _dispatch_event(self, event) -> str: ) ) except Exception: - if dispatched_start: + if dispatched_start and not dispatched_end: try: super()._dispatch_event( ToolCallEndEvent( @@ -180,11 +182,11 @@ def _dispatch_event(self, event) -> str: raw_event=event, ) ) - except Exception as close_err: + except Exception: logger.error( - "Failed to emit compensating TOOL_CALL_END for %s: %s", + "Failed to emit compensating TOOL_CALL_END for %s", tool_call_id, - close_err, + exc_info=True, ) raise return super()._dispatch_event(event) diff --git a/sdk-python/tests/test_emit_tool_call_optional_id.py b/sdk-python/tests/test_emit_tool_call_optional_id.py index d12c274b00..1aa0d3402c 100644 --- a/sdk-python/tests/test_emit_tool_call_optional_id.py +++ b/sdk-python/tests/test_emit_tool_call_optional_id.py @@ -191,17 +191,18 @@ async def test_empty_name_raises(self): await copilotkit_emit_tool_call(config, name="", args={}) @pytest.mark.asyncio - async def test_non_dict_args_raises(self): - """Passing non-dict args should raise CopilotKitMisuseError.""" + 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": {}} - for bad_args in [None, [1, 2], "raw", 42]: - with pytest.raises(CopilotKitMisuseError, match="must be a dict"): - await copilotkit_emit_tool_call(config, name="Tool", args=bad_args) + with pytest.raises(CopilotKitMisuseError, match="not JSON-serializable"): + await copilotkit_emit_tool_call( + config, name="Tool", args={"bad": {1, 2, 3}} + ) # ---- CrewAI variant tests ---- @@ -316,14 +317,13 @@ async def test_whitespace_only_name_raises(self): await copilotkit_emit_tool_call(name=" ", args={}) @pytest.mark.asyncio - async def test_non_dict_args_raises(self): - """Passing non-dict args should raise CopilotKitMisuseError.""" + 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 - for bad_args in [None, [1, 2], "raw", 42]: - with pytest.raises(CopilotKitMisuseError, match="must be a dict"): - await copilotkit_emit_tool_call(name="Tool", args=bad_args) + with pytest.raises(CopilotKitMisuseError, match="not JSON-serializable"): + await copilotkit_emit_tool_call(name="Tool", args={"bad": {1, 2, 3}}) # ---- AG-UI dispatch: custom ID propagates through all events ---- From e1da79a5eaa0c2217a5722f23950ebf3582edfd4 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 19 May 2026 22:39:45 +0200 Subject: [PATCH 14/32] fix(sdk): fix CancelledError swallow, dispatched_end ordering, add JS args validation - Re-raise CancelledError after logging in langgraph copilotkit_emit_tool_call to honor asyncio cancellation contract (was silently un-cancelling tasks) - Move dispatched_end flag to after ToolCallEndEvent dispatch in AG-UI agent so compensating END fires when END itself throws - Add dispatched_end tracking to CrewAI variant to prevent double-END on partial failure - Add JSON.stringify(args) validation in JS SDK matching Python parity - Add 4 CrewAI compensating-END tests and 1 JS serializability test Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/langgraph/__tests__/utils.test.ts | 8 ++ packages/sdk-js/src/langgraph/utils.ts | 12 +- sdk-python/copilotkit/crewai/crewai_sdk.py | 4 +- sdk-python/copilotkit/langgraph.py | 1 + sdk-python/copilotkit/langgraph_agui_agent.py | 2 +- .../tests/test_emit_tool_call_optional_id.py | 103 ++++++++++++++++++ 6 files changed, 126 insertions(+), 4 deletions(-) diff --git a/packages/sdk-js/src/langgraph/__tests__/utils.test.ts b/packages/sdk-js/src/langgraph/__tests__/utils.test.ts index a7d6d06e07..4f17b9d18f 100644 --- a/packages/sdk-js/src/langgraph/__tests__/utils.test.ts +++ b/packages/sdk-js/src/langgraph/__tests__/utils.test.ts @@ -344,6 +344,14 @@ describe("copilotkitEmitToolCall", () => { ).rejects.toThrow("required"); }); + it("throws on non-JSON-serializable args", async () => { + const circular: any = {}; + circular.self = circular; + await expect( + copilotkitEmitToolCall(mockConfig, "SearchTool", circular), + ).rejects.toThrow("not JSON-serializable"); + }); + it("propagates dispatch errors without wrapping", async () => { mockedDispatch.mockRejectedValueOnce(new Error("transport closed")); await expect( diff --git a/packages/sdk-js/src/langgraph/utils.ts b/packages/sdk-js/src/langgraph/utils.ts index 5f38c6304f..1ddc0f0812 100644 --- a/packages/sdk-js/src/langgraph/utils.ts +++ b/packages/sdk-js/src/langgraph/utils.ts @@ -1,4 +1,4 @@ -import { RunnableConfig } from "@langchain/core/runnables"; +import type { RunnableConfig } from "@langchain/core/runnables"; import { dispatchCustomEvent } from "@langchain/core/callbacks/dispatch"; import { convertJsonSchemaToZodSchema, @@ -8,7 +8,7 @@ import { import { interrupt } from "@langchain/langgraph"; import { DynamicStructuredTool } from "@langchain/core/tools"; import { AIMessage } from "@langchain/core/messages"; -import { OptionsConfig } from "./types"; +import type { OptionsConfig } from "./types"; /** * Customize the LangGraph configuration for use in CopilotKit. @@ -354,6 +354,14 @@ export async function copilotkitEmitToolCall( }); } + try { + JSON.stringify(args); + } catch (error) { + throw new CopilotKitMisuseError({ + message: `Tool arguments for '${name}' are not JSON-serializable: ${error instanceof Error ? error.message : String(error)}`, + }); + } + if ( options?.toolCallId !== undefined && (typeof options.toolCallId !== "string" || diff --git a/sdk-python/copilotkit/crewai/crewai_sdk.py b/sdk-python/copilotkit/crewai/crewai_sdk.py index 2f64ac381f..00c06be445 100644 --- a/sdk-python/copilotkit/crewai/crewai_sdk.py +++ b/sdk-python/copilotkit/crewai/crewai_sdk.py @@ -278,6 +278,7 @@ async def copilotkit_emit_tool_call( message_id = tool_call_id if tool_call_id is not None else str(uuid.uuid4()) dispatched_start = False + dispatched_end = False try: await queue_put( action_execution_start( @@ -293,8 +294,9 @@ async def copilotkit_emit_tool_call( await queue_put( action_execution_end(action_execution_id=message_id), ) + dispatched_end = True except Exception: - if dispatched_start: + if dispatched_start and not dispatched_end: try: await queue_put( action_execution_end(action_execution_id=message_id), diff --git a/sdk-python/copilotkit/langgraph.py b/sdk-python/copilotkit/langgraph.py index 1626dbc464..d69a78ba3b 100644 --- a/sdk-python/copilotkit/langgraph.py +++ b/sdk-python/copilotkit/langgraph.py @@ -448,6 +448,7 @@ async def copilotkit_emit_tool_call( "tool_call_id=%s; event was already dispatched", tool_call_id, ) + raise return tool_call_id diff --git a/sdk-python/copilotkit/langgraph_agui_agent.py b/sdk-python/copilotkit/langgraph_agui_agent.py index a720224bff..11cacefece 100644 --- a/sdk-python/copilotkit/langgraph_agui_agent.py +++ b/sdk-python/copilotkit/langgraph_agui_agent.py @@ -164,7 +164,6 @@ def _dispatch_event(self, event) -> str: raw_event=event, ) ) - dispatched_end = True super()._dispatch_event( ToolCallEndEvent( type=EventType.TOOL_CALL_END, @@ -172,6 +171,7 @@ def _dispatch_event(self, event) -> str: raw_event=event, ) ) + dispatched_end = True except Exception: if dispatched_start and not dispatched_end: try: diff --git a/sdk-python/tests/test_emit_tool_call_optional_id.py b/sdk-python/tests/test_emit_tool_call_optional_id.py index 1aa0d3402c..2c9d1a4d29 100644 --- a/sdk-python/tests/test_emit_tool_call_optional_id.py +++ b/sdk-python/tests/test_emit_tool_call_optional_id.py @@ -326,6 +326,109 @@ async def test_non_serializable_args_raises(self): 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.""" + + @pytest.mark.asyncio + async def test_failure_after_start_emits_compensating_end(self): + """If args queue_put fails after start was dispatched, a compensating end is emitted.""" + call_count = 0 + original_queue_put = None + + async def _failing_queue_put(*events): + nonlocal call_count + call_count += 1 + if call_count == 2: + raise RuntimeError("queue closed") + + 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 closed"): + await copilotkit_emit_tool_call( + name="FailTool", args={"x": 1}, tool_call_id="comp-crew-1" + ) + + assert call_count == 3 + + @pytest.mark.asyncio + async def test_failure_on_start_does_not_emit_compensating_end(self): + """If start queue_put itself fails, no compensating end is dispatched.""" + call_count = 0 + + async def _failing_queue_put(*events): + nonlocal call_count + call_count += 1 + raise RuntimeError("start 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="start failed"): + await copilotkit_emit_tool_call( + name="FailTool", args={}, tool_call_id="comp-crew-2" + ) + + assert call_count == 1 + + @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 + if call_count == 1: + return + 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 #2"): + await copilotkit_emit_tool_call( + name="FailTool", args={}, tool_call_id="comp-crew-3" + ) + + assert call_count == 3 + + @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 + if call_count == 1: + return + 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 ---- From 4f81dc38e569ee298c91063c1638b679c4f1b2b3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 20:40:40 +0000 Subject: [PATCH 15/32] style: auto-fix formatting --- sdk-python/tests/test_emit_tool_call_optional_id.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/sdk-python/tests/test_emit_tool_call_optional_id.py b/sdk-python/tests/test_emit_tool_call_optional_id.py index 2c9d1a4d29..6babff3660 100644 --- a/sdk-python/tests/test_emit_tool_call_optional_id.py +++ b/sdk-python/tests/test_emit_tool_call_optional_id.py @@ -345,9 +345,7 @@ async def _failing_queue_put(*events): if call_count == 2: raise RuntimeError("queue closed") - with patch( - "copilotkit.crewai.crewai_sdk.queue_put", new=_failing_queue_put - ): + 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 closed"): @@ -367,9 +365,7 @@ async def _failing_queue_put(*events): call_count += 1 raise RuntimeError("start failed") - with patch( - "copilotkit.crewai.crewai_sdk.queue_put", new=_failing_queue_put - ): + 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="start failed"): @@ -391,9 +387,7 @@ async def _failing_queue_put(*events): return raise RuntimeError(f"queue failure #{call_count}") - with patch( - "copilotkit.crewai.crewai_sdk.queue_put", new=_failing_queue_put - ): + 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 #2"): From d395501de058e907908bf4a8bdf9fab6d8e65329 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 19 May 2026 22:57:22 +0200 Subject: [PATCH 16/32] fix(sdk): enrich JS dispatch errors, shield emit_message, standardize validation order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap JS dispatchCustomEvent in try/catch that enriches error messages with tool name and ID for debuggability - Apply asyncio.shield to copilotkit_emit_message's post-dispatch sleep to match copilotkit_emit_tool_call's behavior under task cancellation - Reorder validation in LangGraph Python and JS to name → toolCallId → args, matching CrewAI's order (cheap checks before serialization) - Narrow AG-UI dispatcher's except clause around json.dumps from Exception to (TypeError, ValueError), matching sibling SDK variants - Add CancelledError propagation and warning-log tests for the shielded sleep path Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/langgraph/__tests__/utils.test.ts | 4 +- packages/sdk-js/src/langgraph/utils.ts | 39 ++++++++------ sdk-python/copilotkit/langgraph.py | 19 ++++--- sdk-python/copilotkit/langgraph_agui_agent.py | 2 +- .../tests/test_emit_tool_call_optional_id.py | 54 +++++++++++++++++++ 5 files changed, 91 insertions(+), 27 deletions(-) diff --git a/packages/sdk-js/src/langgraph/__tests__/utils.test.ts b/packages/sdk-js/src/langgraph/__tests__/utils.test.ts index 4f17b9d18f..653159f7b3 100644 --- a/packages/sdk-js/src/langgraph/__tests__/utils.test.ts +++ b/packages/sdk-js/src/langgraph/__tests__/utils.test.ts @@ -352,10 +352,10 @@ describe("copilotkitEmitToolCall", () => { ).rejects.toThrow("not JSON-serializable"); }); - it("propagates dispatch errors without wrapping", async () => { + it("propagates dispatch errors with tool-call context", async () => { mockedDispatch.mockRejectedValueOnce(new Error("transport closed")); await expect( copilotkitEmitToolCall(mockConfig, "SearchTool", {}), - ).rejects.toThrow("transport closed"); + ).rejects.toThrow(/copilotkitEmitToolCall dispatch failed.*SearchTool.*transport closed/); }); }); diff --git a/packages/sdk-js/src/langgraph/utils.ts b/packages/sdk-js/src/langgraph/utils.ts index 1ddc0f0812..267269d3a4 100644 --- a/packages/sdk-js/src/langgraph/utils.ts +++ b/packages/sdk-js/src/langgraph/utils.ts @@ -348,6 +348,17 @@ export async function copilotkitEmitToolCall( }); } + if ( + options?.toolCallId !== undefined && + (typeof options.toolCallId !== "string" || + options.toolCallId.trim().length === 0) + ) { + throw new CopilotKitMisuseError({ + message: + "Tool call id must be a non-empty string when provided for copilotkitEmitToolCall", + }); + } + if (args === undefined) { throw new CopilotKitMisuseError({ message: "Tool arguments are required for copilotkitEmitToolCall", @@ -362,24 +373,20 @@ export async function copilotkitEmitToolCall( }); } - if ( - options?.toolCallId !== undefined && - (typeof options.toolCallId !== "string" || - options.toolCallId.trim().length === 0) - ) { - throw new CopilotKitMisuseError({ - message: - "Tool call id must be a non-empty string when provided for copilotkitEmitToolCall", - }); - } - const toolCallId = options?.toolCallId ?? randomId(); - await dispatchCustomEvent( - "copilotkit_manually_emit_tool_call", - { name, args, id: toolCallId }, - config, - ); + try { + await dispatchCustomEvent( + "copilotkit_manually_emit_tool_call", + { name, args, id: toolCallId }, + config, + ); + } catch (error) { + if (error instanceof Error) { + error.message = `copilotkitEmitToolCall dispatch failed for tool="${name}" id="${toolCallId}": ${error.message}`; + } + throw error; + } return toolCallId; } diff --git a/sdk-python/copilotkit/langgraph.py b/sdk-python/copilotkit/langgraph.py index d69a78ba3b..78ddfc335f 100644 --- a/sdk-python/copilotkit/langgraph.py +++ b/sdk-python/copilotkit/langgraph.py @@ -369,7 +369,10 @@ async def copilotkit_emit_message(config: RunnableConfig, message: str): {"message": message, "message_id": str(uuid.uuid4()), "role": "assistant"}, config=config, ) - await asyncio.sleep(0.02) + try: + await asyncio.shield(asyncio.sleep(0.02)) + except asyncio.CancelledError: + raise return True @@ -416,13 +419,6 @@ async def copilotkit_emit_tool_call( "Tool name must be a non-empty string for copilotkit_emit_tool_call" ) - try: - json.dumps(args) - except (TypeError, ValueError) as e: - raise CopilotKitMisuseError( - f"Tool arguments for '{name}' are not JSON-serializable: {e}" - ) from e - if tool_call_id is not None: if not isinstance(tool_call_id, str) or not tool_call_id.strip(): raise CopilotKitMisuseError( @@ -431,6 +427,13 @@ async def copilotkit_emit_tool_call( else: tool_call_id = str(uuid.uuid4()) + try: + json.dumps(args) + except (TypeError, ValueError) as e: + raise CopilotKitMisuseError( + f"Tool arguments for '{name}' are not JSON-serializable: {e}" + ) from e + await adispatch_custom_event( "copilotkit_manually_emit_tool_call", {"name": name, "args": args, "id": tool_call_id}, diff --git a/sdk-python/copilotkit/langgraph_agui_agent.py b/sdk-python/copilotkit/langgraph_agui_agent.py index 11cacefece..21561754c4 100644 --- a/sdk-python/copilotkit/langgraph_agui_agent.py +++ b/sdk-python/copilotkit/langgraph_agui_agent.py @@ -138,7 +138,7 @@ def _dispatch_event(self, event) -> str: if isinstance(tool_call_args, str) else json.dumps(tool_call_args) ) - except Exception as e: + except (TypeError, ValueError) as e: raise CopilotKitMisuseError( f"ManuallyEmitToolCall 'args' is not JSON-serializable for tool_call_id={tool_call_id}: {e}" ) from e diff --git a/sdk-python/tests/test_emit_tool_call_optional_id.py b/sdk-python/tests/test_emit_tool_call_optional_id.py index 6babff3660..4e5abc77ab 100644 --- a/sdk-python/tests/test_emit_tool_call_optional_id.py +++ b/sdk-python/tests/test_emit_tool_call_optional_id.py @@ -8,6 +8,7 @@ missing/invalid id, name, args, non-serializable args, and non-dict value """ +import asyncio import json import logging import uuid @@ -204,6 +205,59 @@ async def test_non_serializable_args_raises(self): 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 ---- From 40a4fe6d73b539c3402f6952ca8e34d3a2a0141f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 20:58:18 +0000 Subject: [PATCH 17/32] style: auto-fix formatting --- .../src/langgraph/__tests__/utils.test.ts | 4 ++- .../tests/test_emit_tool_call_optional_id.py | 29 +++++++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/packages/sdk-js/src/langgraph/__tests__/utils.test.ts b/packages/sdk-js/src/langgraph/__tests__/utils.test.ts index 653159f7b3..7728df990a 100644 --- a/packages/sdk-js/src/langgraph/__tests__/utils.test.ts +++ b/packages/sdk-js/src/langgraph/__tests__/utils.test.ts @@ -356,6 +356,8 @@ describe("copilotkitEmitToolCall", () => { mockedDispatch.mockRejectedValueOnce(new Error("transport closed")); await expect( copilotkitEmitToolCall(mockConfig, "SearchTool", {}), - ).rejects.toThrow(/copilotkitEmitToolCall dispatch failed.*SearchTool.*transport closed/); + ).rejects.toThrow( + /copilotkitEmitToolCall dispatch failed.*SearchTool.*transport closed/, + ); }); }); diff --git a/sdk-python/tests/test_emit_tool_call_optional_id.py b/sdk-python/tests/test_emit_tool_call_optional_id.py index 4e5abc77ab..0fe2459187 100644 --- a/sdk-python/tests/test_emit_tool_call_optional_id.py +++ b/sdk-python/tests/test_emit_tool_call_optional_id.py @@ -224,10 +224,19 @@ 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): + 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" + config, + name="CancelTool", + args={}, + tool_call_id="cancel-test-id", ) with pytest.raises(asyncio.CancelledError): @@ -249,11 +258,19 @@ 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 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" + config, + name="Tool", + args={}, + tool_call_id="log-cancel-id", ) assert any("log-cancel-id" in record.message for record in caplog.records) From 90de3cef888674d5b176c774468f0e2a70b98afe Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 19 May 2026 23:08:57 +0200 Subject: [PATCH 18/32] fix(sdk): wrap dispatch errors instead of mutating, fix duplicate-END, use UUID for tool call IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace JS error.message mutation with wrapped Error + cause chain (safe for frozen errors, shared references, non-Error throwables) - Rename dispatched_end → end_attempted, set before END dispatch to prevent duplicate TOOL_CALL_END when END partially flushes before throwing - Switch JS randomId() (ck-prefixed) to randomUUID() for cross-SDK parity with Python's str(uuid.uuid4()) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/sdk-js/src/langgraph/utils.ts | 12 +++++++----- sdk-python/copilotkit/crewai/crewai_sdk.py | 6 +++--- sdk-python/copilotkit/langgraph_agui_agent.py | 6 +++--- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/sdk-js/src/langgraph/utils.ts b/packages/sdk-js/src/langgraph/utils.ts index 267269d3a4..bce3b5dc8a 100644 --- a/packages/sdk-js/src/langgraph/utils.ts +++ b/packages/sdk-js/src/langgraph/utils.ts @@ -3,6 +3,7 @@ import { dispatchCustomEvent } from "@langchain/core/callbacks/dispatch"; import { convertJsonSchemaToZodSchema, randomId, + randomUUID, CopilotKitMisuseError, } from "@copilotkit/shared"; import { interrupt } from "@langchain/langgraph"; @@ -373,7 +374,7 @@ export async function copilotkitEmitToolCall( }); } - const toolCallId = options?.toolCallId ?? randomId(); + const toolCallId = options?.toolCallId ?? randomUUID(); try { await dispatchCustomEvent( @@ -382,10 +383,11 @@ export async function copilotkitEmitToolCall( config, ); } catch (error) { - if (error instanceof Error) { - error.message = `copilotkitEmitToolCall dispatch failed for tool="${name}" id="${toolCallId}": ${error.message}`; - } - throw error; + const wrapped = new Error( + `copilotkitEmitToolCall dispatch failed for tool="${name}" id="${toolCallId}": ${error instanceof Error ? error.message : String(error)}`, + ); + (wrapped as any).cause = error; + throw wrapped; } return toolCallId; diff --git a/sdk-python/copilotkit/crewai/crewai_sdk.py b/sdk-python/copilotkit/crewai/crewai_sdk.py index 00c06be445..df82033f4c 100644 --- a/sdk-python/copilotkit/crewai/crewai_sdk.py +++ b/sdk-python/copilotkit/crewai/crewai_sdk.py @@ -278,7 +278,7 @@ async def copilotkit_emit_tool_call( message_id = tool_call_id if tool_call_id is not None else str(uuid.uuid4()) dispatched_start = False - dispatched_end = False + end_attempted = False try: await queue_put( action_execution_start( @@ -291,12 +291,12 @@ async def copilotkit_emit_tool_call( await queue_put( action_execution_args(action_execution_id=message_id, args=args_json), ) + end_attempted = True await queue_put( action_execution_end(action_execution_id=message_id), ) - dispatched_end = True except Exception: - if dispatched_start and not dispatched_end: + if dispatched_start and not end_attempted: try: await queue_put( action_execution_end(action_execution_id=message_id), diff --git a/sdk-python/copilotkit/langgraph_agui_agent.py b/sdk-python/copilotkit/langgraph_agui_agent.py index 21561754c4..8ff4ed013d 100644 --- a/sdk-python/copilotkit/langgraph_agui_agent.py +++ b/sdk-python/copilotkit/langgraph_agui_agent.py @@ -144,7 +144,7 @@ def _dispatch_event(self, event) -> str: ) from e dispatched_start = False - dispatched_end = False + end_attempted = False try: super()._dispatch_event( ToolCallStartEvent( @@ -164,6 +164,7 @@ def _dispatch_event(self, event) -> str: raw_event=event, ) ) + end_attempted = True super()._dispatch_event( ToolCallEndEvent( type=EventType.TOOL_CALL_END, @@ -171,9 +172,8 @@ def _dispatch_event(self, event) -> str: raw_event=event, ) ) - dispatched_end = True except Exception: - if dispatched_start and not dispatched_end: + if dispatched_start and not end_attempted: try: super()._dispatch_event( ToolCallEndEvent( From f434418df5414ebb39f7084047586ed20897094f Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 19 May 2026 23:23:50 +0200 Subject: [PATCH 19/32] fix(sdk): restore batched queue_put for atomicity, fix end_dispatched flag ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The split queue_put calls introduced interleaving risk (6 yield points vs 2) and the end_attempted flag was set before the END dispatch, causing the compensating END to be skipped when END itself failed. - CrewAI: restore single batched queue_put(start, args, end) call; compensating END is now unconditional on batch failure - AG-UI agent: rename end_attempted → end_dispatched, set after successful END dispatch so compensation fires for all failure modes - Tests: rewrite CrewAI compensating tests for batch semantics, add test_failure_on_end_emits_compensating_end for AG-UI agent Co-Authored-By: Claude Opus 4.6 (1M context) --- sdk-python/copilotkit/crewai/crewai_sdk.py | 29 +++----- sdk-python/copilotkit/langgraph_agui_agent.py | 6 +- .../tests/test_emit_tool_call_optional_id.py | 67 +++++++++---------- 3 files changed, 46 insertions(+), 56 deletions(-) diff --git a/sdk-python/copilotkit/crewai/crewai_sdk.py b/sdk-python/copilotkit/crewai/crewai_sdk.py index df82033f4c..709d84b694 100644 --- a/sdk-python/copilotkit/crewai/crewai_sdk.py +++ b/sdk-python/copilotkit/crewai/crewai_sdk.py @@ -277,8 +277,6 @@ async def copilotkit_emit_tool_call( ) from e message_id = tool_call_id if tool_call_id is not None else str(uuid.uuid4()) - dispatched_start = False - end_attempted = False try: await queue_put( action_execution_start( @@ -286,27 +284,20 @@ async def copilotkit_emit_tool_call( action_name=name, parent_message_id=message_id, ), - ) - dispatched_start = True - await queue_put( action_execution_args(action_execution_id=message_id, args=args_json), - ) - end_attempted = True - await queue_put( action_execution_end(action_execution_id=message_id), ) except Exception: - if dispatched_start and not end_attempted: - try: - await queue_put( - action_execution_end(action_execution_id=message_id), - ) - except Exception: - logger.error( - "Failed to emit compensating action_execution_end for %s", - message_id, - exc_info=True, - ) + try: + await queue_put( + action_execution_end(action_execution_id=message_id), + ) + except Exception: + logger.error( + "Failed to emit compensating action_execution_end for %s", + message_id, + exc_info=True, + ) raise return message_id diff --git a/sdk-python/copilotkit/langgraph_agui_agent.py b/sdk-python/copilotkit/langgraph_agui_agent.py index 8ff4ed013d..a2e11fdef3 100644 --- a/sdk-python/copilotkit/langgraph_agui_agent.py +++ b/sdk-python/copilotkit/langgraph_agui_agent.py @@ -144,7 +144,7 @@ def _dispatch_event(self, event) -> str: ) from e dispatched_start = False - end_attempted = False + end_dispatched = False try: super()._dispatch_event( ToolCallStartEvent( @@ -164,7 +164,6 @@ def _dispatch_event(self, event) -> str: raw_event=event, ) ) - end_attempted = True super()._dispatch_event( ToolCallEndEvent( type=EventType.TOOL_CALL_END, @@ -172,8 +171,9 @@ def _dispatch_event(self, event) -> str: raw_event=event, ) ) + end_dispatched = True except Exception: - if dispatched_start and not end_attempted: + if dispatched_start and not end_dispatched: try: super()._dispatch_event( ToolCallEndEvent( diff --git a/sdk-python/tests/test_emit_tool_call_optional_id.py b/sdk-python/tests/test_emit_tool_call_optional_id.py index 0fe2459187..f400df94c8 100644 --- a/sdk-python/tests/test_emit_tool_call_optional_id.py +++ b/sdk-python/tests/test_emit_tool_call_optional_id.py @@ -402,49 +402,34 @@ async def test_non_serializable_args_raises(self): @pytest.mark.skipif(not _has_crewai, reason="crewai not installed") class TestCrewAICompensatingEnd: - """Tests for the compensating action_execution_end when dispatch fails mid-stream.""" + """Tests for the compensating action_execution_end when dispatch fails mid-stream. - @pytest.mark.asyncio - async def test_failure_after_start_emits_compensating_end(self): - """If args queue_put fails after start was dispatched, a compensating end is emitted.""" - call_count = 0 - original_queue_put = None - - async def _failing_queue_put(*events): - nonlocal call_count - call_count += 1 - if call_count == 2: - raise RuntimeError("queue closed") - - 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 closed"): - await copilotkit_emit_tool_call( - name="FailTool", args={"x": 1}, tool_call_id="comp-crew-1" - ) - - assert call_count == 3 + 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_failure_on_start_does_not_emit_compensating_end(self): - """If start queue_put itself fails, no compensating end is dispatched.""" + 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 - raise RuntimeError("start failed") + 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="start failed"): + with pytest.raises(RuntimeError, match="batch failed"): await copilotkit_emit_tool_call( - name="FailTool", args={}, tool_call_id="comp-crew-2" + name="FailTool", args={"x": 1}, tool_call_id="comp-crew-1" ) - assert call_count == 1 + assert call_count == 2 @pytest.mark.asyncio async def test_compensating_end_failure_reraises_original(self): @@ -454,19 +439,17 @@ async def test_compensating_end_failure_reraises_original(self): async def _failing_queue_put(*events): nonlocal call_count call_count += 1 - if call_count == 1: - return 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 #2"): + 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 == 3 + assert call_count == 2 @pytest.mark.asyncio async def test_compensating_end_failure_emits_log(self, caplog): @@ -476,8 +459,6 @@ async def test_compensating_end_failure_emits_log(self, caplog): async def _failing_queue_put(*events): nonlocal call_count call_count += 1 - if call_count == 1: - return raise RuntimeError(f"queue failure #{call_count}") with caplog.at_level(logging.ERROR, logger="copilotkit.crewai.crewai_sdk"): @@ -806,3 +787,21 @@ def _fail_on_args_and_end(self_inner, evt): 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 From 570bcf4264a3e6533d71698eec450353c4f82093 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 19 May 2026 23:33:07 +0200 Subject: [PATCH 20/32] fix(sdk): remove dead try/except in emit_message, drop unused imports in sdk.py Remove no-op `try/except CancelledError: raise` around asyncio.shield in copilotkit_emit_message (the shield handles cancellation on its own). Remove unused CopilotKitError and CopilotKitMisuseError imports from sdk.py (already re-exported via __init__.py). Co-Authored-By: Claude Opus 4.6 (1M context) --- sdk-python/copilotkit/langgraph.py | 5 +---- sdk-python/copilotkit/sdk.py | 2 -- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/sdk-python/copilotkit/langgraph.py b/sdk-python/copilotkit/langgraph.py index 78ddfc335f..cc796a48ae 100644 --- a/sdk-python/copilotkit/langgraph.py +++ b/sdk-python/copilotkit/langgraph.py @@ -369,10 +369,7 @@ async def copilotkit_emit_message(config: RunnableConfig, message: str): {"message": message, "message_id": str(uuid.uuid4()), "role": "assistant"}, config=config, ) - try: - await asyncio.shield(asyncio.sleep(0.02)) - except asyncio.CancelledError: - raise + await asyncio.shield(asyncio.sleep(0.02)) return True diff --git a/sdk-python/copilotkit/sdk.py b/sdk-python/copilotkit/sdk.py index 4e8e3e5ece..8eeb42ecae 100644 --- a/sdk-python/copilotkit/sdk.py +++ b/sdk-python/copilotkit/sdk.py @@ -10,8 +10,6 @@ from .action import Action, ActionDict, ActionResultDict from .types import Message, MetaEvent from .exc import ( - CopilotKitError, - CopilotKitMisuseError, ActionNotFoundException, AgentNotFoundException, ActionExecutionException, From 2169cc93839bdc121f7bd0698b94beaa26af2605 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 20 May 2026 00:09:12 +0200 Subject: [PATCH 21/32] fix(sdk): widen AG-UI dispatcher args validation to match emitter contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AG-UI dispatcher's ManuallyEmitToolCall handler rejected non-dict, non-string args with CopilotKitMisuseError, but all three emitters (JS, Python LangGraph, Python CrewAI) accept any JSON-serializable value. This mismatch caused JS-emitted list/number args to crash the Python dispatcher. Replace the strict isinstance(dict, str) check with a None guard and rely on the existing json.dumps try/except for serializability. Call-site enumeration: - langgraph_agui_agent.py:129 — changed from isinstance check to None guard - test_emit_tool_call_optional_id.py — updated test_missing_args_raises match, test_non_serializable_args_raises match, converted test_list_args_raises and test_int_args_raises from negative to positive tests - No other call sites reference the removed isinstance pattern Co-Authored-By: Claude Opus 4.6 (1M context) --- sdk-python/copilotkit/langgraph_agui_agent.py | 5 +- .../tests/test_emit_tool_call_optional_id.py | 48 ++++++++++--------- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/sdk-python/copilotkit/langgraph_agui_agent.py b/sdk-python/copilotkit/langgraph_agui_agent.py index a2e11fdef3..1cf8de4569 100644 --- a/sdk-python/copilotkit/langgraph_agui_agent.py +++ b/sdk-python/copilotkit/langgraph_agui_agent.py @@ -126,10 +126,9 @@ def _dispatch_event(self, event) -> str: raise CopilotKitMisuseError( f"ManuallyEmitToolCall event missing valid 'name': got {type(tool_call_name).__name__}" ) - if not isinstance(tool_call_args, (dict, str)): + if tool_call_args is None: raise CopilotKitMisuseError( - f"ManuallyEmitToolCall 'args' must be a dict or pre-serialized JSON string, " - f"got {type(tool_call_args).__name__} for tool_call_id={tool_call_id}" + f"ManuallyEmitToolCall event missing 'args' for tool_call_id={tool_call_id}" ) try: diff --git a/sdk-python/tests/test_emit_tool_call_optional_id.py b/sdk-python/tests/test_emit_tool_call_optional_id.py index f400df94c8..3041c57a3a 100644 --- a/sdk-python/tests/test_emit_tool_call_optional_id.py +++ b/sdk-python/tests/test_emit_tool_call_optional_id.py @@ -647,7 +647,7 @@ def test_missing_args_raises(self, agent): value={"id": "valid-id", "name": "Tool"}, ) with pytest.raises( - CopilotKitMisuseError, match="must be a dict or pre-serialized" + CopilotKitMisuseError, match="missing 'args'" ): agent._dispatch_event(event) @@ -659,7 +659,7 @@ def test_non_serializable_args_raises(self, agent): value={"id": "valid-id", "name": "Tool", "args": {1, 2, 3}}, ) with pytest.raises( - CopilotKitMisuseError, match="must be a dict or pre-serialized" + CopilotKitMisuseError, match="not JSON-serializable" ): agent._dispatch_event(event) @@ -673,30 +673,34 @@ def test_non_dict_value_raises(self, agent): with pytest.raises(CopilotKitMisuseError, match="must be a dict"): agent._dispatch_event(event) - def test_list_args_raises(self, agent): - """Event with list args 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="must be a dict or pre-serialized" - ): + 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) - def test_int_args_raises(self, agent): - """Event with int args should raise CopilotKitMisuseError.""" - event = CustomEvent( - type=EventType.CUSTOM, - name=CustomEventNames.ManuallyEmitToolCall.value, - value={"id": "valid-id", "name": "Tool", "args": 42}, - ) - with pytest.raises( - CopilotKitMisuseError, match="must be a dict or pre-serialized" - ): + 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 ---- From c4ccf02962e8332223246a9866d2cad6fd33375b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 22:20:26 +0000 Subject: [PATCH 22/32] style: auto-fix formatting --- sdk-python/tests/test_emit_tool_call_optional_id.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/sdk-python/tests/test_emit_tool_call_optional_id.py b/sdk-python/tests/test_emit_tool_call_optional_id.py index 3041c57a3a..23d9ae72c7 100644 --- a/sdk-python/tests/test_emit_tool_call_optional_id.py +++ b/sdk-python/tests/test_emit_tool_call_optional_id.py @@ -646,9 +646,7 @@ def test_missing_args_raises(self, agent): name=CustomEventNames.ManuallyEmitToolCall.value, value={"id": "valid-id", "name": "Tool"}, ) - with pytest.raises( - CopilotKitMisuseError, match="missing 'args'" - ): + with pytest.raises(CopilotKitMisuseError, match="missing 'args'"): agent._dispatch_event(event) def test_non_serializable_args_raises(self, agent): @@ -658,9 +656,7 @@ def test_non_serializable_args_raises(self, agent): name=CustomEventNames.ManuallyEmitToolCall.value, value={"id": "valid-id", "name": "Tool", "args": {1, 2, 3}}, ) - with pytest.raises( - CopilotKitMisuseError, match="not JSON-serializable" - ): + with pytest.raises(CopilotKitMisuseError, match="not JSON-serializable"): agent._dispatch_event(event) def test_non_dict_value_raises(self, agent): From cc69fd04efe63fb63de42c1d7054186cefae6720 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 21 May 2026 23:36:29 +0200 Subject: [PATCH 23/32] fix(sdk): add pytest-asyncio dev dependency for async test support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new test_emit_tool_call_optional_id.py uses async test methods decorated with @pytest.mark.asyncio, but pytest-asyncio was missing from dev dependencies — causing all 11 async tests to fail in CI across all Python versions (3.10–3.14). Co-Authored-By: Claude Opus 4.6 (1M context) --- sdk-python/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk-python/pyproject.toml b/sdk-python/pyproject.toml index b8e53bc8d3..b215f9e2d6 100644 --- a/sdk-python/pyproject.toml +++ b/sdk-python/pyproject.toml @@ -48,4 +48,4 @@ requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [dependency-groups] -dev = ["pytest (>=9.0.2,<10.0.0)"] +dev = ["pytest (>=9.0.2,<10.0.0)", "pytest-asyncio (>=1.0.0,<2.0.0)"] From c1874ed64ca8204bd32e0facc1b15d24e7787833 Mon Sep 17 00:00:00 2001 From: Tyler Slaton Date: Tue, 26 May 2026 22:20:02 -0700 Subject: [PATCH 24/32] Refresh shell docs homepage and search modal --- .../shell-docs/src/app/[[...slug]]/page.tsx | 205 +++++-- .../shell-docs/src/components/banners.tsx | 4 +- .../shell-docs/src/components/brand-nav.tsx | 2 +- .../src/components/docs-landing-next.tsx | 96 ++-- .../src/components/icons/framework-icons.tsx | 87 +++ .../src/components/search-modal.tsx | 533 +++++++++++++++--- .../src/components/search-trigger.tsx | 57 +- 7 files changed, 758 insertions(+), 226 deletions(-) diff --git a/showcase/shell-docs/src/app/[[...slug]]/page.tsx b/showcase/shell-docs/src/app/[[...slug]]/page.tsx index 4c21629da7..154d525456 100644 --- a/showcase/shell-docs/src/app/[[...slug]]/page.tsx +++ b/showcase/shell-docs/src/app/[[...slug]]/page.tsx @@ -9,13 +9,23 @@ import React from "react"; import type { Metadata } from "next"; import Link from "next/link"; +import { + ArrowRight, + Blocks, + Bot, + Braces, + Code2, + MessageSquare, + Workflow, +} from "lucide-react"; import { DocsLandingNext } from "@/components/docs-landing-next"; import { ShellDocsLayout } from "@/components/shell-docs-layout"; import { SidebarFrameworkSelector } from "@/components/sidebar-framework-selector"; import { UnscopedDocsPage } from "@/components/unscoped-docs-page"; +import { FrameworkLogo } from "@/components/icons/framework-icons"; import { buildFrameworkOnlyNav, loadDoc } from "@/lib/docs-render"; import { navTreeToPageTree } from "@/lib/page-tree-bridge"; -import { getDocsFolder } from "@/lib/registry"; +import { getDocsFolder, getIntegration } from "@/lib/registry"; import { buildDocMetadata } from "@/lib/seo-metadata"; // Force dynamic rendering so unknown slugs reliably return HTTP 404 @@ -111,85 +121,158 @@ function DocsOverview() { ...pageTree, children: rewriteUrls(pageTree.children), }; + const homeIntegration = getIntegration(HOME_DEFAULT_FRAMEWORK); + const stackItems = [ + { + label: "Frontend", + title: "React UX primitives", + detail: "Chat, generative UI, shared state, and HITL controls.", + icon: , + }, + { + label: "Runtime", + title: "AG-UI transport", + detail: "Event streams, tools, state snapshots, and observability.", + icon: , + }, + { + label: "Agent", + title: "Any backend", + detail: "LangGraph, ADK, Mastra, CrewAI, PydanticAI, and more.", + icon: , + }, + ]; + const primaryCards = [ + { + href: "/built-in-agent/quickstart", + title: "Start building", + body: "Create a working CopilotKit app with the built-in agent.", + icon: , + }, + { + href: "/built-in-agent/generative-ui/tool-rendering", + title: "Render agent UI", + body: "Turn tool calls and state into first-class React components.", + icon: , + }, + { + href: "/reference", + title: "API Reference", + body: "Hooks, components, runtime config, and integration APIs.", + icon: , + }, + ]; return ( }> -
- {/* Hero — typography-led. No eyebrow pill, no atmospheric glow. - Title + lede + an unframed `npm install`-style block. */} -
-

- Welcome to CopilotKit -

-

- CopilotKit is the frontend stack for agents and{" "} - generative UI. Connect any agent framework or model - to your React app for chat, generative UI, canvas apps, and - human-in-the-loop workflows. -

-
- - npx copilotkit@latest create - +
+
+
+
+ + Frontend stack for agents +
+
+

+ CopilotKit connects agent backends to real product interfaces. +

+

+ Build chat, generative UI, shared state, canvas apps, and + human-in-the-loop workflows on top of LangGraph, Google ADK, + Mastra, PydanticAI, CrewAI, the built-in agent, or any AG-UI + compatible backend. +

+
+
+ + Start with the built-in agent + + +
+ + npx copilotkit@latest create + +
+
+
+ +
+
+ {stackItems.map((item) => ( +
+
+ + {item.label} + + + {item.icon} + +
+
+ {item.title} +
+

+ {item.detail} +

+
+ ))} +
+
+ {[ + "LangGraph", + "Google ADK", + "Mastra", + "PydanticAI", + "CrewAI", + "AG-UI", + ].map((label) => ( + + {label} + + ))} +
- {/* ===== PRIMARY NAV GRID ===== */} - {/* Three top-level docs surfaces. Minimal cards — title, body, - trailing arrow. Hover changes only the border + arrow color. */} -
- {[ - { - href: "/concepts/architecture", - title: "Concepts", - body: "Architecture, gen UI types, OSS vs Enterprise.", - }, - { - href: "/reference", - title: "API Reference", - body: "Hooks, components, and config.", - }, - { - href: "/generative-ui/your-components/display-only", - title: "Generative UI", - body: "Render tools as React components.", - }, - ].map((card) => ( +
+ {primaryCards.map((card) => (
+ + {card.icon} + + +
+
{card.title}
- -
-
- {card.body} +
+ {card.body} +
))}
- {/* Conditional next-step block: framework picker if no - storedFramework, "what's next" pointers into that - framework's docs if there is one. */}
diff --git a/showcase/shell-docs/src/components/banners.tsx b/showcase/shell-docs/src/components/banners.tsx index 5670e1a8ea..b021095e15 100644 --- a/showcase/shell-docs/src/components/banners.tsx +++ b/showcase/shell-docs/src/components/banners.tsx @@ -116,10 +116,10 @@ export function Banners() { const content = bannerContent[currentBanner]; return ( -
+
+