diff --git a/.claude/skills/showcase-demo-debugging/SKILL.md b/.claude/skills/showcase-demo-debugging/SKILL.md index 41f75fe9a5b..7726cc8256d 100644 --- a/.claude/skills/showcase-demo-debugging/SKILL.md +++ b/.claude/skills/showcase-demo-debugging/SKILL.md @@ -1,6 +1,8 @@ --- name: showcase-demo-debugging description: Build, debug, and regression-test CopilotKit showcase demos. Use when working under showcase/, creating new showcase demos/items/cells/pills, implementing showcase features, investigating showcase demo bugs, D5/e2e-deep failures, aimock fixture gaps, direct-LLM versus aimock behavior, recording fixtures, testing every demo pill/cell, verifying repeated and interleaved suggestion-pill fixture stability, enforcing langgraph-python 1:1 parity, or migrating a demo to the v2 CopilotKit showcase flow. +metadata: + internal: true --- # Showcase Demo Work diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 6ee4e657046..c55deeaed16 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -74,11 +74,24 @@ jobs: - name: Run tests run: pnpm run test + # Exclude node_modules and build caches from the artifact. Including + # them produces ~6M files for this monorepo, which OOMs + # upload-artifact's Node process at its 4GB heap limit during + # enumeration (each pnpm symlink resolves to a separate entry, and + # the action holds the full manifest in memory before streaming). + # The publish job runs `pnpm install --frozen-lockfile` after + # download to restore node_modules from pnpm-lock.yaml — mirrors + # publish-release.yml. - name: Upload workspace uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: workspace - path: . + path: | + . + !**/node_modules/** + !**/.next/** + !**/.turbo/** + !**/.nx/** include-hidden-files: true retention-days: 1 @@ -107,6 +120,14 @@ jobs: node-version: 22.x registry-url: https://registry.npmjs.org + # Restore node_modules — the build job excludes them from the + # uploaded workspace artifact (see "Upload workspace" above). Uses + # pnpm-lock.yaml from the artifact, so this is a deterministic + # restore of exactly what the build job ran with. `pnpm tsx` in the + # publish step below requires tsx to be installed locally. + - name: Install Dependencies + run: pnpm install --frozen-lockfile + - name: Publish prerelease env: NODE_AUTH_TOKEN: "" diff --git a/packages/react-core/src/__tests__/threadid-propagation.contract.test.tsx b/packages/react-core/src/__tests__/threadid-propagation.contract.test.tsx new file mode 100644 index 00000000000..190a9a3ed3c --- /dev/null +++ b/packages/react-core/src/__tests__/threadid-propagation.contract.test.tsx @@ -0,0 +1,173 @@ +import React from "react"; +import { render, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { AbstractAgent } from "@ag-ui/client"; +import { useCopilotKit } from "../v2/context"; +import { useAgent } from "../v2/hooks/use-agent"; +import { CopilotChatConfigurationProvider } from "../v2/providers/CopilotChatConfigurationProvider"; +import { CopilotKitCoreRuntimeConnectionStatus } from "@copilotkit/core"; + +vi.mock("../v2/context", () => ({ + useCopilotKit: vi.fn(), +})); + +const mockUseCopilotKit = useCopilotKit as ReturnType; + +/** + * Contract-level regression coverage for the threadId-propagation invariant. + * + * Lives at the package root (src/__tests__) rather than next to any one + * implementation site on purpose: the previous regression (issue #5041, root + * cause shared with #4739) slipped through because the original coverage + * (use-agent-thread-isolation.test.tsx, 333 lines) lived next to the + * per-thread-cloning feature and was deleted alongside it in May 2026 when + * cloning was reverted. The threadId-propagation invariant survived the + * feature change but its tests didn't. + * + * The invariant under test: a caller-supplied threadId (via , + * , or ) MUST end up on + * the underlying agent's `threadId` field, because ProxiedCopilotRuntimeAgent + * uses that field to address /agent/run, /agent/connect, /agent/stop. Without + * it the agent ships its own auto-minted UUID and the backend sees a different + * thread than the app code reads via useThreads/useCopilotChatConfiguration. + * + * This file should outlive any specific implementation strategy (cloning, + * effect-based sync, prop drilling, context bridge). If you change the + * mechanism, keep this contract. + */ +describe("useAgent → agent.threadId sync from chat configuration", () => { + let mockCopilotkit: { + getAgent: ReturnType; + runtimeUrl: string | undefined; + runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus; + runtimeTransport: string; + headers: Record; + agents: Record; + subscribeToAgentWithOptions: ( + agent: AbstractAgent, + subscriber: any, + ) => { unsubscribe: () => void }; + }; + + beforeEach(() => { + mockCopilotkit = { + getAgent: vi.fn(() => undefined), + runtimeUrl: "http://localhost:3000/api/copilotkit", + runtimeConnectionStatus: + CopilotKitCoreRuntimeConnectionStatus.Disconnected, + runtimeTransport: "rest", + headers: {}, + agents: {}, + subscribeToAgentWithOptions: (agent, subscriber) => + agent.subscribe(subscriber), + }; + + mockUseCopilotKit.mockReturnValue({ + copilotkit: mockCopilotkit, + executingToolCallIds: new Set(), + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("sets agent.threadId from CopilotChatConfigurationProvider when explicit", () => { + // The agent's threadId is mutable state outside React's render tracking, + // so we assert against the captured reference rather than rendered DOM — + // mutating agent.threadId after commit doesn't trigger a re-render, but + // downstream consumers (HTTP run/connect/stop, syncDelegate) read it + // imperatively at call time, which is the actual contract the fix repairs. + const callerThreadId = "caller-supplied-thread-id"; + let capturedAgent: AbstractAgent | null = null; + + function Probe() { + const { agent } = useAgent({ agentId: "test-agent" }); + capturedAgent = agent; + return null; + } + + render( + + + , + ); + + expect(capturedAgent).not.toBeNull(); + expect(capturedAgent!.threadId).toBe(callerThreadId); + }); + + it("does NOT overwrite the auto-minted threadId when hasExplicitThreadId is false", () => { + // Mirrors the v1 CopilotKit bridge: ThreadsProvider auto-mints a UUID, the + // bridge forwards it but marks it non-explicit so downstream consumers + // don't treat the placeholder as a real backend thread. + const placeholderThreadId = "placeholder-from-threads-provider"; + let capturedAgent: AbstractAgent | null = null; + + function Probe() { + const { agent } = useAgent({ agentId: "test-agent" }); + capturedAgent = agent; + return null; + } + + render( + + + , + ); + + expect(capturedAgent).not.toBeNull(); + expect(capturedAgent!.threadId).not.toBe(placeholderThreadId); + // AbstractAgent's constructor auto-mints a UUID; just confirm it's a + // non-empty string distinct from the placeholder. + expect(capturedAgent!.threadId).toBeTruthy(); + }); + + it("re-syncs agent.threadId when the configuration's threadId changes", () => { + let capturedAgent: AbstractAgent | null = null; + + function Probe() { + const { agent } = useAgent({ agentId: "test-agent" }); + capturedAgent = agent; + return null; + } + + const { rerender } = render( + + + , + ); + + expect(capturedAgent!.threadId).toBe("first-thread"); + + act(() => { + rerender( + + + , + ); + }); + + // Same agent instance (provisional cache keeps reference stable), updated threadId + expect(capturedAgent!.threadId).toBe("second-thread"); + }); + + it("is a no-op when no CopilotChatConfigurationProvider is in scope", () => { + // Headless / pure-v2 use without any configuration provider — useAgent + // should leave the agent's auto-minted threadId alone instead of throwing. + let capturedAgent: AbstractAgent | null = null; + + function Probe() { + const { agent } = useAgent({ agentId: "test-agent" }); + capturedAgent = agent; + return null; + } + + expect(() => render()).not.toThrow(); + expect(capturedAgent).not.toBeNull(); + expect(capturedAgent!.threadId).toBeTruthy(); + }); +}); diff --git a/packages/react-core/src/components/copilot-provider/__tests__/v1-explicit-threadid-bridge.test.tsx b/packages/react-core/src/components/copilot-provider/__tests__/v1-explicit-threadid-bridge.test.tsx index e4878df3101..8fb1545305b 100644 --- a/packages/react-core/src/components/copilot-provider/__tests__/v1-explicit-threadid-bridge.test.tsx +++ b/packages/react-core/src/components/copilot-provider/__tests__/v1-explicit-threadid-bridge.test.tsx @@ -3,8 +3,9 @@ import { render, screen, act } from "@testing-library/react"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { CopilotKit } from "../copilotkit"; import { useCopilotContext } from "../../../context/copilot-context"; -import { useCopilotChatConfiguration } from "../../../v2"; +import { useCopilotChatConfiguration, useAgent } from "../../../v2"; import type { CopilotKitProps } from "../copilotkit-props"; +import type { AbstractAgent } from "@ag-ui/client"; /** * Probe that reads hasExplicitThreadId from the CopilotChatConfigurationProvider @@ -105,3 +106,66 @@ describe("v1 bridge → hasExplicitThreadId", () => { expect(screen.getByTestId("explicit").textContent).toBe("true"); }); }); + +/** + * Regression coverage for issue #5041 (and #4739) at the customer-facing + * surface: a threadId prop on must end up on the underlying + * agent so that ProxiedCopilotRuntimeAgent ships it in /agent/run, + * /agent/connect, /agent/stop. The cloning-revert in May 2026 broke this + * for headless useAgent and V1 chat; CopilotChatConfigurationProvider got + * the value but useAgent never copied it down onto agent.threadId. + */ +describe("v1 → useAgent → agent.threadId", () => { + let warnSpy: ReturnType; + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + afterEach(() => { + warnSpy.mockRestore(); + }); + + function AgentProbe({ + onCapture, + }: { + onCapture: (agent: AbstractAgent) => void; + }) { + const { agent } = useAgent({ agentId: "default" }); + React.useEffect(() => { + onCapture(agent); + }); + return null; + } + + it("sets agent.threadId to the explicit threadId from the prop", () => { + let captured: AbstractAgent | null = null; + render( + + (captured = agent)} /> + , + ); + + expect(captured).not.toBeNull(); + expect((captured as unknown as AbstractAgent).threadId).toBe( + "customer-thread-id", + ); + }); + + it("leaves the agent's auto-minted threadId untouched when no threadId prop is supplied", () => { + // Without an explicit threadId, the ThreadsProvider mints a placeholder + // UUID that's marked non-explicit. The agent should keep its own + // constructor-generated UUID rather than adopt the placeholder, so the + // backend isn't asked to look up a thread it never created. + let captured: AbstractAgent | null = null; + render( + + (captured = agent)} /> + , + ); + + expect(captured).not.toBeNull(); + expect((captured as unknown as AbstractAgent).threadId).toBeTruthy(); + expect((captured as unknown as AbstractAgent).threadId).not.toBe( + "mock-thread-id", + ); + }); +}); diff --git a/packages/react-core/src/v2/hooks/use-agent.tsx b/packages/react-core/src/v2/hooks/use-agent.tsx index a4d4a1c16d1..76b64853232 100644 --- a/packages/react-core/src/v2/hooks/use-agent.tsx +++ b/packages/react-core/src/v2/hooks/use-agent.tsx @@ -1,12 +1,14 @@ import { useCopilotKit } from "../context"; import { useMemo, useEffect, useReducer, useRef } from "react"; import { DEFAULT_AGENT_ID } from "@copilotkit/shared"; -import { AbstractAgent, HttpAgent } from "@ag-ui/client"; +import type { AbstractAgent } from "@ag-ui/client"; +import { HttpAgent } from "@ag-ui/client"; import { ProxiedCopilotRuntimeAgent, CopilotKitCoreRuntimeConnectionStatus, - type SubscribeToAgentSubscriber, } from "@copilotkit/core"; +import type { SubscribeToAgentSubscriber } from "@copilotkit/core"; +import { useCopilotChatConfiguration } from "../providers/CopilotChatConfigurationProvider"; export enum UseAgentUpdate { OnMessagesChanged = "OnMessagesChanged", @@ -221,6 +223,22 @@ export function useAgent({ agentId, updates, throttleMs }: UseAgentProps = {}) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [agent, JSON.stringify(copilotkit.headers)]); + // Propagate the caller-supplied threadId from the chat configuration onto + // the agent. AbstractAgent's constructor auto-mints a UUID when no threadId + // is passed, so without this sync the agent ships its own random UUID in + // /agent/run, /agent/connect, /agent/stop — diverging from the threadId the + // app code reads via useThreads/useCopilotChatConfiguration. Gated on + // hasExplicitThreadId so a ThreadsProvider-minted placeholder UUID doesn't + // overwrite the auto-minted agent UUID (both are random and useless to the + // backend; the explicit gate keeps the agent's UUID stable across renders). + const chatConfig = useCopilotChatConfiguration(); + const configThreadId = chatConfig?.threadId; + const configHasExplicitThreadId = chatConfig?.hasExplicitThreadId; + useEffect(() => { + if (!configHasExplicitThreadId || !configThreadId) return; + agent.threadId = configThreadId; + }, [agent, configThreadId, configHasExplicitThreadId]); + return { agent, }; diff --git a/packages/sdk-js/src/langgraph/__tests__/utils.test.ts b/packages/sdk-js/src/langgraph/__tests__/utils.test.ts index 06140c376b4..7728df990a1 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,105 @@ 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 toolCallId verbatim", async () => { + const result = await copilotkitEmitToolCall( + mockConfig, + "SearchTool", + { steps: 10 }, + { toolCallId: "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); + expect(mockedDispatch).toHaveBeenCalledWith( + "copilotkit_manually_emit_tool_call", + { name: "SearchTool", args: {}, id: result }, + mockConfig, + ); + }); + + it("throws on empty string toolCallId", async () => { + await expect( + copilotkitEmitToolCall(mockConfig, "SearchTool", {}, { toolCallId: "" }), + ).rejects.toThrow("non-empty string"); + }); + + it("throws on whitespace-only toolCallId", async () => { + await expect( + copilotkitEmitToolCall( + mockConfig, + "SearchTool", + {}, + { toolCallId: " " }, + ), + ).rejects.toThrow("non-empty string"); + }); + + it("throws on whitespace-only name", async () => { + await expect(copilotkitEmitToolCall(mockConfig, " ", {})).rejects.toThrow( + "non-empty string", + ); + }); + + it("throws on undefined args", async () => { + await expect( + copilotkitEmitToolCall(mockConfig, "SearchTool", undefined), + ).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 with tool-call context", async () => { + mockedDispatch.mockRejectedValueOnce(new Error("transport closed")); + await expect( + copilotkitEmitToolCall(mockConfig, "SearchTool", {}), + ).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 2979a8c989e..bce3b5dc8a7 100644 --- a/packages/sdk-js/src/langgraph/utils.ts +++ b/packages/sdk-js/src/langgraph/utils.ts @@ -1,14 +1,15 @@ -import { RunnableConfig } from "@langchain/core/runnables"; +import type { RunnableConfig } from "@langchain/core/runnables"; import { dispatchCustomEvent } from "@langchain/core/callbacks/dispatch"; import { convertJsonSchemaToZodSchema, randomId, + randomUUID, CopilotKitMisuseError, } from "@copilotkit/shared"; 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. @@ -301,8 +302,14 @@ export async function copilotkitEmitMessage( * ```typescript * import { copilotkitEmitToolCall } from "@copilotkit/sdk-js"; * - * await copilotkitEmitToolCall(config, name="SearchTool", args={"steps": 10}) + * const autoId = await copilotkitEmitToolCall(config, "SearchTool", { steps: 10 }); + * + * // 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( /** @@ -317,37 +324,73 @@ export async function copilotkitEmitToolCall( * The arguments to emit. */ args: any, -) { + /** + * Options for the tool call emission. + */ + 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. + */ + toolCallId?: string; + }, +): Promise { if (!config) { throw new CopilotKitMisuseError({ message: "LangGraph configuration is required for 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 ( + 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", }); } + 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)}`, + }); + } + + const toolCallId = options?.toolCallId ?? randomUUID(); + try { await dispatchCustomEvent( "copilotkit_manually_emit_tool_call", - { name, args, id: randomId() }, + { name, args, id: toolCallId }, config, ); } catch (error) { - throw new CopilotKitMisuseError({ - message: `Failed to emit tool call '${name}': ${error instanceof Error ? error.message : String(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; } export function convertActionToDynamicStructuredTool( diff --git a/sdk-python/copilotkit/__init__.py b/sdk-python/copilotkit/__init__.py index 2b741e52523..18ffbd0d3b0 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 4dcb15f3203..709d84b6943 100644 --- a/sdk-python/copilotkit/crewai/crewai_sdk.py +++ b/sdk-python/copilotkit/crewai/crewai_sdk.py @@ -5,7 +5,8 @@ import uuid import json import asyncio -from typing_extensions import Any, Dict, List, Literal +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, @@ -226,14 +227,19 @@ 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], tool_call_id: Optional[str] = None +) -> str: """ Manually emits a tool call to CopilotKit. ```python from copilotkit.crewai import copilotkit_emit_tool_call - 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: + custom_id = await copilotkit_emit_tool_call(name="SearchTool", args={"steps": 10}, tool_call_id="my-custom-id") ``` Parameters @@ -242,22 +248,57 @@ 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. + tool_call_id : Optional[str] + Optional tool call ID. If not provided, a random UUID is generated. + 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 ------- - Awaitable[bool] - Always return True. + str + The tool call ID used for the emitted tool call. """ - message_id = 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), - ) + 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 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()) + try: + 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=args_json), + action_execution_end(action_execution_id=message_id), + ) + except Exception: + 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/exc.py b/sdk-python/copilotkit/exc.py index 45f444f18d6..130521a3835 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,10 +35,20 @@ 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): self.name = name self.error = error super().__init__(f"Agent '{name}' failed to execute: {error}") + + +class CopilotKitMisuseError(CopilotKitError, ValueError): + """Exception raised when CopilotKit detects incorrect usage of its APIs. + + 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 bb70c2eee6c..cc796a48aed 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__) @@ -368,21 +369,28 @@ 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) + await asyncio.shield(asyncio.sleep(0.02)) return True async def copilotkit_emit_tool_call( - config: RunnableConfig, *, name: str, args: Dict[str, Any] -): + config: RunnableConfig, + *, + name: str, + args: Dict[str, Any], + tool_call_id: Optional[str] = None, +) -> str: """ Manually emits a tool call to CopilotKit. ```python from copilotkit.langgraph import copilotkit_emit_tool_call - 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: + custom_id = await copilotkit_emit_tool_call(config, name="SearchTool", args={"steps": 10}, tool_call_id="my-custom-id") ``` Parameters @@ -393,21 +401,56 @@ async def copilotkit_emit_tool_call( The name of the tool to emit. args : Dict[str, Any] The arguments to emit. + 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 and parentMessageId + 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. """ + 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 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" + ) + 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": str(uuid.uuid4())}, + {"name": name, "args": args, "id": tool_call_id}, config=config, ) - await asyncio.sleep(0.02) + # 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. + # 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, + ) + raise - return True + return tool_call_id def copilotkit_interrupt( diff --git a/sdk-python/copilotkit/langgraph_agui_agent.py b/sdk-python/copilotkit/langgraph_agui_agent.py index 778118572a9..1cf8de45690 100644 --- a/sdk-python/copilotkit/langgraph_agui_agent.py +++ b/sdk-python/copilotkit/langgraph_agui_agent.py @@ -1,6 +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, @@ -104,33 +108,86 @@ def _dispatch_event(self, event) -> str: return super()._dispatch_event(event) if custom_event.name == CustomEventNames.ManuallyEmitToolCall.value: - # Emit the tool call events - 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"], - raw_event=event, + value = custom_event.value + if not isinstance(value, dict): + raise CopilotKitMisuseError( + f"ManuallyEmitToolCall event 'value' must be a dict, got {type(value).__name__}" ) - ) - 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"]), - raw_event=event, + + tool_call_id = value.get("id") + tool_call_name = value.get("name") + tool_call_args = value.get("args") + + 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__}" ) - ) - super()._dispatch_event( - ToolCallEndEvent( - type=EventType.TOOL_CALL_END, - tool_call_id=custom_event.value["id"], - raw_event=event, + 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 CopilotKitMisuseError( + f"ManuallyEmitToolCall event missing 'args' for tool_call_id={tool_call_id}" + ) + + 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 + + dispatched_start = False + end_dispatched = 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, + ) + ) + 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, + ) + ) + end_dispatched = True + except Exception: + if dispatched_start and not end_dispatched: + try: + super()._dispatch_event( + ToolCallEndEvent( + type=EventType.TOOL_CALL_END, + tool_call_id=tool_call_id, + raw_event=event, + ) + ) + except Exception: + logger.error( + "Failed to emit compensating TOOL_CALL_END for %s", + tool_call_id, + exc_info=True, + ) + raise return super()._dispatch_event(event) if custom_event.name == CustomEventNames.ManuallyEmitState.value: diff --git a/sdk-python/pyproject.toml b/sdk-python/pyproject.toml index c926ef5d7b2..0ceefe44d64 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)"] 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 00000000000..23d9ae72c70 --- /dev/null +++ b/sdk-python/tests/test_emit_tool_call_optional_id.py @@ -0,0 +1,807 @@ +"""Tests for the optional `id` parameter on copilotkit_emit_tool_call. + +Covers: + 1. LangGraph variant: default UUID generation, custom ID passthrough, return value + 2. CrewAI variant: default UUID generation, custom ID passthrough, return value + 3. AG-UI agent dispatch: custom ID propagates to all three TOOL_CALL events + 4. AG-UI dispatch validation: defensive CopilotKitMisuseError paths for + missing/invalid id, name, args, non-serializable args, and non-dict value +""" + +import asyncio +import json +import logging +import uuid +import pytest +from unittest.mock import MagicMock, AsyncMock, patch +from ag_ui.core import ( + EventType, + CustomEvent, +) +from ag_ui_langgraph import LangGraphAgent as AGUIBase +from copilotkit.langgraph_agui_agent import ( + LangGraphAGUIAgent, + CustomEventNames, +) +from copilotkit.exc import CopilotKitMisuseError + + +# ---- Fixtures ---- + + +@pytest.fixture +def agent(): + """Create a LangGraphAGUIAgent with a mocked graph.""" + mock_graph = MagicMock() + mock_graph.get_state = MagicMock() + a = LangGraphAGUIAgent(name="test", graph=mock_graph) + a.active_run = {"id": "run-1", "thread_id": "t-1"} + return a + + +def _track_parent_dispatches(agent): + """Collect events dispatched to the AG-UI base class.""" + from contextlib import contextmanager + + @contextmanager + def _ctx(): + dispatched = [] + original = AGUIBase._dispatch_event + + def _tracking(self_inner, event): + dispatched.append(event) + return original(self_inner, event) + + with patch.object(AGUIBase, "_dispatch_event", new=_tracking): + yield dispatched + + return _ctx() + + +# ---- LangGraph variant tests ---- + + +class TestLangGraphEmitToolCallOptionalId: + """copilotkit_emit_tool_call (langgraph) with optional id parameter.""" + + @pytest.mark.asyncio + async def test_default_generates_uuid(self): + """When no id is provided, a UUID v4 string should be generated and returned.""" + with patch( + "copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock + ) as mock_dispatch: + from copilotkit.langgraph import copilotkit_emit_tool_call + + config = {"metadata": {}} + result = await copilotkit_emit_tool_call( + config, name="MyTool", args={"key": "val"} + ) + + assert isinstance(result, str) + uuid.UUID(result) + + payload = mock_dispatch.call_args[0][1] + assert payload["id"] == result + assert payload["name"] == "MyTool" + assert payload["args"] == {"key": "val"} + + @pytest.mark.asyncio + async def test_custom_id_passthrough(self): + """When a custom id is provided, it should be used as-is.""" + with patch( + "copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock + ) as mock_dispatch: + from copilotkit.langgraph import copilotkit_emit_tool_call + + config = {"metadata": {}} + result = await copilotkit_emit_tool_call( + config, name="MyTool", args={"key": "val"}, tool_call_id="custom-id-123" + ) + + assert result == "custom-id-123" + + payload = mock_dispatch.call_args[0][1] + assert payload["id"] == "custom-id-123" + + @pytest.mark.asyncio + async def test_returns_generated_id(self): + """The return value should be the tool call ID (generated or custom).""" + with patch( + "copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock + ): + from copilotkit.langgraph import copilotkit_emit_tool_call + + config = {"metadata": {}} + + result_auto = await copilotkit_emit_tool_call(config, name="Tool", args={}) + assert isinstance(result_auto, str) + assert len(result_auto) > 0 + + result_custom = await copilotkit_emit_tool_call( + config, name="Tool", args={}, tool_call_id="my-id" + ) + assert result_custom == "my-id" + + @pytest.mark.asyncio + async def test_none_id_generates_uuid(self): + """Explicitly passing tool_call_id=None should behave the same as omitting it.""" + with patch( + "copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock + ) as mock_dispatch: + from copilotkit.langgraph import copilotkit_emit_tool_call + + config = {"metadata": {}} + result = await copilotkit_emit_tool_call( + config, name="Tool", args={}, tool_call_id=None + ) + + assert isinstance(result, str) + uuid.UUID(result) + assert mock_dispatch.call_args[0][1]["id"] == result + + @pytest.mark.asyncio + async def test_empty_string_id_raises(self): + """Passing an empty string should raise ValueError.""" + with patch( + "copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock + ): + from copilotkit.langgraph import copilotkit_emit_tool_call + + config = {"metadata": {}} + with pytest.raises(ValueError, match="non-empty string"): + await copilotkit_emit_tool_call( + config, name="Tool", args={}, tool_call_id="" + ) + + @pytest.mark.asyncio + async def test_whitespace_only_id_raises(self): + """Passing a whitespace-only string should raise ValueError.""" + with patch( + "copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock + ): + from copilotkit.langgraph import copilotkit_emit_tool_call + + config = {"metadata": {}} + with pytest.raises(ValueError, match="non-empty string"): + await copilotkit_emit_tool_call( + config, name="Tool", args={}, tool_call_id=" " + ) + + @pytest.mark.asyncio + async def test_whitespace_only_name_raises(self): + """Passing a whitespace-only name should raise CopilotKitMisuseError.""" + with patch( + "copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock + ): + from copilotkit.langgraph import copilotkit_emit_tool_call + + config = {"metadata": {}} + with pytest.raises(CopilotKitMisuseError, match="non-empty string"): + await copilotkit_emit_tool_call(config, name=" ", args={}) + + @pytest.mark.asyncio + async def test_empty_name_raises(self): + """Passing an empty name should raise CopilotKitMisuseError.""" + with patch( + "copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock + ): + from copilotkit.langgraph import copilotkit_emit_tool_call + + config = {"metadata": {}} + with pytest.raises(CopilotKitMisuseError, match="non-empty string"): + await copilotkit_emit_tool_call(config, name="", args={}) + + @pytest.mark.asyncio + async def test_non_serializable_args_raises(self): + """Passing non-JSON-serializable args should raise CopilotKitMisuseError.""" + with patch( + "copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock + ): + from copilotkit.langgraph import copilotkit_emit_tool_call + + config = {"metadata": {}} + with pytest.raises(CopilotKitMisuseError, match="not JSON-serializable"): + await copilotkit_emit_tool_call( + config, name="Tool", args={"bad": {1, 2, 3}} + ) + + @pytest.mark.asyncio + async def test_cancelled_error_propagates_from_post_dispatch_sleep(self): + """CancelledError during the shielded post-dispatch sleep must propagate.""" + with patch( + "copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock + ) as mock_dispatch: + from copilotkit.langgraph import copilotkit_emit_tool_call + + config = {"metadata": {}} + + async def _run_and_cancel(): + task = asyncio.current_task() + # Schedule cancellation after dispatch completes but during sleep + original_sleep = asyncio.sleep + + async def _cancel_during_sleep(delay): + task.cancel() + await original_sleep(0) + + with patch( + "copilotkit.langgraph.asyncio.sleep", + side_effect=_cancel_during_sleep, + ): + with patch( + "copilotkit.langgraph.asyncio.shield", + side_effect=lambda coro: coro, + ): + return await copilotkit_emit_tool_call( + config, + name="CancelTool", + args={}, + tool_call_id="cancel-test-id", + ) + + with pytest.raises(asyncio.CancelledError): + await _run_and_cancel() + + mock_dispatch.assert_called_once() + + @pytest.mark.asyncio + async def test_cancelled_error_logs_warning(self, caplog): + """CancelledError during post-dispatch sleep should log with the tool_call_id.""" + with patch( + "copilotkit.langgraph.adispatch_custom_event", new_callable=AsyncMock + ): + from copilotkit.langgraph import copilotkit_emit_tool_call + + config = {"metadata": {}} + + async def _cancel_sleep(delay): + raise asyncio.CancelledError() + + with caplog.at_level(logging.WARNING, logger="copilotkit.langgraph"): + with patch( + "copilotkit.langgraph.asyncio.sleep", side_effect=_cancel_sleep + ): + with patch( + "copilotkit.langgraph.asyncio.shield", + side_effect=lambda coro: coro, + ): + with pytest.raises(asyncio.CancelledError): + await copilotkit_emit_tool_call( + config, + name="Tool", + args={}, + tool_call_id="log-cancel-id", + ) + + assert any("log-cancel-id" in record.message for record in caplog.records) + + +# ---- CrewAI variant tests ---- + +try: + import crewai # noqa: F401 + + _has_crewai = True +except ImportError: + _has_crewai = False + + +@pytest.mark.skipif(not _has_crewai, reason="crewai not installed") +class TestCrewAIEmitToolCallOptionalId: + """copilotkit_emit_tool_call (crewai) with optional id parameter.""" + + @pytest.mark.asyncio + async def test_default_generates_uuid(self): + """When no id is provided, a UUID v4 string should be generated and returned.""" + with patch( + "copilotkit.crewai.crewai_sdk.queue_put", new_callable=AsyncMock + ) as mock_queue: + from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call + + result = await copilotkit_emit_tool_call(name="MyTool", args={"key": "val"}) + + assert isinstance(result, str) + uuid.UUID(result) + + start_ev, args_ev, end_ev = mock_queue.call_args[0] + assert start_ev["actionExecutionId"] == result + assert start_ev["parentMessageId"] == result + assert start_ev["actionName"] == "MyTool" + assert args_ev["actionExecutionId"] == result + assert json.loads(args_ev["args"]) == {"key": "val"} + assert end_ev["actionExecutionId"] == result + + @pytest.mark.asyncio + async def test_custom_id_passthrough(self): + """When a custom id is provided, it should be used as the message_id.""" + with patch( + "copilotkit.crewai.crewai_sdk.queue_put", new_callable=AsyncMock + ) as mock_queue: + from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call + + result = await copilotkit_emit_tool_call( + name="MyTool", args={"key": "val"}, tool_call_id="crew-custom-id" + ) + + assert result == "crew-custom-id" + + start_ev, args_ev, end_ev = mock_queue.call_args[0] + assert start_ev["actionExecutionId"] == "crew-custom-id" + assert start_ev["parentMessageId"] == "crew-custom-id" + assert args_ev["actionExecutionId"] == "crew-custom-id" + assert end_ev["actionExecutionId"] == "crew-custom-id" + + @pytest.mark.asyncio + async def test_returns_id(self): + """Should return the tool call ID regardless of whether it was auto or custom.""" + with patch("copilotkit.crewai.crewai_sdk.queue_put", new_callable=AsyncMock): + from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call + + result_auto = await copilotkit_emit_tool_call(name="T", args={}) + assert isinstance(result_auto, str) + assert len(result_auto) > 0 + + result_custom = await copilotkit_emit_tool_call( + name="T", args={}, tool_call_id="explicit" + ) + assert result_custom == "explicit" + + @pytest.mark.asyncio + async def test_empty_string_id_raises(self): + """Passing an empty string should raise ValueError.""" + with patch("copilotkit.crewai.crewai_sdk.queue_put", new_callable=AsyncMock): + from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call + + with pytest.raises(ValueError, match="non-empty string"): + await copilotkit_emit_tool_call(name="Tool", args={}, tool_call_id="") + + @pytest.mark.asyncio + async def test_whitespace_only_id_raises(self): + """Passing a whitespace-only string should raise ValueError.""" + with patch("copilotkit.crewai.crewai_sdk.queue_put", new_callable=AsyncMock): + from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call + + with pytest.raises(ValueError, match="non-empty string"): + await copilotkit_emit_tool_call( + name="Tool", args={}, tool_call_id=" " + ) + + @pytest.mark.asyncio + async def test_none_id_generates_uuid(self): + """Explicitly passing tool_call_id=None should behave the same as omitting it.""" + with patch("copilotkit.crewai.crewai_sdk.queue_put", new_callable=AsyncMock): + from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call + + result = await copilotkit_emit_tool_call( + name="Tool", args={}, tool_call_id=None + ) + assert isinstance(result, str) + uuid.UUID(result) + + @pytest.mark.asyncio + async def test_whitespace_only_name_raises(self): + """Passing a whitespace-only name should raise CopilotKitMisuseError.""" + with patch("copilotkit.crewai.crewai_sdk.queue_put", new_callable=AsyncMock): + from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call + + with pytest.raises(CopilotKitMisuseError, match="non-empty string"): + await copilotkit_emit_tool_call(name=" ", args={}) + + @pytest.mark.asyncio + async def test_non_serializable_args_raises(self): + """Passing non-JSON-serializable args should raise CopilotKitMisuseError.""" + with patch("copilotkit.crewai.crewai_sdk.queue_put", new_callable=AsyncMock): + from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call + + with pytest.raises(CopilotKitMisuseError, match="not JSON-serializable"): + await copilotkit_emit_tool_call(name="Tool", args={"bad": {1, 2, 3}}) + + +# ---- CrewAI variant: compensating action_execution_end tests ---- + + +@pytest.mark.skipif(not _has_crewai, reason="crewai not installed") +class TestCrewAICompensatingEnd: + """Tests for the compensating action_execution_end when dispatch fails mid-stream. + + queue_put is called once with all three events (start, args, end) in a single + batch for atomicity. If the batch fails, a compensating end is always attempted + as a best-effort measure — an orphaned END is harmless, but an orphaned START + hangs the client UI. + """ + + @pytest.mark.asyncio + async def test_batch_failure_emits_compensating_end(self): + """If the batched queue_put fails, a compensating end is emitted.""" + call_count = 0 + + async def _failing_queue_put(*events): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise RuntimeError("batch failed") + + with patch("copilotkit.crewai.crewai_sdk.queue_put", new=_failing_queue_put): + from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call + + with pytest.raises(RuntimeError, match="batch failed"): + await copilotkit_emit_tool_call( + name="FailTool", args={"x": 1}, tool_call_id="comp-crew-1" + ) + + assert call_count == 2 + + @pytest.mark.asyncio + async def test_compensating_end_failure_reraises_original(self): + """If the compensating end also fails, the original error still propagates.""" + call_count = 0 + + async def _failing_queue_put(*events): + nonlocal call_count + call_count += 1 + raise RuntimeError(f"queue failure #{call_count}") + + with patch("copilotkit.crewai.crewai_sdk.queue_put", new=_failing_queue_put): + from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call + + with pytest.raises(RuntimeError, match="queue failure #1"): + await copilotkit_emit_tool_call( + name="FailTool", args={}, tool_call_id="comp-crew-3" + ) + + assert call_count == 2 + + @pytest.mark.asyncio + async def test_compensating_end_failure_emits_log(self, caplog): + """The logger.error call includes the message_id when compensating end fails.""" + call_count = 0 + + async def _failing_queue_put(*events): + nonlocal call_count + call_count += 1 + raise RuntimeError(f"queue failure #{call_count}") + + with caplog.at_level(logging.ERROR, logger="copilotkit.crewai.crewai_sdk"): + with patch( + "copilotkit.crewai.crewai_sdk.queue_put", new=_failing_queue_put + ): + from copilotkit.crewai.crewai_sdk import copilotkit_emit_tool_call + + with pytest.raises(RuntimeError): + await copilotkit_emit_tool_call( + name="FailTool", args={}, tool_call_id="log-crew-id" + ) + + assert any("log-crew-id" in record.message for record in caplog.records) + + +# ---- AG-UI dispatch: custom ID propagates through all events ---- + + +class TestCustomIdPropagatesThroughAGUI: + """When a custom id is used, the downstream AG-UI events carry that exact ID.""" + + def test_custom_id_in_all_tool_call_events(self, agent): + """TOOL_CALL_START, TOOL_CALL_ARGS, and TOOL_CALL_END should all carry the custom id.""" + with _track_parent_dispatches(agent) as dispatched: + event = CustomEvent( + type=EventType.CUSTOM, + name=CustomEventNames.ManuallyEmitToolCall.value, + value={ + "id": "user-provided-id-42", + "name": "CustomTool", + "args": {"x": 1}, + }, + ) + agent._dispatch_event(event) + + tool_events = [e for e in dispatched if hasattr(e, "tool_call_id")] + assert len(tool_events) == 3 + for e in tool_events: + assert e.tool_call_id == "user-provided-id-42" + + def test_custom_id_in_parent_message_id(self, agent): + """ToolCallStartEvent.parent_message_id should match the custom id.""" + with _track_parent_dispatches(agent) as dispatched: + event = CustomEvent( + type=EventType.CUSTOM, + name=CustomEventNames.ManuallyEmitToolCall.value, + value={ + "id": "parent-test-id", + "name": "ParentTool", + "args": {}, + }, + ) + agent._dispatch_event(event) + + start_events = [e for e in dispatched if e.type == EventType.TOOL_CALL_START] + assert len(start_events) == 1 + assert start_events[0].parent_message_id == "parent-test-id" + + def test_custom_id_with_dict_args_serialized(self, agent): + """Custom id + dict args should both work: args JSON-serialized, id preserved.""" + with _track_parent_dispatches(agent) as dispatched: + event = CustomEvent( + type=EventType.CUSTOM, + name=CustomEventNames.ManuallyEmitToolCall.value, + value={ + "id": "combo-test", + "name": "ComboTool", + "args": {"nested": {"deep": True}}, + }, + ) + agent._dispatch_event(event) + + args_events = [e for e in dispatched if e.type == EventType.TOOL_CALL_ARGS] + assert len(args_events) == 1 + assert args_events[0].tool_call_id == "combo-test" + assert json.loads(args_events[0].delta) == {"nested": {"deep": True}} + + def test_string_args_passed_through_unchanged(self, agent): + """When args is already a JSON string, it should be passed through without re-serializing.""" + with _track_parent_dispatches(agent) as dispatched: + event = CustomEvent( + type=EventType.CUSTOM, + name=CustomEventNames.ManuallyEmitToolCall.value, + value={ + "id": "string-args-test", + "name": "StringArgsTool", + "args": '{"x": 1}', + }, + ) + agent._dispatch_event(event) + + args_events = [e for e in dispatched if e.type == EventType.TOOL_CALL_ARGS] + assert len(args_events) == 1 + assert args_events[0].delta == '{"x": 1}' + + def test_empty_dict_args_does_not_raise(self, agent): + """An empty dict for args is valid and should not raise.""" + with _track_parent_dispatches(agent) as dispatched: + event = CustomEvent( + type=EventType.CUSTOM, + name=CustomEventNames.ManuallyEmitToolCall.value, + value={ + "id": "empty-args-test", + "name": "EmptyArgsTool", + "args": {}, + }, + ) + agent._dispatch_event(event) + + tool_events = [e for e in dispatched if hasattr(e, "tool_call_id")] + assert len(tool_events) == 3 + + +# ---- AG-UI dispatch: validation negative tests ---- + + +class TestAGUIDispatchValidation: + """Negative tests for defensive validation in _dispatch_event.""" + + def test_missing_id_raises(self, agent): + """Event with no 'id' field should raise CopilotKitMisuseError.""" + event = CustomEvent( + type=EventType.CUSTOM, + name=CustomEventNames.ManuallyEmitToolCall.value, + value={"name": "Tool", "args": {}}, + ) + with pytest.raises(CopilotKitMisuseError, match="valid 'id'"): + agent._dispatch_event(event) + + def test_non_string_id_raises(self, agent): + """Event with non-string 'id' should raise CopilotKitMisuseError.""" + event = CustomEvent( + type=EventType.CUSTOM, + name=CustomEventNames.ManuallyEmitToolCall.value, + value={"id": 42, "name": "Tool", "args": {}}, + ) + with pytest.raises(CopilotKitMisuseError, match="valid 'id'"): + agent._dispatch_event(event) + + def test_empty_string_id_raises(self, agent): + """Event with empty string 'id' should raise CopilotKitMisuseError.""" + event = CustomEvent( + type=EventType.CUSTOM, + name=CustomEventNames.ManuallyEmitToolCall.value, + value={"id": "", "name": "Tool", "args": {}}, + ) + with pytest.raises(CopilotKitMisuseError, match="valid 'id'"): + agent._dispatch_event(event) + + def test_whitespace_only_id_raises(self, agent): + """Event with whitespace-only 'id' should raise CopilotKitMisuseError.""" + event = CustomEvent( + type=EventType.CUSTOM, + name=CustomEventNames.ManuallyEmitToolCall.value, + value={"id": " ", "name": "Tool", "args": {}}, + ) + with pytest.raises(CopilotKitMisuseError, match="valid 'id'"): + agent._dispatch_event(event) + + def test_missing_name_raises(self, agent): + """Event with no 'name' field should raise CopilotKitMisuseError.""" + event = CustomEvent( + type=EventType.CUSTOM, + name=CustomEventNames.ManuallyEmitToolCall.value, + value={"id": "valid-id", "args": {}}, + ) + with pytest.raises(CopilotKitMisuseError, match="valid 'name'"): + agent._dispatch_event(event) + + def test_whitespace_only_name_raises(self, agent): + """Event with whitespace-only 'name' should raise CopilotKitMisuseError.""" + event = CustomEvent( + type=EventType.CUSTOM, + name=CustomEventNames.ManuallyEmitToolCall.value, + value={"id": "valid-id", "name": " ", "args": {}}, + ) + with pytest.raises(CopilotKitMisuseError, match="valid 'name'"): + agent._dispatch_event(event) + + def test_missing_args_raises(self, agent): + """Event with no 'args' field should raise CopilotKitMisuseError.""" + event = CustomEvent( + type=EventType.CUSTOM, + name=CustomEventNames.ManuallyEmitToolCall.value, + value={"id": "valid-id", "name": "Tool"}, + ) + with pytest.raises(CopilotKitMisuseError, match="missing 'args'"): + agent._dispatch_event(event) + + def test_non_serializable_args_raises(self, agent): + """Event with non-JSON-serializable args (set) should raise CopilotKitMisuseError.""" + event = CustomEvent( + type=EventType.CUSTOM, + name=CustomEventNames.ManuallyEmitToolCall.value, + value={"id": "valid-id", "name": "Tool", "args": {1, 2, 3}}, + ) + with pytest.raises(CopilotKitMisuseError, match="not JSON-serializable"): + agent._dispatch_event(event) + + def test_non_dict_value_raises(self, agent): + """Event with non-dict value should raise CopilotKitMisuseError.""" + event = CustomEvent( + type=EventType.CUSTOM, + name=CustomEventNames.ManuallyEmitToolCall.value, + value=None, + ) + with pytest.raises(CopilotKitMisuseError, match="must be a dict"): + agent._dispatch_event(event) + + def test_list_args_accepted(self, agent): + """Event with list args should be accepted (JSON-serializable).""" + with _track_parent_dispatches(agent) as dispatched: + event = CustomEvent( + type=EventType.CUSTOM, + name=CustomEventNames.ManuallyEmitToolCall.value, + value={"id": "list-args-id", "name": "Tool", "args": [1, 2, 3]}, + ) + agent._dispatch_event(event) + + args_events = [e for e in dispatched if e.type == EventType.TOOL_CALL_ARGS] + assert len(args_events) == 1 + assert args_events[0].delta == "[1, 2, 3]" + + def test_int_args_accepted(self, agent): + """Event with int args should be accepted (JSON-serializable).""" + with _track_parent_dispatches(agent) as dispatched: + event = CustomEvent( + type=EventType.CUSTOM, + name=CustomEventNames.ManuallyEmitToolCall.value, + value={"id": "int-args-id", "name": "Tool", "args": 42}, + ) + agent._dispatch_event(event) + + args_events = [e for e in dispatched if e.type == EventType.TOOL_CALL_ARGS] + assert len(args_events) == 1 + assert args_events[0].delta == "42" + + +# ---- AG-UI dispatch: compensating TOOL_CALL_END on mid-stream failure ---- + + +class TestAGUICompensatingEnd: + """Tests for the compensating TOOL_CALL_END when dispatch fails mid-stream.""" + + def _make_event(self, tool_call_id="comp-test-id"): + return CustomEvent( + type=EventType.CUSTOM, + name=CustomEventNames.ManuallyEmitToolCall.value, + value={ + "id": tool_call_id, + "name": "FailTool", + "args": {"x": 1}, + }, + ) + + def test_failure_after_start_emits_compensating_end(self, agent): + """If TOOL_CALL_ARGS fails after START was sent, a compensating END is dispatched.""" + call_count = 0 + original = AGUIBase._dispatch_event + + def _fail_on_args(self_inner, evt): + nonlocal call_count + call_count += 1 + if call_count == 2: + raise RuntimeError("args dispatch failed") + return original(self_inner, evt) + + with patch.object(AGUIBase, "_dispatch_event", new=_fail_on_args): + with pytest.raises(RuntimeError, match="args dispatch failed"): + agent._dispatch_event(self._make_event()) + + assert call_count == 3 + + def test_failure_on_start_does_not_emit_compensating_end(self, agent): + """If TOOL_CALL_START itself fails, no compensating END is dispatched.""" + call_count = 0 + original = AGUIBase._dispatch_event + + def _fail_on_start(self_inner, evt): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise RuntimeError("start dispatch failed") + return original(self_inner, evt) + + with patch.object(AGUIBase, "_dispatch_event", new=_fail_on_start): + with pytest.raises(RuntimeError, match="start dispatch failed"): + agent._dispatch_event(self._make_event()) + + assert call_count == 1 + + def test_compensating_end_failure_reraises_original(self, agent): + """If the compensating END also fails, the original error propagates.""" + call_count = 0 + original = AGUIBase._dispatch_event + + def _fail_on_args_and_end(self_inner, evt): + nonlocal call_count + call_count += 1 + if call_count == 1: + return original(self_inner, evt) + raise RuntimeError(f"dispatch failure #{call_count}") + + with patch.object(AGUIBase, "_dispatch_event", new=_fail_on_args_and_end): + with pytest.raises(RuntimeError, match="dispatch failure #2"): + agent._dispatch_event(self._make_event()) + + assert call_count == 3 + + def test_compensating_end_failure_emits_log(self, agent, caplog): + """The logger.error call includes the tool_call_id when compensating END fails.""" + call_count = 0 + original = AGUIBase._dispatch_event + + def _fail_on_args_and_end(self_inner, evt): + nonlocal call_count + call_count += 1 + if call_count == 1: + return original(self_inner, evt) + raise RuntimeError(f"dispatch failure #{call_count}") + + with caplog.at_level(logging.ERROR, logger="copilotkit.langgraph_agui_agent"): + with patch.object(AGUIBase, "_dispatch_event", new=_fail_on_args_and_end): + with pytest.raises(RuntimeError): + agent._dispatch_event(self._make_event("log-test-id")) + + assert any("log-test-id" in record.message for record in caplog.records) + + def test_failure_on_end_emits_compensating_end(self, agent): + """If TOOL_CALL_END itself fails, a compensating END is still attempted.""" + call_count = 0 + original = AGUIBase._dispatch_event + + def _fail_on_end(self_inner, evt): + nonlocal call_count + call_count += 1 + if call_count == 3: + raise RuntimeError("end dispatch failed") + return original(self_inner, evt) + + with patch.object(AGUIBase, "_dispatch_event", new=_fail_on_end): + with pytest.raises(RuntimeError, match="end dispatch failed"): + agent._dispatch_event(self._make_event("end-fail-id")) + + assert call_count == 4 diff --git a/showcase/shell-docs/src/app/[[...slug]]/page.tsx b/showcase/shell-docs/src/app/[[...slug]]/page.tsx index 4c21629da7f..154d5254561 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 5670e1a8ea5..b021095e156 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 ( -
+
+