chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
@@ -0,0 +1,683 @@
/**
* Shared test utilities for BuiltInAgent factory-mode tests.
*
* Re-exports everything from the existing test-helpers module and adds
* BuiltInAgent-specific factories, mock stream builders, and assertion helpers.
*/
import { EventType } from "@ag-ui/client";
import type { BaseEvent, RunAgentInput } from "@ag-ui/client";
import type { Observable } from "rxjs";
import { MockLanguageModelV3, simulateReadableStream } from "ai/test";
import { BuiltInAgent } from "../index";
import type {
AgentFactoryContext,
BuiltInAgentFactoryConfig,
ToolDefinition,
} from "../index";
import type { MockStreamEvent } from "./test-helpers";
// Re-export everything from existing test helpers
export {
type MockStreamEvent,
mockStreamTextResponse,
textStart,
textDelta,
toolCallStreamingStart,
toolCallDelta,
toolCall,
toolResult,
reasoningStart,
reasoningDelta,
reasoningEnd,
finish,
abort,
error,
collectEvents,
} from "./test-helpers";
// Re-export for test files that need to construct agents directly
export {
BuiltInAgent,
type AgentFactoryContext,
type BuiltInAgentFactoryConfig,
};
// ---------------------------------------------------------------------------
// Default input factory
// ---------------------------------------------------------------------------
/**
* Returns a valid `RunAgentInput` with sensible defaults.
* Any field can be overridden via the `overrides` parameter.
*/
export function createDefaultInput(overrides?: Partial<RunAgentInput>) {
return {
threadId: "test-thread",
runId: "test-run",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: {},
...overrides,
} as RunAgentInput;
}
// ---------------------------------------------------------------------------
// AI SDK native human-in-the-loop stream part
// ---------------------------------------------------------------------------
/**
* AI SDK `fullStream` part emitted when a tool declared `needsApproval: true`
* is called. In the real SDK the tool name + args arrive in a preceding
* `tool-call` part; this part only carries the id to pause on.
*/
export function aisdkToolApprovalRequest(toolCallId: string): MockStreamEvent {
return {
type: "tool-approval-request",
toolCallId,
approvalId: `approval-${toolCallId}`,
};
}
// ---------------------------------------------------------------------------
// TanStack mock stream chunk builders
// ---------------------------------------------------------------------------
/** TanStack text content chunk */
export function tanstackTextChunk(delta: string) {
return { type: "TEXT_MESSAGE_CONTENT", delta } as const;
}
/** TanStack tool call start chunk */
export function tanstackToolCallStart(
toolCallId: string,
toolCallName: string,
) {
return { type: "TOOL_CALL_START", toolCallId, toolCallName } as const;
}
/** TanStack tool call args chunk */
export function tanstackToolCallArgs(toolCallId: string, delta: string) {
return { type: "TOOL_CALL_ARGS", toolCallId, delta } as const;
}
/** TanStack tool call end chunk */
export function tanstackToolCallEnd(toolCallId: string) {
return { type: "TOOL_CALL_END", toolCallId } as const;
}
/** TanStack tool-call result chunk. `content` is what the tool's `execute()` returned. */
export function tanstackToolCallResult(toolCallId: string, content: unknown) {
return { type: "TOOL_CALL_RESULT", toolCallId, content } as const;
}
/**
* TanStack native human-in-the-loop chunk: the CUSTOM "approval-requested"
* event emitted by `chat()` when a tool declared `needsApproval: true` is called.
*/
export function tanstackApprovalRequested(
toolCallId: string,
toolName: string,
input?: unknown,
) {
return {
type: "CUSTOM",
name: "approval-requested",
value: {
toolCallId,
toolName,
input,
approval: { id: `approval-${toolCallId}`, needsApproval: true },
},
} as const;
}
/** TanStack reasoning lifecycle chunk builders */
export function tanstackReasoningStart(messageId: string) {
return { type: "REASONING_START", messageId } as const;
}
export function tanstackReasoningMessageStart(messageId: string) {
return {
type: "REASONING_MESSAGE_START",
messageId,
role: "reasoning",
} as const;
}
export function tanstackReasoningMessageContent(
messageId: string,
delta: string,
) {
return { type: "REASONING_MESSAGE_CONTENT", messageId, delta } as const;
}
export function tanstackReasoningMessageEnd(messageId: string) {
return { type: "REASONING_MESSAGE_END", messageId } as const;
}
export function tanstackReasoningEnd(messageId: string) {
return { type: "REASONING_END", messageId } as const;
}
// ---------------------------------------------------------------------------
// Mock async iterable builders
// ---------------------------------------------------------------------------
/**
* Creates an `AsyncIterable<unknown>` from an array of TanStack-style chunks.
*/
export function mockTanStackStream(chunks: Record<string, unknown>[]) {
return {
[Symbol.asyncIterator]: async function* () {
for (const chunk of chunks) {
yield chunk;
}
},
};
}
/**
* Creates an `AsyncIterable<BaseEvent>` from an array of AG-UI events.
*/
export function mockCustomStream(
events: BaseEvent[],
): AsyncIterable<BaseEvent> {
return {
[Symbol.asyncIterator]: async function* () {
for (const event of events) {
yield event;
}
},
};
}
// ---------------------------------------------------------------------------
// BuiltInAgent factories
// ---------------------------------------------------------------------------
export type AgentType = "aisdk" | "tanstack" | "custom";
/**
* Creates a BuiltInAgent backed by a mock factory that yields the given stream data.
*
* Overloaded for each supported agent type:
* - `"aisdk"` expects `MockStreamEvent[]` (AI SDK fullStream events)
* - `"tanstack"` expects `Record<string, unknown>[]` (TanStack chunks)
* - `"custom"` expects `BaseEvent[]` (AG-UI events directly)
*/
export function createAgent(
type: "aisdk",
streamData: MockStreamEvent[],
): BuiltInAgent;
export function createAgent(
type: "tanstack",
streamData: Record<string, unknown>[],
): BuiltInAgent;
export function createAgent(
type: "custom",
streamData: BaseEvent[],
): BuiltInAgent;
export function createAgent(
type: AgentType,
streamData: MockStreamEvent[] | Record<string, unknown>[] | BaseEvent[],
): BuiltInAgent;
export function createAgent(
type: AgentType,
streamData: MockStreamEvent[] | Record<string, unknown>[] | BaseEvent[],
): BuiltInAgent {
switch (type) {
case "aisdk": {
// Cast needed: TypeScript's control-flow narrowing doesn't propagate
// through overload signatures to narrow the union parameter type.
const events = streamData as MockStreamEvent[];
return new BuiltInAgent({
type: "aisdk",
factory: () => ({
fullStream: (async function* () {
for (const event of events) {
yield event;
}
})(),
}),
});
}
case "tanstack": {
// Cast needed: same overload-narrowing limitation as above.
const chunks = streamData as Record<string, unknown>[];
return new BuiltInAgent({
type: "tanstack",
factory: () => mockTanStackStream(chunks),
});
}
case "custom": {
// Cast needed: same overload-narrowing limitation as above.
const events = streamData as BaseEvent[];
return new BuiltInAgent({
type: "custom",
factory: () => mockCustomStream(events),
});
}
}
}
// ---------------------------------------------------------------------------
// Classic (model-based) agent factory with a capturing mock model
// ---------------------------------------------------------------------------
/**
* The element type of the `ReadableStream` returned by a V3 model's `doStream`.
* Derived from `MockLanguageModelV3` so we never need a direct (non-resolvable
* from this package) `@ai-sdk/provider` import.
*/
type V3StreamResult = Awaited<ReturnType<MockLanguageModelV3["doStream"]>>;
type V3StreamPart =
V3StreamResult["stream"] extends ReadableStream<infer P> ? P : never;
type V3FinishPart = Extract<V3StreamPart, { type: "finish" }>;
/** The shape of the options object the model's `doStream` is invoked with. */
type V3CallOptions = MockLanguageModelV3["doStreamCalls"][number];
function toV3FinishReason(value: unknown): V3FinishPart["finishReason"] {
if (value && typeof value === "object" && "unified" in value) {
return value as V3FinishPart["finishReason"];
}
const raw = typeof value === "string" ? value : undefined;
switch (raw) {
case "length":
case "content-filter":
case "tool-calls":
case "error":
case "other":
case "stop":
return { unified: raw, raw };
default:
return { unified: "stop", raw };
}
}
/** A BuiltInAgent augmented with a test-only seam exposing the messages the
* model received on its most recent `doStream` invocation. */
export type ClassicAgentWithCapture = BuiltInAgent & {
/** The `prompt` (provider-level message list) passed to the mock model on its
* most recent `doStream` call, or `undefined` if the model was never run. */
readonly __lastModelMessages: V3CallOptions["prompt"] | undefined;
};
/**
* Translates the existing fullStream-style `MockStreamEvent` chunks (built via
* `textDelta`, `toolCall`, `finish`, …) into the provider-level
* `LanguageModelV3` stream parts that the REAL `streamText` consumes from a
* model's `doStream`.
*
* Only the chunk kinds needed by the classic-path interrupt tests are
* translated; anything else is passed through unchanged so it can be extended
* later without breaking existing callers.
*/
function toV3StreamParts(chunks: MockStreamEvent[]): V3StreamPart[] {
const parts: V3StreamPart[] = [];
let openTextId: string | undefined;
const closeTextIfOpen = () => {
if (openTextId !== undefined) {
parts.push({ type: "text-end", id: openTextId } as V3StreamPart);
openTextId = undefined;
}
};
let autoId = 0;
for (const chunk of chunks) {
switch (chunk.type) {
case "text-start": {
const id = (chunk.id as string | undefined) ?? `text-${autoId++}`;
openTextId = id;
parts.push({ type: "text-start", id } as V3StreamPart);
break;
}
case "text-delta": {
if (openTextId === undefined) {
openTextId = `text-${autoId++}`;
parts.push({ type: "text-start", id: openTextId } as V3StreamPart);
}
parts.push({
type: "text-delta",
id: openTextId,
delta: String(chunk.text ?? ""),
} as V3StreamPart);
break;
}
case "tool-call": {
closeTextIfOpen();
// Provider-level tool calls carry a stringified-JSON `input`; the
// fullStream builder accepts an object, so stringify it here.
const input =
typeof chunk.input === "string"
? chunk.input
: JSON.stringify(chunk.input ?? {});
parts.push({
type: "tool-call",
toolCallId: String(chunk.toolCallId),
toolName: String(chunk.toolName),
input,
} as V3StreamPart);
break;
}
case "finish": {
closeTextIfOpen();
const finishPart: V3FinishPart = {
type: "finish",
finishReason: toV3FinishReason(chunk.finishReason),
usage: {
inputTokens: {
total: 0,
noCache: 0,
cacheRead: 0,
cacheWrite: 0,
},
outputTokens: { total: 0, text: 0, reasoning: 0 },
},
};
parts.push(finishPart);
break;
}
default: {
// Pass through any already-provider-shaped part unchanged.
parts.push(chunk as unknown as V3StreamPart);
}
}
}
closeTextIfOpen();
return parts;
}
/**
* Creates a CLASSIC (model-based) `BuiltInAgent` driven by a mock
* `LanguageModelV3`, wired with the given `tools`.
*
* The mock model surfaces `streamChunks` (the same fullStream-style chunks used
* by the AI-SDK factory tests — `toolCall`, `textDelta`, `finish`, …) through
* the real `streamText` pipeline, and RECORDS the messages it was invoked with.
* Read them via `agent.__lastModelMessages` after running the agent.
*/
export function createClassicAgentWithTools(
streamChunks: MockStreamEvent[],
tools: ToolDefinition[],
): ClassicAgentWithCapture {
const model = new MockLanguageModelV3({
doStream: async () => ({
stream: simulateReadableStream({
chunks: toV3StreamParts(streamChunks),
}),
}),
});
const agent = new BuiltInAgent({ model, tools }) as ClassicAgentWithCapture;
Object.defineProperty(agent, "__lastModelMessages", {
configurable: true,
enumerable: false,
get() {
const calls = model.doStreamCalls;
return calls.length > 0 ? calls[calls.length - 1].prompt : undefined;
},
});
return agent;
}
// ---------------------------------------------------------------------------
// Error agent factories
// ---------------------------------------------------------------------------
/**
* Creates a BuiltInAgent whose factory immediately throws.
*/
export function createThrowingAgent(
type: AgentType,
errorMessage: string,
): BuiltInAgent {
// All three factory signatures accept (ctx) and can throw before returning.
// Since the factory throws, the return type is irrelevant — TypeScript's
// `never` return satisfies all three config shapes.
const thrower = (): never => {
throw new Error(errorMessage);
};
switch (type) {
case "aisdk":
return new BuiltInAgent({ type: "aisdk", factory: thrower });
case "tanstack":
return new BuiltInAgent({ type: "tanstack", factory: thrower });
case "custom":
return new BuiltInAgent({ type: "custom", factory: thrower });
}
}
/**
* Creates a BuiltInAgent that yields one partial event and then throws.
*
* - `"aisdk"`: yields `{ type: "text-delta", text: "partial" }` then throws
* - `"tanstack"`: yields `{ type: "TEXT_MESSAGE_CONTENT", delta: "partial" }` then throws
* - `"custom"`: yields a `TEXT_MESSAGE_CHUNK` BaseEvent then throws
*/
export function createMidStreamErrorAgent(
type: AgentType,
errorMessage: string,
): BuiltInAgent {
switch (type) {
case "aisdk": {
return new BuiltInAgent({
type: "aisdk",
factory: () => ({
fullStream: (async function* () {
yield { type: "text-delta", text: "partial" };
throw new Error(errorMessage);
})(),
}),
});
}
case "tanstack": {
return new BuiltInAgent({
type: "tanstack",
factory: () => ({
[Symbol.asyncIterator]: async function* () {
yield {
type: "TEXT_MESSAGE_CONTENT",
delta: "partial",
};
throw new Error(errorMessage);
},
}),
});
}
case "custom": {
return new BuiltInAgent({
type: "custom",
factory: () => ({
[Symbol.asyncIterator]: async function* () {
yield {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: "partial",
} as const as BaseEvent;
throw new Error(errorMessage);
},
}),
});
}
}
}
// ---------------------------------------------------------------------------
// Event collection utilities
// ---------------------------------------------------------------------------
/**
* Result of collecting events from an observable that may error.
*/
export interface CollectedEventsResult {
events: BaseEvent[];
/** Whether the observable completed via error (true) or normal completion (false) */
errored: boolean;
/** Whether the safety timeout fired (indicates a hung observable) */
timedOut: boolean;
}
/**
* Like `collectEvents` but resolves (instead of rejecting) when the
* observable errors. Returns the events collected up to and including
* the error point, along with whether it errored or completed normally.
*/
export async function collectEventsIncludingErrors(
observable: Observable<BaseEvent>,
): Promise<CollectedEventsResult> {
return new Promise((resolve) => {
const events: BaseEvent[] = [];
const timeoutId = setTimeout(() => {
subscription.unsubscribe();
resolve({ events, errored: false, timedOut: true });
}, 5000);
const subscription = observable.subscribe({
next: (event) => events.push(event),
error: () => {
clearTimeout(timeoutId);
resolve({ events, errored: true, timedOut: false });
},
complete: () => {
clearTimeout(timeoutId);
resolve({ events, errored: false, timedOut: false });
},
});
});
}
// ---------------------------------------------------------------------------
// Typed event field accessors (avoids `as any` casts in tests)
// ---------------------------------------------------------------------------
/**
* Reads a field from a BaseEvent. AG-UI's BaseEvent uses `.passthrough()` so
* extra fields exist at runtime but aren't in the static type. This helper
* provides typed access without casts.
*/
export function eventField<T = unknown>(event: BaseEvent, field: string): T {
return (event as Record<string, unknown>)[field] as T;
}
// ---------------------------------------------------------------------------
// Assertion helpers
// ---------------------------------------------------------------------------
/**
* Asserts that the events are wrapped with RUN_STARTED as the first event
* and RUN_FINISHED as the last event. Optionally checks threadId and runId.
*/
export function expectLifecycleWrapped(
events: BaseEvent[],
threadId?: string,
runId?: string,
): void {
if (events.length < 2) {
throw new Error(
`Expected at least 2 events (RUN_STARTED + RUN_FINISHED), got ${events.length}`,
);
}
const first = events[0];
const last = events[events.length - 1];
if (first.type !== EventType.RUN_STARTED) {
throw new Error(
`Expected first event to be RUN_STARTED, got ${first.type}`,
);
}
if (last.type !== EventType.RUN_FINISHED) {
throw new Error(`Expected last event to be RUN_FINISHED, got ${last.type}`);
}
if (threadId !== undefined) {
if (eventField<string>(first, "threadId") !== threadId) {
throw new Error(
`Expected RUN_STARTED threadId to be "${threadId}", got "${eventField(first, "threadId")}"`,
);
}
if (eventField<string>(last, "threadId") !== threadId) {
throw new Error(
`Expected RUN_FINISHED threadId to be "${threadId}", got "${eventField(last, "threadId")}"`,
);
}
}
if (runId !== undefined) {
if (eventField<string>(first, "runId") !== runId) {
throw new Error(
`Expected RUN_STARTED runId to be "${runId}", got "${eventField(first, "runId")}"`,
);
}
if (eventField<string>(last, "runId") !== runId) {
throw new Error(
`Expected RUN_FINISHED runId to be "${runId}", got "${eventField(last, "runId")}"`,
);
}
}
}
/**
* Asserts that the event types match the expected sequence exactly.
*/
export function expectEventSequence(
events: BaseEvent[],
expectedTypes: EventType[],
): void {
const actualTypes = events.map((e) => e.type);
if (actualTypes.length !== expectedTypes.length) {
throw new Error(
`Event count mismatch: expected ${expectedTypes.length}, got ${actualTypes.length}.\n` +
`Expected: [${expectedTypes.join(", ")}]\n` +
`Actual: [${actualTypes.join(", ")}]`,
);
}
for (let i = 0; i < expectedTypes.length; i++) {
if (actualTypes[i] !== expectedTypes[i]) {
throw new Error(
`Event type mismatch at index ${i}: expected ${expectedTypes[i]}, got ${actualTypes[i]}.\n` +
`Expected: [${expectedTypes.join(", ")}]\n` +
`Actual: [${actualTypes.join(", ")}]`,
);
}
}
}
/**
* Asserts that no content events appear after the specified terminal event type.
*
* "Content events" are everything except RUN_STARTED, RUN_FINISHED, and RUN_ERROR.
*/
export function expectNoEventAfter(
events: BaseEvent[],
terminalType: EventType,
): void {
const terminalIndex = events.findIndex((e) => e.type === terminalType);
if (terminalIndex === -1) {
throw new Error(`Terminal event type ${terminalType} not found in events`);
}
const lifecycleTypes = new Set([
EventType.RUN_STARTED,
EventType.RUN_FINISHED,
EventType.RUN_ERROR,
]);
const eventsAfter = events.slice(terminalIndex + 1);
const contentEventsAfter = eventsAfter.filter(
(e) => !lifecycleTypes.has(e.type),
);
if (contentEventsAfter.length > 0) {
throw new Error(
`Found ${contentEventsAfter.length} content event(s) after ${terminalType}: ` +
`[${contentEventsAfter.map((e) => e.type).join(", ")}]`,
);
}
}
@@ -0,0 +1,987 @@
import { describe, it, expect } from "vitest";
import { EventType } from "@ag-ui/client";
import type {
BaseEvent,
Interrupt,
ResumeEntry,
RunAgentInput,
} from "@ag-ui/client";
import { z } from "zod";
import {
BuiltInAgent,
createDefaultInput,
createAgent,
createThrowingAgent,
createMidStreamErrorAgent,
createClassicAgentWithTools,
collectEvents,
collectEventsIncludingErrors,
expectLifecycleWrapped,
eventField,
textDelta,
finish,
toolCall,
tanstackTextChunk,
tanstackToolCallStart,
tanstackToolCallEnd,
tanstackApprovalRequested,
aisdkToolApprovalRequest,
} from "./agent-test-helpers";
import type {
AgentFactoryContext,
BuiltInAgentFactoryConfig,
AgentType,
MockStreamEvent,
} from "./agent-test-helpers";
// ---------------------------------------------------------------------------
// Local helpers for parameterized tests
// ---------------------------------------------------------------------------
const allTypes: AgentType[] = ["aisdk", "tanstack", "custom"];
function minimalStreamData(
type: AgentType,
): MockStreamEvent[] | Record<string, unknown>[] | BaseEvent[] {
switch (type) {
case "aisdk":
return [textDelta("hi"), finish()];
case "tanstack":
return [tanstackTextChunk("hi")];
case "custom":
return [
{
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: "hi",
} as BaseEvent,
];
}
}
function emptyStreamData(
type: AgentType,
): MockStreamEvent[] | Record<string, unknown>[] | BaseEvent[] {
switch (type) {
case "aisdk":
return [finish()];
case "tanstack":
return [];
case "custom":
return [];
}
}
// ---------------------------------------------------------------------------
// Parameterized test suites
// ---------------------------------------------------------------------------
describe.each(allTypes)("Agent [%s]", (type) => {
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
describe("lifecycle", () => {
it("emits RUN_STARTED as the first event with correct threadId/runId", async () => {
const agent = createAgent(type, minimalStreamData(type));
const input = createDefaultInput({ threadId: "t1", runId: "r1" });
const events = await collectEvents(agent.run(input));
expect(events.length).toBeGreaterThanOrEqual(2);
const first = events[0];
expect(first.type).toBe(EventType.RUN_STARTED);
expect(eventField<string>(first, "threadId")).toBe("t1");
expect(eventField<string>(first, "runId")).toBe("r1");
});
it("emits RUN_FINISHED as the last event with correct threadId/runId", async () => {
const agent = createAgent(type, minimalStreamData(type));
const input = createDefaultInput({ threadId: "t2", runId: "r2" });
const events = await collectEvents(agent.run(input));
const last = events[events.length - 1];
expect(last.type).toBe(EventType.RUN_FINISHED);
expect(eventField<string>(last, "threadId")).toBe("t2");
expect(eventField<string>(last, "runId")).toBe("r2");
});
it("emits RUN_FINISHED for an empty stream", async () => {
const agent = createAgent(type, emptyStreamData(type));
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expect(events.length).toBeGreaterThanOrEqual(2);
expect(events[0].type).toBe(EventType.RUN_STARTED);
expect(events[events.length - 1].type).toBe(EventType.RUN_FINISHED);
});
it("wraps content with lifecycle events", async () => {
const agent = createAgent(type, minimalStreamData(type));
const input = createDefaultInput({ threadId: "wrap-t", runId: "wrap-r" });
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events, "wrap-t", "wrap-r");
// There should be content events between the lifecycle bookends
const contentEvents = events.slice(1, -1);
expect(contentEvents.length).toBeGreaterThan(0);
for (const e of contentEvents) {
expect(e.type).not.toBe(EventType.RUN_STARTED);
expect(e.type).not.toBe(EventType.RUN_FINISHED);
}
});
});
// -------------------------------------------------------------------------
// RUN_ERROR
// -------------------------------------------------------------------------
describe("RUN_ERROR", () => {
it("emits RUN_ERROR when factory throws", async () => {
const agent = createThrowingAgent(type, "factory-boom");
const input = createDefaultInput();
const { events, errored } = await collectEventsIncludingErrors(
agent.run(input),
);
expect(errored).toBe(true);
const errorEvents = events.filter((e) => e.type === EventType.RUN_ERROR);
expect(errorEvents.length).toBe(1);
expect(eventField<string>(errorEvents[0], "message")).toBe(
"factory-boom",
);
});
it("emits RUN_ERROR when stream throws mid-iteration", async () => {
const agent = createMidStreamErrorAgent(type, "mid-stream-boom");
const input = createDefaultInput();
const { events, errored } = await collectEventsIncludingErrors(
agent.run(input),
);
expect(errored).toBe(true);
const errorEvents = events.filter((e) => e.type === EventType.RUN_ERROR);
expect(errorEvents.length).toBe(1);
expect(eventField<string>(errorEvents[0], "message")).toBe(
"mid-stream-boom",
);
});
it("does not emit RUN_FINISHED after RUN_ERROR", async () => {
const agent = createThrowingAgent(type, "no-finish");
const input = createDefaultInput();
const { events } = await collectEventsIncludingErrors(agent.run(input));
const errorIdx = events.findIndex((e) => e.type === EventType.RUN_ERROR);
expect(errorIdx).toBeGreaterThanOrEqual(0);
const eventsAfterError = events.slice(errorIdx + 1);
const finishAfterError = eventsAfterError.filter(
(e) => e.type === EventType.RUN_FINISHED,
);
expect(finishAfterError.length).toBe(0);
});
});
// -------------------------------------------------------------------------
// Abort
// -------------------------------------------------------------------------
describe("abort", () => {
it("completes without error after abortRun()", async () => {
// Use a signal to synchronize: abort after the first chunk is emitted
let emittedFirstChunk: () => void;
const firstChunkEmitted = new Promise<void>(
(r) => (emittedFirstChunk = r),
);
let config: BuiltInAgentFactoryConfig;
switch (type) {
case "aisdk":
config = {
type: "aisdk",
factory: ({ abortSignal }: AgentFactoryContext) => ({
fullStream: (async function* () {
yield { type: "text-delta", text: "tick" };
emittedFirstChunk();
// Wait for abort — use a promise that resolves on abort
await new Promise<void>((r) => {
if (abortSignal.aborted) return r();
abortSignal.addEventListener("abort", () => r(), {
once: true,
});
});
})(),
}),
};
break;
case "tanstack":
config = {
type: "tanstack",
factory: ({ abortSignal }: AgentFactoryContext) => ({
[Symbol.asyncIterator]: async function* () {
yield { type: "TEXT_MESSAGE_CONTENT", delta: "tick" };
emittedFirstChunk();
await new Promise<void>((r) => {
if (abortSignal.aborted) return r();
abortSignal.addEventListener("abort", () => r(), {
once: true,
});
});
},
}),
};
break;
case "custom":
config = {
type: "custom",
factory: ({ abortSignal }: AgentFactoryContext) => ({
[Symbol.asyncIterator]: async function* () {
yield {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: "tick",
} as BaseEvent;
emittedFirstChunk();
await new Promise<void>((r) => {
if (abortSignal.aborted) return r();
abortSignal.addEventListener("abort", () => r(), {
once: true,
});
});
},
}),
};
break;
}
const agent = new BuiltInAgent(config);
const input = createDefaultInput();
const completed = await new Promise<boolean>((resolve) => {
agent.run(input).subscribe({
next: () => {},
error: () => resolve(false),
complete: () => resolve(true),
});
// Wait for the first chunk to be emitted, then abort
firstChunkEmitted.then(() => agent.abortRun());
});
expect(completed).toBe(true);
});
});
// -------------------------------------------------------------------------
// Factory Context
// -------------------------------------------------------------------------
describe("factory context", () => {
it("receives correct input with threadId, runId, and forwardedProps", async () => {
let capturedCtx: AgentFactoryContext | null = null;
let config: BuiltInAgentFactoryConfig;
switch (type) {
case "aisdk":
config = {
type: "aisdk",
factory: (ctx: AgentFactoryContext) => {
capturedCtx = ctx;
return {
fullStream: (async function* () {
yield { type: "finish", finishReason: "stop" };
})(),
};
},
};
break;
case "tanstack":
config = {
type: "tanstack",
factory: (ctx: AgentFactoryContext) => {
capturedCtx = ctx;
return (async function* () {
// empty stream
})();
},
};
break;
case "custom":
config = {
type: "custom",
factory: (ctx: AgentFactoryContext) => {
capturedCtx = ctx;
return (async function* () {
// empty stream
})();
},
};
break;
}
const agent = new BuiltInAgent(config);
const input = createDefaultInput({
threadId: "ctx-thread",
runId: "ctx-run",
forwardedProps: { model: "gpt-4" },
});
await collectEvents(agent.run(input));
expect(capturedCtx).not.toBeNull();
expect(capturedCtx!.input.threadId).toBe("ctx-thread");
expect(capturedCtx!.input.runId).toBe("ctx-run");
expect(capturedCtx!.input.forwardedProps).toEqual({ model: "gpt-4" });
});
it("receives abortController and abortSignal", async () => {
let capturedCtx: AgentFactoryContext | null = null;
let config: BuiltInAgentFactoryConfig;
switch (type) {
case "aisdk":
config = {
type: "aisdk",
factory: (ctx: AgentFactoryContext) => {
capturedCtx = ctx;
return {
fullStream: (async function* () {
yield { type: "finish", finishReason: "stop" };
})(),
};
},
};
break;
case "tanstack":
config = {
type: "tanstack",
factory: (ctx: AgentFactoryContext) => {
capturedCtx = ctx;
return (async function* () {
// empty
})();
},
};
break;
case "custom":
config = {
type: "custom",
factory: (ctx: AgentFactoryContext) => {
capturedCtx = ctx;
return (async function* () {
// empty
})();
},
};
break;
}
const agent = new BuiltInAgent(config);
const input = createDefaultInput();
await collectEvents(agent.run(input));
expect(capturedCtx!.abortController).toBeInstanceOf(AbortController);
expect(capturedCtx!.abortSignal).toBe(
capturedCtx!.abortController.signal,
);
});
});
// -------------------------------------------------------------------------
// clone()
// -------------------------------------------------------------------------
describe("clone()", () => {
it("returns a new Agent instance (not the same reference)", () => {
const agent = createAgent(type, minimalStreamData(type));
const cloned = agent.clone();
expect(cloned).toBeInstanceOf(BuiltInAgent);
expect(cloned).not.toBe(agent);
});
it("produces correct lifecycle events from a cloned agent", async () => {
const agent = createAgent(type, minimalStreamData(type));
const cloned = agent.clone();
const input = createDefaultInput({
threadId: "clone-t",
runId: "clone-r",
});
const events = await collectEvents(cloned.run(input));
expectLifecycleWrapped(events, "clone-t", "clone-r");
});
});
});
// ---------------------------------------------------------------------------
// Type Discrimination (NOT parameterized)
// ---------------------------------------------------------------------------
describe("Agent type discrimination", () => {
it('"aisdk" routes to AI SDK converter and produces text content', async () => {
const agent = createAgent("aisdk", [
textDelta("hello from aisdk"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents.length).toBe(1);
expect(eventField<string>(textEvents[0], "delta")).toBe("hello from aisdk");
});
it('"tanstack" routes to TanStack converter and produces text content', async () => {
const agent = createAgent("tanstack", [
tanstackTextChunk("hello from tanstack"),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents.length).toBe(1);
expect(eventField<string>(textEvents[0], "delta")).toBe(
"hello from tanstack",
);
});
it('"custom" forwards events directly without conversion', async () => {
const customEvent: BaseEvent = {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: "hello from custom",
} as BaseEvent;
const agent = createAgent("custom", [customEvent]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents.length).toBe(1);
expect(eventField<string>(textEvents[0], "delta")).toBe(
"hello from custom",
);
});
});
// ---------------------------------------------------------------------------
// Async Factory (Promise-returning)
// ---------------------------------------------------------------------------
describe("Async factory (Promise-returning)", () => {
it("aisdk: async factory resolves and streams correctly", async () => {
const agent = new BuiltInAgent({
type: "aisdk",
factory: async () => {
// Simulate async setup (e.g., fetching API key)
await new Promise((r) => setTimeout(r, 5));
return {
fullStream: (async function* () {
yield { type: "text-delta", text: "async-aisdk" };
yield { type: "finish", finishReason: "stop" };
})(),
};
},
});
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents).toHaveLength(1);
expect(eventField<string>(textEvents[0], "delta")).toBe("async-aisdk");
});
it("tanstack: async factory resolves and streams correctly", async () => {
const agent = new BuiltInAgent({
type: "tanstack",
factory: async () => {
await new Promise((r) => setTimeout(r, 5));
return (async function* () {
yield { type: "TEXT_MESSAGE_CONTENT", delta: "async-tanstack" };
})();
},
});
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents).toHaveLength(1);
expect(eventField<string>(textEvents[0], "delta")).toBe("async-tanstack");
});
it("custom: async factory resolves and streams correctly", async () => {
const agent = new BuiltInAgent({
type: "custom",
factory: async () => {
await new Promise((r) => setTimeout(r, 5));
return (async function* () {
yield {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: "async-custom",
} as BaseEvent;
})();
},
});
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents).toHaveLength(1);
expect(eventField<string>(textEvents[0], "delta")).toBe("async-custom");
});
});
// ---------------------------------------------------------------------------
// RUN_ERROR includes threadId and runId
// ---------------------------------------------------------------------------
describe("RUN_ERROR correlation fields", () => {
it("RUN_ERROR includes threadId and runId for run correlation", async () => {
const agent = new BuiltInAgent({
type: "aisdk",
factory: () => {
throw new Error("test-error");
},
});
const input = createDefaultInput({
threadId: "err-thread",
runId: "err-run",
});
const { events, errored } = await collectEventsIncludingErrors(
agent.run(input),
);
expect(errored).toBe(true);
const errorEvents = events.filter((e) => e.type === EventType.RUN_ERROR);
expect(errorEvents).toHaveLength(1);
expect(eventField<string>(errorEvents[0], "threadId")).toBe("err-thread");
expect(eventField<string>(errorEvents[0], "runId")).toBe("err-run");
});
});
// ---------------------------------------------------------------------------
// Concurrent run guard
// ---------------------------------------------------------------------------
describe("Concurrent run guard", () => {
it("throws when run() is called while another run is in progress", async () => {
let resolveFactory: () => void;
const factoryBlocked = new Promise<void>((r) => (resolveFactory = r));
const agent = new BuiltInAgent({
type: "custom",
factory: async function* ({ abortSignal }) {
// Block until resolved externally
await new Promise<void>((r) => {
if (abortSignal.aborted) return r();
abortSignal.addEventListener("abort", () => r(), { once: true });
factoryBlocked.then(() => r());
});
},
});
const input = createDefaultInput();
// Start first run — abortController is now set synchronously in run()
const sub = agent.run(input).subscribe({ next: () => {} });
// Second run should throw immediately (no timing dependency)
expect(() => agent.run(input)).toThrow("Agent is already running");
// Cleanup
resolveFactory!();
sub.unsubscribe();
});
});
// ---------------------------------------------------------------------------
// BuiltInAgent factory interrupt() primitive
// ---------------------------------------------------------------------------
describe("BuiltInAgent factory interrupt() primitive", () => {
const INT: Interrupt = {
id: "int-1",
reason: "confirmation",
message: "Approve?",
};
function makeCustomInterruptAgent() {
return new BuiltInAgent({
type: "custom",
factory: async function* (ctx) {
const responses = await ctx.interrupt([INT]); // pauses on fresh run
// resume run continues here:
yield {
type: EventType.TEXT_MESSAGE_START,
messageId: "m1",
role: "assistant",
} as any;
yield {
type: EventType.TEXT_MESSAGE_CONTENT,
messageId: "m1",
delta: `resolved:${JSON.stringify(responses)}`,
} as any;
yield { type: EventType.TEXT_MESSAGE_END, messageId: "m1" } as any;
},
});
}
it("emits RUN_FINISHED with outcome:interrupt on a fresh run", async () => {
const agent = makeCustomInterruptAgent();
const events = await collectEvents(agent.run(createDefaultInput()));
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
) as any;
expect(finished).toBeDefined();
expect(finished.outcome).toEqual({ type: "interrupt", interrupts: [INT] });
// No text emitted on the paused run:
expect(events.some((e) => e.type === EventType.TEXT_MESSAGE_CONTENT)).toBe(
false,
);
});
it("continues past interrupt() on a resume run, returning resume payloads", async () => {
const agent = makeCustomInterruptAgent();
const resume: ResumeEntry[] = [
{ interruptId: "int-1", status: "resolved", payload: { ok: true } },
];
const events = await collectEvents(
agent.run(createDefaultInput({ resume })),
);
const content = events.find(
(e) => e.type === EventType.TEXT_MESSAGE_CONTENT,
) as any;
expect(content.delta).toContain("resolved:");
expect(content.delta).toContain('"ok":true');
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
) as any;
expect(finished.outcome).toBeUndefined(); // normal completion
});
});
// ---------------------------------------------------------------------------
// BuiltInAgent classic interrupt tools
// ---------------------------------------------------------------------------
describe("BuiltInAgent classic interrupt tools", () => {
const interruptTool = {
name: "confirm_action",
description: "Ask the human to confirm",
parameters: z.object({ summary: z.string() }),
interrupt: true as const,
interruptReason: "confirmation",
interruptMessage: "Please confirm",
};
it("pauses + emits outcome:interrupt (keyed by toolCallId), withholding the tool result", async () => {
// mock stream: assistant calls the interrupt tool, then 'finish'
const agent = createClassicAgentWithTools(
[toolCall("tc-1", "confirm_action", { summary: "delete X" }), finish()],
[interruptTool],
);
const events = await collectEvents(
agent.run(
createDefaultInput({
messages: [{ id: "u1", role: "user", content: "do it" }] as any,
}),
),
);
expect(events.some((e) => e.type === EventType.TOOL_CALL_START)).toBe(true);
expect(events.some((e) => e.type === EventType.TOOL_CALL_END)).toBe(true);
// result withheld:
expect(events.some((e) => e.type === EventType.TOOL_CALL_RESULT)).toBe(
false,
);
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
) as any;
expect(finished.outcome.type).toBe("interrupt");
expect(finished.outcome.interrupts[0]).toMatchObject({
id: "tc-1",
toolCallId: "tc-1",
reason: "confirmation",
message: "Please confirm",
});
});
it("on resume, injects the resume payload as the interrupt tool's result and continues", async () => {
// Resume run: the model just replies after the injected tool result.
const agent = createClassicAgentWithTools(
[textDelta("done"), finish()],
[interruptTool],
);
const input = createDefaultInput({
resume: [
{
interruptId: "tc-1",
status: "resolved",
payload: { approved: true },
},
],
messages: [
{ id: "u1", role: "user", content: "do it" },
{
id: "a1",
role: "assistant",
content: "",
toolCalls: [
{
id: "tc-1",
type: "function",
function: {
name: "confirm_action",
arguments: '{"summary":"delete X"}',
},
},
],
},
] as any,
});
const events = await collectEvents(agent.run(input));
// The run completes normally (no interrupt outcome on the resume run):
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
) as any;
expect(finished.outcome).toBeUndefined();
// The synthesized tool result reached the model: a tool-role message
// addressing tc-1 with the resume payload must be present.
expect(agent.__lastModelMessages).toEqual(
expect.arrayContaining([expect.objectContaining({ role: "tool" })]),
);
});
});
// ---------------------------------------------------------------------------
// BuiltInAgent factory-mode NATIVE approval interrupts (aisdk + tanstack)
//
// A tool declared with the SDK's native `needsApproval: true` pauses the run:
// AI SDK emits a `tool-approval-request` fullStream part; TanStack emits a
// CUSTOM `approval-requested` chunk. Both surface as RUN_FINISHED
// outcome:interrupt, and on resume the payload is injected as that tool call's
// native tool-result so the factory continues.
// ---------------------------------------------------------------------------
describe("BuiltInAgent factory native approval interrupts", () => {
it("aisdk: tool-approval-request → outcome:interrupt keyed by toolCallId", async () => {
const agent = createAgent("aisdk", [
toolCall("tc-1", "bookFlight", { dest: "NRT" }),
aisdkToolApprovalRequest("tc-1"),
finish(),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
// The tool call is surfaced so the client knows what it's approving.
expect(events.some((e) => e.type === EventType.TOOL_CALL_START)).toBe(true);
expect(events.some((e) => e.type === EventType.TOOL_CALL_END)).toBe(true);
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
) as any;
expect(finished.outcome.type).toBe("interrupt");
expect(finished.outcome.interrupts[0]).toMatchObject({
id: "tc-1",
toolCallId: "tc-1",
});
});
it("aisdk: multiple needsApproval tool calls → one interrupt each", async () => {
const agent = createAgent("aisdk", [
toolCall("tc-1", "bookFlight", { dest: "NRT" }),
aisdkToolApprovalRequest("tc-1"),
toolCall("tc-2", "bookFlight", { dest: "CDG" }),
aisdkToolApprovalRequest("tc-2"),
finish(),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
) as any;
expect(finished.outcome.type).toBe("interrupt");
expect(finished.outcome.interrupts.map((i: any) => i.toolCallId)).toEqual([
"tc-1",
"tc-2",
]);
});
it("multiple resume entries each inject their own tool-role message", async () => {
let captured: RunAgentInput | undefined;
const agent = new BuiltInAgent({
type: "aisdk",
factory: (ctx) => {
captured = ctx.input;
return {
fullStream: (async function* () {
yield textDelta("done");
yield finish();
})(),
};
},
});
const resume: ResumeEntry[] = [
{ interruptId: "tc-1", status: "resolved", payload: { approved: true } },
{ interruptId: "tc-2", status: "cancelled" },
];
await collectEvents(agent.run(createDefaultInput({ resume })));
const toolMsgs = captured!.messages.filter(
(m) => m.role === "tool",
) as any[];
expect(toolMsgs.map((m) => m.toolCallId)).toEqual(["tc-1", "tc-2"]);
expect(toolMsgs[0].content).toContain("approved");
expect(toolMsgs[1].content).toContain("cancelled");
});
it("resume injection is idempotent — does not double-answer a tool call the client already recorded", async () => {
// The client (useInterrupt) persists the resolution as a tool message so the
// thread stays well-formed across turns. The runtime must NOT then inject a
// second tool-result for the same toolCallId.
let captured: RunAgentInput | undefined;
const agent = new BuiltInAgent({
type: "aisdk",
factory: (ctx) => {
captured = ctx.input;
return {
fullStream: (async function* () {
yield textDelta("ok");
yield finish();
})(),
};
},
});
const resume: ResumeEntry[] = [
{ interruptId: "tc-1", status: "resolved", payload: { approved: true } },
];
await collectEvents(
agent.run(
createDefaultInput({
resume,
messages: [
{ id: "u1", role: "user", content: "go" },
{
id: "a1",
role: "assistant",
content: "",
toolCalls: [
{
id: "tc-1",
type: "function",
function: { name: "bookFlight", arguments: "{}" },
},
],
},
{
id: "t1",
role: "tool",
toolCallId: "tc-1",
content: JSON.stringify({ approved: true }),
},
] as RunAgentInput["messages"],
}),
),
);
const toolMsgs = captured!.messages.filter(
(m) => m.role === "tool" && (m as any).toolCallId === "tc-1",
);
expect(toolMsgs).toHaveLength(1);
});
it("tanstack: CUSTOM approval-requested (even after RUN_FINISHED) → outcome:interrupt", async () => {
// The approval chunk is built from the finish event and can arrive AFTER
// RUN_FINISHED — assert it's still captured (ordering-robust).
const agent = createAgent("tanstack", [
tanstackToolCallStart("tc-9", "bookFlight"),
tanstackToolCallEnd("tc-9"),
{ type: "RUN_FINISHED" },
tanstackApprovalRequested("tc-9", "bookFlight", { dest: "NRT" }),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
) as any;
expect(finished.outcome.type).toBe("interrupt");
expect(finished.outcome.interrupts[0]).toMatchObject({
id: "tc-9",
toolCallId: "tc-9",
message: 'Approve "bookFlight"?',
});
});
it("aisdk: resume injects the payload as a tool-role message the factory sees", async () => {
let captured: RunAgentInput | undefined;
const agent = new BuiltInAgent({
type: "aisdk",
factory: (ctx) => {
captured = ctx.input;
return {
fullStream: (async function* () {
yield textDelta("booked");
yield finish();
})(),
};
},
});
const resume: ResumeEntry[] = [
{ interruptId: "tc-1", status: "resolved", payload: { approved: true } },
];
const events = await collectEvents(
agent.run(createDefaultInput({ resume })),
);
// Normal completion on the resume run (no re-interrupt).
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
) as any;
expect(finished.outcome).toBeUndefined();
const toolMsg = captured!.messages.find(
(m) => m.role === "tool" && (m as any).toolCallId === "tc-1",
) as any;
expect(toolMsg).toBeDefined();
expect(toolMsg.content).toContain("approved");
});
it("tanstack: cancelled resume injects a cancellation tool-role message", async () => {
let captured: RunAgentInput | undefined;
const agent = new BuiltInAgent({
type: "tanstack",
factory: (ctx) => {
captured = ctx.input;
return {
[Symbol.asyncIterator]: async function* () {
yield tanstackTextChunk("ok");
},
};
},
});
const resume: ResumeEntry[] = [
{ interruptId: "tc-9", status: "cancelled" },
];
await collectEvents(agent.run(createDefaultInput({ resume })));
const toolMsg = captured!.messages.find(
(m) => m.role === "tool" && (m as any).toolCallId === "tc-9",
) as any;
expect(toolMsg).toBeDefined();
expect(toolMsg.content).toContain("cancelled");
});
});
@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { BuiltInAgent } from "../index";
import { EventType, type RunAgentInput } from "@ag-ui/client";
import { streamText } from "ai";
import {
mockStreamTextResponse,
textDelta,
finish,
collectEvents,
} from "./test-helpers";
// Mock the ai module
vi.mock("ai", () => ({
streamText: vi.fn(),
tool: vi.fn((config) => config),
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
}));
// Mock SDK providers (not used directly, but resolveModel imports them)
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
specificationVersion: "v3",
modelId,
provider: "openai",
})),
}));
vi.mock("@ai-sdk/anthropic", () => ({
createAnthropic: vi.fn(() => (modelId: string) => ({
specificationVersion: "v3",
modelId,
provider: "anthropic",
})),
}));
vi.mock("@ai-sdk/google", () => ({
createGoogleGenerativeAI: vi.fn(() => (modelId: string) => ({
specificationVersion: "v3",
modelId,
provider: "google",
})),
}));
describe("AI SDK v6 Compatibility", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
});
afterEach(() => {
process.env = originalEnv;
});
it("should accept a LanguageModelV3 instance (specificationVersion 'v3')", async () => {
// Simulate what @ai-sdk/openai@^3 (AI SDK v6) returns:
// a model object with specificationVersion: "v3"
const v3Model = {
specificationVersion: "v3" as const,
modelId: "gpt-4o-mini",
provider: "openai",
supportedUrls: {},
doGenerate: vi.fn(),
doStream: vi.fn(),
};
// After upgrading to ai@^6, LanguageModel = string | LanguageModelV2 | LanguageModelV3.
// No 'as any' cast needed — the type accepts V3 models natively.
const agent = new BuiltInAgent({
model: v3Model,
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
textDelta("Hello from V3 model"),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread-v3",
runId: "run-v3",
messages: [{ id: "1", role: "user", content: "Hi" }],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
// Verify the model was passed through to streamText
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.model).toBe(v3Model);
// Verify normal event emission still works
expect(events[0]).toMatchObject({
type: EventType.RUN_STARTED,
threadId: "thread-v3",
runId: "run-v3",
});
const textEvents = events.filter(
(e: any) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents).toHaveLength(1);
expect(textEvents[0]).toMatchObject({
delta: "Hello from V3 model",
});
expect(events[events.length - 1]).toMatchObject({
type: EventType.RUN_FINISHED,
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
import { describe, it, expect } from "vitest";
import { BuiltInAgent } from "../index";
describe("BuiltInAgent.getCapabilities", () => {
it("should return default inferred capabilities", async () => {
const agent = new BuiltInAgent({
model: "openai/gpt-5.5",
});
const capabilities = await agent.getCapabilities();
expect(capabilities).toEqual({
tools: {
supported: true,
clientProvided: true,
},
transport: {
streaming: true,
},
humanInTheLoop: {
interrupts: true,
},
});
});
it("should merge explicit overrides with inferred defaults", async () => {
const agent = new BuiltInAgent({
model: "openai/gpt-5.5",
capabilities: {
reasoning: {
supported: true,
streaming: true,
},
identity: {
name: "my-agent",
type: "custom",
},
},
});
const capabilities = await agent.getCapabilities();
expect(capabilities).toEqual({
tools: {
supported: true,
clientProvided: true,
},
transport: {
streaming: true,
},
humanInTheLoop: {
interrupts: true,
},
reasoning: {
supported: true,
streaming: true,
},
identity: {
name: "my-agent",
type: "custom",
},
});
});
it("should allow overrides to replace entire categories", async () => {
const agent = new BuiltInAgent({
model: "openai/gpt-5.5",
capabilities: {
tools: {
supported: true,
clientProvided: true,
parallelCalls: true,
},
},
});
const capabilities = await agent.getCapabilities();
expect(capabilities.tools).toEqual({
supported: true,
clientProvided: true,
parallelCalls: true,
});
// transport still inferred
expect(capabilities.transport).toEqual({ streaming: true });
});
});
@@ -0,0 +1,519 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { z } from "zod";
import { BasicAgent, defineTool } from "../index";
import { EventType } from "@ag-ui/client";
import type { RunAgentInput } from "@ag-ui/client";
import { streamText } from "ai";
import {
mockStreamTextResponse,
toolCallStreamingStart,
toolCall,
toolResult,
finish,
collectEvents,
} from "./test-helpers";
// Mock the ai module
vi.mock("ai", () => ({
streamText: vi.fn(),
tool: vi.fn((config) => config),
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
}));
// Mock the SDK clients
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
modelId,
provider: "openai",
})),
}));
describe("Config Tools Server-Side Execution", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
});
afterEach(() => {
process.env = originalEnv;
});
describe("Tool Definition with Execute", () => {
it("should pass execute function to streamText tools", async () => {
const executeFn = vi.fn().mockResolvedValue({ result: "executed" });
const weatherTool = defineTool({
name: "getWeather",
description: "Get weather for a city",
parameters: z.object({
city: z.string().describe("The city name"),
}),
execute: executeFn,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
tools: [weatherTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
// Verify streamText was called with tools that have execute functions
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("getWeather");
expect(callArgs.tools.getWeather).toHaveProperty("execute");
expect(typeof callArgs.tools.getWeather.execute).toBe("function");
});
it("should include all tool properties in the Vercel AI SDK tool", async () => {
const executeFn = vi.fn().mockResolvedValue({ temperature: 72 });
const weatherTool = defineTool({
name: "getWeather",
description: "Get weather for a city",
parameters: z.object({
city: z.string(),
units: z.enum(["celsius", "fahrenheit"]).optional(),
}),
execute: executeFn,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
tools: [weatherTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
const tool = callArgs.tools.getWeather;
expect(tool.description).toBe("Get weather for a city");
expect(tool.inputSchema).toBeDefined();
expect(tool.execute).toBe(executeFn);
});
it("should handle multiple config tools with execute functions", async () => {
const weatherExecute = vi.fn().mockResolvedValue({ temp: 72 });
const searchExecute = vi.fn().mockResolvedValue({ results: [] });
const weatherTool = defineTool({
name: "getWeather",
description: "Get weather",
parameters: z.object({ city: z.string() }),
execute: weatherExecute,
});
const searchTool = defineTool({
name: "search",
description: "Search the web",
parameters: z.object({ query: z.string() }),
execute: searchExecute,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
tools: [weatherTool, searchTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools.getWeather.execute).toBe(weatherExecute);
expect(callArgs.tools.search.execute).toBe(searchExecute);
});
});
describe("Config Tools vs Input Tools", () => {
it("config tools should have execute, input tools should not", async () => {
const configExecute = vi.fn().mockResolvedValue({ result: "server" });
const configTool = defineTool({
name: "serverTool",
description: "Runs on server",
parameters: z.object({ data: z.string() }),
execute: configExecute,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
tools: [configTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [
{
name: "clientTool",
description: "Runs on client",
parameters: {
type: "object",
properties: { input: { type: "string" } },
},
},
],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
// Config tool has execute
expect(callArgs.tools.serverTool.execute).toBe(configExecute);
// Input tool does NOT have execute (client-side execution)
expect(callArgs.tools.clientTool.execute).toBeUndefined();
});
});
describe("Execute Function Invocation", () => {
it("execute function can be called with correct arguments", async () => {
const executeFn = vi
.fn()
.mockResolvedValue({ weather: "sunny", temp: 72 });
const weatherTool = defineTool({
name: "getWeather",
description: "Get weather",
parameters: z.object({
city: z.string(),
units: z.enum(["celsius", "fahrenheit"]),
}),
execute: executeFn,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
tools: [weatherTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
// Get the execute function that was passed to streamText
const callArgs = vi.mocked(streamText).mock.calls[0][0];
const passedExecute = callArgs.tools.getWeather.execute;
// Manually invoke it to verify it works correctly
const result = await passedExecute(
{ city: "New York", units: "fahrenheit" },
{ toolCallId: "tool-call-1", messages: [] },
);
expect(executeFn).toHaveBeenCalledWith(
{ city: "New York", units: "fahrenheit" },
{ toolCallId: "tool-call-1", messages: [] },
);
expect(result).toEqual({ weather: "sunny", temp: 72 });
});
it("execute function errors are propagated", async () => {
const executeFn = vi.fn().mockRejectedValue(new Error("API unavailable"));
const failingTool = defineTool({
name: "failingTool",
description: "A tool that fails",
parameters: z.object({}),
execute: executeFn,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
tools: [failingTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
const passedExecute = callArgs.tools.failingTool.execute;
await expect(
passedExecute({}, { toolCallId: "tool-call-1", messages: [] }),
).rejects.toThrow("API unavailable");
});
});
describe("Built-in State Tools Still Work", () => {
it("AGUISendStateSnapshot should have execute alongside config tools", async () => {
const configExecute = vi.fn().mockResolvedValue({});
const configTool = defineTool({
name: "myTool",
description: "My tool",
parameters: z.object({}),
execute: configExecute,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
tools: [configTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: { value: 1 },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
// Both config tool and state tools should have execute
expect(callArgs.tools.myTool.execute).toBe(configExecute);
expect(callArgs.tools.AGUISendStateSnapshot.execute).toBeDefined();
expect(callArgs.tools.AGUISendStateDelta.execute).toBeDefined();
});
});
describe("Message ID Generation", () => {
it("should use messageId from text-start event", async () => {
const executeFn = vi.fn().mockResolvedValue({ result: "ok" });
const tool = defineTool({
name: "myTool",
description: "My tool",
parameters: z.object({}),
execute: executeFn,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
tools: [tool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
{ type: "text-start", id: "msg-1" },
{ type: "text-delta", text: "Before " },
{ type: "text-delta", text: "tool" },
toolCallStreamingStart("call1", "myTool"),
toolCall("call1", "myTool"),
toolResult("call1", "myTool", { result: "ok" }),
{ type: "text-start", id: "msg-2" },
{ type: "text-delta", text: "After " },
{ type: "text-delta", text: "tool" },
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
const textEvents = events.filter(
(e: any) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
// First two text chunks should have messageId from first text-start
expect(textEvents[0].messageId).toBe("msg-1");
expect(textEvents[1].messageId).toBe("msg-1");
// After tool result, text chunks should have messageId from second text-start
expect(textEvents[2].messageId).toBe("msg-2");
expect(textEvents[3].messageId).toBe("msg-2");
});
});
describe("Multi-Step Execution (maxSteps)", () => {
it("should pass stopWhen with stepCountIs when maxSteps is configured", async () => {
const executeFn = vi.fn().mockResolvedValue({ result: "ok" });
const tool = defineTool({
name: "myTool",
description: "My tool",
parameters: z.object({}),
execute: executeFn,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
maxSteps: 5,
tools: [tool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
// stopWhen should be set with stepCountIs(5)
expect(callArgs.stopWhen).toEqual({ type: "stepCount", count: 5 });
});
it("should not set stopWhen when maxSteps is not configured", async () => {
const executeFn = vi.fn().mockResolvedValue({ result: "ok" });
const tool = defineTool({
name: "myTool",
description: "My tool",
parameters: z.object({}),
execute: executeFn,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
// maxSteps not set
tools: [tool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
// stopWhen should be undefined (defaults to stepCountIs(1) in SDK)
expect(callArgs.stopWhen).toBeUndefined();
});
it("should allow high maxSteps for complex tool chains", async () => {
const executeFn = vi.fn().mockResolvedValue({});
const tool = defineTool({
name: "chainTool",
description: "Tool for chaining",
parameters: z.object({}),
execute: executeFn,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
maxSteps: 10,
tools: [tool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.stopWhen).toEqual({ type: "stepCount", count: 10 });
});
});
});
@@ -0,0 +1,692 @@
import { describe, it, expect } from "vitest";
import { EventType } from "@ag-ui/client";
import {
createAgent,
createDefaultInput,
collectEvents,
collectEventsIncludingErrors,
expectLifecycleWrapped,
expectEventSequence,
eventField,
textStart,
textDelta,
toolCallStreamingStart,
toolCallDelta,
toolCall,
toolResult,
reasoningStart,
reasoningDelta,
reasoningEnd,
finish,
} from "./agent-test-helpers";
// ---------------------------------------------------------------------------
// Basic Event Emission
// ---------------------------------------------------------------------------
describe("AI SDK Converter", () => {
describe("Basic Event Emission", () => {
it("text delta emits TEXT_MESSAGE_CHUNK with correct role, messageId, and delta", async () => {
const agent = createAgent("aisdk", [textDelta("Hello"), finish()]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
const textChunks = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textChunks).toHaveLength(1);
expect(eventField<string>(textChunks[0], "role")).toBe("assistant");
expect(eventField<string>(textChunks[0], "delta")).toBe("Hello");
expect(eventField<string>(textChunks[0], "messageId")).toBeDefined();
expect(typeof eventField<string>(textChunks[0], "messageId")).toBe(
"string",
);
});
it("text-start with provider id uses that id as messageId", async () => {
const agent = createAgent("aisdk", [
textStart("custom-msg-id"),
textDelta("Hi"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const chunk = events.find(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
)!;
expect(eventField<string>(chunk, "messageId")).toBe("custom-msg-id");
});
it('text-start with "0" generates a unique messageId (not "0")', async () => {
const agent = createAgent("aisdk", [
textStart("0"),
textDelta("Hi"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const chunk = events.find(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
)!;
expect(eventField<string>(chunk, "messageId")).not.toBe("0");
expect(eventField<string>(chunk, "messageId")).toBeDefined();
expect(eventField<string>(chunk, "messageId").length).toBeGreaterThan(0);
});
it("multiple text deltas share the same messageId", async () => {
const agent = createAgent("aisdk", [
textDelta("Hello "),
textDelta("world"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const textChunks = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textChunks).toHaveLength(2);
expect(eventField<string>(textChunks[0], "messageId")).toBe(
eventField<string>(textChunks[1], "messageId"),
);
});
it("empty stream (only finish) emits only RUN_STARTED + RUN_FINISHED", async () => {
const agent = createAgent("aisdk", [finish()]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.RUN_FINISHED,
]);
});
});
// ---------------------------------------------------------------------------
// Tool Call Events
// ---------------------------------------------------------------------------
describe("Tool Call Events", () => {
it("streamed tool call emits correct START/ARGS/END/RESULT events", async () => {
const agent = createAgent("aisdk", [
toolCallStreamingStart("tc-1", "myTool"),
toolCallDelta("tc-1", '{"key":'),
toolCallDelta("tc-1", '"value"}'),
toolCall("tc-1", "myTool"),
toolResult("tc-1", "myTool", { result: "ok" }),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
// Check the sequence of tool events
const toolEvents = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_START ||
e.type === EventType.TOOL_CALL_ARGS ||
e.type === EventType.TOOL_CALL_END ||
e.type === EventType.TOOL_CALL_RESULT,
);
expectEventSequence(toolEvents, [
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_END,
EventType.TOOL_CALL_RESULT,
]);
// Verify TOOL_CALL_START details
expect(eventField<string>(toolEvents[0], "toolCallId")).toBe("tc-1");
expect(eventField<string>(toolEvents[0], "toolCallName")).toBe("myTool");
// Verify TOOL_CALL_ARGS deltas
const argsEvts = toolEvents.filter(
(e) => e.type === EventType.TOOL_CALL_ARGS,
);
expect(eventField<string>(argsEvts[0], "delta")).toBe('{"key":');
expect(eventField<string>(argsEvts[1], "delta")).toBe('"value"}');
// Verify TOOL_CALL_END
expect(eventField<string>(toolEvents[2 + 1], "toolCallId")).toBe("tc-1");
// Verify TOOL_CALL_RESULT
expect(eventField<string>(toolEvents[4], "toolCallId")).toBe("tc-1");
expect(JSON.parse(eventField<string>(toolEvents[4], "content"))).toEqual({
result: "ok",
});
});
it("non-streamed tool call (tool-call with input, no prior tool-input-start) emits START + ARGS + END", async () => {
const agent = createAgent("aisdk", [
toolCall("tc-2", "directTool", { foo: "bar" }),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
const toolEvents = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_START ||
e.type === EventType.TOOL_CALL_ARGS ||
e.type === EventType.TOOL_CALL_END,
);
expectEventSequence(toolEvents, [
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_END,
]);
expect(eventField<string>(toolEvents[0], "toolCallId")).toBe("tc-2");
expect(eventField<string>(toolEvents[0], "toolCallName")).toBe(
"directTool",
);
expect(JSON.parse(eventField<string>(toolEvents[1], "delta"))).toEqual({
foo: "bar",
});
});
it("no duplicate START after tool-input-start followed by tool-call", async () => {
const agent = createAgent("aisdk", [
toolCallStreamingStart("tc-3", "myTool"),
toolCallDelta("tc-3", "{}"),
toolCall("tc-3", "myTool"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const startEvents = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_START &&
eventField<string>(e, "toolCallId") === "tc-3",
);
expect(startEvents).toHaveLength(1);
});
it("multiple concurrent tool calls have events correctly paired by toolCallId", async () => {
const agent = createAgent("aisdk", [
toolCallStreamingStart("tc-a", "toolA"),
toolCallStreamingStart("tc-b", "toolB"),
toolCallDelta("tc-a", '{"a":1}'),
toolCallDelta("tc-b", '{"b":2}'),
toolCall("tc-a", "toolA"),
toolCall("tc-b", "toolB"),
toolResult("tc-a", "toolA", "resultA"),
toolResult("tc-b", "toolB", "resultB"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
// Verify each tool call has its own START
const startsA = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_START &&
eventField<string>(e, "toolCallId") === "tc-a",
);
const startsB = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_START &&
eventField<string>(e, "toolCallId") === "tc-b",
);
expect(startsA).toHaveLength(1);
expect(startsB).toHaveLength(1);
// Verify args are correctly paired
const argsA = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_ARGS &&
eventField<string>(e, "toolCallId") === "tc-a",
);
const argsB = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_ARGS &&
eventField<string>(e, "toolCallId") === "tc-b",
);
expect(eventField<string>(argsA[0], "delta")).toBe('{"a":1}');
expect(eventField<string>(argsB[0], "delta")).toBe('{"b":2}');
// Verify results are correctly paired
const resultsA = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_RESULT &&
eventField<string>(e, "toolCallId") === "tc-a",
);
const resultsB = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_RESULT &&
eventField<string>(e, "toolCallId") === "tc-b",
);
expect(JSON.parse(eventField<string>(resultsA[0], "content"))).toBe(
"resultA",
);
expect(JSON.parse(eventField<string>(resultsB[0], "content"))).toBe(
"resultB",
);
});
});
// ---------------------------------------------------------------------------
// Reasoning Events
// ---------------------------------------------------------------------------
describe("Reasoning Events", () => {
it("full reasoning lifecycle emits correct REASONING_START/MESSAGE_START/CONTENT/MESSAGE_END/END events", async () => {
const agent = createAgent("aisdk", [
reasoningStart("r-1"),
reasoningDelta("thinking..."),
reasoningEnd(),
textDelta("Answer"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
const reasoningEvents = events.filter(
(e) =>
e.type === EventType.REASONING_START ||
e.type === EventType.REASONING_MESSAGE_START ||
e.type === EventType.REASONING_MESSAGE_CONTENT ||
e.type === EventType.REASONING_MESSAGE_END ||
e.type === EventType.REASONING_END,
);
expectEventSequence(reasoningEvents, [
EventType.REASONING_START,
EventType.REASONING_MESSAGE_START,
EventType.REASONING_MESSAGE_CONTENT,
EventType.REASONING_MESSAGE_END,
EventType.REASONING_END,
]);
// Verify messageId consistency
expect(eventField<string>(reasoningEvents[0], "messageId")).toBe("r-1");
expect(eventField<string>(reasoningEvents[1], "messageId")).toBe("r-1");
expect(eventField<string>(reasoningEvents[1], "role")).toBe("reasoning");
expect(eventField<string>(reasoningEvents[2], "messageId")).toBe("r-1");
expect(eventField<string>(reasoningEvents[2], "delta")).toBe(
"thinking...",
);
expect(eventField<string>(reasoningEvents[3], "messageId")).toBe("r-1");
expect(eventField<string>(reasoningEvents[4], "messageId")).toBe("r-1");
});
it("empty reasoning deltas are skipped", async () => {
const agent = createAgent("aisdk", [
reasoningStart(),
reasoningDelta(""),
reasoningDelta("actual content"),
reasoningDelta(""),
reasoningEnd(),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const contentEvents = events.filter(
(e) => e.type === EventType.REASONING_MESSAGE_CONTENT,
);
expect(contentEvents).toHaveLength(1);
expect(eventField<string>(contentEvents[0], "delta")).toBe(
"actual content",
);
});
it("auto-close reasoning before text-delta", async () => {
// No explicit reasoning-end — the converter should auto-close
const agent = createAgent("aisdk", [
reasoningStart("r-auto"),
reasoningDelta("thinking"),
textDelta("Answer"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
// Reasoning should be closed before the text event
const types = events.map((e) => e.type);
const msgEndIdx = types.indexOf(EventType.REASONING_MESSAGE_END);
const reasoningEndIdx = types.indexOf(EventType.REASONING_END);
const textIdx = types.indexOf(EventType.TEXT_MESSAGE_CHUNK);
expect(msgEndIdx).toBeGreaterThan(-1);
expect(reasoningEndIdx).toBeGreaterThan(-1);
expect(textIdx).toBeGreaterThan(reasoningEndIdx);
});
it("auto-close reasoning before tool-input-start", async () => {
const agent = createAgent("aisdk", [
reasoningStart(),
reasoningDelta("thinking about tools"),
toolCallStreamingStart("tc-r", "someTool"),
toolCallDelta("tc-r", "{}"),
toolCall("tc-r", "someTool"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
const types = events.map((e) => e.type);
const reasoningEndIdx = types.indexOf(EventType.REASONING_END);
const toolStartIdx = types.indexOf(EventType.TOOL_CALL_START);
expect(reasoningEndIdx).toBeGreaterThan(-1);
expect(toolStartIdx).toBeGreaterThan(reasoningEndIdx);
});
it("auto-close reasoning before finish", async () => {
const agent = createAgent("aisdk", [
reasoningStart(),
reasoningDelta("deep thought"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
// Should contain reasoning close events
const types = events.map((e) => e.type);
expect(types).toContain(EventType.REASONING_MESSAGE_END);
expect(types).toContain(EventType.REASONING_END);
// They should appear before RUN_FINISHED
const reasoningEndIdx = types.indexOf(EventType.REASONING_END);
const runFinishedIdx = types.indexOf(EventType.RUN_FINISHED);
expect(reasoningEndIdx).toBeLessThan(runFinishedIdx);
});
});
// ---------------------------------------------------------------------------
// State Management Tool Results
// ---------------------------------------------------------------------------
describe("State Management Tool Results", () => {
it("AGUISendStateSnapshot tool result emits STATE_SNAPSHOT event", async () => {
const agent = createAgent("aisdk", [
toolCallStreamingStart("tc-state", "AGUISendStateSnapshot"),
toolCallDelta("tc-state", '{"snapshot":{"count":1}}'),
toolCall("tc-state", "AGUISendStateSnapshot"),
toolResult("tc-state", "AGUISendStateSnapshot", {
snapshot: { count: 1 },
}),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const stateSnapshots = events.filter(
(e) => e.type === EventType.STATE_SNAPSHOT,
);
expect(stateSnapshots).toHaveLength(1);
expect(eventField(stateSnapshots[0], "snapshot")).toEqual({ count: 1 });
});
it("AGUISendStateDelta tool result emits STATE_DELTA event", async () => {
const delta = [{ op: "replace", path: "/count", value: 2 }];
const agent = createAgent("aisdk", [
toolCallStreamingStart("tc-delta", "AGUISendStateDelta"),
toolCallDelta("tc-delta", JSON.stringify({ delta })),
toolCall("tc-delta", "AGUISendStateDelta"),
toolResult("tc-delta", "AGUISendStateDelta", { delta }),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const stateDeltas = events.filter(
(e) => e.type === EventType.STATE_DELTA,
);
expect(stateDeltas).toHaveLength(1);
expect(eventField(stateDeltas[0], "delta")).toEqual(delta);
});
it("state tool result also emits TOOL_CALL_RESULT event", async () => {
const agent = createAgent("aisdk", [
toolCallStreamingStart("tc-state2", "AGUISendStateSnapshot"),
toolCallDelta("tc-state2", "{}"),
toolCall("tc-state2", "AGUISendStateSnapshot"),
toolResult("tc-state2", "AGUISendStateSnapshot", {
snapshot: { x: 1 },
}),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const toolResults = events.filter(
(e) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(toolResults).toHaveLength(1);
expect(eventField<string>(toolResults[0], "toolCallId")).toBe(
"tc-state2",
);
});
it("does not emit STATE_SNAPSHOT when snapshot field is undefined", async () => {
const agent = createAgent("aisdk", [
toolCallStreamingStart("tc-no-snap", "AGUISendStateSnapshot"),
toolCallDelta("tc-no-snap", "{}"),
toolCall("tc-no-snap", "AGUISendStateSnapshot"),
toolResult("tc-no-snap", "AGUISendStateSnapshot", {
success: true,
}),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const stateSnapshots = events.filter(
(e) => e.type === EventType.STATE_SNAPSHOT,
);
expect(stateSnapshots).toHaveLength(0);
// Should still emit the TOOL_CALL_RESULT
const toolResults = events.filter(
(e) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(toolResults).toHaveLength(1);
});
it("does not emit STATE_DELTA when delta field is undefined", async () => {
const agent = createAgent("aisdk", [
toolCallStreamingStart("tc-no-delta", "AGUISendStateDelta"),
toolCallDelta("tc-no-delta", "{}"),
toolCall("tc-no-delta", "AGUISendStateDelta"),
toolResult("tc-no-delta", "AGUISendStateDelta", {
success: true,
}),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const stateDeltas = events.filter(
(e) => e.type === EventType.STATE_DELTA,
);
expect(stateDeltas).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Tool Result Property Compatibility
// ---------------------------------------------------------------------------
describe("Tool Result Property Compatibility", () => {
it("reads tool result from 'result' property when 'output' is absent", async () => {
// Simulate older AI SDK that uses "result" instead of "output"
const agent = createAgent("aisdk", [
toolCall("tc-compat", "myTool"),
{
type: "tool-result",
toolCallId: "tc-compat",
toolName: "myTool",
result: { data: "from-result-prop" },
},
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const resultEvents = events.filter(
(e) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(resultEvents).toHaveLength(1);
expect(
JSON.parse(eventField<string>(resultEvents[0], "content")),
).toEqual({ data: "from-result-prop" });
});
it("prefers 'output' over 'result' when both are present", async () => {
const agent = createAgent("aisdk", [
toolCall("tc-both", "myTool"),
{
type: "tool-result",
toolCallId: "tc-both",
toolName: "myTool",
output: { source: "output" },
result: { source: "result" },
},
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const resultEvents = events.filter(
(e) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(
JSON.parse(eventField<string>(resultEvents[0], "content")),
).toEqual({ source: "output" });
});
});
// ---------------------------------------------------------------------------
// Error Event Handling
// ---------------------------------------------------------------------------
describe("Error Event Handling", () => {
it("wraps undefined error in a descriptive Error", async () => {
const agent = createAgent("aisdk", [
textDelta("before error"),
{ type: "error" }, // no .error property
]);
const input = createDefaultInput();
const { events, errored } = await collectEventsIncludingErrors(
agent.run(input),
);
expect(errored).toBe(true);
const errorEvents = events.filter((e) => e.type === EventType.RUN_ERROR);
expect(errorEvents).toHaveLength(1);
// Should contain a useful message, not just "undefined"
expect(eventField<string>(errorEvents[0], "message")).not.toBe(
"undefined",
);
expect(
eventField<string>(errorEvents[0], "message").length,
).toBeGreaterThan(5);
});
it("preserves Error instances from error event", async () => {
const agent = createAgent("aisdk", [
{ type: "error", error: new Error("rate limit exceeded") },
]);
const input = createDefaultInput();
const { events, errored } = await collectEventsIncludingErrors(
agent.run(input),
);
expect(errored).toBe(true);
const errorEvents = events.filter((e) => e.type === EventType.RUN_ERROR);
expect(eventField<string>(errorEvents[0], "message")).toBe(
"rate limit exceeded",
);
});
it("handles string error from error event", async () => {
const agent = createAgent("aisdk", [
{ type: "error", message: "auth failed" },
]);
const input = createDefaultInput();
const { events, errored } = await collectEventsIncludingErrors(
agent.run(input),
);
expect(errored).toBe(true);
const errorEvents = events.filter((e) => e.type === EventType.RUN_ERROR);
expect(eventField<string>(errorEvents[0], "message")).toBe("auth failed");
});
});
// ---------------------------------------------------------------------------
// Edge Cases
// ---------------------------------------------------------------------------
describe("Edge Cases", () => {
it("unknown event types are silently ignored", async () => {
const agent = createAgent("aisdk", [
{ type: "some-unknown-event", data: "hello" },
textDelta("text after unknown"),
{ type: "another-mystery-event" },
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
// Should still have the text chunk
const textChunks = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textChunks).toHaveLength(1);
// No events for unknown types — only RUN_STARTED, TEXT_MESSAGE_CHUNK, RUN_FINISHED
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TEXT_MESSAGE_CHUNK,
EventType.RUN_FINISHED,
]);
});
it("large text deltas (100k chars) are passed through", async () => {
const largeText = "x".repeat(100_000);
const agent = createAgent("aisdk", [textDelta(largeText), finish()]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
const chunk = events.find(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
)!;
expect(eventField<string>(chunk, "delta")).toBe(largeText);
expect(eventField<string>(chunk, "delta").length).toBe(100_000);
});
});
});
@@ -0,0 +1,319 @@
import { describe, it, expect } from "vitest";
import { EventType, type BaseEvent } from "@ag-ui/client";
import { BuiltInAgent } from "../index";
import {
createAgent,
createDefaultInput,
collectEvents,
expectLifecycleWrapped,
expectEventSequence,
eventField,
mockCustomStream,
} from "./agent-test-helpers";
describe("Custom Converter (passthrough)", () => {
// -----------------------------------------------------------------------
// Event Forwarding
// -----------------------------------------------------------------------
describe("Event Forwarding", () => {
it("should forward a single TEXT_MESSAGE_CHUNK as-is between lifecycle events", async () => {
const chunk: BaseEvent = {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: "Hello world",
} as BaseEvent;
const agent = createAgent("custom", [chunk]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events, "test-thread", "test-run");
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TEXT_MESSAGE_CHUNK,
EventType.RUN_FINISHED,
]);
expect(eventField<string>(events[1], "delta")).toBe("Hello world");
expect(eventField<string>(events[1], "role")).toBe("assistant");
});
it("should forward multiple event types in order", async () => {
const userEvents: BaseEvent[] = [
{ type: EventType.TEXT_MESSAGE_START, role: "assistant" } as BaseEvent,
{
type: EventType.TEXT_MESSAGE_CONTENT,
role: "assistant",
content: "Hi",
} as BaseEvent,
{ type: EventType.TEXT_MESSAGE_END } as BaseEvent,
];
const agent = createAgent("custom", userEvents);
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TEXT_MESSAGE_START,
EventType.TEXT_MESSAGE_CONTENT,
EventType.TEXT_MESSAGE_END,
EventType.RUN_FINISHED,
]);
});
it("should forward TOOL_CALL_START + TOOL_CALL_ARGS + TOOL_CALL_END", async () => {
const toolEvents: BaseEvent[] = [
{
type: EventType.TOOL_CALL_START,
toolCallId: "tc-1",
toolCallName: "myTool",
} as BaseEvent,
{
type: EventType.TOOL_CALL_ARGS,
toolCallId: "tc-1",
delta: '{"key":"value"}',
} as BaseEvent,
{
type: EventType.TOOL_CALL_END,
toolCallId: "tc-1",
} as BaseEvent,
];
const agent = createAgent("custom", toolEvents);
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_END,
EventType.RUN_FINISHED,
]);
expect(eventField<string>(events[1], "toolCallId")).toBe("tc-1");
expect(eventField<string>(events[1], "toolCallName")).toBe("myTool");
expect(eventField<string>(events[2], "toolCallId")).toBe("tc-1");
expect(eventField<string>(events[2], "delta")).toBe('{"key":"value"}');
expect(eventField<string>(events[3], "toolCallId")).toBe("tc-1");
});
it("should forward a STATE_SNAPSHOT event", async () => {
const snapshot: BaseEvent = {
type: EventType.STATE_SNAPSHOT,
snapshot: { counter: 42, items: ["a", "b"] },
} as BaseEvent;
const agent = createAgent("custom", [snapshot]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.STATE_SNAPSHOT,
EventType.RUN_FINISHED,
]);
expect(
eventField<Record<string, unknown>>(events[1], "snapshot"),
).toEqual({ counter: 42, items: ["a", "b"] });
});
it("should forward a STATE_DELTA event", async () => {
const delta: BaseEvent = {
type: EventType.STATE_DELTA,
delta: [{ op: "replace", path: "/counter", value: 43 }],
} as BaseEvent;
const agent = createAgent("custom", [delta]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.STATE_DELTA,
EventType.RUN_FINISHED,
]);
expect(eventField<unknown[]>(events[1], "delta")).toEqual([
{ op: "replace", path: "/counter", value: 43 },
]);
});
it("should forward reasoning events in order", async () => {
const reasoningEvents: BaseEvent[] = [
{ type: EventType.REASONING_START } as BaseEvent,
{ type: EventType.REASONING_MESSAGE_START } as BaseEvent,
{
type: EventType.REASONING_MESSAGE_CONTENT,
content: "Thinking step 1",
} as BaseEvent,
{
type: EventType.REASONING_MESSAGE_CONTENT,
content: "Thinking step 2",
} as BaseEvent,
{ type: EventType.REASONING_MESSAGE_END } as BaseEvent,
{ type: EventType.REASONING_END } as BaseEvent,
];
const agent = createAgent("custom", reasoningEvents);
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.REASONING_START,
EventType.REASONING_MESSAGE_START,
EventType.REASONING_MESSAGE_CONTENT,
EventType.REASONING_MESSAGE_CONTENT,
EventType.REASONING_MESSAGE_END,
EventType.REASONING_END,
EventType.RUN_FINISHED,
]);
expect(eventField<string>(events[3], "content")).toBe("Thinking step 1");
expect(eventField<string>(events[4], "content")).toBe("Thinking step 2");
});
});
// -----------------------------------------------------------------------
// Lifecycle Boundary
// -----------------------------------------------------------------------
describe("Lifecycle Boundary", () => {
it("should result in duplicate RUN_STARTED when user emits one in custom stream", async () => {
const userEvents: BaseEvent[] = [
{
type: EventType.RUN_STARTED,
threadId: "user-thread",
runId: "user-run",
} as BaseEvent,
{
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: "Hello",
} as BaseEvent,
];
const agent = createAgent("custom", userEvents);
const events = await collectEvents(agent.run(createDefaultInput()));
// Agent emits its own RUN_STARTED, then the user's RUN_STARTED is forwarded
const runStartedEvents = events.filter(
(e) => e.type === EventType.RUN_STARTED,
);
expect(runStartedEvents).toHaveLength(2);
// First is from the Agent lifecycle
expect(runStartedEvents[0]).toMatchObject({
type: EventType.RUN_STARTED,
threadId: "test-thread",
runId: "test-run",
});
// Second is the user-emitted one, forwarded as-is
expect(eventField<string>(runStartedEvents[1], "threadId")).toBe(
"user-thread",
);
expect(eventField<string>(runStartedEvents[1], "runId")).toBe("user-run");
});
});
// -----------------------------------------------------------------------
// Edge Cases
// -----------------------------------------------------------------------
describe("Edge Cases", () => {
it("should emit only lifecycle events for an empty async iterable", async () => {
const agent = createAgent("custom", []);
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.RUN_FINISHED,
]);
expectLifecycleWrapped(events, "test-thread", "test-run");
});
it("should work correctly with an async generator factory", async () => {
const agent = new BuiltInAgent({
type: "custom",
factory: async function* () {
yield {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: "from generator",
} as BaseEvent;
yield {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: " factory",
} as BaseEvent;
},
});
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TEXT_MESSAGE_CHUNK,
EventType.TEXT_MESSAGE_CHUNK,
EventType.RUN_FINISHED,
]);
expect(eventField<string>(events[1], "delta")).toBe("from generator");
expect(eventField<string>(events[2], "delta")).toBe(" factory");
});
it("should pass through events with extra/unknown fields", async () => {
const eventWithExtras: BaseEvent = {
type: EventType.CUSTOM,
customField: "custom-value",
nestedData: { deep: { value: 123 } },
arrayField: [1, 2, 3],
} as BaseEvent;
const agent = createAgent("custom", [eventWithExtras]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.CUSTOM,
EventType.RUN_FINISHED,
]);
expect(eventField<string>(events[1], "customField")).toBe("custom-value");
expect(
eventField<{ deep: { value: number } }>(events[1], "nestedData"),
).toEqual({ deep: { value: 123 } });
expect(eventField<number[]>(events[1], "arrayField")).toEqual([1, 2, 3]);
});
it("should forward 1000+ events without loss", async () => {
const count = 1500;
const manyEvents: BaseEvent[] = Array.from({ length: count }, (_, i) => ({
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: `chunk-${i}`,
})) as BaseEvent[];
const agent = createAgent("custom", manyEvents);
const events = await collectEvents(agent.run(createDefaultInput()));
// Total = RUN_STARTED + 1500 chunks + RUN_FINISHED
expect(events).toHaveLength(count + 2);
// First and last are lifecycle
expect(events[0].type).toBe(EventType.RUN_STARTED);
expect(events[events.length - 1].type).toBe(EventType.RUN_FINISHED);
// All content events are TEXT_MESSAGE_CHUNK
const contentEvents = events.slice(1, -1);
expect(contentEvents).toHaveLength(count);
// Verify order preservation
for (let i = 0; i < count; i++) {
expect(contentEvents[i].type).toBe(EventType.TEXT_MESSAGE_CHUNK);
expect(eventField<string>(contentEvents[i], "delta")).toBe(
`chunk-${i}`,
);
}
});
});
});
@@ -0,0 +1,291 @@
import { describe, it, expect } from "vitest";
import { convertInputToTanStackAI } from "../converters/tanstack";
import { createDefaultInput } from "./agent-test-helpers";
describe("convertInputToTanStackAI", () => {
// -------------------------------------------------------------------------
// Message filtering
// -------------------------------------------------------------------------
describe("message filtering", () => {
it("filters out system messages from the messages array", () => {
const input = createDefaultInput({
messages: [
{ id: "msg-1", role: "system", content: "You are helpful" },
{ id: "msg-2", role: "user", content: "Hello" },
],
});
const { messages } = convertInputToTanStackAI(input);
expect(messages).toHaveLength(1);
expect(messages[0].role).toBe("user");
expect(messages[0].content).toBe("Hello");
});
it("filters out developer messages from the messages array", () => {
const input = createDefaultInput({
messages: [
{ id: "msg-3", role: "developer", content: "Internal instruction" },
{ id: "msg-4", role: "user", content: "Hi" },
],
});
const { messages } = convertInputToTanStackAI(input);
expect(messages).toHaveLength(1);
expect(messages[0].role).toBe("user");
});
it("extracts system and developer messages into systemPrompts", () => {
const input = createDefaultInput({
messages: [
{ id: "msg-5", role: "system", content: "System prompt" },
{ id: "msg-6", role: "developer", content: "Dev instruction" },
{ id: "msg-7", role: "user", content: "Hello" },
],
});
const { systemPrompts } = convertInputToTanStackAI(input);
expect(systemPrompts).toContain("System prompt");
expect(systemPrompts).toContain("Dev instruction");
});
it("preserves user and assistant messages in order", () => {
const input = createDefaultInput({
messages: [
{ id: "msg-8", role: "user", content: "Question 1" },
{ id: "msg-9", role: "assistant", content: "Answer 1" },
{ id: "msg-10", role: "user", content: "Question 2" },
],
});
const { messages } = convertInputToTanStackAI(input);
expect(messages).toHaveLength(3);
expect(messages[0]).toMatchObject({
role: "user",
content: "Question 1",
});
expect(messages[1]).toMatchObject({
role: "assistant",
content: "Answer 1",
});
expect(messages[2]).toMatchObject({
role: "user",
content: "Question 2",
});
});
});
// -------------------------------------------------------------------------
// Tool call mapping
// -------------------------------------------------------------------------
describe("tool call mapping", () => {
it("maps assistant message toolCalls to TanStack format", () => {
const input = createDefaultInput({
messages: [
{
id: "msg-14",
role: "assistant",
content: null,
toolCalls: [
{
id: "tc-1",
type: "function",
function: { name: "getWeather", arguments: '{"city":"NYC"}' },
},
],
},
],
});
const { messages } = convertInputToTanStackAI(input);
expect(messages).toHaveLength(1);
expect(messages[0].toolCalls).toHaveLength(1);
expect(messages[0].toolCalls![0]).toEqual({
id: "tc-1",
type: "function",
function: { name: "getWeather", arguments: '{"city":"NYC"}' },
});
});
it("maps tool messages with toolCallId", () => {
const input = createDefaultInput({
messages: [
{
id: "msg-15",
role: "tool",
content: '{"temp": 72}',
toolCallId: "tc-1",
},
],
});
const { messages } = convertInputToTanStackAI(input);
expect(messages).toHaveLength(1);
expect(messages[0].role).toBe("tool");
expect(messages[0].toolCallId).toBe("tc-1");
});
});
// -------------------------------------------------------------------------
// Client tools (frontend-provided tools → TanStack client tools)
// -------------------------------------------------------------------------
describe("client tools", () => {
it("converts input.tools to TanStack client tools (no executor)", () => {
const params = {
type: "object" as const,
properties: { issues: { type: "array" as const } },
required: ["issues"],
};
const input = createDefaultInput({
tools: [
{
name: "issue_list",
description: "Render a list of issues",
parameters: params,
},
],
});
const { tools } = convertInputToTanStackAI(input);
expect(tools).toHaveLength(1);
expect(tools[0]).toEqual({
__toolSide: "client",
name: "issue_list",
description: "Render a list of issues",
inputSchema: params,
});
// Client tools must NOT carry an executor — TanStack pauses and hands
// the call back to the AG-UI client instead of running it.
expect("execute" in tools[0]).toBe(false);
});
it("returns an empty tools array when input has no tools", () => {
const { tools } = convertInputToTanStackAI(createDefaultInput());
expect(tools).toEqual([]);
});
it("closes open objects (additionalProperties → false) for OpenAI compatibility", () => {
const input = createDefaultInput({
tools: [
{
name: "render_chart",
description: "Render a chart",
parameters: {
type: "object",
properties: {
// z.record(z.string(), z.any()) → additionalProperties: {}
options: { type: "object", additionalProperties: {} },
data: {
type: "array",
// .passthrough() → additionalProperties: true on items
items: {
type: "object",
properties: { v: { type: "number" } },
additionalProperties: true,
},
},
},
additionalProperties: false,
},
},
],
});
const { tools } = convertInputToTanStackAI(input);
const schema = tools[0].inputSchema as Record<string, any>;
expect(schema.properties.options.additionalProperties).toBe(false);
expect(schema.properties.data.items.additionalProperties).toBe(false);
// Nested defined properties are preserved.
expect(schema.properties.data.items.properties.v).toEqual({
type: "number",
});
});
});
// -------------------------------------------------------------------------
// Context injection
// -------------------------------------------------------------------------
describe("context injection", () => {
it("appends context entries to systemPrompts", () => {
const input = createDefaultInput({
context: [
{ description: "User preferences", value: "Dark mode enabled" },
{ description: "Current page", value: "/dashboard" },
],
});
const { systemPrompts } = convertInputToTanStackAI(input);
expect(systemPrompts).toContain("User preferences:\nDark mode enabled");
expect(systemPrompts).toContain("Current page:\n/dashboard");
});
it("does not add context when context array is empty", () => {
const input = createDefaultInput({ context: [] });
const { systemPrompts } = convertInputToTanStackAI(input);
expect(systemPrompts).toHaveLength(0);
});
});
// -------------------------------------------------------------------------
// State serialization
// -------------------------------------------------------------------------
describe("state serialization", () => {
it("serializes non-empty state into systemPrompts", () => {
const input = createDefaultInput({
state: { count: 42, items: ["a", "b"] },
});
const { systemPrompts } = convertInputToTanStackAI(input);
const statePrompt = systemPrompts.find((p) =>
p.startsWith("Application State:"),
);
expect(statePrompt).toBeDefined();
expect(statePrompt).toContain('"count": 42');
});
it("does not add state when state is empty object", () => {
const input = createDefaultInput({ state: {} });
const { systemPrompts } = convertInputToTanStackAI(input);
expect(
systemPrompts.find((p) => p.startsWith("Application State:")),
).toBeUndefined();
});
it("does not add state when state is null", () => {
const input = createDefaultInput({ state: null });
const { systemPrompts } = convertInputToTanStackAI(input);
expect(
systemPrompts.find((p) => p.startsWith("Application State:")),
).toBeUndefined();
});
});
// -------------------------------------------------------------------------
// Empty input
// -------------------------------------------------------------------------
describe("empty input", () => {
it("returns empty messages and systemPrompts for default input", () => {
const input = createDefaultInput();
const { messages, systemPrompts } = convertInputToTanStackAI(input);
expect(messages).toHaveLength(0);
expect(systemPrompts).toHaveLength(0);
});
});
});
@@ -0,0 +1,688 @@
import { describe, it, expect } from "vitest";
import { EventType } from "@ag-ui/client";
import {
createAgent,
createDefaultInput,
collectEvents,
collectEventsIncludingErrors,
expectLifecycleWrapped,
expectEventSequence,
eventField,
tanstackTextChunk,
tanstackToolCallStart,
tanstackToolCallArgs,
tanstackToolCallEnd,
tanstackToolCallResult,
tanstackReasoningStart,
tanstackReasoningMessageStart,
tanstackReasoningMessageContent,
tanstackReasoningMessageEnd,
tanstackReasoningEnd,
} from "./agent-test-helpers";
describe("TanStack AI converter (via Agent)", () => {
// -------------------------------------------------------------------------
// Text Events
// -------------------------------------------------------------------------
describe("Text Events", () => {
it("TEXT_MESSAGE_CONTENT chunk produces TEXT_MESSAGE_CHUNK with role assistant and correct delta", async () => {
const agent = createAgent("tanstack", [tanstackTextChunk("Hello world")]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents).toHaveLength(1);
expect(eventField<string>(textEvents[0], "role")).toBe("assistant");
expect(eventField<string>(textEvents[0], "delta")).toBe("Hello world");
expect(eventField<string>(textEvents[0], "messageId")).toBeDefined();
expect(typeof eventField<string>(textEvents[0], "messageId")).toBe(
"string",
);
expect(
eventField<string>(textEvents[0], "messageId").length,
).toBeGreaterThan(0);
});
it("multiple text chunks share the same messageId", async () => {
const agent = createAgent("tanstack", [
tanstackTextChunk("Hello "),
tanstackTextChunk("world"),
tanstackTextChunk("!"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents).toHaveLength(3);
const messageIds = new Set(
textEvents.map((e) => eventField<string>(e, "messageId")),
);
expect(messageIds.size).toBe(1);
});
it("empty stream produces only RUN_STARTED + RUN_FINISHED", async () => {
const agent = createAgent("tanstack", []);
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.RUN_FINISHED,
]);
});
});
// -------------------------------------------------------------------------
// Tool Call Events
// -------------------------------------------------------------------------
describe("Tool Call Events", () => {
it("full tool call lifecycle produces START, ARGS, END events in order", async () => {
const agent = createAgent("tanstack", [
tanstackToolCallStart("tc-1", "myTool"),
tanstackToolCallArgs("tc-1", '{"key":'),
tanstackToolCallArgs("tc-1", '"value"}'),
tanstackToolCallEnd("tc-1"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_END,
EventType.RUN_FINISHED,
]);
expect(eventField<string>(events[1], "toolCallId")).toBe("tc-1");
expect(eventField<string>(events[1], "toolCallName")).toBe("myTool");
expect(eventField<string>(events[2], "toolCallId")).toBe("tc-1");
expect(eventField<string>(events[2], "delta")).toBe('{"key":');
expect(eventField<string>(events[3], "toolCallId")).toBe("tc-1");
expect(eventField<string>(events[3], "delta")).toBe('"value"}');
expect(eventField<string>(events[4], "toolCallId")).toBe("tc-1");
});
it("TOOL_CALL_START sets parentMessageId", async () => {
const agent = createAgent("tanstack", [
tanstackTextChunk("before"),
tanstackToolCallStart("tc-1", "myTool"),
tanstackToolCallEnd("tc-1"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const textEvent = events.find(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
)!;
const toolStartEvent = events.find(
(e) => e.type === EventType.TOOL_CALL_START,
)!;
expect(
eventField<string>(toolStartEvent, "parentMessageId"),
).toBeDefined();
expect(eventField<string>(toolStartEvent, "parentMessageId")).toBe(
eventField<string>(textEvent, "messageId"),
);
});
it("multiple tool calls in sequence each get correct events", async () => {
const agent = createAgent("tanstack", [
tanstackToolCallStart("tc-1", "toolA"),
tanstackToolCallArgs("tc-1", '{"a":1}'),
tanstackToolCallEnd("tc-1"),
tanstackToolCallStart("tc-2", "toolB"),
tanstackToolCallArgs("tc-2", '{"b":2}'),
tanstackToolCallEnd("tc-2"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_END,
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_END,
EventType.RUN_FINISHED,
]);
// Verify first tool call
expect(eventField<string>(events[1], "toolCallId")).toBe("tc-1");
expect(eventField<string>(events[1], "toolCallName")).toBe("toolA");
expect(eventField<string>(events[2], "toolCallId")).toBe("tc-1");
expect(eventField<string>(events[3], "toolCallId")).toBe("tc-1");
// Verify second tool call
expect(eventField<string>(events[4], "toolCallId")).toBe("tc-2");
expect(eventField<string>(events[4], "toolCallName")).toBe("toolB");
expect(eventField<string>(events[5], "toolCallId")).toBe("tc-2");
expect(eventField<string>(events[6], "toolCallId")).toBe("tc-2");
});
it("tool call with no ARGS chunks produces only START + END", async () => {
const agent = createAgent("tanstack", [
tanstackToolCallStart("tc-1", "noArgsTool"),
tanstackToolCallEnd("tc-1"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_END,
EventType.RUN_FINISHED,
]);
});
});
// -------------------------------------------------------------------------
// Tool Call Result Events
// -------------------------------------------------------------------------
describe("Tool Call Result Events", () => {
it("TOOL_CALL_RESULT chunk produces TOOL_CALL_RESULT event with correct content", async () => {
const agent = createAgent("tanstack", [
tanstackToolCallStart("tc-1", "myTool"),
tanstackToolCallArgs("tc-1", '{"key":"value"}'),
tanstackToolCallEnd("tc-1"),
{
type: "TOOL_CALL_RESULT",
toolCallId: "tc-1",
content: JSON.stringify({ result: "ok" }),
},
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
const resultEvents = events.filter(
(e) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(resultEvents).toHaveLength(1);
expect(eventField<string>(resultEvents[0], "toolCallId")).toBe("tc-1");
expect(eventField<string>(resultEvents[0], "role")).toBe("tool");
expect(
JSON.parse(eventField<string>(resultEvents[0], "content")),
).toEqual({ result: "ok" });
});
it("TOOL_CALL_RESULT with object content serializes to JSON", async () => {
const agent = createAgent("tanstack", [
tanstackToolCallStart("tc-2", "myTool"),
tanstackToolCallEnd("tc-2"),
{
type: "TOOL_CALL_RESULT",
toolCallId: "tc-2",
result: { data: 42 },
},
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const resultEvents = events.filter(
(e) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(resultEvents).toHaveLength(1);
expect(
JSON.parse(eventField<string>(resultEvents[0], "content")),
).toEqual({ data: 42 });
});
});
// -------------------------------------------------------------------------
// Mixed Content
// -------------------------------------------------------------------------
describe("Mixed Content", () => {
it("text interleaved with tool calls produces correct event types and order", async () => {
const agent = createAgent("tanstack", [
tanstackTextChunk("Let me help. "),
tanstackToolCallStart("tc-1", "search"),
tanstackToolCallArgs("tc-1", '{"q":"test"}'),
tanstackToolCallEnd("tc-1"),
tanstackTextChunk("Here are the results."),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TEXT_MESSAGE_CHUNK,
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_END,
EventType.TEXT_MESSAGE_CHUNK,
EventType.RUN_FINISHED,
]);
// Verify content of text events
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(eventField<string>(textEvents[0], "delta")).toBe("Let me help. ");
expect(eventField<string>(textEvents[1], "delta")).toBe(
"Here are the results.",
);
});
});
// -------------------------------------------------------------------------
// Edge Cases
// -------------------------------------------------------------------------
describe("Edge Cases", () => {
it("unknown chunk types are silently ignored", async () => {
const agent = createAgent("tanstack", [
tanstackTextChunk("hello"),
{ type: "SOME_UNKNOWN_TYPE", data: "foo" },
{ type: "ANOTHER_MYSTERY", value: 42 },
tanstackTextChunk(" world"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TEXT_MESSAGE_CHUNK,
EventType.TEXT_MESSAGE_CHUNK,
EventType.RUN_FINISHED,
]);
});
it("large deltas (100k chars) are passed through", async () => {
const largeDelta = "x".repeat(100_000);
const agent = createAgent("tanstack", [tanstackTextChunk(largeDelta)]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
const textEvent = events.find(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
)!;
expect(eventField<string>(textEvent, "delta")).toBe(largeDelta);
expect(eventField<string>(textEvent, "delta").length).toBe(100_000);
});
});
});
describe("TanStack AI converter — multi-turn agent loop", () => {
// chat() emits a RUN_STARTED / RUN_FINISHED pair PER model turn and executes
// server-side tools (MCP / provider tools) itself between turns. The run
// lifecycle is owned by the Agent wrapper, so per-turn markers must be
// dropped and every turn's content converted — a regression here truncated
// the run at the first tool turn, dropping the tool result and final answer.
it("does NOT truncate at a per-turn RUN_FINISHED — tool result + final text still convert", async () => {
const agent = createAgent("tanstack", [
// Turn 1: model emits a tool call, then the turn finishes.
tanstackToolCallStart("tc-1", "list_issues"),
tanstackToolCallArgs("tc-1", '{"team":"CPK"}'),
tanstackToolCallEnd("tc-1"),
{ type: "RUN_FINISHED" },
// Between turns: chat() executes the MCP tool itself.
tanstackToolCallResult("tc-1", { issues: [] }),
// Turn 2: model produces the final answer.
{ type: "RUN_STARTED" },
tanstackTextChunk("No open issues."),
{ type: "RUN_FINISHED" },
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_END,
EventType.TOOL_CALL_RESULT,
EventType.TEXT_MESSAGE_CHUNK,
EventType.RUN_FINISHED,
]);
// Exactly one RUN_FINISHED — the outer wrapper's, not TanStack's per-turn ones.
expect(
events.filter((e) => e.type === EventType.RUN_FINISHED),
).toHaveLength(1);
// The final answer survived the per-turn RUN_FINISHED.
const textEvent = events.find(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
)!;
expect(eventField<string>(textEvent, "delta")).toBe("No open issues.");
});
it("de-duplicates a tool call re-announced after execution", async () => {
const agent = createAgent("tanstack", [
tanstackToolCallStart("tc-1", "search"),
tanstackToolCallEnd("tc-1"),
{ type: "RUN_FINISHED" },
tanstackToolCallResult("tc-1", { ok: true }),
// chat() re-announces the same call when it re-prompts — must be dropped.
tanstackToolCallStart("tc-1", "search"),
tanstackToolCallArgs("tc-1", '{"q":"x"}'),
tanstackToolCallEnd("tc-1"),
{ type: "RUN_STARTED" },
tanstackTextChunk("done"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_END,
EventType.TOOL_CALL_RESULT,
EventType.TEXT_MESSAGE_CHUNK,
EventType.RUN_FINISHED,
]);
});
it("surfaces a RUN_ERROR chunk as a terminal RUN_ERROR event", async () => {
const agent = createAgent("tanstack", [
tanstackTextChunk("partial"),
{ type: "RUN_ERROR", message: "provider 400" },
// Anything after the error must never be converted.
tanstackTextChunk("should not appear"),
]);
const { events, errored } = await collectEventsIncludingErrors(
agent.run(createDefaultInput()),
);
expect(errored).toBe(true);
const errorEvent = events.find((e) => e.type === EventType.RUN_ERROR)!;
expect(errorEvent).toBeDefined();
expect(eventField<string>(errorEvent, "message")).toBe("provider 400");
// The post-error text chunk was not emitted.
const texts = events.filter((e) => e.type === EventType.TEXT_MESSAGE_CHUNK);
expect(texts).toHaveLength(1);
expect(eventField<string>(texts[0], "delta")).toBe("partial");
});
});
describe("TanStack AI converter — state tools", () => {
it("emits STATE_SNAPSHOT before TOOL_CALL_RESULT for AGUISendStateSnapshot", async () => {
const snapshot = { counter: 5, items: ["x", "y"] };
const agent = createAgent("tanstack", [
tanstackToolCallStart("call1", "AGUISendStateSnapshot"),
tanstackToolCallEnd("call1"),
tanstackToolCallResult("call1", { success: true, snapshot }),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
const snapshotIdx = events.findIndex(
(e) => e.type === EventType.STATE_SNAPSHOT,
);
const resultIdx = events.findIndex(
(e) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(snapshotIdx).toBeGreaterThanOrEqual(0);
expect(resultIdx).toBeGreaterThanOrEqual(0);
expect(snapshotIdx).toBeLessThan(resultIdx);
expect(eventField<unknown>(events[snapshotIdx], "snapshot")).toEqual(
snapshot,
);
});
it("emits STATE_DELTA before TOOL_CALL_RESULT for AGUISendStateDelta", async () => {
const delta = [{ op: "replace", path: "/counter", value: 7 }];
const agent = createAgent("tanstack", [
tanstackToolCallStart("call1", "AGUISendStateDelta"),
tanstackToolCallEnd("call1"),
tanstackToolCallResult("call1", { success: true, delta }),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
const deltaIdx = events.findIndex((e) => e.type === EventType.STATE_DELTA);
const resultIdx = events.findIndex(
(e) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(deltaIdx).toBeGreaterThanOrEqual(0);
expect(deltaIdx).toBeLessThan(resultIdx);
expect(eventField<unknown>(events[deltaIdx], "delta")).toEqual(delta);
});
it("emits STATE_SNAPSHOT when payload arrives in raw.result instead of raw.content", async () => {
// Regression: serialization fell back to raw.result (`?? raw.result ?? null`)
// but state-tool detection only inspected raw.content, so STATE_* events
// were silently dropped if upstream used `result` for the state-tool body.
// See the "TOOL_CALL_RESULT with object content serializes to JSON" test
// above which already exercises the `result` field on a non-state tool.
const snapshot = { counter: 99 };
const agent = createAgent("tanstack", [
tanstackToolCallStart("call1", "AGUISendStateSnapshot"),
tanstackToolCallEnd("call1"),
{
type: "TOOL_CALL_RESULT",
toolCallId: "call1",
result: { success: true, snapshot },
},
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
const snapshotIdx = events.findIndex(
(e) => e.type === EventType.STATE_SNAPSHOT,
);
expect(snapshotIdx).toBeGreaterThanOrEqual(0);
expect(eventField<unknown>(events[snapshotIdx], "snapshot")).toEqual(
snapshot,
);
});
it("does NOT emit STATE_* for non-state tool results", async () => {
const agent = createAgent("tanstack", [
tanstackToolCallStart("call1", "getWeather"),
tanstackToolCallEnd("call1"),
tanstackToolCallResult("call1", {
snapshot: { spoofed: true },
delta: [{ op: "x" }],
}),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
expect(
events.find(
(e) =>
e.type === EventType.STATE_SNAPSHOT ||
e.type === EventType.STATE_DELTA,
),
).toBeUndefined();
expect(
events.find((e) => e.type === EventType.TOOL_CALL_RESULT),
).toBeDefined();
});
});
describe("TanStack AI converter — reasoning", () => {
it("emits the full REASONING lifecycle for reasoning chunks", async () => {
const agent = createAgent("tanstack", [
tanstackReasoningStart("r1"),
tanstackReasoningMessageStart("r1"),
tanstackReasoningMessageContent("r1", "thinking"),
tanstackReasoningMessageEnd("r1"),
tanstackReasoningEnd("r1"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
// Strip the lifecycle wrap and inspect the inner sequence.
const inner = events.slice(1, -1).map((e) => e.type);
expect(inner).toEqual([
EventType.REASONING_START,
EventType.REASONING_MESSAGE_START,
EventType.REASONING_MESSAGE_CONTENT,
EventType.REASONING_MESSAGE_END,
EventType.REASONING_END,
]);
});
it("auto-closes an open reasoning lifecycle when text starts", async () => {
const agent = createAgent("tanstack", [
tanstackReasoningStart("r1"),
tanstackReasoningMessageStart("r1"),
tanstackReasoningMessageContent("r1", "thinking"),
// No REASONING_MESSAGE_END / REASONING_END before text
tanstackTextChunk("Hi"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const types = events.map((e) => e.type);
const reasonEndIdx = types.indexOf(EventType.REASONING_END);
const reasonMsgEndIdx = types.indexOf(EventType.REASONING_MESSAGE_END);
const textIdx = types.indexOf(EventType.TEXT_MESSAGE_CHUNK);
expect(reasonMsgEndIdx).toBeGreaterThan(-1);
expect(reasonEndIdx).toBeGreaterThan(-1);
expect(reasonEndIdx).toBeLessThan(textIdx);
});
it("auto-closes an open reasoning lifecycle when a tool call starts", async () => {
const agent = createAgent("tanstack", [
tanstackReasoningStart("r1"),
tanstackReasoningMessageStart("r1"),
tanstackReasoningMessageContent("r1", "..."),
tanstackToolCallStart("t1", "x"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const types = events.map((e) => e.type);
expect(types).toContain(EventType.REASONING_MESSAGE_END);
expect(types).toContain(EventType.REASONING_END);
expect(types.indexOf(EventType.REASONING_END)).toBeLessThan(
types.indexOf(EventType.TOOL_CALL_START),
);
});
it("auto-closes when the stream ends without explicit reasoning end", async () => {
const agent = createAgent("tanstack", [
tanstackReasoningStart("r1"),
tanstackReasoningMessageStart("r1"),
tanstackReasoningMessageContent("r1", "x"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const types = events.map((e) => e.type);
expect(types).toContain(EventType.REASONING_MESSAGE_END);
expect(types).toContain(EventType.REASONING_END);
});
it("emits REASONING_MESSAGE_END before REASONING_END when upstream sends END with message still open", async () => {
// Regression: if the converter received REASONING_END while a message
// was still open, it cleared run-open and emitted END only — leaving
// message-open true. The next non-reasoning chunk then triggered
// closeReasoningIfOpen, which emitted MSG_END AFTER END (wrong order).
// Fix: REASONING_END handler closes any open message first.
const agent = createAgent("tanstack", [
tanstackReasoningStart("r1"),
tanstackReasoningMessageStart("r1"),
tanstackReasoningMessageContent("r1", "thinking"),
// Upstream skips MSG_END and goes straight to END
tanstackReasoningEnd("r1"),
tanstackTextChunk("Hi"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const types = events.map((e) => e.type);
const msgEndIdx = types.indexOf(EventType.REASONING_MESSAGE_END);
const endIdx = types.indexOf(EventType.REASONING_END);
expect(msgEndIdx).toBeGreaterThan(-1);
expect(endIdx).toBeGreaterThan(-1);
expect(msgEndIdx).toBeLessThan(endIdx);
// No duplicate MSG_END or END from auto-close on the text chunk
expect(
types.filter((t) => t === EventType.REASONING_MESSAGE_END),
).toHaveLength(1);
expect(types.filter((t) => t === EventType.REASONING_END)).toHaveLength(1);
});
it("auto-closes prior reasoning run when a new REASONING_START arrives without END", async () => {
// Regression: REASONING_START used to overwrite reasoningMessageId
// unconditionally, orphaning the prior run's MSG_END / END.
// Fix: REASONING_START handler calls closeReasoningIfOpen() first.
const agent = createAgent("tanstack", [
tanstackReasoningStart("r1"),
tanstackReasoningMessageStart("r1"),
tanstackReasoningMessageContent("r1", "first"),
// Second START with no intervening END
tanstackReasoningStart("r2"),
tanstackReasoningMessageStart("r2"),
tanstackReasoningMessageContent("r2", "second"),
tanstackReasoningMessageEnd("r2"),
tanstackReasoningEnd("r2"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const types = events.map((e) => e.type);
// Two complete START → MSG_START → ... → MSG_END → END sequences
expect(types.filter((t) => t === EventType.REASONING_START)).toHaveLength(
2,
);
expect(types.filter((t) => t === EventType.REASONING_END)).toHaveLength(2);
expect(
types.filter((t) => t === EventType.REASONING_MESSAGE_END),
).toHaveLength(2);
// First START's prior message gets closed BEFORE the second START
const firstEndIdx = types.indexOf(EventType.REASONING_END);
const secondStartIdx = types.indexOf(
EventType.REASONING_START,
firstEndIdx + 1,
);
expect(firstEndIdx).toBeLessThan(secondStartIdx);
});
it("does NOT duplicate REASONING_MESSAGE_END when upstream emits it explicitly before text", async () => {
// Regression: a single isInReasoning flag conflated message-open with
// run-open, so closeReasoningIfOpen on TEXT_MESSAGE_CONTENT emitted a
// second REASONING_MESSAGE_END after upstream's own. Track message-open
// and run-open separately so closeReasoningIfOpen owes only what's still
// open.
const agent = createAgent("tanstack", [
tanstackReasoningStart("r1"),
tanstackReasoningMessageStart("r1"),
tanstackReasoningMessageContent("r1", "thinking"),
tanstackReasoningMessageEnd("r1"),
// No explicit REASONING_END before text — closeReasoningIfOpen should
// emit REASONING_END but NOT a second REASONING_MESSAGE_END.
tanstackTextChunk("Hi"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const types = events.map((e) => e.type);
const msgEndCount = types.filter(
(t) => t === EventType.REASONING_MESSAGE_END,
).length;
const endCount = types.filter((t) => t === EventType.REASONING_END).length;
expect(msgEndCount).toBe(1);
expect(endCount).toBe(1);
expect(types.indexOf(EventType.REASONING_END)).toBeLessThan(
types.indexOf(EventType.TEXT_MESSAGE_CHUNK),
);
});
});
@@ -0,0 +1,160 @@
/**
* Integration tests for factory-mode native interrupts driven by the REAL
* AI SDK `streamText` (via a mock LanguageModelV3 — no network). These verify
* the two things synthetic-stream tests can't:
* 1. a tool declared `needsApproval: true` actually makes `streamText` emit a
* `tool-approval-request` part → our converter turns it into outcome:interrupt;
* 2. on resume, the tool-result the runtime injects from the resume payload is
* accepted by `streamText` so the run continues (no re-approval loop).
*/
import { describe, it, expect } from "vitest";
import { EventType } from "@ag-ui/client";
import type { RunAgentInput } from "@ag-ui/client";
import { streamText, tool } from "ai";
import type { FlexibleSchema } from "ai";
import { MockLanguageModelV3, simulateReadableStream } from "ai/test";
import { z } from "zod";
import { BuiltInAgent, convertMessagesToVercelAISDKMessages } from "../index";
import { collectEvents, createDefaultInput } from "./agent-test-helpers";
const USAGE = {
inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 0, text: 0, reasoning: 0 },
} as const;
type BookFlightInput = { destination: string };
const bookFlightInputSchema = z.object({
destination: z.string(),
}) as unknown as FlexibleSchema<BookFlightInput>;
function finishReason(unified: "stop" | "tool-calls") {
return { unified, raw: unified };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function modelEmitting(chunks: any[]): MockLanguageModelV3 {
return new MockLanguageModelV3({
doStream: async () => ({
stream: simulateReadableStream({ chunks }),
}),
});
}
function aisdkApprovalAgent(model: MockLanguageModelV3): BuiltInAgent {
return new BuiltInAgent({
type: "aisdk",
factory: ({ input, abortSignal }) =>
streamText({
model,
messages: convertMessagesToVercelAISDKMessages(input.messages),
tools: {
bookFlight: tool<BookFlightInput, never>({
description: "Book a flight. Requires approval.",
inputSchema: bookFlightInputSchema,
needsApproval: true,
}),
},
abortSignal,
}),
});
}
describe("factory aisdk native interrupt (real streamText)", () => {
it("a needsApproval tool call → RUN_FINISHED outcome:interrupt", async () => {
const model = modelEmitting([
{
type: "tool-call",
toolCallId: "tc-1",
toolName: "bookFlight",
input: JSON.stringify({ destination: "Tokyo" }),
},
{
type: "finish",
finishReason: finishReason("tool-calls"),
usage: USAGE,
},
]);
const agent = aisdkApprovalAgent(model);
const events = await collectEvents(
agent.run(
createDefaultInput({
messages: [
{ id: "u1", role: "user", content: "Book a flight to Tokyo" },
] as RunAgentInput["messages"],
}),
),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any;
expect(finished.outcome?.type).toBe("interrupt");
expect(finished.outcome.interrupts[0]).toMatchObject({
id: "tc-1",
toolCallId: "tc-1",
});
});
it("on resume, the injected tool-result is accepted and the run completes", async () => {
// Resume run: model now just replies (the tool call is already answered by
// the injected result, so streamText must NOT re-request approval).
const model = modelEmitting([
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: "Booked your flight to Tokyo!" },
{ type: "text-end", id: "t1" },
{ type: "finish", finishReason: finishReason("stop"), usage: USAGE },
]);
const agent = aisdkApprovalAgent(model);
const events = await collectEvents(
agent.run(
createDefaultInput({
resume: [
{
interruptId: "tc-1",
status: "resolved",
payload: { approved: true },
},
],
messages: [
{ id: "u1", role: "user", content: "Book a flight to Tokyo" },
{
id: "a1",
role: "assistant",
content: "",
toolCalls: [
{
id: "tc-1",
type: "function",
function: {
name: "bookFlight",
arguments: JSON.stringify({ destination: "Tokyo" }),
},
},
],
},
] as RunAgentInput["messages"],
}),
),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any;
// Normal completion — no re-interrupt.
expect(finished.outcome).toBeUndefined();
const text = events
.filter((e) => e.type === EventType.TEXT_MESSAGE_CHUNK)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map((e) => (e as any).delta)
.join("");
expect(text).toContain("Booked your flight");
});
});
@@ -0,0 +1,252 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { BasicAgent } from "../index";
import type { MCPClientProvider } from "../index";
import { EventType } from "@ag-ui/client";
import type { RunAgentInput } from "@ag-ui/client";
import { streamText } from "ai";
import type { ToolSet } from "ai";
import {
mockStreamTextResponse,
textDelta,
finish,
collectEvents,
} from "./test-helpers";
// Mock the ai module
vi.mock("ai", () => ({
streamText: vi.fn(),
tool: vi.fn((config) => config),
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
}));
// Mock the SDK clients
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
modelId,
provider: "openai",
})),
}));
// Mock MCP imports so mcpServers code path doesn't fail when tested alongside mcpClients
vi.mock("@ai-sdk/mcp", () => ({
createMCPClient: vi.fn(),
}));
describe("mcpClients — user-managed MCP clients", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
});
afterEach(() => {
process.env = originalEnv;
});
const baseInput: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
function makeMockProvider(
tools: Record<string, any>,
): MCPClientProvider & { close: ReturnType<typeof vi.fn> } {
return {
tools: vi.fn().mockResolvedValue(tools),
close: vi.fn(),
};
}
it("tools from mcpClients are passed to streamText", async () => {
const mockTools = {
listEnvelopes: { description: "List envelopes", execute: vi.fn() },
};
const provider = makeMockProvider(mockTools);
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpClients: [provider],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("Hello"), finish()]) as any,
);
await collectEvents(agent["run"](baseInput));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("listEnvelopes");
expect(callArgs.tools.listEnvelopes.description).toBe("List envelopes");
expect(provider.tools).toHaveBeenCalledOnce();
});
it("mcpClients are NOT closed after run completes", async () => {
const provider = makeMockProvider({
myTool: { description: "A tool", execute: vi.fn() },
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpClients: [provider],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
await collectEvents(agent["run"](baseInput));
expect(provider.close).not.toHaveBeenCalled();
});
it("mcpServers tools override mcpClients tools on name collision", async () => {
const clientExecute = vi.fn();
const serverExecute = vi.fn();
const provider = makeMockProvider({
sharedTool: { description: "from client", execute: clientExecute },
});
// Mock mcpServers flow: createMCPClient returns a client with tools()
const { createMCPClient } = await import("@ai-sdk/mcp");
vi.mocked(createMCPClient).mockResolvedValue({
tools: vi.fn().mockResolvedValue({
sharedTool: { description: "from server", execute: serverExecute },
}),
close: vi.fn(),
} as any);
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpClients: [provider],
mcpServers: [{ type: "http", url: "http://localhost:9999" }],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
await collectEvents(agent["run"](baseInput));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
// mcpServers runs after mcpClients, so "from server" should win
expect(callArgs.tools.sharedTool.description).toBe("from server");
});
it("multiple mcpClients merge in order (later overrides earlier)", async () => {
const provider1 = makeMockProvider({
toolA: { description: "from provider 1", execute: vi.fn() },
shared: { description: "from provider 1", execute: vi.fn() },
});
const provider2 = makeMockProvider({
toolB: { description: "from provider 2", execute: vi.fn() },
shared: { description: "from provider 2", execute: vi.fn() },
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpClients: [provider1, provider2],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
await collectEvents(agent["run"](baseInput));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("toolA");
expect(callArgs.tools).toHaveProperty("toolB");
expect(callArgs.tools.shared.description).toBe("from provider 2");
});
it("empty mcpClients array is a no-op", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpClients: [],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("Hi"), finish()]) as any,
);
const events = await collectEvents(agent["run"](baseInput));
// Should still work normally
const textEvents = events.filter(
(e: any) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents.length).toBeGreaterThan(0);
});
it("mcpClients .tools() rejection emits RUN_ERROR", async () => {
const failingProvider: MCPClientProvider = {
tools: vi.fn().mockRejectedValue(new Error("MCP connection lost")),
};
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpClients: [failingProvider],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
// Collect events manually so we can capture RUN_ERROR before the rejection
const events: any[] = [];
try {
await new Promise((resolve, reject) => {
agent["run"](baseInput).subscribe({
next: (event: any) => events.push(event),
error: (err: any) => reject(err),
complete: () => resolve(events),
});
});
} catch {
// Expected — Observable errors after emitting RUN_ERROR
}
// streamText should NOT have been called (error before reaching it)
expect(streamText).not.toHaveBeenCalled();
// A RUN_ERROR event should have been emitted
expect(events.some((e) => e.type === EventType.RUN_ERROR)).toBe(true);
});
it("clone() shares the same mcpClients references", () => {
const provider = makeMockProvider({});
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpClients: [provider],
});
const cloned = agent.clone();
// Access the config to verify same reference
// Both agents share the same config object (by reference)
expect((cloned as any).config.mcpClients[0]).toBe(provider);
});
it("type compatibility: @ai-sdk/mcp MCPClient satisfies MCPClientProvider", async () => {
// Compile-time check that `MCPClientProvider` is structurally compatible
// with `@ai-sdk/mcp`'s `MCPClient`. After the refactor `MCPClientProvider`
// is an alias for `Pick<MCPClient, "tools">`, so this is trivially true —
// but keeping the test guards against future divergence.
type MCPClient = Awaited<
ReturnType<typeof import("@ai-sdk/mcp").createMCPClient>
>;
const client = {} as MCPClient;
const _assignable: MCPClientProvider = {
tools: () => client.tools() as unknown as Promise<ToolSet>,
};
void _assignable;
expect(true).toBe(true);
});
});
@@ -0,0 +1,381 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { BasicAgent } from "../index";
import { EventType } from "@ag-ui/client";
import { streamText } from "ai";
import { LLMock, MCPMock } from "@copilotkit/aimock";
import {
mockStreamTextResponse,
textDelta,
finish,
collectEvents,
toolCall,
toolResult,
} from "./test-helpers";
// Mock the ai module — we don't want real LLM calls
vi.mock("ai", () => ({
streamText: vi.fn(),
tool: vi.fn((config) => config),
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
}));
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
modelId,
provider: "openai",
})),
}));
// Do NOT mock @ai-sdk/mcp or @modelcontextprotocol/sdk transports —
// we want real HTTP connections to the MCPMock server.
describe("mcpServers — real MCP server integration", () => {
const originalEnv = process.env;
let llm: LLMock;
let mcpMock: MCPMock;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
});
afterEach(async () => {
process.env = originalEnv;
if (llm) {
await llm.stop().catch(() => {});
}
});
const baseInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
/**
* Start an LLMock with an MCPMock mounted at /mcp.
* Returns the full MCP endpoint URL.
*/
async function startMcpServer(
tools: Array<{ name: string; description?: string }>,
): Promise<{ mcpUrl: string; llm: LLMock; mcpMock: MCPMock }> {
const mock = new MCPMock();
for (const t of tools) {
mock.addTool({
name: t.name,
description: t.description ?? `${t.name} tool`,
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
},
});
mock.onToolCall(t.name, () => `result from ${t.name}`);
}
const server = new LLMock({ port: 0 });
server.mount("/mcp", mock);
await server.start();
return {
mcpUrl: `${server.url}/mcp`,
llm: server,
mcpMock: mock,
};
}
it("HTTP transport fetches tools from MCPMock", async () => {
const result = await startMcpServer([
{ name: "get_weather", description: "Get the weather" },
]);
llm = result.llm;
mcpMock = result.mcpMock;
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [{ type: "http", url: result.mcpUrl }],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("Hello"), finish()]) as any,
);
await collectEvents(agent["run"](baseInput));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("get_weather");
});
it("an unreachable MCP server (SSE vs HTTP-only mock) is skipped, not fatal", async () => {
// MCPMock only supports Streamable HTTP, not SSE — so the SSE client
// fails to connect. The run must degrade gracefully: no RUN_ERROR, and
// streamText still runs (just without that server's tools).
const result = await startMcpServer([
{ name: "get_weather", description: "Get the weather" },
]);
llm = result.llm;
mcpMock = result.mcpMock;
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [{ type: "sse", url: result.mcpUrl }],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("ok"), finish()]) as any,
);
const events = await collectEvents(agent["run"](baseInput));
expect(events.some((e: any) => e.type === EventType.RUN_ERROR)).toBe(false);
expect(vi.mocked(streamText).mock.calls.length).toBeGreaterThan(0);
// The failed server contributed no tools.
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools ?? {}).not.toHaveProperty("get_weather");
});
it("a down MCP server is skipped while a healthy one still loads its tools", async () => {
const result = await startMcpServer([
{ name: "get_weather", description: "Get the weather" },
]);
llm = result.llm;
mcpMock = result.mcpMock;
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [
{ type: "http", url: "http://127.0.0.1:1/mcp" }, // unreachable
{ type: "http", url: result.mcpUrl }, // healthy
],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("ok"), finish()]) as any,
);
const events = await collectEvents(agent["run"](baseInput));
// The dead server is skipped; the run completes and the healthy server's
// tools are present.
expect(events.some((e: any) => e.type === EventType.RUN_ERROR)).toBe(false);
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("get_weather");
});
it("tool call round-trip emits TOOL_CALL_START, TOOL_CALL_RESULT, and TEXT_MESSAGE_CHUNK", async () => {
const result = await startMcpServer([
{ name: "get_weather", description: "Get the weather" },
]);
llm = result.llm;
mcpMock = result.mcpMock;
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [{ type: "http", url: result.mcpUrl }],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCall("tc1", "get_weather", { query: "NYC" }),
toolResult("tc1", "get_weather", "Sunny 72F"),
textDelta("The weather is sunny."),
finish(),
]) as any,
);
const events = await collectEvents(agent["run"](baseInput));
const types = events.map((e: any) => e.type);
expect(types).toContain(EventType.TOOL_CALL_START);
expect(types).toContain(EventType.TOOL_CALL_RESULT);
expect(types).toContain(EventType.TEXT_MESSAGE_CHUNK);
// Verify the tool call result content
const resultEvent = events.find(
(e: any) => e.type === EventType.TOOL_CALL_RESULT,
) as any;
expect(resultEvent.toolCallId).toBe("tc1");
expect(resultEvent.content).toContain("Sunny 72F");
});
it("MCP clients are cleaned up after completion — second run creates fresh connections", async () => {
const result = await startMcpServer([
{ name: "get_weather", description: "Get the weather" },
]);
llm = result.llm;
mcpMock = result.mcpMock;
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [{ type: "http", url: result.mcpUrl }],
});
// First run
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("Run 1"), finish()]) as any,
);
const events1 = await collectEvents(agent["run"](baseInput));
expect(events1.some((e: any) => e.type === EventType.RUN_FINISHED)).toBe(
true,
);
// Second run — should succeed with fresh MCP client connections
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("Run 2"), finish()]) as any,
);
const events2 = await collectEvents(agent["run"](baseInput));
expect(events2.some((e: any) => e.type === EventType.RUN_FINISHED)).toBe(
true,
);
// streamText was called twice (once per run), each time with MCP tools
expect(vi.mocked(streamText).mock.calls).toHaveLength(2);
expect(vi.mocked(streamText).mock.calls[0][0].tools).toHaveProperty(
"get_weather",
);
expect(vi.mocked(streamText).mock.calls[1][0].tools).toHaveProperty(
"get_weather",
);
});
it("the only MCP server being unreachable does NOT fail the run", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [{ type: "http", url: "http://localhost:59999" }],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("ok"), finish()]) as any,
);
const events = await collectEvents(agent["run"](baseInput));
// Graceful degradation: the unreachable server is skipped, no RUN_ERROR,
// and the run still proceeds (just with no MCP tools).
expect(events.some((e: any) => e.type === EventType.RUN_ERROR)).toBe(false);
expect(vi.mocked(streamText).mock.calls.length).toBeGreaterThan(0);
});
it("MCP clients are cleaned up after streamText error — subsequent run still works", async () => {
const result = await startMcpServer([
{ name: "get_weather", description: "Get the weather" },
]);
llm = result.llm;
mcpMock = result.mcpMock;
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [{ type: "http", url: result.mcpUrl }],
});
// First run — streamText throws an error
vi.mocked(streamText).mockImplementation(() => {
throw new Error("LLM connection failed");
});
const events1: any[] = [];
try {
await new Promise((resolve, reject) => {
agent["run"](baseInput).subscribe({
next: (event: any) => events1.push(event),
error: (err: any) => reject(err),
complete: () => resolve(events1),
});
});
} catch {
// Expected — streamText threw
}
// Should have emitted RUN_ERROR
expect(events1.some((e) => e.type === EventType.RUN_ERROR)).toBe(true);
// Second run — streamText works normally, proving MCP cleanup happened
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("Recovery"), finish()]) as any,
);
const events2 = await collectEvents(agent["run"](baseInput));
expect(events2.some((e: any) => e.type === EventType.RUN_FINISHED)).toBe(
true,
);
// The second run should have MCP tools available
const secondCallArgs = vi.mocked(streamText).mock.calls[1][0];
expect(secondCallArgs.tools).toHaveProperty("get_weather");
});
it("MCP tool descriptions are passed to streamText tools config", async () => {
const result = await startMcpServer([
{ name: "get_weather", description: "Get the weather" },
]);
llm = result.llm;
mcpMock = result.mcpMock;
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [{ type: "http", url: result.mcpUrl }],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("Hello"), finish()]) as any,
);
await collectEvents(agent["run"](baseInput));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("get_weather");
// The MCP tool should include the description from the MCPMock server
expect(callArgs.tools.get_weather.description).toBe("Get the weather");
});
it("multiple MCP servers merge tools from both", async () => {
// First server with get_weather
const result1 = await startMcpServer([
{ name: "get_weather", description: "Get the weather" },
]);
llm = result1.llm;
// Second server with search_docs
const mock2 = new MCPMock();
mock2.addTool({
name: "search_docs",
description: "Search documentation",
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
},
});
mock2.onToolCall("search_docs", () => "doc results");
const llm2 = new LLMock({ port: 0 });
llm2.mount("/mcp", mock2);
await llm2.start();
try {
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [
{ type: "http", url: result1.mcpUrl },
{ type: "http", url: `${llm2.url}/mcp` },
],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
textDelta("Both tools available"),
finish(),
]) as any,
);
await collectEvents(agent["run"](baseInput));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("get_weather");
expect(callArgs.tools).toHaveProperty("search_docs");
} finally {
await llm2.stop().catch(() => {});
}
});
});
@@ -0,0 +1,284 @@
import { describe, it, expect } from "vitest";
import { convertInputToTanStackAI } from "../converters/tanstack";
import { createDefaultInput } from "./agent-test-helpers";
import type { Message, InputContent } from "@ag-ui/client";
/**
* Helper: build a user message with the given content parts, run through
* convertInputToTanStackAI, and return the first converted message for assertion.
*/
function convertUserContent(content: string | InputContent[]) {
const input = createDefaultInput({
messages: [{ role: "user", content } as Message],
});
const { messages } = convertInputToTanStackAI(input);
return messages[0];
}
/** Shorthand factories for AG-UI input parts */
function dataSource(value: string, mimeType: string) {
return { type: "data" as const, value, mimeType };
}
function urlSource(value: string, mimeType?: string) {
return { type: "url" as const, value, ...(mimeType ? { mimeType } : {}) };
}
describe("convertInputToTanStackAI — multimodal", () => {
it("passes through plain string user content", () => {
const result = convertUserContent("Hello");
expect(result.content).toBe("Hello");
});
it("converts text-only InputContent[] to TanStack TextPart array", () => {
const result = convertUserContent([{ type: "text", text: "Hello world" }]);
expect(result.content).toEqual([{ type: "text", content: "Hello world" }]);
});
it("converts ImageInputPart with data source", () => {
const result = convertUserContent([
{ type: "text", text: "What is this?" },
{ type: "image", source: dataSource("iVBORw0KGgo=", "image/png") },
]);
expect(result.content).toEqual([
{ type: "text", content: "What is this?" },
{
type: "image",
source: { type: "data", value: "iVBORw0KGgo=", mimeType: "image/png" },
},
]);
});
it("converts ImageInputPart with url source", () => {
const result = convertUserContent([
{
type: "image",
source: urlSource("https://example.com/photo.jpg", "image/jpeg"),
},
]);
expect(result.content).toEqual([
{
type: "image",
source: {
type: "url",
value: "https://example.com/photo.jpg",
mimeType: "image/jpeg",
},
},
]);
});
it("converts AudioInputPart", () => {
const result = convertUserContent([
{ type: "audio", source: dataSource("base64audiodata", "audio/mp3") },
]);
expect(result.content).toEqual([
{
type: "audio",
source: {
type: "data",
value: "base64audiodata",
mimeType: "audio/mp3",
},
},
]);
});
it("converts VideoInputPart with url source", () => {
const result = convertUserContent([
{
type: "video",
source: urlSource("https://example.com/video.mp4", "video/mp4"),
},
]);
expect(result.content).toEqual([
{
type: "video",
source: {
type: "url",
value: "https://example.com/video.mp4",
mimeType: "video/mp4",
},
},
]);
});
it("converts DocumentInputPart", () => {
const result = convertUserContent([
{
type: "document",
source: dataSource("base64pdfdata", "application/pdf"),
},
]);
expect(result.content).toEqual([
{
type: "document",
source: {
type: "data",
value: "base64pdfdata",
mimeType: "application/pdf",
},
},
]);
});
it("handles mixed text and multimodal parts", () => {
const result = convertUserContent([
{ type: "text", text: "Analyze these:" },
{ type: "image", source: dataSource("imgdata", "image/png") },
{
type: "document",
source: dataSource("docdata", "application/pdf"),
},
]);
expect(result.content).toEqual([
{ type: "text", content: "Analyze these:" },
{
type: "image",
source: { type: "data", value: "imgdata", mimeType: "image/png" },
},
{
type: "document",
source: {
type: "data",
value: "docdata",
mimeType: "application/pdf",
},
},
]);
});
it("returns empty string for empty content array", () => {
const result = convertUserContent([]);
expect(result.content).toBe("");
});
it("preserves empty string text parts", () => {
const result = convertUserContent([{ type: "text", text: "" }]);
expect(result.content).toEqual([{ type: "text", content: "" }]);
});
it("skips parts with missing source without crashing", () => {
const result = convertUserContent([
{ type: "text", text: "check this" },
{ type: "image" } as any,
]);
expect(result.content).toEqual([{ type: "text", content: "check this" }]);
});
it("silently skips unknown part types", () => {
const result = convertUserContent([
{ type: "text", text: "hello" },
{ type: "spreadsheet", source: dataSource("data", "text/csv") } as any,
]);
expect(result.content).toEqual([{ type: "text", content: "hello" }]);
});
it("returns null for null or undefined content", () => {
const nullInput = createDefaultInput({
messages: [{ role: "user", content: null } as unknown as Message],
});
const { messages: nullMessages } = convertInputToTanStackAI(nullInput);
expect(nullMessages[0].content).toBeNull();
const undefinedInput = createDefaultInput({
messages: [{ role: "user", content: undefined } as unknown as Message],
});
const { messages: undefinedMessages } =
convertInputToTanStackAI(undefinedInput);
expect(undefinedMessages[0].content).toBeNull();
});
describe("legacy BinaryInputContent backward compat", () => {
it("converts binary with image mimeType and data", () => {
const legacyPart = {
type: "binary",
mimeType: "image/jpeg",
data: "legacybase64",
};
const input = createDefaultInput({
messages: [
{
role: "user",
content: [legacyPart] as unknown as InputContent[],
} as Message,
],
});
const { messages } = convertInputToTanStackAI(input);
expect(messages[0].content).toEqual([
{
type: "image",
source: {
type: "data",
value: "legacybase64",
mimeType: "image/jpeg",
},
},
]);
});
it("skips binary with neither data nor url", () => {
const legacyPart = {
type: "binary",
mimeType: "image/png",
};
const input = createDefaultInput({
messages: [
{
role: "user",
content: [legacyPart] as unknown as InputContent[],
} as Message,
],
});
const { messages } = convertInputToTanStackAI(input);
expect(messages[0].content).toBe("");
});
it("converts binary with non-image mimeType and url", () => {
const legacyPart = {
type: "binary",
mimeType: "application/pdf",
url: "https://example.com/doc.pdf",
};
const input = createDefaultInput({
messages: [
{
role: "user",
content: [legacyPart] as unknown as InputContent[],
} as Message,
],
});
const { messages } = convertInputToTanStackAI(input);
expect(messages[0].content).toEqual([
{
type: "document",
source: {
type: "url",
value: "https://example.com/doc.pdf",
mimeType: "application/pdf",
},
},
]);
});
});
it("only converts user message content, not assistant messages", () => {
const input = createDefaultInput({
messages: [
{
role: "user",
content: [
{ type: "text", text: "Look at this" },
{ type: "image", source: dataSource("imgdata", "image/png") },
],
} as Message,
{ role: "assistant", content: "I see an image" } as Message,
],
});
const { messages } = convertInputToTanStackAI(input);
// User message should have array content
expect(Array.isArray(messages[0].content)).toBe(true);
// Assistant message should keep string content
expect(messages[1].content).toBe("I see an image");
});
});
@@ -0,0 +1,176 @@
import { describe, it, expect } from "vitest";
import { convertMessagesToVercelAISDKMessages } from "../index";
import type { Message, InputContent } from "@ag-ui/client";
import type { UserModelMessage } from "ai";
/**
* Helper: build a user message with the given content parts and convert it.
* Returns the converted UserModelMessage for assertion.
*/
function convertUserContent(content: string | InputContent[]) {
const messages: Message[] = [{ id: "1", role: "user", content }];
const result = convertMessagesToVercelAISDKMessages(messages);
return result[0] as UserModelMessage;
}
/** Shorthand factories for AG-UI input parts */
function dataSource(value: string, mimeType: string) {
return { type: "data" as const, value, mimeType };
}
function urlSource(value: string, mimeType?: string) {
return { type: "url" as const, value, ...(mimeType ? { mimeType } : {}) };
}
describe("convertMessagesToVercelAISDKMessages — multimodal", () => {
it("passes through plain string user content", () => {
const result = convertUserContent("Hello");
expect(result).toEqual({ role: "user", content: "Hello" });
});
it("converts text-only InputContent[] to parts array", () => {
const result = convertUserContent([{ type: "text", text: "Hello world" }]);
expect(result.role).toBe("user");
expect(result.content).toEqual([{ type: "text", text: "Hello world" }]);
});
it("converts ImageInputPart with data source to ImagePart", () => {
const result = convertUserContent([
{ type: "text", text: "What is this?" },
{ type: "image", source: dataSource("iVBORw0KGgo=", "image/png") },
]);
expect(result.content).toEqual([
{ type: "text", text: "What is this?" },
{ type: "image", image: "iVBORw0KGgo=", mediaType: "image/png" },
]);
});
it("converts ImageInputPart with url source to ImagePart with URL", () => {
const result = convertUserContent([
{
type: "image",
source: urlSource("https://example.com/photo.jpg", "image/jpeg"),
},
]);
expect(result.content).toEqual([
{
type: "image",
image: new URL("https://example.com/photo.jpg"),
mediaType: "image/jpeg",
},
]);
});
it("converts AudioInputPart to FilePart", () => {
const result = convertUserContent([
{ type: "audio", source: dataSource("base64audiodata", "audio/mp3") },
]);
expect(result.content).toEqual([
{ type: "file", data: "base64audiodata", mediaType: "audio/mp3" },
]);
});
it("converts VideoInputPart with url source to FilePart", () => {
const result = convertUserContent([
{
type: "video",
source: urlSource("https://example.com/video.mp4", "video/mp4"),
},
]);
expect(result.content).toEqual([
{
type: "file",
data: new URL("https://example.com/video.mp4"),
mediaType: "video/mp4",
},
]);
});
it("converts DocumentInputPart to FilePart", () => {
const result = convertUserContent([
{
type: "document",
source: dataSource("base64pdfdata", "application/pdf"),
},
]);
expect(result.content).toEqual([
{ type: "file", data: "base64pdfdata", mediaType: "application/pdf" },
]);
});
it("handles mixed text and multimodal parts", () => {
const result = convertUserContent([
{ type: "text", text: "Analyze these:" },
{ type: "image", source: dataSource("imgdata", "image/png") },
{ type: "document", source: dataSource("docdata", "application/pdf") },
]);
expect(result.content).toEqual([
{ type: "text", text: "Analyze these:" },
{ type: "image", image: "imgdata", mediaType: "image/png" },
{ type: "file", data: "docdata", mediaType: "application/pdf" },
]);
});
it("returns empty string for empty content array", () => {
const result = convertUserContent([]);
expect(result.content).toBe("");
});
it("skips image parts with malformed URLs without crashing", () => {
const result = convertUserContent([
{ type: "text", text: "check this" },
{ type: "image", source: { type: "url", value: "not-a-url" } },
]);
// Malformed URL part is skipped, text part preserved
expect(result.content).toEqual([{ type: "text", text: "check this" }]);
});
// Legacy backward compat — BinaryInputContent is not in the current schema
// but older clients may still send it. We intentionally construct untyped
// objects here to simulate that scenario.
describe("legacy BinaryInputContent backward compat", () => {
it("converts binary with image mimeType and data to ImagePart", () => {
const legacyPart = {
type: "binary",
mimeType: "image/jpeg",
data: "legacybase64",
};
const messages: Message[] = [
{
id: "1",
role: "user",
content: [legacyPart] as unknown as InputContent[],
},
];
const result = convertMessagesToVercelAISDKMessages(messages);
const userMsg = result[0] as UserModelMessage;
expect(userMsg.content).toEqual([
{ type: "image", image: "legacybase64", mediaType: "image/jpeg" },
]);
});
it("converts binary with non-image mimeType and url to FilePart", () => {
const legacyPart = {
type: "binary",
mimeType: "application/pdf",
url: "https://example.com/doc.pdf",
};
const messages: Message[] = [
{
id: "1",
role: "user",
content: [legacyPart] as unknown as InputContent[],
},
];
const result = convertMessagesToVercelAISDKMessages(messages);
const userMsg = result[0] as UserModelMessage;
expect(userMsg.content).toEqual([
{
type: "file",
data: new URL("https://example.com/doc.pdf"),
mediaType: "application/pdf",
},
]);
});
});
});
@@ -0,0 +1,599 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { BasicAgent } from "../index";
import type { RunAgentInput } from "@ag-ui/client";
import { streamText } from "ai";
import { mockStreamTextResponse, finish, collectEvents } from "./test-helpers";
// Mock the ai module
vi.mock("ai", () => ({
streamText: vi.fn(),
tool: vi.fn((config) => config),
}));
// Mock the SDK clients
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
modelId,
provider: "openai",
})),
}));
vi.mock("@ai-sdk/anthropic", () => ({
createAnthropic: vi.fn(() => (modelId: string) => ({
modelId,
provider: "anthropic",
})),
}));
describe("Property Overrides - Edge Cases", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
process.env.ANTHROPIC_API_KEY = "test-key";
});
afterEach(() => {
process.env = originalEnv;
});
describe("Model Override", () => {
it("should override model when allowed", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
overridableProperties: ["model"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { model: "anthropic/claude-sonnet-4.5" },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
const model = callArgs.model as { provider: string; modelId: string };
expect(model.provider).toBe("anthropic");
expect(model.modelId).toBe("claude-sonnet-4.5");
});
it("should accept LanguageModel instance for override", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
overridableProperties: ["model"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const customModel = {
modelId: "custom-model",
provider: "custom",
};
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { model: customModel },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.model).toBe(customModel);
});
it("should ignore invalid model override types", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
overridableProperties: ["model"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { model: 123 }, // Invalid type
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect((callArgs.model as { modelId: string }).modelId).toBe("gpt-4o"); // Original value
});
});
describe("ToolChoice Override", () => {
it("should override with 'auto'", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
toolChoice: "required",
overridableProperties: ["toolChoice"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { toolChoice: "auto" },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.toolChoice).toBe("auto");
});
it("should override with 'required'", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
toolChoice: "auto",
overridableProperties: ["toolChoice"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { toolChoice: "required" },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.toolChoice).toBe("required");
});
it("should override with 'none'", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
toolChoice: "auto",
overridableProperties: ["toolChoice"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { toolChoice: "none" },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.toolChoice).toBe("none");
});
it("should override with specific tool selection", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
toolChoice: "auto",
overridableProperties: ["toolChoice"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: {
toolChoice: { type: "tool", toolName: "specificTool" },
},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.toolChoice).toEqual({
type: "tool",
toolName: "specificTool",
});
});
it("should ignore invalid toolChoice values", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
toolChoice: "auto",
overridableProperties: ["toolChoice"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { toolChoice: "invalid" },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.toolChoice).toBe("auto"); // Original value
});
});
describe("StopSequences Override", () => {
it("should override stopSequences with valid array", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
stopSequences: ["STOP"],
overridableProperties: ["stopSequences"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { stopSequences: ["END", "FINISH"] },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.stopSequences).toEqual(["END", "FINISH"]);
});
it("should ignore stopSequences with non-string elements", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
stopSequences: ["STOP"],
overridableProperties: ["stopSequences"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { stopSequences: ["STOP", 123, "END"] as any },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.stopSequences).toEqual(["STOP"]); // Original value
});
it("should ignore non-array stopSequences", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
stopSequences: ["STOP"],
overridableProperties: ["stopSequences"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { stopSequences: "STOP" as any },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.stopSequences).toEqual(["STOP"]); // Original value
});
});
describe("Numeric Property Overrides", () => {
it("should override all numeric properties when allowed", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
maxOutputTokens: 100,
temperature: 0.5,
topP: 0.9,
topK: 50,
presencePenalty: 0.0,
frequencyPenalty: 0.0,
seed: 123,
maxRetries: 3,
overridableProperties: [
"maxOutputTokens",
"temperature",
"topP",
"topK",
"presencePenalty",
"frequencyPenalty",
"seed",
"maxRetries",
],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: {
maxOutputTokens: 500,
temperature: 0.8,
topP: 0.95,
topK: 100,
presencePenalty: 0.5,
frequencyPenalty: 0.5,
seed: 456,
maxRetries: 5,
},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.maxOutputTokens).toBe(500);
expect(callArgs.temperature).toBe(0.8);
expect(callArgs.topP).toBe(0.95);
expect(callArgs.topK).toBe(100);
expect(callArgs.presencePenalty).toBe(0.5);
expect(callArgs.frequencyPenalty).toBe(0.5);
expect(callArgs.seed).toBe(456);
expect(callArgs.maxRetries).toBe(5);
});
it("should ignore non-numeric values for numeric properties", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
temperature: 0.5,
overridableProperties: ["temperature"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { temperature: "high" as any },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.temperature).toBe(0.5); // Original value
});
});
describe("Multiple Property Overrides", () => {
it("should only override allowed properties", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
temperature: 0.5,
topP: 0.9,
maxOutputTokens: 100,
overridableProperties: ["temperature"], // Only temperature is overridable
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: {
temperature: 0.9,
topP: 0.5,
maxOutputTokens: 500,
},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.temperature).toBe(0.9); // Overridden
expect(callArgs.topP).toBe(0.9); // Original
expect(callArgs.maxOutputTokens).toBe(100); // Original
});
it("should handle undefined forwardedProps", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
temperature: 0.5,
overridableProperties: ["temperature"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
// No forwardedProps
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.temperature).toBe(0.5); // Original value
});
it("should handle non-object forwardedProps", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
temperature: 0.5,
overridableProperties: ["temperature"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: "not an object" as any,
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.temperature).toBe(0.5); // Original value
});
it("should handle undefined property values in forwardedProps", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
temperature: 0.5,
overridableProperties: ["temperature"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { temperature: undefined },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.temperature).toBe(0.5); // Original value (undefined is ignored)
});
});
describe("canOverride method", () => {
it("should return true for overridable properties", () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
overridableProperties: ["temperature", "topP"],
});
expect(agent.canOverride("temperature")).toBe(true);
expect(agent.canOverride("topP")).toBe(true);
});
it("should return false for non-overridable properties", () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
overridableProperties: ["temperature"],
});
expect(agent.canOverride("topP")).toBe(false);
expect(agent.canOverride("seed")).toBe(false);
});
it("should return false when overridableProperties is undefined", () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
expect(agent.canOverride("temperature")).toBe(false);
expect(agent.canOverride("topP")).toBe(false);
});
it("should return false when overridableProperties is empty array", () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
overridableProperties: [],
});
expect(agent.canOverride("temperature")).toBe(false);
});
});
});
@@ -0,0 +1,195 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { BuiltInAgent } from "../index";
import { EventType, type RunAgentInput } from "@ag-ui/client";
import { streamText } from "ai";
import {
mockStreamTextResponse,
textDelta,
finish,
collectEvents,
} from "./test-helpers";
// Mock the ai module
vi.mock("ai", () => ({
streamText: vi.fn(),
tool: vi.fn((config) => config),
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
}));
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
specificationVersion: "v3",
modelId,
provider: "openai",
})),
}));
vi.mock("@ai-sdk/anthropic", () => ({
createAnthropic: vi.fn(() => (modelId: string) => ({
specificationVersion: "v3",
modelId,
provider: "anthropic",
})),
}));
vi.mock("@ai-sdk/google", () => ({
createGoogleGenerativeAI: vi.fn(() => (modelId: string) => ({
specificationVersion: "v3",
modelId,
provider: "google",
})),
}));
describe("Provider ID collision (#3410, #3623)", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
});
afterEach(() => {
process.env = originalEnv;
});
it('should replace text-start providedId "txt-0" with a UUID', async () => {
const agent = new BuiltInAgent({ model: "openai:gpt-4o-mini" });
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
{ type: "text-start", id: "txt-0" },
textDelta("Hello"),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread-1",
runId: "run-1",
messages: [{ id: "1", role: "user", content: "Hi" }],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
// Find the TEXT_MESSAGE_CHUNK event and check its messageId
const textChunks = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textChunks.length).toBeGreaterThan(0);
const messageId = (textChunks[0] as any).messageId;
// The messageId should NOT be "txt-0" — it should be a UUID
expect(messageId).not.toBe("txt-0");
// UUID v4 pattern
expect(messageId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
});
it('should replace reasoning-start providedId "reasoning-0" with a UUID', async () => {
const agent = new BuiltInAgent({ model: "openai:gpt-4o-mini" });
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
{ type: "reasoning-start", id: "reasoning-0" },
{ type: "reasoning-delta", text: "Thinking..." },
{ type: "reasoning-end" },
{ type: "text-start", id: "txt-0" },
textDelta("Answer"),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread-2",
runId: "run-2",
messages: [{ id: "1", role: "user", content: "Hi" }],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
// Find REASONING_START event
const reasoningStarts = events.filter(
(e) => e.type === EventType.REASONING_START,
);
expect(reasoningStarts.length).toBeGreaterThan(0);
const reasoningId = (reasoningStarts[0] as any).messageId;
// Should NOT be "reasoning-0"
expect(reasoningId).not.toBe("reasoning-0");
expect(reasoningId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
});
it('should replace providedId "msg-0" with a UUID', async () => {
const agent = new BuiltInAgent({ model: "openai:gpt-4o-mini" });
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
{ type: "text-start", id: "msg-0" },
textDelta("Hello"),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread-3",
runId: "run-3",
messages: [{ id: "1", role: "user", content: "Hi" }],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
const textChunks = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textChunks.length).toBeGreaterThan(0);
const messageId = (textChunks[0] as any).messageId;
expect(messageId).not.toBe("msg-0");
expect(messageId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
});
it("should preserve legitimate provider IDs", async () => {
const agent = new BuiltInAgent({ model: "openai:gpt-4o-mini" });
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
{ type: "text-start", id: "custom-msg-id-123" },
textDelta("Hello"),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread-4",
runId: "run-4",
messages: [{ id: "1", role: "user", content: "Hi" }],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
const textChunks = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textChunks.length).toBeGreaterThan(0);
// Legitimate IDs should be preserved
expect((textChunks[0] as any).messageId).toBe("custom-msg-id-123");
});
});
@@ -0,0 +1,317 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { z } from "zod";
import * as v from "valibot";
import { toStandardJsonSchema } from "@valibot/to-json-schema";
import { type } from "arktype";
import type { StandardSchemaV1 } from "@copilotkit/shared";
import {
defineTool,
convertToolDefinitionsToVercelAITools,
BuiltInAgent,
} from "../index";
import type { RunAgentInput } from "@ag-ui/client";
import { streamText } from "ai";
import { mockStreamTextResponse, finish, collectEvents } from "./test-helpers";
const arktype = <Out>(def: Record<string, string>) =>
(type as unknown as (d: unknown) => StandardSchemaV1<unknown, Out>)(def);
// Mock the ai module — keep jsonSchema real for non-Zod path
vi.mock("ai", async () => {
const actual = await vi.importActual("ai");
return {
...actual,
streamText: vi.fn(),
tool: vi.fn((config) => config),
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
};
});
// Mock the SDK clients
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
modelId,
provider: "openai",
})),
}));
describe("Standard Schema support in agent tools", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
});
afterEach(() => {
process.env = originalEnv;
});
describe("defineTool with different schema libraries", () => {
it("accepts a Zod schema (existing behavior)", () => {
const tool = defineTool({
name: "zodTool",
description: "A tool with Zod schema",
parameters: z.object({
city: z.string(),
units: z.enum(["celsius", "fahrenheit"]).optional(),
}),
execute: async (args) => {
return { city: args.city };
},
});
expect(tool.name).toBe("zodTool");
expect(tool.parameters["~standard"].vendor).toBe("zod");
});
it("accepts a Valibot schema (via toStandardJsonSchema)", () => {
const tool = defineTool({
name: "valibotTool",
description: "A tool with Valibot schema",
parameters: toStandardJsonSchema(
v.object({
query: v.string(),
limit: v.optional(v.number()),
}),
),
execute: async (args) => {
return { query: args.query };
},
});
expect(tool.name).toBe("valibotTool");
expect(tool.parameters["~standard"].vendor).toBe("valibot");
});
it("accepts an ArkType schema", () => {
const tool = defineTool({
name: "arktypeTool",
description: "A tool with ArkType schema",
parameters: arktype<{ query: string; limit: number }>({
query: "string",
limit: "number",
}),
execute: async (args) => {
return { query: args.query };
},
});
expect(tool.name).toBe("arktypeTool");
expect(tool.parameters["~standard"].vendor).toBe("arktype");
});
});
describe("convertToolDefinitionsToVercelAITools", () => {
it("converts Zod tool definitions to AI SDK tools (passes Zod directly)", () => {
const tools = [
defineTool({
name: "zodTool",
description: "Zod test",
parameters: z.object({ name: z.string() }),
execute: async () => ({}),
}),
];
const aiTools = convertToolDefinitionsToVercelAITools(tools);
expect(aiTools).toHaveProperty("zodTool");
expect(aiTools.zodTool).toHaveProperty("description", "Zod test");
expect(aiTools.zodTool).toHaveProperty("execute");
expect(aiTools.zodTool.inputSchema).toBeDefined();
});
it("converts Valibot tool definitions to AI SDK tools", () => {
const tools = [
defineTool({
name: "valibotTool",
description: "Valibot test",
parameters: toStandardJsonSchema(v.object({ name: v.string() })),
execute: async () => ({}),
}),
];
const aiTools = convertToolDefinitionsToVercelAITools(tools);
expect(aiTools).toHaveProperty("valibotTool");
expect(aiTools.valibotTool).toHaveProperty("description", "Valibot test");
expect(aiTools.valibotTool).toHaveProperty("execute");
expect(aiTools.valibotTool.inputSchema).toBeDefined();
});
it("converts ArkType tool definitions to AI SDK tools", () => {
const tools = [
defineTool({
name: "arktypeTool",
description: "ArkType test",
parameters: arktype<{ name: string }>({ name: "string" }),
execute: async () => ({}),
}),
];
const aiTools = convertToolDefinitionsToVercelAITools(tools);
expect(aiTools).toHaveProperty("arktypeTool");
expect(aiTools.arktypeTool).toHaveProperty("description", "ArkType test");
expect(aiTools.arktypeTool).toHaveProperty("execute");
expect(aiTools.arktypeTool.inputSchema).toBeDefined();
});
it("converts mixed schema types in the same tool set", () => {
const tools = [
defineTool({
name: "zodTool",
description: "Zod",
parameters: z.object({ a: z.string() }),
execute: async () => ({}),
}),
defineTool({
name: "valibotTool",
description: "Valibot",
parameters: toStandardJsonSchema(v.object({ b: v.string() })),
execute: async () => ({}),
}),
defineTool({
name: "arktypeTool",
description: "ArkType",
parameters: arktype<{ c: string }>({ c: "string" }),
execute: async () => ({}),
}),
];
const aiTools = convertToolDefinitionsToVercelAITools(tools);
expect(Object.keys(aiTools)).toEqual([
"zodTool",
"valibotTool",
"arktypeTool",
]);
for (const key of Object.keys(aiTools)) {
expect(aiTools[key]).toHaveProperty("execute");
expect(aiTools[key]).toHaveProperty("inputSchema");
}
});
});
describe("BuiltInAgent with non-Zod schemas", () => {
it("includes Valibot-based config tools alongside input tools", async () => {
const executeFn = vi.fn().mockResolvedValue({ result: "ok" });
const valibotTool = defineTool({
name: "searchValibot",
description: "Search with Valibot schema",
parameters: toStandardJsonSchema(v.object({ query: v.string() })),
execute: executeFn,
});
const agent = new BuiltInAgent({
model: "openai/gpt-4o",
tools: [valibotTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("searchValibot");
expect(typeof callArgs.tools?.searchValibot.execute).toBe("function");
});
it("includes ArkType-based config tools alongside input tools", async () => {
const executeFn = vi.fn().mockResolvedValue({ result: "ok" });
const arktypeTool = defineTool({
name: "searchArktype",
description: "Search with ArkType schema",
parameters: arktype<{ query: string }>({ query: "string" }),
execute: executeFn,
});
const agent = new BuiltInAgent({
model: "openai/gpt-4o",
tools: [arktypeTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("searchArktype");
expect(typeof callArgs.tools?.searchArktype.execute).toBe("function");
});
it("mixes Zod, Valibot, and ArkType tools in one agent", async () => {
const agent = new BuiltInAgent({
model: "openai/gpt-4o",
tools: [
defineTool({
name: "zodTool",
description: "Zod",
parameters: z.object({ a: z.string() }),
execute: async () => ({}),
}),
defineTool({
name: "valibotTool",
description: "Valibot",
parameters: toStandardJsonSchema(v.object({ b: v.string() })),
execute: async () => ({}),
}),
defineTool({
name: "arktypeTool",
description: "ArkType",
parameters: arktype<{ c: string }>({ c: "string" }),
execute: async () => ({}),
}),
],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("zodTool");
expect(callArgs.tools).toHaveProperty("valibotTool");
expect(callArgs.tools).toHaveProperty("arktypeTool");
// State tools should also be present
expect(callArgs.tools).toHaveProperty("AGUISendStateSnapshot");
expect(callArgs.tools).toHaveProperty("AGUISendStateDelta");
});
});
});
@@ -0,0 +1,162 @@
import { describe, it, expectTypeOf } from "vitest";
import { z } from "zod";
import * as v from "valibot";
import { type } from "arktype";
import type { StandardSchemaV1 } from "@copilotkit/shared";
import { defineTool } from "../index";
import type { ToolDefinition } from "../index";
const arktype = <Out>(def: Record<string, string>) =>
(type as unknown as (d: unknown) => StandardSchemaV1<unknown, Out>)(def);
describe("ToolDefinition type inference", () => {
describe("defineTool with Zod", () => {
it("infers execute args from Zod schema", () => {
const tool = defineTool({
name: "weather",
description: "Get weather",
parameters: z.object({
city: z.string(),
units: z.enum(["celsius", "fahrenheit"]),
}),
execute: async (args) => {
// args should be fully typed
expectTypeOf(args).toEqualTypeOf<{
city?: string;
units?: "celsius" | "fahrenheit";
}>();
return {};
},
});
expectTypeOf(tool.name).toBeString();
expectTypeOf(tool.description).toBeString();
expectTypeOf(tool.parameters).toMatchTypeOf<StandardSchemaV1>();
});
it("infers execute args with optional Zod fields", () => {
defineTool({
name: "search",
description: "Search",
parameters: z.object({
query: z.string(),
limit: z.number().optional(),
}),
execute: async (args) => {
expectTypeOf(args).toEqualTypeOf<{
query?: string;
limit?: number;
}>();
return {};
},
});
});
});
describe("defineTool with Valibot", () => {
it("infers execute args from Valibot schema", () => {
defineTool({
name: "search",
description: "Search",
parameters: v.object({
query: v.string(),
limit: v.number(),
}),
execute: async (args) => {
expectTypeOf(args).toEqualTypeOf<{
query: string;
limit: number;
}>();
return {};
},
});
});
it("infers execute args with optional Valibot fields", () => {
defineTool({
name: "search",
description: "Search",
parameters: v.object({
query: v.string(),
limit: v.optional(v.number()),
}),
execute: async (args) => {
expectTypeOf(args).toEqualTypeOf<{
query: string;
limit?: number | undefined;
}>();
return {};
},
});
});
});
describe("defineTool with ArkType", () => {
it("infers execute args from ArkType schema", () => {
defineTool({
name: "search",
description: "Search",
parameters: arktype<{ query: string; limit: number }>({
query: "string",
limit: "number",
}),
execute: async (args) => {
expectTypeOf(args).toEqualTypeOf<{
query: string;
limit: number;
}>();
return {};
},
});
});
it("infers execute args with optional ArkType fields", () => {
defineTool({
name: "profile",
description: "Profile",
parameters: arktype<{ name: string; age?: number }>({
name: "string",
"age?": "number",
}),
execute: async (args) => {
expectTypeOf(args).toEqualTypeOf<{
name: string;
age?: number;
}>();
return {};
},
});
});
});
describe("ToolDefinition interface", () => {
it("accepts Zod schema as generic parameter", () => {
type ZodTool = ToolDefinition<z.ZodObject<{ city: z.ZodString }>>;
expectTypeOf<ZodTool["execute"]>().toBeFunction();
expectTypeOf<Parameters<ZodTool["execute"]>[0]>().toEqualTypeOf<{
city?: string;
}>();
});
it("default generic parameter is StandardSchemaV1", () => {
type DefaultTool = ToolDefinition;
expectTypeOf<
DefaultTool["parameters"]
>().toMatchTypeOf<StandardSchemaV1>();
});
it("preserves schema type through defineTool return", () => {
const schema = z.object({ x: z.number() });
const tool = defineTool({
name: "t",
description: "d",
parameters: schema,
execute: async () => ({}),
});
expectTypeOf(tool.parameters).toEqualTypeOf<typeof schema>();
});
});
});
@@ -0,0 +1,436 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { BasicAgent } from "../index";
import { EventType, type RunAgentInput } from "@ag-ui/client";
import { streamText } from "ai";
import {
mockStreamTextResponse,
toolCallStreamingStart,
toolCall,
toolResult,
finish,
collectEvents,
} from "./test-helpers";
// Mock the ai module
vi.mock("ai", () => ({
streamText: vi.fn(),
tool: vi.fn((config) => config),
}));
// Mock the SDK clients
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
modelId,
provider: "openai",
})),
}));
describe("State Update Tools", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
});
afterEach(() => {
process.env = originalEnv;
});
describe("AGUISendStateSnapshot", () => {
it("should emit STATE_SNAPSHOT event when tool is called", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
const newState = { counter: 5, items: ["x", "y"] };
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateSnapshot"),
toolCall("call1", "AGUISendStateSnapshot"),
toolResult("call1", "AGUISendStateSnapshot", {
success: true,
snapshot: newState,
}),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: { counter: 0 },
};
const events = await collectEvents(agent["run"](input));
// Find STATE_SNAPSHOT event
const snapshotEvent = events.find(
(e: any) => e.type === EventType.STATE_SNAPSHOT,
);
expect(snapshotEvent).toBeDefined();
expect(snapshotEvent).toMatchObject({
type: EventType.STATE_SNAPSHOT,
snapshot: newState,
});
});
it("should still emit TOOL_CALL_RESULT for the LLM", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateSnapshot"),
toolCall("call1", "AGUISendStateSnapshot"),
toolResult("call1", "AGUISendStateSnapshot", {
success: true,
snapshot: { value: 1 },
}),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
// Should have both STATE_SNAPSHOT and TOOL_CALL_RESULT
const snapshotEvent = events.find(
(e: any) => e.type === EventType.STATE_SNAPSHOT,
);
const toolResultEvent = events.find(
(e: any) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(snapshotEvent).toBeDefined();
expect(toolResultEvent).toBeDefined();
expect(toolResultEvent).toMatchObject({
type: EventType.TOOL_CALL_RESULT,
toolCallId: "call1",
});
});
});
describe("AGUISendStateDelta", () => {
it("should emit STATE_DELTA event when tool is called", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
const delta = [
{ op: "replace", path: "/counter", value: 10 },
{ op: "add", path: "/newField", value: "test" },
];
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateDelta"),
toolCall("call1", "AGUISendStateDelta"),
toolResult("call1", "AGUISendStateDelta", { success: true, delta }),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: { counter: 0 },
};
const events = await collectEvents(agent["run"](input));
// Find STATE_DELTA event
const deltaEvent = events.find(
(e: any) => e.type === EventType.STATE_DELTA,
);
expect(deltaEvent).toBeDefined();
expect(deltaEvent).toMatchObject({
type: EventType.STATE_DELTA,
delta,
});
});
it("should handle add operations", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
const delta = [{ op: "add", path: "/items/0", value: "new item" }];
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateDelta"),
toolCall("call1", "AGUISendStateDelta"),
toolResult("call1", "AGUISendStateDelta", { success: true, delta }),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: { items: [] },
};
const events = await collectEvents(agent["run"](input));
const deltaEvent = events.find(
(e: any) => e.type === EventType.STATE_DELTA,
);
expect(deltaEvent?.delta).toEqual(delta);
});
it("should handle replace operations", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
const delta = [{ op: "replace", path: "/status", value: "active" }];
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateDelta"),
toolCall("call1", "AGUISendStateDelta"),
toolResult("call1", "AGUISendStateDelta", { success: true, delta }),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: { status: "inactive" },
};
const events = await collectEvents(agent["run"](input));
const deltaEvent = events.find(
(e: any) => e.type === EventType.STATE_DELTA,
);
expect(deltaEvent?.delta).toEqual(delta);
});
it("should handle remove operations", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
const delta = [{ op: "remove", path: "/oldField" }];
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateDelta"),
toolCall("call1", "AGUISendStateDelta"),
toolResult("call1", "AGUISendStateDelta", { success: true, delta }),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: { oldField: "value", keepField: "keep" },
};
const events = await collectEvents(agent["run"](input));
const deltaEvent = events.find(
(e: any) => e.type === EventType.STATE_DELTA,
);
expect(deltaEvent?.delta).toEqual(delta);
});
it("should handle multiple operations in a single delta", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
const delta = [
{ op: "replace", path: "/counter", value: 5 },
{ op: "add", path: "/items/-", value: "new" },
{ op: "remove", path: "/temp" },
];
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateDelta"),
toolCall("call1", "AGUISendStateDelta"),
toolResult("call1", "AGUISendStateDelta", { success: true, delta }),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: { counter: 0, items: [], temp: "remove me" },
};
const events = await collectEvents(agent["run"](input));
const deltaEvent = events.find(
(e: any) => e.type === EventType.STATE_DELTA,
);
expect(deltaEvent?.delta).toEqual(delta);
});
it("should still emit TOOL_CALL_RESULT for the LLM", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
const delta = [{ op: "replace", path: "/value", value: 1 }];
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateDelta"),
toolCall("call1", "AGUISendStateDelta"),
toolResult("call1", "AGUISendStateDelta", { success: true, delta }),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
// Should have both STATE_DELTA and TOOL_CALL_RESULT
const deltaEvent = events.find(
(e: any) => e.type === EventType.STATE_DELTA,
);
const toolResultEvent = events.find(
(e: any) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(deltaEvent).toBeDefined();
expect(toolResultEvent).toBeDefined();
expect(toolResultEvent).toMatchObject({
type: EventType.TOOL_CALL_RESULT,
toolCallId: "call1",
});
});
});
describe("State Tools Integration", () => {
it("should handle both snapshot and delta in same run", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateSnapshot"),
toolCall("call1", "AGUISendStateSnapshot"),
toolResult("call1", "AGUISendStateSnapshot", {
success: true,
snapshot: { value: 1 },
}),
toolCallStreamingStart("call2", "AGUISendStateDelta"),
toolCall("call2", "AGUISendStateDelta"),
toolResult("call2", "AGUISendStateDelta", {
success: true,
delta: [{ op: "replace", path: "/value", value: 2 }],
}),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
const snapshotEvents = events.filter(
(e: any) => e.type === EventType.STATE_SNAPSHOT,
);
const deltaEvents = events.filter(
(e: any) => e.type === EventType.STATE_DELTA,
);
expect(snapshotEvents).toHaveLength(1);
expect(deltaEvents).toHaveLength(1);
});
it("should not emit state events for non-state tools", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "otherTool"),
toolCall("call1", "otherTool"),
toolResult("call1", "otherTool", { result: "data" }),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [
{
name: "otherTool",
description: "Other tool",
parameters: { type: "object", properties: {} },
},
],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
const stateEvents = events.filter(
(e: any) =>
e.type === EventType.STATE_SNAPSHOT ||
e.type === EventType.STATE_DELTA,
);
expect(stateEvents).toHaveLength(0);
});
});
});
@@ -0,0 +1,197 @@
/**
* Test helpers for mocking streamText responses
*/
import type { streamText } from "ai";
import type { Observable } from "rxjs";
import type { BaseEvent } from "@ag-ui/client";
export interface MockStreamEvent {
type: string;
[key: string]: unknown;
}
/**
* Creates a mock streamText response with controlled events.
*/
export function mockStreamTextResponse(
events: MockStreamEvent[],
): ReturnType<typeof streamText> {
return {
fullStream: (async function* () {
for (const event of events) {
yield event;
}
})(),
} as unknown as ReturnType<typeof streamText>;
}
/**
* Helper to create a text-start event
*/
export function textStart(id?: string): MockStreamEvent {
const event: MockStreamEvent = {
type: "text-start",
};
if (id !== undefined) {
event.id = id;
}
return event;
}
/**
* Helper to create a text delta event
*/
export function textDelta(text: string): MockStreamEvent {
return {
type: "text-delta",
text,
};
}
/**
* Helper to create a tool call streaming start event
*/
export function toolCallStreamingStart(
toolCallId: string,
toolName: string,
): MockStreamEvent {
return {
type: "tool-input-start",
id: toolCallId,
toolName,
};
}
/**
* Helper to create a tool call delta event
*/
export function toolCallDelta(
toolCallId: string,
argsTextDelta: string,
): MockStreamEvent {
return {
type: "tool-input-delta",
id: toolCallId,
delta: argsTextDelta,
};
}
/**
* Helper to create a tool call event
*/
export function toolCall(
toolCallId: string,
toolName: string,
input: unknown = {},
): MockStreamEvent {
return {
type: "tool-call",
toolCallId,
toolName,
input,
};
}
/**
* Helper to create a tool result event
*/
export function toolResult(
toolCallId: string,
toolName: string,
output: unknown,
): MockStreamEvent {
return {
type: "tool-result",
toolCallId,
toolName,
output,
};
}
/**
* Helper to create a reasoning-start event
*/
export function reasoningStart(id?: string): MockStreamEvent {
const event: MockStreamEvent = {
type: "reasoning-start",
};
if (id !== undefined) {
event.id = id;
}
return event;
}
/**
* Helper to create a reasoning-delta event
*/
export function reasoningDelta(text: string): MockStreamEvent {
return {
type: "reasoning-delta",
text,
};
}
/**
* Helper to create a reasoning-end event
*/
export function reasoningEnd(): MockStreamEvent {
return {
type: "reasoning-end",
};
}
/**
* Helper to create a finish event
*/
export function finish(): MockStreamEvent {
return {
type: "finish",
finishReason: "stop",
};
}
/**
* Helper to create an abort event
*/
export function abort(): MockStreamEvent {
return {
type: "abort",
};
}
/**
* Helper to create an error event
*/
export function error(errorMessage: string): MockStreamEvent {
return {
type: "error",
error: new Error(errorMessage),
};
}
/**
* Collects all events from an Observable<BaseEvent> into an array.
*/
export async function collectEvents(
observable: Observable<BaseEvent>,
): Promise<BaseEvent[]> {
return new Promise((resolve, reject) => {
const events: BaseEvent[] = [];
const timeoutId = setTimeout(() => {
subscription.unsubscribe();
reject(new Error("Observable did not complete within timeout"));
}, 5000);
const subscription = observable.subscribe({
next: (event) => events.push(event),
error: (err: unknown) => {
clearTimeout(timeoutId);
reject(err);
},
complete: () => {
clearTimeout(timeoutId);
resolve(events);
},
});
});
}
@@ -0,0 +1,542 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { z } from "zod";
import {
resolveModel,
convertMessagesToVercelAISDKMessages,
convertJsonSchemaToZodSchema,
convertToolsToVercelAITools,
convertToolDefinitionsToVercelAITools,
defineTool,
} from "../index";
import type { ToolDefinition } from "../index";
import type { Message } from "@ag-ui/client";
describe("resolveModel", () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-openai-key";
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";
process.env.GOOGLE_API_KEY = "test-google-key";
});
afterEach(() => {
process.env = originalEnv;
});
it("should resolve OpenAI models with / separator", () => {
const model = resolveModel("openai/gpt-4o");
expect(model).toBeDefined();
expect((model as { modelId: string }).modelId).toBe("gpt-4o");
});
it("should resolve OpenAI models with : separator", () => {
const model = resolveModel("openai:gpt-4o-mini");
expect(model).toBeDefined();
expect((model as { modelId: string }).modelId).toBe("gpt-4o-mini");
});
it("should resolve Anthropic models", () => {
const model = resolveModel("anthropic/claude-sonnet-4.5");
expect(model).toBeDefined();
expect((model as { modelId: string }).modelId).toBe("claude-sonnet-4.5");
});
it("should resolve Google models", () => {
const model = resolveModel("google/gemini-2.5-pro");
expect(model).toBeDefined();
expect((model as { modelId: string }).modelId).toBe("gemini-2.5-pro");
});
it("should handle gemini provider alias", () => {
const model = resolveModel("gemini/gemini-2.5-flash");
expect(model).toBeDefined();
expect((model as { modelId: string }).modelId).toBe("gemini-2.5-flash");
});
it("should throw error for invalid format", () => {
expect(() => resolveModel("invalid")).toThrow("Invalid model string");
});
it("should throw error for unknown provider", () => {
expect(() => resolveModel("unknown/model")).toThrow("Unknown provider");
});
it("should pass through LanguageModel instances", () => {
const mockModel = resolveModel("openai/gpt-4o");
const result = resolveModel(mockModel as any);
expect(result).toBe(mockModel);
});
});
describe("convertMessagesToVercelAISDKMessages", () => {
it("should convert user messages", () => {
const messages: Message[] = [
{
id: "1",
role: "user",
content: "Hello",
},
];
const result = convertMessagesToVercelAISDKMessages(messages);
expect(result).toEqual([
{
role: "user",
content: "Hello",
},
]);
});
it("should convert assistant messages with text content", () => {
const messages: Message[] = [
{
id: "1",
role: "assistant",
content: "Hello there",
},
];
const result = convertMessagesToVercelAISDKMessages(messages);
expect(result).toEqual([
{
role: "assistant",
content: [{ type: "text", text: "Hello there" }],
},
]);
});
it("should convert assistant messages with tool calls", () => {
const messages: Message[] = [
{
id: "1",
role: "assistant",
content: "Let me help",
toolCalls: [
{
id: "call1",
type: "function",
function: {
name: "getTool",
arguments: '{"arg":"value"}',
},
},
],
},
];
const result = convertMessagesToVercelAISDKMessages(messages);
expect(result[0]).toEqual({
role: "assistant",
content: [
{ type: "text", text: "Let me help" },
{
type: "tool-call",
toolCallId: "call1",
toolName: "getTool",
input: { arg: "value" },
},
],
});
});
it("should convert tool messages", () => {
const messages: Message[] = [
{
id: "1",
role: "assistant",
toolCalls: [
{
id: "call1",
type: "function",
function: {
name: "getTool",
arguments: "{}",
},
},
],
},
{
id: "2",
role: "tool",
content: "result",
toolCallId: "call1",
},
];
const result = convertMessagesToVercelAISDKMessages(messages);
expect(result[1]).toEqual({
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call1",
toolName: "getTool",
output: {
type: "text",
value: "result",
},
},
],
});
});
it("should handle multiple messages", () => {
const messages: Message[] = [
{ id: "1", role: "user", content: "Hi" },
{ id: "2", role: "assistant", content: "Hello" },
{ id: "3", role: "user", content: "How are you?" },
];
const result = convertMessagesToVercelAISDKMessages(messages);
expect(result).toHaveLength(3);
expect(result[0].role).toBe("user");
expect(result[1].role).toBe("assistant");
expect(result[2].role).toBe("user");
});
});
describe("convertJsonSchemaToZodSchema", () => {
it("should convert string schema", () => {
const jsonSchema = {
type: "string" as const,
description: "A string field",
};
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, true);
expect(zodSchema.parse("test")).toBe("test");
expect(() => zodSchema.parse(123)).toThrow();
});
it("should convert number schema", () => {
const jsonSchema = {
type: "number" as const,
description: "A number field",
};
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, true);
expect(zodSchema.parse(123)).toBe(123);
expect(() => zodSchema.parse("test")).toThrow();
});
it("should convert boolean schema", () => {
const jsonSchema = {
type: "boolean" as const,
description: "A boolean field",
};
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, true);
expect(zodSchema.parse(true)).toBe(true);
expect(() => zodSchema.parse("true")).toThrow();
});
it("should convert array schema", () => {
const jsonSchema = {
type: "array" as const,
description: "An array field",
items: {
type: "string" as const,
},
};
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, true);
expect(zodSchema.parse(["a", "b"])).toEqual(["a", "b"]);
expect(() => zodSchema.parse([1, 2])).toThrow();
});
it("should convert object schema with properties", () => {
const jsonSchema = {
type: "object" as const,
description: "An object",
properties: {
name: { type: "string" as const },
age: { type: "number" as const },
},
required: ["name"],
};
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, true);
const valid = zodSchema.parse({ name: "John", age: 30 });
expect(valid).toEqual({ name: "John", age: 30 });
expect(() => zodSchema.parse({ age: 30 })).toThrow();
});
it("should make schema optional when required is false", () => {
const jsonSchema = {
type: "string" as const,
description: "Optional string",
};
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, false);
expect(zodSchema.parse(undefined)).toBeUndefined();
expect(zodSchema.parse("test")).toBe("test");
});
it("should preserve string enum constraints", () => {
const jsonSchema = {
type: "object" as const,
properties: {
status: {
type: "string" as const,
description: "The status",
enum: ["todo", "done"],
},
},
required: ["status"],
};
const result = convertJsonSchemaToZodSchema(jsonSchema, true);
const statusSchema = (result as z.ZodObject<any>).shape.status;
expect(statusSchema._def.typeName).toBe("ZodEnum");
expect(statusSchema._def.values).toEqual(["todo", "done"]);
});
it("should handle nested object schemas", () => {
const jsonSchema = {
type: "object" as const,
properties: {
user: {
type: "object" as const,
properties: {
name: { type: "string" as const },
},
required: ["name"],
},
},
required: ["user"],
};
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, true);
const valid = zodSchema.parse({ user: { name: "John" } });
expect(valid).toEqual({ user: { name: "John" } });
});
});
describe("convertToolsToVercelAITools", () => {
it("should convert AG-UI tools to Vercel AI tools", () => {
const tools = [
{
name: "testTool",
description: "A test tool",
parameters: {
type: "object" as const,
properties: {
input: { type: "string" as const },
},
required: ["input"],
},
},
];
const result = convertToolsToVercelAITools(tools);
expect(result).toHaveProperty("testTool");
expect(result.testTool).toBeDefined();
});
it("should throw error for invalid JSON schema", () => {
const tools = [
{
name: "testTool",
description: "A test tool",
parameters: { invalid: "schema" } as any,
},
];
expect(() => convertToolsToVercelAITools(tools)).toThrow(
"Invalid JSON schema",
);
});
it("should handle multiple tools", () => {
const tools = [
{
name: "tool1",
description: "First tool",
parameters: {
type: "object" as const,
properties: {},
},
},
{
name: "tool2",
description: "Second tool",
parameters: {
type: "object" as const,
properties: {},
},
},
];
const result = convertToolsToVercelAITools(tools);
expect(result).toHaveProperty("tool1");
expect(result).toHaveProperty("tool2");
});
});
describe("convertToolDefinitionsToVercelAITools", () => {
it("should convert ToolDefinitions to Vercel AI tools", () => {
const tools: ToolDefinition[] = [
{
name: "testTool",
description: "A test tool",
parameters: z.object({
input: z.string(),
}),
execute: async () => undefined,
},
];
const result = convertToolDefinitionsToVercelAITools(tools);
expect(result).toHaveProperty("testTool");
expect(result.testTool).toBeDefined();
});
it("should handle multiple tool definitions", () => {
const tools: ToolDefinition[] = [
{
name: "tool1",
description: "First tool",
parameters: z.object({}),
execute: async () => undefined,
},
{
name: "tool2",
description: "Second tool",
parameters: z.object({ value: z.number() }),
execute: async () => undefined,
},
];
const result = convertToolDefinitionsToVercelAITools(tools);
expect(result).toHaveProperty("tool1");
expect(result).toHaveProperty("tool2");
expect(Object.keys(result)).toHaveLength(2);
});
});
describe("ensureObjectArgs via convertMessagesToVercelAISDKMessages", () => {
function makeAssistantWithToolArgs(argsJson: string): Message[] {
return [
{
id: "1",
role: "assistant",
content: "calling tool",
toolCalls: [
{
id: "call1",
type: "function",
function: {
name: "myTool",
arguments: argsJson,
},
},
],
},
];
}
it("should pass through valid object arguments", () => {
const result = convertMessagesToVercelAISDKMessages(
makeAssistantWithToolArgs('{"key":"value"}'),
);
const toolCall = (result[0] as any).content[1];
expect(toolCall.input).toEqual({ key: "value" });
});
it("should replace a string argument with an empty object", () => {
const result = convertMessagesToVercelAISDKMessages(
makeAssistantWithToolArgs('""'),
);
const toolCall = (result[0] as any).content[1];
expect(toolCall.input).toEqual({});
});
it("should replace an array argument with an empty object", () => {
const result = convertMessagesToVercelAISDKMessages(
makeAssistantWithToolArgs("[1,2,3]"),
);
const toolCall = (result[0] as any).content[1];
expect(toolCall.input).toEqual({});
});
it("should replace a null argument with an empty object", () => {
const result = convertMessagesToVercelAISDKMessages(
makeAssistantWithToolArgs("null"),
);
const toolCall = (result[0] as any).content[1];
expect(toolCall.input).toEqual({});
});
it("should replace a numeric argument with an empty object", () => {
const result = convertMessagesToVercelAISDKMessages(
makeAssistantWithToolArgs("42"),
);
const toolCall = (result[0] as any).content[1];
expect(toolCall.input).toEqual({});
});
it("should replace a boolean argument with an empty object", () => {
const result = convertMessagesToVercelAISDKMessages(
makeAssistantWithToolArgs("true"),
);
const toolCall = (result[0] as any).content[1];
expect(toolCall.input).toEqual({});
});
it("should replace unparseable JSON with an empty object", () => {
const result = convertMessagesToVercelAISDKMessages(
makeAssistantWithToolArgs("{broken"),
);
const toolCall = (result[0] as any).content[1];
expect(toolCall.input).toEqual({});
});
});
describe("defineTool", () => {
it("should create a ToolDefinition", () => {
const tool = defineTool({
name: "myTool",
description: "My test tool",
parameters: z.object({
input: z.string(),
}),
execute: async () => undefined,
});
expect(tool).toEqual({
name: "myTool",
description: "My test tool",
parameters: expect.any(Object),
execute: expect.any(Function),
});
expect(tool.name).toBe("myTool");
expect(tool.description).toBe("My test tool");
});
it("should preserve type information", () => {
const tool = defineTool({
name: "calc",
description: "Calculate",
parameters: z.object({
a: z.number(),
b: z.number(),
}),
execute: async () => undefined,
});
// Should be able to parse valid input
const result = tool.parameters.parse({ a: 1, b: 2 });
expect(result).toEqual({ a: 1, b: 2 });
});
});
@@ -0,0 +1,356 @@
/**
* Regression tests proving that the agent package's defineTool and
* convertToolDefinitionsToVercelAITools still work identically with
* Zod schemas after the Standard Schema migration.
*
* Covers:
* 1. defineTool with complex Zod schemas
* 2. convertToolDefinitionsToVercelAITools passes Zod schemas directly
* (not through JSON Schema conversion — critical behavioral regression)
* 3. BuiltInAgent with Zod tools still works end-to-end
* 4. execute callback receives correctly shaped args
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { z } from "zod";
import {
defineTool,
convertToolDefinitionsToVercelAITools,
BuiltInAgent,
} from "../index";
import type { RunAgentInput } from "@ag-ui/client";
import { streamText } from "ai";
import { mockStreamTextResponse, finish, collectEvents } from "./test-helpers";
vi.mock("ai", async () => {
const actual = await vi.importActual("ai");
return {
...actual,
streamText: vi.fn(),
tool: vi.fn((config) => config),
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
};
});
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
modelId,
provider: "openai",
})),
}));
describe("defineTool Zod regression", () => {
it("complex Zod schema with nested objects, arrays, unions", () => {
const tool = defineTool({
name: "complexTool",
description: "A complex tool",
parameters: z.object({
query: z.string().describe("Search query"),
filters: z
.object({
category: z.enum(["books", "movies", "music"]).optional(),
minRating: z.number().min(0).max(5).optional(),
tags: z.array(z.string()).optional(),
})
.optional(),
pagination: z
.object({
page: z.number().int().positive().default(1),
perPage: z.number().int().positive().default(20),
})
.optional(),
}),
execute: async (args) => {
return { query: args.query };
},
});
expect(tool.name).toBe("complexTool");
expect(tool.parameters["~standard"].vendor).toBe("zod");
expect(tool.parameters["~standard"].version).toBe(1);
});
it("Zod schema with discriminated union", () => {
const tool = defineTool({
name: "actionTool",
description: "An action tool",
parameters: z.object({
action: z.discriminatedUnion("type", [
z.object({ type: z.literal("search"), query: z.string() }),
z.object({ type: z.literal("navigate"), url: z.string().url() }),
z.object({
type: z.literal("execute"),
code: z.string(),
language: z.enum(["javascript", "python"]),
}),
]),
}),
execute: async (args) => {
return { action: args.action.type };
},
});
expect(tool.name).toBe("actionTool");
expect(tool.parameters["~standard"].vendor).toBe("zod");
});
it("Zod schema with nullable and record fields", () => {
const tool = defineTool({
name: "flexTool",
description: "Flexible tool",
parameters: z.object({
title: z.string(),
description: z.string().nullable(),
metadata: z.record(z.string(), z.unknown()).optional(),
}),
execute: async (args) => {
return { title: args.title };
},
});
expect(tool.name).toBe("flexTool");
expect(tool.parameters["~standard"].vendor).toBe("zod");
});
it("execute callback receives correctly typed args", async () => {
const receivedArgs: unknown[] = [];
const tool = defineTool({
name: "argCapture",
description: "Captures args",
parameters: z.object({
city: z.string(),
units: z.enum(["celsius", "fahrenheit"]),
}),
execute: async (args) => {
receivedArgs.push(args);
return { temp: 22 };
},
});
await tool.execute({ city: "Berlin", units: "celsius" });
expect(receivedArgs).toHaveLength(1);
expect(receivedArgs[0]).toEqual({ city: "Berlin", units: "celsius" });
});
});
describe("convertToolDefinitionsToVercelAITools Zod regression", () => {
it("Zod schemas are passed directly to AI SDK (not converted via JSON Schema)", () => {
const zodSchema = z.object({
city: z.string(),
units: z.enum(["celsius", "fahrenheit"]).optional(),
});
const tools = [
defineTool({
name: "weather",
description: "Get weather",
parameters: zodSchema,
execute: async () => ({ temp: 22 }),
}),
];
const aiTools = convertToolDefinitionsToVercelAITools(tools);
expect(aiTools).toHaveProperty("weather");
// The inputSchema should be the original Zod schema (passed directly),
// NOT a jsonSchema() wrapper. We verify by checking the Zod-specific
// ~standard.vendor property is preserved on the schema.
const inputSchema = aiTools.weather.inputSchema;
expect(inputSchema).toBeDefined();
// For Zod schemas, the AI SDK receives the raw Zod object (which has ~standard.vendor === "zod")
// If it went through jsonSchema(), it would lose the ~standard property
expect(inputSchema["~standard"]?.vendor).toBe("zod");
});
it("multiple Zod tools all get direct pass-through", () => {
const tools = [
defineTool({
name: "tool1",
description: "Tool 1",
parameters: z.object({ a: z.string() }),
execute: async () => ({}),
}),
defineTool({
name: "tool2",
description: "Tool 2",
parameters: z.object({ b: z.number(), c: z.boolean().optional() }),
execute: async () => ({}),
}),
defineTool({
name: "tool3",
description: "Tool 3",
parameters: z.object({
nested: z.object({ x: z.number(), y: z.number() }),
}),
execute: async () => ({}),
}),
];
const aiTools = convertToolDefinitionsToVercelAITools(tools);
for (const name of ["tool1", "tool2", "tool3"]) {
expect(aiTools).toHaveProperty(name);
expect(aiTools[name].inputSchema["~standard"]?.vendor).toBe("zod");
expect(typeof aiTools[name].execute).toBe("function");
}
});
it("preserves tool description and execute function", () => {
const executeFn = vi.fn().mockResolvedValue({ result: "ok" });
const tools = [
defineTool({
name: "myTool",
description: "My custom tool description",
parameters: z.object({ input: z.string() }),
execute: executeFn,
}),
];
const aiTools = convertToolDefinitionsToVercelAITools(tools);
expect(aiTools.myTool.description).toBe("My custom tool description");
expect(aiTools.myTool.execute).toBeDefined();
});
});
describe("BuiltInAgent Zod regression", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
});
afterEach(() => {
process.env = originalEnv;
});
it("Zod tools are included in streamText call", async () => {
const agent = new BuiltInAgent({
model: "openai/gpt-4o",
tools: [
defineTool({
name: "weather",
description: "Get weather",
parameters: z.object({
city: z.string(),
units: z.enum(["celsius", "fahrenheit"]).optional(),
}),
execute: async () => ({ temp: 22 }),
}),
],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("weather");
expect(typeof callArgs.tools?.weather.execute).toBe("function");
});
it("multiple Zod tools coexist with built-in state tools", async () => {
const agent = new BuiltInAgent({
model: "openai/gpt-4o",
tools: [
defineTool({
name: "search",
description: "Search",
parameters: z.object({ query: z.string() }),
execute: async () => ({}),
}),
defineTool({
name: "calculate",
description: "Calculate",
parameters: z.object({
expression: z.string(),
precision: z.number().int().optional(),
}),
execute: async () => ({}),
}),
],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
// User-defined Zod tools
expect(callArgs.tools).toHaveProperty("search");
expect(callArgs.tools).toHaveProperty("calculate");
// Built-in state tools
expect(callArgs.tools).toHaveProperty("AGUISendStateSnapshot");
expect(callArgs.tools).toHaveProperty("AGUISendStateDelta");
});
it("Zod tool execute is invocable after conversion", async () => {
const executeFn = vi.fn().mockResolvedValue({ temp: 22 });
const agent = new BuiltInAgent({
model: "openai/gpt-4o",
tools: [
defineTool({
name: "weather",
description: "Get weather",
parameters: z.object({ city: z.string() }),
execute: executeFn,
}),
],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
const weatherTool = callArgs.tools?.weather;
// Invoke the execute function to verify it's wired correctly
await weatherTool.execute(
{ city: "Berlin" },
{ toolCallId: "tool-call-1", messages: [] },
);
expect(executeFn).toHaveBeenCalledWith(
{ city: "Berlin" },
{ toolCallId: "tool-call-1", messages: [] },
);
});
});
@@ -0,0 +1,405 @@
import type {
BaseEvent,
Interrupt,
ReasoningEndEvent,
ReasoningMessageContentEvent,
ReasoningMessageEndEvent,
ReasoningMessageStartEvent,
ReasoningStartEvent,
TextMessageChunkEvent,
ToolCallArgsEvent,
ToolCallEndEvent,
ToolCallStartEvent,
ToolCallResultEvent,
StateSnapshotEvent,
StateDeltaEvent,
} from "@ag-ui/client";
import { EventType } from "@ag-ui/client";
import { randomUUID } from "@copilotkit/shared";
/**
* Converts an AI SDK `fullStream` into AG-UI `BaseEvent` objects.
*
* This is a pure converter — it does NOT emit lifecycle events
* (RUN_STARTED / RUN_FINISHED / RUN_ERROR). The caller (Agent class)
* is responsible for those.
*
* Terminal stream events (finish, error, abort) cause the generator to
* return so the caller can handle lifecycle appropriately.
*
* `pendingInterrupts`, when provided, is filled with one AG-UI Interrupt per
* AI SDK `tool-approval-request` part (a tool declared `needsApproval: true`).
* The caller turns a non-empty array into a RUN_FINISHED `outcome:interrupt`.
*/
export async function* convertAISDKStream(
fullStream: AsyncIterable<unknown>,
abortSignal: AbortSignal,
pendingInterrupts?: Interrupt[],
): AsyncGenerator<BaseEvent> {
let messageId = randomUUID();
let reasoningMessageId = randomUUID();
let isInReasoning = false;
const toolCallStates = new Map<
string,
{
started: boolean;
hasArgsDelta: boolean;
ended: boolean;
toolName?: string;
}
>();
const ensureToolCallState = (toolCallId: string) => {
let state = toolCallStates.get(toolCallId);
if (!state) {
state = { started: false, hasArgsDelta: false, ended: false };
toolCallStates.set(toolCallId, state);
}
return state;
};
/**
* Auto-close an open reasoning lifecycle.
* Some AI SDK providers (notably @ai-sdk/anthropic) never emit "reasoning-end",
* which leaves downstream state machines stuck. This helper emits the
* missing REASONING_MESSAGE_END + REASONING_END events so the stream
* can transition to text, tool-call, or finish phases.
*/
function* closeReasoningIfOpen(): Generator<BaseEvent> {
if (!isInReasoning) return;
isInReasoning = false;
const reasoningMsgEnd: ReasoningMessageEndEvent = {
type: EventType.REASONING_MESSAGE_END,
messageId: reasoningMessageId,
};
yield reasoningMsgEnd;
const reasoningEnd: ReasoningEndEvent = {
type: EventType.REASONING_END,
messageId: reasoningMessageId,
};
yield reasoningEnd;
}
try {
for await (const part of fullStream) {
const p = part as Record<string, unknown>;
// Close any open reasoning lifecycle on every event except
// reasoning-delta, which arrives mid-block and must not interrupt it.
if (p.type !== "reasoning-delta") {
yield* closeReasoningIfOpen();
}
switch (p.type) {
case "abort": {
// Terminal — let the caller handle lifecycle
return;
}
case "reasoning-start": {
// Use SDK-provided id, or generate a fresh UUID if id is falsy/"0"
// to prevent consecutive reasoning blocks from sharing a messageId
const providedId = "id" in p ? p.id : undefined;
reasoningMessageId =
providedId && providedId !== "0"
? (providedId as string)
: randomUUID();
const reasoningStartEvent: ReasoningStartEvent = {
type: EventType.REASONING_START,
messageId: reasoningMessageId,
};
yield reasoningStartEvent;
const reasoningMessageStart: ReasoningMessageStartEvent = {
type: EventType.REASONING_MESSAGE_START,
messageId: reasoningMessageId,
role: "reasoning",
};
yield reasoningMessageStart;
isInReasoning = true;
break;
}
case "reasoning-delta": {
const delta = (p.text as string) ?? "";
if (!delta) break; // skip — @ag-ui/core schema requires delta to be non-empty
const reasoningDeltaEvent: ReasoningMessageContentEvent = {
type: EventType.REASONING_MESSAGE_CONTENT,
messageId: reasoningMessageId,
delta,
};
yield reasoningDeltaEvent;
break;
}
case "reasoning-end": {
// closeReasoningIfOpen() already called before the switch — no-op here
// if the SDK never emits this event (e.g. @ai-sdk/anthropic).
break;
}
case "tool-input-start": {
const toolCallId = p.id as string;
const state = ensureToolCallState(toolCallId);
state.toolName = p.toolName as string;
if (!state.started) {
state.started = true;
const startEvent: ToolCallStartEvent = {
type: EventType.TOOL_CALL_START,
parentMessageId: messageId,
toolCallId,
toolCallName: p.toolName as string,
};
yield startEvent;
}
break;
}
case "tool-input-delta": {
const toolCallId = p.id as string;
const state = ensureToolCallState(toolCallId);
state.hasArgsDelta = true;
const argsEvent: ToolCallArgsEvent = {
type: EventType.TOOL_CALL_ARGS,
toolCallId,
delta: p.delta as string,
};
yield argsEvent;
break;
}
case "tool-input-end": {
// No direct event the subsequent "tool-call" part marks completion.
break;
}
case "text-start": {
// New text message starting - use the SDK-provided id
// Use randomUUID() if part.id is falsy or "0" to prevent message merging issues
const providedId = "id" in p ? p.id : undefined;
messageId =
providedId && providedId !== "0"
? (providedId as string)
: randomUUID();
break;
}
case "text-delta": {
// AI SDK text-delta events use 'text' (not 'delta')
const textDelta = "text" in p ? (p.text as string) : "";
const textEvent: TextMessageChunkEvent = {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
messageId,
delta: textDelta,
};
yield textEvent;
break;
}
case "tool-call": {
const toolCallId = p.toolCallId as string;
const state = ensureToolCallState(toolCallId);
state.toolName = (p.toolName as string) ?? state.toolName;
if (!state.started) {
state.started = true;
const startEvent: ToolCallStartEvent = {
type: EventType.TOOL_CALL_START,
parentMessageId: messageId,
toolCallId,
toolCallName: p.toolName as string,
};
yield startEvent;
}
if (!state.hasArgsDelta && "input" in p && p.input !== undefined) {
let serializedInput = "";
if (typeof p.input === "string") {
serializedInput = p.input;
} else {
try {
serializedInput = JSON.stringify(p.input);
} catch {
serializedInput = String(p.input);
}
}
if (serializedInput.length > 0) {
const argsEvent: ToolCallArgsEvent = {
type: EventType.TOOL_CALL_ARGS,
toolCallId,
delta: serializedInput,
};
yield argsEvent;
state.hasArgsDelta = true;
}
}
if (!state.ended) {
state.ended = true;
const endEvent: ToolCallEndEvent = {
type: EventType.TOOL_CALL_END,
toolCallId,
};
yield endEvent;
}
break;
}
case "tool-approval-request": {
// AI SDK native human-in-the-loop: a tool declared `needsApproval`
// was called. The preceding "tool-call" part already streamed the
// tool name + args; this part carries the toolCallId to pause on.
// (Some shapes nest the call under `toolCall` — read both.)
const nested = (p.toolCall ?? {}) as {
toolCallId?: string;
toolName?: string;
input?: unknown;
};
const toolCallId =
(p.toolCallId as string | undefined) ?? nested.toolCallId;
if (!toolCallId) {
throw new Error(
"AI SDK tool-approval-request is missing toolCallId",
);
}
const state = ensureToolCallState(toolCallId);
const toolName = state.toolName ?? nested.toolName;
// Defensive: emit the tool-call lifecycle if the "tool-call" part
// didn't precede this one, so the assistant tool-call is in history
// for the resume run (which keys the injected result by toolCallId).
if (!state.started) {
state.started = true;
const startEvent: ToolCallStartEvent = {
type: EventType.TOOL_CALL_START,
parentMessageId: messageId,
toolCallId,
toolCallName: toolName ?? "",
};
yield startEvent;
}
if (!state.hasArgsDelta && nested.input !== undefined) {
let serialized = "";
try {
serialized =
typeof nested.input === "string"
? nested.input
: JSON.stringify(nested.input);
} catch {
serialized = String(nested.input);
}
if (serialized.length > 0) {
const argsEvent: ToolCallArgsEvent = {
type: EventType.TOOL_CALL_ARGS,
toolCallId,
delta: serialized,
};
yield argsEvent;
state.hasArgsDelta = true;
}
}
if (!state.ended) {
state.ended = true;
const endEvent: ToolCallEndEvent = {
type: EventType.TOOL_CALL_END,
toolCallId,
};
yield endEvent;
}
pendingInterrupts?.push({
id: toolCallId,
toolCallId,
reason: "tool_approval",
message: toolName ? `Approve "${toolName}"?` : undefined,
...(toolName ? { metadata: { toolName } } : {}),
});
break;
}
case "tool-result": {
// AI SDK tool-result uses "output"; older versions used "result" — check both
const toolResult =
"output" in p ? p.output : "result" in p ? p.result : null;
const toolName = "toolName" in p ? (p.toolName as string) : "";
toolCallStates.delete(p.toolCallId as string);
// Check if this is a state update tool
if (
toolName === "AGUISendStateSnapshot" &&
toolResult &&
typeof toolResult === "object"
) {
const snapshot = (toolResult as Record<string, unknown>).snapshot;
if (snapshot !== undefined) {
const stateSnapshotEvent: StateSnapshotEvent = {
type: EventType.STATE_SNAPSHOT,
snapshot,
};
yield stateSnapshotEvent;
}
} else if (
toolName === "AGUISendStateDelta" &&
toolResult &&
typeof toolResult === "object"
) {
const delta = (toolResult as { delta?: StateDeltaEvent["delta"] })
.delta;
if (delta !== undefined) {
const stateDeltaEvent: StateDeltaEvent = {
type: EventType.STATE_DELTA,
delta,
};
yield stateDeltaEvent;
}
}
// Always emit the tool result event for the LLM
let serializedResult: string;
try {
serializedResult = JSON.stringify(toolResult);
} catch {
serializedResult = `[Unserializable tool result from ${toolName || "unknown tool"}]`;
}
const resultEvent: ToolCallResultEvent = {
type: EventType.TOOL_CALL_RESULT,
role: "tool",
messageId: randomUUID(),
toolCallId: p.toolCallId as string,
content: serializedResult,
};
yield resultEvent;
break;
}
case "finish": {
// Terminal — let the caller handle lifecycle
return;
}
case "error": {
if (abortSignal.aborted) {
return;
}
// Re-throw so the caller can emit RUN_ERROR
const err = p.error ?? p.message ?? p.cause;
if (err instanceof Error) throw err;
throw new Error(
typeof err === "string"
? err
: `AI SDK stream error: ${JSON.stringify(p)}`,
);
}
default:
// Unknown event types are silently ignored
break;
}
}
} finally {
// Always close reasoning on exit (normal or exceptional)
yield* closeReasoningIfOpen();
}
}
@@ -0,0 +1,7 @@
export { convertAISDKStream } from "./aisdk";
export {
convertTanStackStream,
convertInputToTanStackAI,
type TanStackChatMessage,
type TanStackInputResult,
} from "./tanstack";
@@ -0,0 +1,594 @@
import type {
BaseEvent,
Interrupt,
RunAgentInput,
Message,
TextMessageChunkEvent,
ToolCallArgsEvent,
ToolCallEndEvent,
ToolCallStartEvent,
ToolCallResultEvent,
StateSnapshotEvent,
StateDeltaEvent,
ReasoningStartEvent,
ReasoningMessageStartEvent,
ReasoningMessageContentEvent,
ReasoningMessageEndEvent,
ReasoningEndEvent,
} from "@ag-ui/client";
import { EventType } from "@ag-ui/client";
import { randomUUID } from "@copilotkit/shared";
type ContentPartSource =
| { type: "data"; value: string; mimeType: string }
| { type: "url"; value: string; mimeType?: string };
/**
* A TanStack AI content part (text, image, audio, video, or document).
*/
export type TanStackContentPart =
| { type: "text"; content: string }
| { type: "image"; source: ContentPartSource }
| { type: "audio"; source: ContentPartSource }
| { type: "video"; source: ContentPartSource }
| { type: "document"; source: ContentPartSource };
/**
* Message format expected by TanStack AI's `chat()`.
*
* Content is typed as `any[]` for the multimodal case so messages are directly
* passable to any adapter without casts — different adapters constrain which
* modalities they accept (e.g. OpenAI only allows text + image).
* Use `TanStackContentPart` to inspect individual parts if needed.
*/
export interface TanStackChatMessage {
role: "user" | "assistant" | "tool";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
content: string | null | any[];
name?: string;
toolCalls?: Array<{
id: string;
type: "function";
function: { name: string; arguments: string };
}>;
toolCallId?: string;
}
/**
* A TanStack AI client-side tool, derived from a frontend-provided AG-UI tool.
*
* Shaped to match `@tanstack/ai`'s `ClientTool` (`__toolSide: "client"`, no
* `execute`): the model may CALL it, but TanStack does not run it — it pauses
* the run and hands the call back to the AG-UI client (the CopilotKit frontend
* / bot) to execute, mirroring CopilotKit's client-tool round-trip. `chat()`
* accepts a JSON Schema directly as `inputSchema`, so the AG-UI tool's
* `parameters` pass through unchanged.
*/
export interface TanStackClientTool {
__toolSide: "client";
name: string;
description: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
inputSchema: any;
}
/**
* Result of converting RunAgentInput to TanStack AI format.
*/
export interface TanStackInputResult {
/** Chat messages (only user/assistant/tool roles; all others excluded) */
messages: TanStackChatMessage[];
/** System prompts extracted from system/developer messages, context, and state */
systemPrompts: string[];
/**
* Client-side tools derived from `input.tools` (the frontend-provided tools
* the CopilotKit client forwards on every run). Pass these into `chat()`
* alongside any server/provider tools so the model can call the frontend's
* generative-UI and human-in-the-loop tools; TanStack pauses the run on a
* client-tool call and the client executes it.
*/
tools: TanStackClientTool[];
}
/**
* Converts AG-UI user message content to TanStack AI format.
* Handles plain strings, multimodal parts (image/audio/video/document),
* and legacy BinaryInputContent for backward compatibility.
*/
function convertUserContent(
content: unknown,
): string | null | TanStackContentPart[] {
if (!content) return null;
if (typeof content === "string") return content;
if (!Array.isArray(content)) return null;
if (content.length === 0) return "";
const parts: TanStackContentPart[] = [];
for (const part of content) {
if (!part || typeof part !== "object" || !("type" in part)) continue;
switch ((part as { type: string }).type) {
case "text": {
const text = (part as { text?: string }).text;
if (text != null) parts.push({ type: "text", content: text });
break;
}
case "image":
case "audio":
case "video":
case "document": {
const source = (part as { source?: any }).source;
if (!source) break;
const partType = (part as { type: string }).type as
| "image"
| "audio"
| "video"
| "document";
if (source.type === "data") {
parts.push({
type: partType,
source: {
type: "data",
value: source.value,
mimeType: source.mimeType,
},
});
} else if (source.type === "url") {
parts.push({
type: partType,
source: {
type: "url",
value: source.value,
...(source.mimeType ? { mimeType: source.mimeType } : {}),
},
});
}
break;
}
// Legacy BinaryInputContent backward compatibility
case "binary": {
const legacy = part as {
mimeType?: string;
data?: string;
url?: string;
};
const mimeType = legacy.mimeType ?? "application/octet-stream";
const isImage = mimeType.startsWith("image/");
if (legacy.data) {
const partType = isImage ? "image" : "document";
parts.push({
type: partType,
source: { type: "data", value: legacy.data, mimeType },
});
} else if (legacy.url) {
const partType = isImage ? "image" : "document";
parts.push({
type: partType,
source: { type: "url", value: legacy.url, mimeType },
});
}
break;
}
}
}
return parts.length > 0 ? parts : "";
}
/**
* Recursively normalizes a frontend tool's JSON Schema so OpenAI accepts it as
* a function-tool schema.
*
* Frontend tools are often authored with permissive Zod (`z.any()`,
* `z.record(...)`, `.passthrough()`), which serialize to open objects —
* `additionalProperties: {}` (an empty sub-schema) or `additionalProperties:
* true`. OpenAI rejects both: strict mode requires `additionalProperties:
* false`, and an empty `{}` sub-schema fails base validation ("schema must
* have a 'type' key"). The classic (Vercel AI SDK) path sanitized these
* implicitly via a Zod round-trip; the TanStack path forwards the raw schema,
* so we close open objects here to match. (Models can't supply free-form extra
* keys either way — same as the classic path.)
*/
function sanitizeClientToolSchema(schema: unknown): unknown {
if (Array.isArray(schema)) {
return schema.map(sanitizeClientToolSchema);
}
if (!schema || typeof schema !== "object") {
return schema;
}
const node: Record<string, unknown> = {
...(schema as Record<string, unknown>),
};
// Any `additionalProperties` (empty `{}`, `true`, or a sub-schema) becomes
// `false` — the only form OpenAI accepts for strict function tools.
if ("additionalProperties" in node) {
node.additionalProperties = false;
}
if (node.properties && typeof node.properties === "object") {
const props: Record<string, unknown> = {};
for (const [key, value] of Object.entries(
node.properties as Record<string, unknown>,
)) {
props[key] = sanitizeClientToolSchema(value);
}
node.properties = props;
}
if ("items" in node) {
node.items = sanitizeClientToolSchema(node.items);
}
for (const combinator of ["anyOf", "allOf", "oneOf"] as const) {
if (Array.isArray(node[combinator])) {
node[combinator] = (node[combinator] as unknown[]).map(
sanitizeClientToolSchema,
);
}
}
return node;
}
/**
* Converts a RunAgentInput into the format expected by TanStack AI's `chat()`.
*
* - Keeps only user/assistant/tool messages (activity, reasoning, and other roles are also excluded)
* - Extracts system/developer messages into `systemPrompts`
* - Appends context entries and application state to `systemPrompts`
* - Preserves tool calls on assistant messages and toolCallId on tool messages
*/
export function convertInputToTanStackAI(
input: RunAgentInput,
): TanStackInputResult {
// Allowlist: only pass user/assistant/tool messages to TanStack.
// Other roles (system, developer, activity, reasoning) are either
// extracted into systemPrompts or not applicable.
const chatRoles = new Set(["user", "assistant", "tool"]);
const messages: TanStackChatMessage[] = input.messages
.filter((m: Message) => chatRoles.has(m.role))
.map((m: Message): TanStackChatMessage => {
const msg: TanStackChatMessage = {
role: m.role as "user" | "assistant" | "tool",
content:
m.role === "user"
? convertUserContent(m.content)
: typeof m.content === "string"
? m.content
: null,
};
if (m.role === "assistant" && "toolCalls" in m && m.toolCalls) {
msg.toolCalls = m.toolCalls.map((tc) => ({
id: tc.id,
type: "function" as const,
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
}));
}
if (m.role === "tool" && "toolCallId" in m) {
msg.toolCallId = (m as Record<string, unknown>).toolCallId as string;
}
return msg;
});
const systemPrompts: string[] = [];
for (const m of input.messages) {
if ((m.role === "system" || m.role === "developer") && m.content) {
systemPrompts.push(
typeof m.content === "string" ? m.content : JSON.stringify(m.content),
);
}
}
if (input.context?.length) {
for (const ctx of input.context) {
systemPrompts.push(`${ctx.description}:\n${ctx.value}`);
}
}
if (
input.state !== undefined &&
input.state !== null &&
typeof input.state === "object" &&
Object.keys(input.state).length > 0
) {
systemPrompts.push(
`Application State:\n\`\`\`json\n${JSON.stringify(input.state, null, 2)}\n\`\`\``,
);
}
// Frontend-provided tools become client-side TanStack tools (no executor):
// the model can call them, TanStack pauses the run, and the AG-UI client
// executes them and resumes — the CopilotKit client-tool round-trip.
const tools: TanStackClientTool[] = (input.tools ?? []).map((t) => ({
__toolSide: "client",
name: t.name,
description: t.description,
inputSchema: sanitizeClientToolSchema(t.parameters),
}));
return { messages, systemPrompts, tools };
}
/**
* Converts a TanStack AI stream into AG-UI `BaseEvent` objects.
*
* This is a pure converter — it does NOT emit lifecycle events
* (RUN_STARTED / RUN_FINISHED / RUN_ERROR). The caller (Agent class)
* is responsible for those.
*
* `pendingInterrupts`, when provided, is filled with one AG-UI Interrupt per
* CUSTOM "approval-requested" chunk (a tool declared `needsApproval: true`).
* The caller turns a non-empty array into a RUN_FINISHED `outcome:interrupt`.
*/
export async function* convertTanStackStream(
stream: AsyncIterable<unknown>,
abortSignal: AbortSignal,
pendingInterrupts?: Interrupt[],
): AsyncGenerator<BaseEvent> {
const messageId = randomUUID();
const toolNamesById = new Map<string, string>();
// Track the reasoning lifecycle at two granularities so closeReasoningIfOpen
// emits exactly the events still owed. A single boolean conflates the run
// (REASONING_START → REASONING_END) with the message
// (REASONING_MESSAGE_START → REASONING_MESSAGE_END) and produces a duplicate
// REASONING_MESSAGE_END when upstream emits MSG_END but not END before
// text/tools resume.
let reasoningRunOpen = false;
let reasoningMessageOpen = false;
let reasoningMessageId = randomUUID();
function* closeReasoningIfOpen(): Generator<BaseEvent> {
if (reasoningMessageOpen) {
reasoningMessageOpen = false;
const msgEnd: ReasoningMessageEndEvent = {
type: EventType.REASONING_MESSAGE_END,
messageId: reasoningMessageId,
};
yield msgEnd;
}
if (reasoningRunOpen) {
reasoningRunOpen = false;
const end: ReasoningEndEvent = {
type: EventType.REASONING_END,
messageId: reasoningMessageId,
};
yield end;
}
}
// TanStack's chat() engine runs a multi-turn agent loop and emits a
// RUN_STARTED / RUN_FINISHED pair PER model turn — not once for the whole
// run. When it executes a tool itself (an MCP server tool or a provider tool
// like web_search), it does so between turns and streams a TOOL_CALL_RESULT
// followed by the next turn's text. The overall run lifecycle is owned by the
// Agent wrapper (it emits exactly one outer RUN_STARTED / RUN_FINISHED), so
// we drop TanStack's per-turn lifecycle markers and convert every content
// event across all turns. (A previous version stopped converting at the first
// RUN_FINISHED — that truncated the run at the first tool turn and silently
// dropped both the tool result and the model's final answer.)
//
// chat() can re-announce a tool call when it re-prompts after executing it,
// so START / END are de-duplicated by toolCallId to avoid emitting a pair
// twice (which would violate the ag-ui verify middleware).
const startedToolCalls = new Set<string>();
const endedToolCalls = new Set<string>();
for await (const chunk of stream) {
if (abortSignal.aborted) break;
const raw = chunk as Record<string, unknown>;
const type = raw.type as string;
// TanStack native human-in-the-loop: a tool declared `needsApproval: true`
// emits a CUSTOM "approval-requested" chunk. These are built from the
// finish event and can arrive around lifecycle markers, so handle them
// before dropping TanStack's per-turn lifecycle events.
// The tool-call lifecycle was already streamed in the model pass.
if (type === "CUSTOM" && raw.name === "approval-requested") {
const value = (raw.value ?? {}) as {
toolCallId?: string;
toolName?: string;
};
const toolCallId = value.toolCallId;
if (toolCallId) {
pendingInterrupts?.push({
id: toolCallId,
toolCallId,
reason: "tool_approval",
message: value.toolName ? `Approve "${value.toolName}"?` : undefined,
...(value.toolName ? { metadata: { toolName: value.toolName } } : {}),
});
}
continue;
}
// Per-turn lifecycle markers are owned by the Agent wrapper, not forwarded.
if (type === "RUN_STARTED" || type === "RUN_FINISHED") {
continue;
}
// Surface engine errors instead of dropping them: throw so the Agent
// wrapper emits a terminal RUN_ERROR. Without this a failed run (e.g. a
// provider 4xx) would finish empty with no indication of what went wrong.
if (type === "RUN_ERROR") {
throw new Error(
typeof raw.message === "string" ? raw.message : "TanStack AI run error",
);
}
if (type === "TEXT_MESSAGE_CONTENT" && raw.delta != null) {
yield* closeReasoningIfOpen();
const textEvent: TextMessageChunkEvent = {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
messageId,
delta: raw.delta as string,
};
yield textEvent;
} else if (type === "TOOL_CALL_START") {
const toolCallId = raw.toolCallId as string;
if (startedToolCalls.has(toolCallId)) continue;
startedToolCalls.add(toolCallId);
yield* closeReasoningIfOpen();
toolNamesById.set(toolCallId, raw.toolCallName as string);
const startEvent: ToolCallStartEvent = {
type: EventType.TOOL_CALL_START,
parentMessageId: messageId,
toolCallId,
toolCallName: raw.toolCallName as string,
};
yield startEvent;
} else if (type === "TOOL_CALL_ARGS") {
// Drop args re-announced after the call has ended (the re-prompt pass);
// forwarding them would corrupt the already-closed call's accumulated args.
if (endedToolCalls.has(raw.toolCallId as string)) continue;
yield* closeReasoningIfOpen();
const argsEvent: ToolCallArgsEvent = {
type: EventType.TOOL_CALL_ARGS,
toolCallId: raw.toolCallId as string,
delta: raw.delta as string,
};
yield argsEvent;
} else if (type === "TOOL_CALL_END") {
const toolCallId = raw.toolCallId as string;
if (endedToolCalls.has(toolCallId)) continue;
endedToolCalls.add(toolCallId);
yield* closeReasoningIfOpen();
const endEvent: ToolCallEndEvent = {
type: EventType.TOOL_CALL_END,
toolCallId,
};
yield endEvent;
} else if (type === "TOOL_CALL_RESULT") {
yield* closeReasoningIfOpen();
const toolCallId = raw.toolCallId as string;
const toolName = toolNamesById.get(toolCallId);
// Accept the payload from either `content` (canonical TanStack shape)
// or `result` (alternate shape used by some adapters / tests). Both
// state-tool detection and the final TOOL_CALL_RESULT serialization
// must read the same field, otherwise STATE_SNAPSHOT/STATE_DELTA can
// be silently dropped when upstream uses `result`.
const rawPayload = raw.content ?? raw.result;
const parsedContent =
typeof rawPayload === "string" ? safeParse(rawPayload) : rawPayload;
if (
toolName === "AGUISendStateSnapshot" &&
parsedContent &&
typeof parsedContent === "object" &&
"snapshot" in parsedContent
) {
const stateSnapshotEvent: StateSnapshotEvent = {
type: EventType.STATE_SNAPSHOT,
snapshot: (parsedContent as Record<string, unknown>).snapshot,
};
yield stateSnapshotEvent;
}
if (
toolName === "AGUISendStateDelta" &&
parsedContent &&
typeof parsedContent === "object" &&
"delta" in parsedContent
) {
const stateDeltaEvent: StateDeltaEvent = {
type: EventType.STATE_DELTA,
delta: (parsedContent as Record<string, unknown>).delta as never,
};
yield stateDeltaEvent;
}
let serializedContent: string;
if (typeof rawPayload === "string") {
serializedContent = rawPayload;
} else {
try {
serializedContent = JSON.stringify(rawPayload ?? null);
} catch {
serializedContent = "[Unserializable tool result]";
}
}
const resultEvent: ToolCallResultEvent = {
type: EventType.TOOL_CALL_RESULT,
role: "tool",
messageId: randomUUID(),
toolCallId,
content: serializedContent,
};
yield resultEvent;
toolNamesById.delete(toolCallId);
} else if (type === "REASONING_START") {
// If a prior reasoning run is still open (no REASONING_END before this
// new START), close it cleanly first so MSG_END / END pair correctly.
yield* closeReasoningIfOpen();
reasoningRunOpen = true;
reasoningMessageId = (raw.messageId as string) ?? randomUUID();
const startEvt: ReasoningStartEvent = {
type: EventType.REASONING_START,
messageId: reasoningMessageId,
};
yield startEvt;
} else if (type === "REASONING_MESSAGE_START") {
reasoningMessageOpen = true;
const evt: ReasoningMessageStartEvent = {
type: EventType.REASONING_MESSAGE_START,
messageId: reasoningMessageId,
role: "reasoning",
};
yield evt;
} else if (type === "REASONING_MESSAGE_CONTENT") {
const evt: ReasoningMessageContentEvent = {
type: EventType.REASONING_MESSAGE_CONTENT,
messageId: reasoningMessageId,
delta: raw.delta as string,
};
yield evt;
} else if (type === "REASONING_MESSAGE_END") {
reasoningMessageOpen = false;
const evt: ReasoningMessageEndEvent = {
type: EventType.REASONING_MESSAGE_END,
messageId: reasoningMessageId,
};
yield evt;
} else if (type === "REASONING_END") {
// If upstream sends REASONING_END while a message is still open, emit
// the missing REASONING_MESSAGE_END FIRST so the closing pair stays in
// order (MSG_END before END). Otherwise the next non-reasoning chunk
// would trigger closeReasoningIfOpen and emit MSG_END after END.
if (reasoningMessageOpen) {
reasoningMessageOpen = false;
const msgEnd: ReasoningMessageEndEvent = {
type: EventType.REASONING_MESSAGE_END,
messageId: reasoningMessageId,
};
yield msgEnd;
}
reasoningRunOpen = false;
const evt: ReasoningEndEvent = {
type: EventType.REASONING_END,
messageId: reasoningMessageId,
};
yield evt;
}
}
yield* closeReasoningIfOpen();
}
function safeParse(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return value;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,256 @@
import { RemoteLangGraphEventSource } from "../event-source";
import { LangGraphEventTypes } from "../events";
/**
* Access private methods for testing via a helper.
* These are pure functions that extract data from LangGraph events.
*/
function getSource() {
return new RemoteLangGraphEventSource();
}
// Helper to call private methods. Returns `any` — type safety is traded for
// access to private implementation details that have no public test surface.
function callPrivate(
source: RemoteLangGraphEventSource,
method: string,
...args: any[]
) {
return (source as any)[method](...args);
}
describe("shouldEmitToolCall", () => {
const source = getSource();
it("returns true when shouldEmitToolCalls is true (boolean)", () => {
expect(callPrivate(source, "shouldEmitToolCall", true, "anyTool")).toBe(
true,
);
});
it("returns false when shouldEmitToolCalls is false (boolean)", () => {
expect(callPrivate(source, "shouldEmitToolCall", false, "anyTool")).toBe(
false,
);
});
it("returns true when tool name matches string", () => {
expect(
callPrivate(source, "shouldEmitToolCall", "SearchTool", "SearchTool"),
).toBe(true);
});
it("returns false when tool name does not match string", () => {
expect(
callPrivate(source, "shouldEmitToolCall", "SearchTool", "OtherTool"),
).toBe(false);
});
it("returns true when tool name is in array", () => {
expect(
callPrivate(
source,
"shouldEmitToolCall",
["SearchTool", "FetchTool"],
"FetchTool",
),
).toBe(true);
});
it("returns false when tool name is not in array", () => {
expect(
callPrivate(
source,
"shouldEmitToolCall",
["SearchTool", "FetchTool"],
"OtherTool",
),
).toBe(false);
});
});
describe("getCurrentMessageId", () => {
const source = getSource();
it("extracts id from standard kwargs layout", () => {
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: { kwargs: { id: "msg-std-123" } } },
};
expect(callPrivate(source, "getCurrentMessageId", event)).toBe(
"msg-std-123",
);
});
it("extracts id from LangGraph Platform layout (no kwargs)", () => {
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: { id: "msg-plat-456" } },
};
expect(callPrivate(source, "getCurrentMessageId", event)).toBe(
"msg-plat-456",
);
});
it("prefers kwargs layout when both present", () => {
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: { kwargs: { id: "kwargs-id" }, id: "direct-id" } },
};
expect(callPrivate(source, "getCurrentMessageId", event)).toBe("kwargs-id");
});
it("returns undefined when neither layout is present", () => {
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: {} },
};
expect(callPrivate(source, "getCurrentMessageId", event)).toBeUndefined();
});
it("handles missing data gracefully", () => {
const event = { event: LangGraphEventTypes.OnChatModelStream };
expect(callPrivate(source, "getCurrentMessageId", event)).toBeUndefined();
});
});
describe("getCurrentToolCallChunks", () => {
const source = getSource();
it("extracts chunks from standard kwargs layout", () => {
const chunks = [{ name: "tool1", args: "{}", id: "tc-1", index: 0 }];
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: { kwargs: { tool_call_chunks: chunks } } },
};
expect(callPrivate(source, "getCurrentToolCallChunks", event)).toEqual(
chunks,
);
});
it("extracts chunks from LangGraph Platform layout", () => {
const chunks = [{ name: "tool2", args: "{}", id: "tc-2", index: 0 }];
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: { tool_call_chunks: chunks } },
};
expect(callPrivate(source, "getCurrentToolCallChunks", event)).toEqual(
chunks,
);
});
it("returns undefined when no chunks present", () => {
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: {} },
};
expect(
callPrivate(source, "getCurrentToolCallChunks", event),
).toBeUndefined();
});
});
describe("getResponseMetadata", () => {
const source = getSource();
it("extracts metadata from standard kwargs layout", () => {
const meta = { finish_reason: "stop" };
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: { kwargs: { response_metadata: meta } } },
};
expect(callPrivate(source, "getResponseMetadata", event)).toEqual(meta);
});
it("extracts metadata from LangGraph Platform layout", () => {
const meta = { finish_reason: "tool_calls" };
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: { response_metadata: meta } },
};
expect(callPrivate(source, "getResponseMetadata", event)).toEqual(meta);
});
it("returns undefined when no metadata present", () => {
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: {} },
};
expect(callPrivate(source, "getResponseMetadata", event)).toBeUndefined();
});
});
describe("getCurrentContent", () => {
const source = getSource();
it("extracts string content from kwargs layout", () => {
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: { kwargs: { content: "hello world" } } },
};
expect(callPrivate(source, "getCurrentContent", event)).toBe("hello world");
});
it("extracts string content from Platform layout", () => {
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: { content: "platform content" } },
};
expect(callPrivate(source, "getCurrentContent", event)).toBe(
"platform content",
);
});
it("extracts text from array content (Anthropic format)", () => {
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: {
chunk: {
kwargs: {
content: [{ text: "array text", type: "text", index: 0 }],
},
},
},
};
expect(callPrivate(source, "getCurrentContent", event)).toBe("array text");
});
it("returns null when no content and no tool call chunks", () => {
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: { kwargs: {} } },
};
expect(callPrivate(source, "getCurrentContent", event)).toBeNull();
});
it("falls back to tool_call_chunks args when no content", () => {
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: {
chunk: {
kwargs: {
content: "",
tool_call_chunks: [{ args: '{"key":"val"}' }],
},
},
},
};
expect(callPrivate(source, "getCurrentContent", event)).toBe(
'{"key":"val"}',
);
});
it("handles missing data gracefully", () => {
const event = { event: LangGraphEventTypes.OnChatModelStream };
expect(callPrivate(source, "getCurrentContent", event)).toBeNull();
});
it("returns empty string when content is empty string", () => {
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: { kwargs: { content: "" } } },
};
// Empty string is a valid string, so typeof === "string" returns it as-is
expect(callPrivate(source, "getCurrentContent", event)).toBe("");
});
});
@@ -0,0 +1,365 @@
import {
CopilotKitLowLevelError,
isStructuredCopilotKitError,
} from "@copilotkit/shared";
import { catchError, mergeMap, ReplaySubject, scan } from "rxjs";
import { generateHelpfulErrorMessage } from "../../lib/streaming";
import type { RuntimeEvent } from "../../service-adapters/events";
import {
RuntimeEventTypes,
RuntimeMetaEventName,
} from "../../service-adapters/events";
import type { LangGraphEvent } from "./events";
import { CustomEventNames, LangGraphEventTypes } from "./events";
interface LangGraphEventWithState {
event: LangGraphEvent | null;
isMessageStart: boolean;
isMessageEnd: boolean;
isToolCallStart: boolean;
isToolCallEnd: boolean;
isToolCall: boolean;
lastMessageId: string | null;
lastToolCallId: string | null;
lastToolCallName: string | null;
currentContent: string | null;
processedToolCallIds: Set<string>;
}
export class RemoteLangGraphEventSource {
public eventStream$ = new ReplaySubject<LangGraphEvent>();
private shouldEmitToolCall(
shouldEmitToolCalls: string | string[] | boolean,
toolCallName: string,
) {
if (typeof shouldEmitToolCalls === "boolean") {
return shouldEmitToolCalls;
}
if (Array.isArray(shouldEmitToolCalls)) {
return shouldEmitToolCalls.includes(toolCallName);
}
return shouldEmitToolCalls === toolCallName;
}
// LangGraph Platform implementation stores data outside of kwargs, and not
// every member of the LangGraphEvent union carries a `data` payload.
private getEventData(event: LangGraphEvent) {
return "data" in event ? event.data : undefined;
}
private getCurrentContent(event: LangGraphEvent) {
const data = this.getEventData(event);
const content = data?.chunk?.kwargs?.content ?? data?.chunk?.content;
if (!content) {
const toolCallChunks = this.getCurrentToolCallChunks(event) ?? [];
for (const chunk of toolCallChunks) {
if (chunk.args) {
return chunk.args;
}
}
}
if (typeof content === "string") {
return content;
} else if (Array.isArray(content) && content.length > 0) {
return content[0].text;
}
return null;
}
private getCurrentMessageId(event: LangGraphEvent) {
const data = this.getEventData(event);
return data?.chunk?.kwargs?.id ?? data?.chunk?.id;
}
private getCurrentToolCallChunks(event: LangGraphEvent) {
const data = this.getEventData(event);
return (
data?.chunk?.kwargs?.tool_call_chunks ?? data?.chunk?.tool_call_chunks
);
}
private getResponseMetadata(event: LangGraphEvent) {
const data = this.getEventData(event);
return (
data?.chunk?.kwargs?.response_metadata ?? data?.chunk?.response_metadata
);
}
processLangGraphEvents() {
let lastEventWithState: LangGraphEventWithState | null = null;
return this.eventStream$.pipe(
scan(
(acc, event) => {
if (event.event === LangGraphEventTypes.OnChatModelStream) {
const prevMessageId = acc.lastMessageId;
acc.currentContent = this.getCurrentContent(event);
acc.lastMessageId =
this.getCurrentMessageId(event) ?? acc.lastMessageId;
const toolCallChunks = this.getCurrentToolCallChunks(event) ?? [];
const responseMetadata = this.getResponseMetadata(event);
// Check if a given event is a tool call
const toolCallCheck = toolCallChunks && toolCallChunks.length > 0;
let isToolCallEnd =
responseMetadata?.finish_reason === "tool_calls";
acc.isToolCallStart = toolCallChunks.some(
(chunk: any) => chunk.name && chunk.id,
);
acc.isMessageStart =
prevMessageId !== acc.lastMessageId && !acc.isToolCallStart;
let previousRoundHadToolCall = acc.isToolCall;
acc.isToolCall = toolCallCheck;
// Previous "acc.isToolCall" was set but now it won't pass the check, it means the tool call just ended.
if (previousRoundHadToolCall && !toolCallCheck) {
isToolCallEnd = true;
}
acc.isToolCallEnd = isToolCallEnd;
acc.isMessageEnd = responseMetadata?.finish_reason === "stop";
({ name: acc.lastToolCallName, id: acc.lastToolCallId } =
toolCallChunks.find((chunk: any) => chunk.name && chunk.id) ?? {
name: acc.lastToolCallName,
id: acc.lastToolCallId,
});
}
acc.event = event;
lastEventWithState = acc; // Capture the state
return acc;
},
{
event: null,
isMessageStart: false,
isMessageEnd: false,
isToolCallStart: false,
isToolCallEnd: false,
isToolCall: false,
lastMessageId: null,
lastToolCallId: null,
lastToolCallName: null,
currentContent: null,
processedToolCallIds: new Set<string>(),
} as LangGraphEventWithState,
),
mergeMap((acc): RuntimeEvent[] => {
const events: RuntimeEvent[] = [];
let shouldEmitMessages = true;
let shouldEmitToolCalls: string | string[] | boolean = true;
if (acc.event.event == LangGraphEventTypes.OnChatModelStream) {
if ("copilotkit:emit-tool-calls" in (acc.event.metadata || {})) {
shouldEmitToolCalls =
acc.event.metadata["copilotkit:emit-tool-calls"];
}
if ("copilotkit:emit-messages" in (acc.event.metadata || {})) {
shouldEmitMessages = acc.event.metadata["copilotkit:emit-messages"];
}
}
if (acc.event.event === LangGraphEventTypes.OnInterrupt) {
events.push({
type: RuntimeEventTypes.MetaEvent,
name: RuntimeMetaEventName.LangGraphInterruptEvent,
value: acc.event.value,
});
}
if (acc.event.event === LangGraphEventTypes.OnCopilotKitInterrupt) {
events.push({
type: RuntimeEventTypes.MetaEvent,
name: RuntimeMetaEventName.CopilotKitLangGraphInterruptEvent,
data: acc.event.data,
});
}
// Handle CopilotKit error events with preserved semantic information
if (acc.event.event === LangGraphEventTypes.OnCopilotKitError) {
const errorData = acc.event.data.error;
// Create a structured error with the original semantic information
const preservedError = new CopilotKitLowLevelError({
error: new Error(errorData.message),
url: "langgraph agent",
message: `${errorData.type}: ${errorData.message}`,
});
// Add additional error context to the error object
if (errorData.status_code) {
(preservedError as any).statusCode = errorData.status_code;
}
if (errorData.response_data) {
(preservedError as any).responseData = errorData.response_data;
}
(preservedError as any).agentName = errorData.agent_name;
(preservedError as any).originalErrorType = errorData.type;
// Throw the structured error to be handled by the catchError operator
throw preservedError;
}
const responseMetadata = this.getResponseMetadata(acc.event);
// Tool call ended: emit ActionExecutionEnd
if (
acc.isToolCallEnd &&
this.shouldEmitToolCall(shouldEmitToolCalls, acc.lastToolCallName) &&
acc.lastToolCallId &&
!acc.processedToolCallIds.has(acc.lastToolCallId)
) {
acc.processedToolCallIds.add(acc.lastToolCallId);
events.push({
type: RuntimeEventTypes.ActionExecutionEnd,
actionExecutionId: acc.lastToolCallId,
});
}
// Message ended: emit TextMessageEnd
else if (
responseMetadata?.finish_reason === "stop" &&
shouldEmitMessages
) {
events.push({
type: RuntimeEventTypes.TextMessageEnd,
messageId: acc.lastMessageId,
});
}
switch (acc.event!.event) {
//
// Custom events
//
case LangGraphEventTypes.OnCustomEvent:
//
// Manually emit a message
//
if (
acc.event.name === CustomEventNames.CopilotKitManuallyEmitMessage
) {
events.push({
type: RuntimeEventTypes.TextMessageStart,
messageId: acc.event.data.message_id,
});
events.push({
type: RuntimeEventTypes.TextMessageContent,
messageId: acc.event.data.message_id,
content: acc.event.data.message,
});
events.push({
type: RuntimeEventTypes.TextMessageEnd,
messageId: acc.event.data.message_id,
});
}
//
// Manually emit a tool call
//
else if (
acc.event.name === CustomEventNames.CopilotKitManuallyEmitToolCall
) {
events.push({
type: RuntimeEventTypes.ActionExecutionStart,
actionExecutionId: acc.event.data.id,
actionName: acc.event.data.name,
parentMessageId: acc.event.data.id,
});
events.push({
type: RuntimeEventTypes.ActionExecutionArgs,
actionExecutionId: acc.event.data.id,
args: JSON.stringify(acc.event.data.args),
});
events.push({
type: RuntimeEventTypes.ActionExecutionEnd,
actionExecutionId: acc.event.data.id,
});
}
break;
case LangGraphEventTypes.OnCopilotKitStateSync:
events.push({
type: RuntimeEventTypes.AgentStateMessage,
threadId: acc.event.thread_id,
role: acc.event.role,
agentName: acc.event.agent_name,
nodeName: acc.event.node_name,
runId: acc.event.run_id,
active: acc.event.active,
state: JSON.stringify(acc.event.state),
running: acc.event.running,
});
break;
case LangGraphEventTypes.OnChatModelStream:
if (
acc.isToolCallStart &&
this.shouldEmitToolCall(shouldEmitToolCalls, acc.lastToolCallName)
) {
events.push({
type: RuntimeEventTypes.ActionExecutionStart,
actionExecutionId: acc.lastToolCallId,
actionName: acc.lastToolCallName,
parentMessageId: acc.lastMessageId,
});
}
// Message started: emit TextMessageStart
else if (acc.isMessageStart && shouldEmitMessages) {
acc.processedToolCallIds.clear();
events.push({
type: RuntimeEventTypes.TextMessageStart,
messageId: acc.lastMessageId,
});
}
// Tool call args: emit ActionExecutionArgs
if (
acc.isToolCall &&
acc.currentContent &&
this.shouldEmitToolCall(shouldEmitToolCalls, acc.lastToolCallName)
) {
events.push({
type: RuntimeEventTypes.ActionExecutionArgs,
actionExecutionId: acc.lastToolCallId,
args: acc.currentContent,
});
}
// Message content: emit TextMessageContent
else if (
!acc.isToolCall &&
acc.currentContent &&
shouldEmitMessages
) {
events.push({
type: RuntimeEventTypes.TextMessageContent,
messageId: acc.lastMessageId,
content: acc.currentContent,
});
}
break;
}
return events;
}),
catchError((error) => {
// If it's a structured CopilotKitError, re-throw it to be handled by the frontend error system
if (isStructuredCopilotKitError(error)) {
throw error;
}
// Determine a more helpful error message based on context
let helpfulMessage = generateHelpfulErrorMessage(
error,
"LangGraph agent connection",
);
// For all other errors, preserve the raw error information in a structured format
throw new CopilotKitLowLevelError({
error: error instanceof Error ? error : new Error(String(error)),
url: "langgraph event stream",
message: helpfulMessage,
});
}),
);
}
}
@@ -0,0 +1,394 @@
import {
ActionExecutionMessage,
ResultMessage,
TextMessage,
} from "../../graphql/types/converted";
export enum LangGraphEventTypes {
OnChainStart = "on_chain_start",
OnChainStream = "on_chain_stream",
OnChainEnd = "on_chain_end",
OnChatModelStart = "on_chat_model_start",
OnChatModelStream = "on_chat_model_stream",
OnChatModelEnd = "on_chat_model_end",
OnToolStart = "on_tool_start",
OnToolEnd = "on_tool_end",
OnCopilotKitStateSync = "on_copilotkit_state_sync",
OnCopilotKitEmitMessage = "on_copilotkit_emit_message",
OnCopilotKitEmitToolCall = "on_copilotkit_emit_tool_call",
OnCustomEvent = "on_custom_event",
OnInterrupt = "on_interrupt",
OnCopilotKitInterrupt = "on_copilotkit_interrupt",
OnCopilotKitError = "on_copilotkit_error",
}
export enum MetaEventNames {
LangGraphInterruptEvent = "LangGraphInterruptEvent",
CopilotKitLangGraphInterruptEvent = "CopilotKitLangGraphInterruptEvent",
}
export enum CustomEventNames {
CopilotKitManuallyEmitMessage = "copilotkit_manually_emit_message",
CopilotKitManuallyEmitToolCall = "copilotkit_manually_emit_tool_call",
CopilotKitManuallyEmitIntermediateState = "copilotkit_manually_emit_intermediate_state",
CopilotKitExit = "copilotkit_exit",
}
type LangGraphOnCopilotKitStateSyncEvent = {
event: LangGraphEventTypes.OnCopilotKitStateSync;
thread_id: string;
agent_name: string;
node_name: string;
run_id: string;
active: boolean;
role: string;
state: any;
running: boolean;
};
type LangGraphOnChainStartEvent = {
event: LangGraphEventTypes.OnChainStart;
run_id: string;
name: string;
tags: string[];
metadata: { thread_id: string };
data: {
input: any;
};
parent_ids: string[];
};
type LangGraphOnChainEndEvent = {
event: LangGraphEventTypes.OnChainEnd;
name: string;
run_id: string;
tags: string[];
metadata: {
thread_id: string;
langgraph_step: number;
langgraph_node: string;
langgraph_triggers: string[];
langgraph_task_idx: number;
thread_ts: string;
};
data: {
input: any;
output: any;
};
parent_ids: string[];
};
type LangGraphOnChatModelStartEvent = {
event: LangGraphEventTypes.OnChatModelStart;
name: string;
run_id: string;
tags: string[];
metadata: {
thread_id: string;
langgraph_step: number;
langgraph_node: string;
langgraph_triggers: string[];
langgraph_task_idx: number;
thread_ts: string;
ls_provider: string;
ls_model_name: string;
ls_model_type: string;
ls_temperature: number;
};
data: {
input: {
messages: {
lc: number;
type: string;
id: string[];
kwargs: {
content: string;
type: string;
id: string;
};
}[][];
};
};
parent_ids: string[];
};
type LangGraphOnChatModelStreamEvent = {
event: LangGraphEventTypes.OnChatModelStream;
name: string;
run_id: string;
tags: string[];
metadata: {
thread_id: string;
langgraph_step: number;
langgraph_node: string;
langgraph_triggers: string[];
langgraph_task_idx: number;
thread_ts: string;
ls_provider: string;
ls_model_name: string;
ls_model_type: string;
ls_temperature: number;
};
data: {
chunk: {
lc: number;
type: string;
id: string;
kwargs: {
content: string | { text: string; type: string; index: number }[];
additional_kwargs: {
tool_calls: {
index: number;
id: string;
function: { arguments: string; name: string };
type: string;
}[];
};
type: string;
id: string;
tool_calls: { name: string; args: {}; id: string; type: string }[];
tool_call_chunks: {
name: string;
args: string;
id: string;
index: number;
type: string;
}[];
invalid_tool_calls: any[];
};
};
};
parent_ids: string[];
};
type LangGraphOnChatModelEndEvent = {
event: LangGraphEventTypes.OnChatModelEnd;
name: string;
run_id: string;
tags: string[];
metadata: {
thread_id: string;
langgraph_step: number;
langgraph_node: string;
langgraph_triggers: string[];
langgraph_task_idx: number;
thread_ts: string;
ls_provider: string;
ls_model_name: string;
ls_model_type: string;
ls_temperature: number;
};
data: {
input: any;
output: {
generations: {
text: string;
generation_info: {
finish_reason: string;
model_name: string;
system_fingerprint: string;
};
type: string;
message: {
lc: number;
type: string;
id: string[];
kwargs: {
content: string;
additional_kwargs: {
tool_calls: {
index: number;
id: string;
function: { arguments: string; name: string };
type: string;
}[];
};
response_metadata: {
finish_reason: string;
model_name: string;
system_fingerprint: string;
};
type: string;
id: string;
tool_calls: {
name: string;
args: { query: string };
id: string;
type: string;
}[];
invalid_tool_calls: any[];
};
};
}[][];
llm_output: any;
run: any;
};
};
parent_ids: string[];
};
type LangGraphOnChainStreamEvent = {
event: LangGraphEventTypes.OnChainStream;
name: string;
run_id: string;
tags: string[];
metadata: {
thread_id: string;
langgraph_step?: number;
langgraph_node?: string;
langgraph_triggers?: string[];
langgraph_task_idx?: number;
thread_ts?: string;
};
data: {
chunk: {
messages: {
lc: number;
type: string;
id: string[];
kwargs: {
content: string;
additional_kwargs?: {
tool_calls?: {
index: number;
id: string;
function: { arguments: string; name: string };
type: string;
}[];
};
response_metadata?: {
finish_reason: string;
model_name: string;
system_fingerprint: string;
};
type: string;
id: string;
tool_calls?: {
name: string;
args: { query: string };
id: string;
type: string;
}[];
invalid_tool_calls?: any[];
};
}[];
};
};
parent_ids: string[];
};
type LangGraphOnToolStartEvent = {
event: LangGraphEventTypes.OnToolStart;
name: string;
run_id: string;
tags: string[];
metadata: {
thread_id: string;
langgraph_step: number;
langgraph_node: string;
langgraph_triggers: string[];
langgraph_task_idx: number;
thread_ts: string;
};
data: {
input: {
query: string;
};
};
parent_ids: string[];
};
type LangGraphOnToolEndEvent = {
event: LangGraphEventTypes.OnToolEnd;
name: string;
run_id: string;
tags: string[];
metadata: {
thread_id: string;
langgraph_step: number;
langgraph_node: string;
langgraph_triggers: string[];
langgraph_task_idx: number;
thread_ts: string;
};
data: {
input: {
query: string;
};
output: {
lc: number;
type: string;
id: string[];
kwargs: {
content: string[];
type: string;
name: string;
tool_call_id: string;
status: string;
};
};
};
parent_ids: string[];
};
type LangGraphOnCustomEvent = {
event: LangGraphEventTypes.OnCustomEvent;
run_id: string;
name: string;
tags: string[];
metadata: {
thread_id: string;
langgraph_step: number;
langgraph_node: string;
langgraph_triggers: string[];
langgraph_path: [string, string];
langgraph_checkpoint_ns: string;
checkpoint_ns: string;
};
data: any;
parent_ids: string[];
};
interface LangGraphInterruptEvent {
event: LangGraphEventTypes.OnInterrupt;
value: string;
}
interface CopilotKitLangGraphInterruptEvent {
event: LangGraphEventTypes.OnCopilotKitInterrupt;
data: {
value: string;
messages: (TextMessage | ActionExecutionMessage | ResultMessage)[];
};
}
interface CopilotKitLangGraphErrorEvent {
event: LangGraphEventTypes.OnCopilotKitError;
data: {
error: {
message: string;
type: string;
agent_name: string;
status_code?: number;
response_data?: any;
};
thread_id: string;
agent_name: string;
node_name: string;
};
}
export type LangGraphEvent =
| LangGraphOnChainStartEvent
| LangGraphOnChainStreamEvent
| LangGraphOnChainEndEvent
| LangGraphOnChatModelStartEvent
| LangGraphOnChatModelStreamEvent
| LangGraphOnChatModelEndEvent
| LangGraphOnToolStartEvent
| LangGraphOnToolEndEvent
| LangGraphOnCopilotKitStateSyncEvent
| LangGraphOnCustomEvent
| LangGraphInterruptEvent
| CopilotKitLangGraphInterruptEvent
| CopilotKitLangGraphErrorEvent;
@@ -0,0 +1,16 @@
import { Field, InputType } from "type-graphql";
import { ActionInputAvailability } from "../types/enums";
@InputType()
export class ActionInput {
@Field(() => String)
name: string;
@Field(() => String)
description: string;
@Field(() => String)
jsonSchema: string;
@Field(() => ActionInputAvailability, { nullable: true })
available?: ActionInputAvailability;
}
@@ -0,0 +1,13 @@
import { Field, InputType } from "type-graphql";
@InputType()
export class AgentSessionInput {
@Field(() => String)
agentName: string;
@Field(() => String, { nullable: true })
threadId?: string;
@Field(() => String, { nullable: true })
nodeName?: string;
}
@@ -0,0 +1,13 @@
import { Field, InputType } from "type-graphql";
@InputType()
export class AgentStateInput {
@Field(() => String)
agentName: string;
@Field(() => String)
state: string;
@Field(() => String, { nullable: true })
config?: string;
}
@@ -0,0 +1,16 @@
import { Field, InputType } from "type-graphql";
@InputType()
export class GuardrailsRuleInput {
@Field(() => [String], { nullable: true })
allowList?: string[] = [];
@Field(() => [String], { nullable: true })
denyList?: string[] = [];
}
@InputType()
export class GuardrailsInput {
@Field(() => GuardrailsRuleInput, { nullable: false })
inputValidationRules: GuardrailsRuleInput;
}
@@ -0,0 +1,8 @@
import { Field, InputType } from "type-graphql";
import { GuardrailsInput } from "./cloud-guardrails.input";
@InputType()
export class CloudInput {
@Field(() => GuardrailsInput, { nullable: true })
guardrails?: GuardrailsInput;
}
@@ -0,0 +1,10 @@
import { Field, InputType } from "type-graphql";
@InputType()
export class ContextPropertyInput {
@Field(() => String)
value: string;
@Field(() => String)
description: string;
}
@@ -0,0 +1,10 @@
import { Field, InputType } from "type-graphql";
@InputType()
export class CopilotContextInput {
@Field(() => String)
description: string;
@Field(() => String)
value: string;
}
@@ -0,0 +1,15 @@
import { Field, InputType, Int, createUnionType } from "type-graphql";
const PrimitiveUnion = createUnionType({
name: "Primitive",
types: () => [String, Number, Boolean] as const,
});
@InputType()
export class CustomPropertyInput {
@Field(() => String)
key: string;
@Field(() => PrimitiveUnion)
value: string;
}
@@ -0,0 +1,21 @@
import { Field, InputType } from "type-graphql";
/**
* The extensions input is used to pass additional information to the copilot runtime, specific to a
* service adapter or agent framework.
*/
@InputType()
export class OpenAIApiAssistantAPIInput {
@Field(() => String, { nullable: true })
runId?: string;
@Field(() => String, { nullable: true })
threadId?: string;
}
@InputType()
export class ExtensionsInput {
@Field(() => OpenAIApiAssistantAPIInput, { nullable: true })
openaiAssistantAPI?: OpenAIApiAssistantAPIInput;
}
@@ -0,0 +1,22 @@
import { Field, InputType } from "type-graphql";
@InputType()
export class ForwardedParametersInput {
@Field(() => String, { nullable: true })
model?: string;
@Field(() => Number, { nullable: true })
maxTokens?: number;
@Field(() => [String], { nullable: true })
stop?: string[];
@Field(() => String, { nullable: true })
toolChoice?: String;
@Field(() => String, { nullable: true })
toolChoiceFunctionName?: string;
@Field(() => Number, { nullable: true })
temperature?: number;
}
@@ -0,0 +1,14 @@
import { Field, InputType } from "type-graphql";
import { ActionInput } from "./action.input";
@InputType()
export class FrontendInput {
@Field(() => String, { nullable: true })
toDeprecate_fullContext?: string;
@Field(() => [ActionInput])
actions: ActionInput[];
@Field(() => String, { nullable: true })
url?: string;
}
@@ -0,0 +1,59 @@
import { Field, InputType } from "type-graphql";
import { MessageInput } from "./message.input";
import { FrontendInput } from "./frontend.input";
import { CloudInput } from "./cloud.input";
import { CopilotRequestType } from "../types/enums";
import { ForwardedParametersInput } from "./forwarded-parameters.input";
import { AgentSessionInput } from "./agent-session.input";
import { AgentStateInput } from "./agent-state.input";
import { ExtensionsInput } from "./extensions.input";
import { MetaEventInput } from "./meta-event.input";
import { CopilotContextInput } from "./copilot-context.input";
@InputType()
export class GenerateCopilotResponseMetadataInput {
@Field(() => CopilotRequestType, { nullable: true })
requestType: CopilotRequestType;
}
@InputType()
export class GenerateCopilotResponseInput {
@Field(() => GenerateCopilotResponseMetadataInput, { nullable: false })
metadata: GenerateCopilotResponseMetadataInput;
@Field(() => String, { nullable: true })
threadId?: string;
@Field(() => String, { nullable: true })
runId?: string;
@Field(() => [MessageInput])
messages: MessageInput[];
@Field(() => FrontendInput)
frontend: FrontendInput;
@Field(() => CloudInput, { nullable: true })
cloud?: CloudInput;
@Field(() => ForwardedParametersInput, { nullable: true })
forwardedParameters?: ForwardedParametersInput;
@Field(() => AgentSessionInput, { nullable: true })
agentSession?: AgentSessionInput;
@Field(() => AgentStateInput, { nullable: true })
agentState?: AgentStateInput;
@Field(() => [AgentStateInput], { nullable: true })
agentStates?: AgentStateInput[];
@Field(() => ExtensionsInput, { nullable: true })
extensions?: ExtensionsInput;
@Field(() => [MetaEventInput], { nullable: true })
metaEvents?: MetaEventInput[];
@Field(() => [CopilotContextInput], { nullable: true })
context?: CopilotContextInput[];
}
@@ -0,0 +1,10 @@
import { Field, InputType } from "type-graphql";
@InputType()
export class LoadAgentStateInput {
@Field(() => String)
threadId: string;
@Field(() => String)
agentName: string;
}
@@ -0,0 +1,110 @@
import { Field, InputType } from "type-graphql";
import { MessageRole } from "../types/enums";
import { BaseMessageInput } from "../types/base";
@InputType()
export class TextMessageInput {
@Field(() => String)
content: string;
@Field(() => String, { nullable: true })
parentMessageId?: string;
@Field(() => MessageRole)
role: MessageRole;
}
@InputType()
export class ActionExecutionMessageInput {
@Field(() => String)
name: string;
@Field(() => String)
arguments: string;
@Field(() => String, { nullable: true })
parentMessageId?: string;
@Field(() => String, {
nullable: true,
deprecationReason: "This field will be removed in a future version",
})
scope?: String;
}
@InputType()
export class ResultMessageInput {
@Field(() => String)
actionExecutionId: string;
@Field(() => String)
actionName: string;
@Field(() => String, { nullable: true })
parentMessageId?: string;
@Field(() => String)
result: string;
}
@InputType()
export class AgentStateMessageInput {
@Field(() => String)
threadId: string;
@Field(() => String)
agentName: string;
@Field(() => MessageRole)
role: MessageRole;
@Field(() => String)
state: string;
@Field(() => Boolean)
running: boolean;
@Field(() => String)
nodeName: string;
@Field(() => String)
runId: string;
@Field(() => Boolean)
active: boolean;
}
@InputType()
export class ImageMessageInput {
@Field(() => String)
format: string;
@Field(() => String)
bytes: string;
@Field(() => String, { nullable: true })
parentMessageId?: string;
@Field(() => MessageRole)
role: MessageRole;
}
// GraphQL does not support union types in inputs, so we need to use
// optional fields for the different subtypes.
@InputType()
export class MessageInput extends BaseMessageInput {
@Field(() => TextMessageInput, { nullable: true })
textMessage?: TextMessageInput;
@Field(() => ActionExecutionMessageInput, { nullable: true })
actionExecutionMessage?: ActionExecutionMessageInput;
@Field(() => ResultMessageInput, { nullable: true })
resultMessage?: ResultMessageInput;
@Field(() => AgentStateMessageInput, { nullable: true })
agentStateMessage?: AgentStateMessageInput;
@Field(() => ImageMessageInput, { nullable: true })
imageMessage?: ImageMessageInput;
}
@@ -0,0 +1,18 @@
import { Field, InputType } from "type-graphql";
import { MetaEventName } from "../types/meta-events.type";
import { MessageInput } from "./message.input";
@InputType()
export class MetaEventInput {
@Field(() => MetaEventName)
name: MetaEventName;
@Field(() => String)
value?: string;
@Field(() => String, { nullable: true })
response?: string;
@Field(() => [MessageInput], { nullable: true })
messages?: MessageInput[];
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,391 @@
import * as gql from "../types/converted/index";
import { MessageRole } from "../types/enums";
import * as agui from "@copilotkit/shared"; // named agui for clarity, but this only includes agui message types
// Helper function to extract agent name from message
function extractAgentName(message: agui.Message): string {
if (message.role !== "assistant") {
throw new Error(
`Cannot extract agent name from message with role ${message.role}`,
);
}
return message.agentName || "unknown";
}
// Type guard for agent state message
function isAgentStateMessage(message: agui.Message): boolean {
return (
message.role === "assistant" && "agentName" in message && "state" in message
);
}
// Type guard for messages with image property
function hasImageProperty(
message: agui.Message,
): message is agui.Message & { image: agui.ImageData } {
const canContainImage =
message.role === "assistant" || message.role === "user";
if (!canContainImage) {
return false;
}
const image: { format?: string; bytes?: string } | undefined =
"image" in message ? message.image : undefined;
if (image === undefined) {
return false;
}
const isMalformed = image.format === undefined || image.bytes === undefined;
if (isMalformed) {
return false;
}
return true;
}
function normalizeMessageContent(content: agui.Message["content"]): string {
if (typeof content === "string" || typeof content === "undefined") {
return content || "";
}
if (Array.isArray(content)) {
return content
.map((part) => {
if (part?.type === "text") {
return part.text;
}
if (part?.type === "binary") {
return (
part.data ||
part.url ||
part.filename ||
`[binary:${part.mimeType}]`
);
}
return "";
})
.filter(Boolean)
.join("\n");
}
if (content && typeof content === "object") {
try {
return JSON.stringify(content);
} catch (error) {
console.warn("Failed to serialize message content", error);
}
}
return String(content ?? "");
}
/*
----------------------------
AGUI Message -> GQL Message
----------------------------
*/
export function aguiToGQL(
messages: agui.Message[] | agui.Message,
actions?: Record<string, any>,
coAgentStateRenders?: Record<string, any>,
): gql.Message[] {
const gqlMessages: gql.Message[] = [];
messages = Array.isArray(messages) ? messages : [messages];
// Track tool call names by their IDs for use in result messages
const toolCallNames: Record<string, string> = {};
for (const message of messages) {
// Agent state message support
if (isAgentStateMessage(message)) {
const agentName = extractAgentName(message);
const state = "state" in message && message.state ? message.state : {};
gqlMessages.push(
new gql.AgentStateMessage({
id: message.id,
agentName,
state,
role: gql.Role.assistant,
}),
);
// Optionally preserve render function
if (
"generativeUI" in message &&
message.generativeUI &&
coAgentStateRenders
) {
coAgentStateRenders[agentName] = {
name: agentName,
render: message.generativeUI,
};
}
continue;
}
if (hasImageProperty(message)) {
gqlMessages.push(aguiMessageWithImageToGQLMessage(message));
continue;
}
// Action execution message support
if (message.role === "assistant" && message.toolCalls) {
gqlMessages.push(aguiTextMessageToGQLMessage(message));
for (const toolCall of message.toolCalls) {
// Track the tool call name by its ID
toolCallNames[toolCall.id] = toolCall.function.name;
const actionExecMsg = aguiToolCallToGQLActionExecution(
toolCall,
message.id,
);
// Preserve render function in actions context
if ("generativeUI" in message && message.generativeUI && actions) {
const actionName = toolCall.function.name;
// Check for specific action first, then wild card action
const specificAction = Object.values(actions).find(
(action: any) => action.name === actionName,
);
const wildcardAction = Object.values(actions).find(
(action: any) => action.name === "*",
);
// Assign render function to the matching action (specific takes priority)
if (specificAction) {
specificAction.render = message.generativeUI;
} else if (wildcardAction) {
wildcardAction.render = message.generativeUI;
}
}
gqlMessages.push(actionExecMsg);
}
continue;
}
// Reasoning messages are ephemeral display-only content with no GQL equivalent — skip them
if (message.role === "reasoning") {
continue;
}
// Regular text messages
if (
message.role === "developer" ||
message.role === "system" ||
message.role === "assistant" ||
message.role === "user"
) {
gqlMessages.push(aguiTextMessageToGQLMessage(message));
continue;
}
// Tool result message
if (message.role === "tool") {
gqlMessages.push(
aguiToolMessageToGQLResultMessage(message, toolCallNames),
);
continue;
}
throw new Error(
`Unknown message role: "${(message as any).role}" in message with id: ${(message as any).id}`,
);
}
return gqlMessages;
}
export function aguiTextMessageToGQLMessage(
message: agui.Message,
): gql.TextMessage {
if (
message.role !== "developer" &&
message.role !== "system" &&
message.role !== "assistant" &&
message.role !== "user"
) {
throw new Error(
`Cannot convert message with role ${message.role} to TextMessage`,
);
}
let roleValue: MessageRole;
if (message.role === "developer") {
roleValue = gql.Role.developer;
} else if (message.role === "system") {
roleValue = gql.Role.system;
} else if (message.role === "assistant") {
roleValue = gql.Role.assistant;
} else {
roleValue = gql.Role.user;
}
return new gql.TextMessage({
id: message.id,
content: normalizeMessageContent(message.content),
role: roleValue,
});
}
export function aguiToolCallToGQLActionExecution(
toolCall: agui.ToolCall,
parentMessageId: string,
): gql.ActionExecutionMessage {
if (toolCall.type !== "function") {
throw new Error(`Unsupported tool call type: ${toolCall.type}`);
}
// Handle arguments - they should be a JSON string in AGUI format,
// but we need to convert them to an object for GQL format
let argumentsObj: any;
if (typeof toolCall.function.arguments === "string") {
// Expected case: arguments is a JSON string
try {
argumentsObj = JSON.parse(toolCall.function.arguments);
} catch {
console.warn(
`[CopilotKit] Failed to parse tool arguments, falling back to empty object`,
);
// Provide fallback empty object to prevent application crash
argumentsObj = {};
}
} else if (
typeof toolCall.function.arguments === "object" &&
toolCall.function.arguments !== null
) {
// Backward compatibility: arguments is already an object
argumentsObj = toolCall.function.arguments;
} else {
// Fallback for undefined, null, or other types
console.warn(
`[CopilotKit] Tool arguments parsed to non-object (${typeof toolCall.function.arguments}), falling back to empty object`,
);
argumentsObj = {};
}
// Guard against successfully parsed non-object values (e.g. JSON.parse('""') → "")
if (
typeof argumentsObj !== "object" ||
argumentsObj === null ||
Array.isArray(argumentsObj)
) {
console.warn(
`[CopilotKit] Tool arguments parsed to non-object (${typeof argumentsObj}), falling back to empty object`,
);
argumentsObj = {};
}
// Always include name and arguments
return new gql.ActionExecutionMessage({
id: toolCall.id,
name: toolCall.function.name,
arguments: argumentsObj,
parentMessageId: parentMessageId,
});
}
export function aguiToolMessageToGQLResultMessage(
message: agui.Message,
toolCallNames: Record<string, string>,
): gql.ResultMessage {
if (message.role !== "tool") {
throw new Error(
`Cannot convert message with role ${message.role} to ResultMessage`,
);
}
if (!message.toolCallId) {
throw new Error("Tool message must have a toolCallId");
}
const actionName = toolCallNames[message.toolCallId] || "unknown";
// Handle result content - it could be a string or an object that needs serialization
let resultContent: string;
const messageContent = message.content || "";
if (typeof messageContent === "string") {
// Expected case: content is already a string
resultContent = messageContent;
} else if (typeof messageContent === "object" && messageContent !== null) {
// Handle case where content is an object that needs to be serialized
try {
resultContent = JSON.stringify(messageContent);
} catch (error) {
console.warn(`Failed to stringify tool result for ${actionName}:`, error);
resultContent = String(messageContent);
}
} else {
// Handle other types (number, boolean, etc.)
resultContent = String(messageContent);
}
return new gql.ResultMessage({
id: message.id,
result: resultContent,
actionExecutionId: message.toolCallId,
actionName: message.toolName || actionName,
});
}
// New function to handle AGUI messages with render functions
export function aguiMessageWithRenderToGQL(
message: agui.Message,
actions?: Record<string, any>,
coAgentStateRenders?: Record<string, any>,
): gql.Message[] {
// Handle the special case: assistant messages with render function but no tool calls
if (
message.role === "assistant" &&
"generativeUI" in message &&
message.generativeUI &&
!message.toolCalls
) {
const gqlMessages: gql.Message[] = [];
gqlMessages.push(
new gql.AgentStateMessage({
id: message.id,
agentName: "unknown",
state: {},
role: gql.Role.assistant,
}),
);
if (coAgentStateRenders) {
coAgentStateRenders.unknown = {
name: "unknown",
render: message.generativeUI,
};
}
return gqlMessages;
}
// For all other cases, delegate to aguiToGQL
return aguiToGQL([message], actions, coAgentStateRenders);
}
export function aguiMessageWithImageToGQLMessage(
message: agui.Message,
): gql.ImageMessage {
if (!hasImageProperty(message)) {
throw new Error(
`Cannot convert message to ImageMessage: missing format or bytes`,
);
}
let roleValue: MessageRole;
if (message.role === "assistant") {
roleValue = gql.Role.assistant;
} else {
roleValue = gql.Role.user;
}
if (message.role !== "assistant" && message.role !== "user") {
throw new Error(
`Cannot convert message with role ${message.role} to ImageMessage`,
);
}
return new gql.ImageMessage({
id: message.id,
format: message.image.format,
bytes: message.image.bytes,
role: roleValue,
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,297 @@
import * as gql from "../types/converted/index";
import * as agui from "@copilotkit/shared";
import { MessageStatusCode } from "../types/message-status.type";
// Define valid image formats based on the supported formats in the codebase
const VALID_IMAGE_FORMATS = ["jpeg", "png", "webp", "gif"] as const;
type ValidImageFormat = (typeof VALID_IMAGE_FORMATS)[number];
// Validation function for image format
function validateImageFormat(format: string): format is ValidImageFormat {
return VALID_IMAGE_FORMATS.includes(format as ValidImageFormat);
}
/*
----------------------------
GQL Message -> AGUI Message
----------------------------
*/
export function gqlToAGUI(
messages: gql.Message[] | gql.Message,
actions?: Record<string, any>,
coAgentStateRenders?: Record<string, any>,
): agui.Message[] {
let aguiMessages: agui.Message[] = [];
messages = Array.isArray(messages) ? messages : [messages];
// Create a map of action execution ID to result for completed actions
const actionResults = new Map<string, string>();
for (const message of messages) {
if (message.isResultMessage()) {
actionResults.set(message.actionExecutionId, message.result);
}
}
for (const message of messages) {
if (message.isTextMessage()) {
aguiMessages.push(gqlTextMessageToAGUIMessage(message));
} else if (message.isResultMessage()) {
aguiMessages.push(gqlResultMessageToAGUIMessage(message));
} else if (message.isActionExecutionMessage()) {
aguiMessages.push(
gqlActionExecutionMessageToAGUIMessage(message, actions, actionResults),
);
} else if (message.isAgentStateMessage()) {
aguiMessages.push(
gqlAgentStateMessageToAGUIMessage(message, coAgentStateRenders),
);
} else if (message.isImageMessage()) {
aguiMessages.push(gqlImageMessageToAGUIMessage(message));
} else {
throw new Error("Unknown message type");
}
}
return aguiMessages;
}
export function gqlActionExecutionMessageToAGUIMessage(
message: gql.ActionExecutionMessage,
actions?: Record<string, any>,
actionResults?: Map<string, string>,
): agui.Message {
// Check if we have actions and if there's a specific action or wild card action
const hasSpecificAction =
actions &&
Object.values(actions).some((action: any) => action.name === message.name);
const hasWildcardAction =
actions &&
Object.values(actions).some((action: any) => action.name === "*");
if (!actions || (!hasSpecificAction && !hasWildcardAction)) {
return {
id: message.id,
role: "assistant",
toolCalls: [actionExecutionMessageToAGUIMessage(message)],
name: message.name,
};
}
// Find the specific action first, then fall back to wild card action
const action =
Object.values(actions).find(
(action: any) => action.name === message.name,
) || Object.values(actions).find((action: any) => action.name === "*");
// Create render function wrapper that provides proper props
const createRenderWrapper = (originalRender: any) => {
if (!originalRender) return undefined;
return (props?: any) => {
// Determine the correct status based on the same logic as RenderActionExecutionMessage
let actionResult: any = actionResults?.get(message.id);
let status: "inProgress" | "executing" | "complete" = "inProgress";
if (actionResult !== undefined) {
status = "complete";
} else if (message.status?.code !== MessageStatusCode.Pending) {
status = "executing";
}
// if props.result is a string, parse it as JSON but don't throw an error if it's not valid JSON
if (typeof props?.result === "string") {
try {
props.result = JSON.parse(props.result);
} catch (e) {
/* do nothing */
}
}
// if actionResult is a string, parse it as JSON but don't throw an error if it's not valid JSON
if (typeof actionResult === "string") {
try {
actionResult = JSON.parse(actionResult);
} catch (e) {
/* do nothing */
}
}
// Base props that all actions receive
const baseProps = {
status: props?.status || status,
args: message.arguments || {},
result: props?.result || actionResult || undefined,
messageId: message.id,
};
// Add properties based on action type
if (action.name === "*") {
// Wildcard actions get the tool name; ensure it cannot be overridden by incoming props
return originalRender({
...baseProps,
...props,
name: message.name,
});
} else {
// Regular actions get respond (defaulting to a no-op if not provided)
const respond = props?.respond ?? (() => {});
return originalRender({
...baseProps,
...props,
respond,
});
}
};
};
return {
id: message.id,
role: "assistant",
content: "",
toolCalls: [actionExecutionMessageToAGUIMessage(message)],
generativeUI: createRenderWrapper(action.render),
name: message.name,
} as agui.AIMessage;
}
function gqlAgentStateMessageToAGUIMessage(
message: gql.AgentStateMessage,
coAgentStateRenders?: Record<string, any>,
): agui.Message {
if (
coAgentStateRenders &&
Object.values(coAgentStateRenders).some(
(render: any) => render.name === message.agentName,
)
) {
const render = Object.values(coAgentStateRenders).find(
(render: any) => render.name === message.agentName,
);
// Create render function wrapper that provides proper props
const createRenderWrapper = (originalRender: any) => {
if (!originalRender) return undefined;
return (props?: any) => {
// Determine the correct status based on the same logic as RenderActionExecutionMessage
const state = message.state;
// Provide the full props structure that the render function expects
const renderProps = {
state: state,
};
return originalRender(renderProps);
};
};
return {
id: message.id,
role: "assistant",
generativeUI: createRenderWrapper(render.render),
agentName: message.agentName,
state: message.state,
};
}
return {
id: message.id,
role: "assistant",
agentName: message.agentName,
state: message.state,
};
}
function actionExecutionMessageToAGUIMessage(
actionExecutionMessage: gql.ActionExecutionMessage,
): agui.ToolCall {
return {
id: actionExecutionMessage.id,
function: {
name: actionExecutionMessage.name,
arguments: JSON.stringify(actionExecutionMessage.arguments),
},
type: "function",
};
}
export function gqlTextMessageToAGUIMessage(
message: gql.TextMessage,
): agui.Message {
switch (message.role) {
case gql.Role.developer:
return {
id: message.id,
role: "developer",
content: message.content,
};
case gql.Role.system:
return {
id: message.id,
role: "system",
content: message.content,
};
case gql.Role.assistant:
return {
id: message.id,
role: "assistant",
content: message.content,
};
case gql.Role.user:
return {
id: message.id,
role: "user",
content: message.content,
};
default:
throw new Error("Unknown message role");
}
}
export function gqlResultMessageToAGUIMessage(
message: gql.ResultMessage,
): agui.Message {
return {
id: message.id,
role: "tool",
content: message.result,
toolCallId: message.actionExecutionId,
toolName: message.actionName,
};
}
export function gqlImageMessageToAGUIMessage(
message: gql.ImageMessage,
): agui.Message {
// Validate image format
if (!validateImageFormat(message.format)) {
throw new Error(
`Invalid image format: ${message.format}. Supported formats are: ${VALID_IMAGE_FORMATS.join(", ")}`,
);
}
// Validate that bytes is a non-empty string
if (
!message.bytes ||
typeof message.bytes !== "string" ||
message.bytes.trim() === ""
) {
throw new Error("Image bytes must be a non-empty string");
}
// Determine the role based on the message role
const role = message.role === gql.Role.assistant ? "assistant" : "user";
// Create the image message with proper typing
const imageMessage: agui.Message = {
id: message.id,
role,
content: "",
image: {
format: message.format,
bytes: message.bytes,
},
};
return imageMessage;
}
@@ -0,0 +1,2 @@
export * from "./agui-to-gql";
export * from "./gql-to-agui";
@@ -0,0 +1,561 @@
import { vi } from "vitest";
import * as gql from "../types/converted/index";
import * as agui from "@copilotkit/shared";
import { aguiToGQL } from "./agui-to-gql";
import { gqlToAGUI } from "./gql-to-agui";
// Helper to strip functions for deep equality
function stripFunctions(obj: any): any {
if (typeof obj === "function") return undefined;
if (Array.isArray(obj)) return obj.map(stripFunctions);
if (obj && typeof obj === "object") {
const out: any = {};
for (const k in obj) {
if (typeof obj[k] !== "function") {
out[k] = stripFunctions(obj[k]);
}
}
return out;
}
return obj;
}
describe("roundtrip message conversion", () => {
test("text message AGUI -> GQL -> AGUI", () => {
const aguiMsg: agui.Message = {
id: "user-1",
role: "user",
content: "Hello!",
};
const gqlMsgs = aguiToGQL(aguiMsg);
const aguiMsgs2 = gqlToAGUI(gqlMsgs);
expect(stripFunctions(aguiMsgs2[0])).toEqual(stripFunctions(aguiMsg));
});
test("text message GQL -> AGUI -> GQL", () => {
const gqlMsg = new gql.TextMessage({
id: "assistant-1",
content: "Hi!",
role: gql.Role.assistant,
});
const aguiMsgs = gqlToAGUI(gqlMsg);
const gqlMsgs2 = aguiToGQL(aguiMsgs);
// Should be equivalent in content, id, and role
expect(gqlMsgs2[0].id).toBe(gqlMsg.id);
expect((gqlMsgs2[0] as any).content).toBe(gqlMsg.content);
expect((gqlMsgs2[0] as any).role).toBe(gqlMsg.role);
});
test("tool message AGUI -> GQL -> AGUI", () => {
const aguiMsg: agui.Message = {
id: "tool-1",
role: "tool",
content: "Tool result",
toolCallId: "tool-call-1",
toolName: "testAction",
};
const gqlMsgs = aguiToGQL(aguiMsg);
const aguiMsgs2 = gqlToAGUI(gqlMsgs);
expect(stripFunctions(aguiMsgs2[0])).toEqual(stripFunctions(aguiMsg));
});
test("tool message GQL -> AGUI -> GQL", () => {
const gqlMsg = new gql.ResultMessage({
id: "tool-1",
result: "Tool result",
actionExecutionId: "tool-call-1",
actionName: "testAction",
});
const aguiMsgs = gqlToAGUI(gqlMsg);
const gqlMsgs2 = aguiToGQL(aguiMsgs);
expect(gqlMsgs2[0].id).toBe(gqlMsg.id);
expect((gqlMsgs2[0] as any).result).toBe(gqlMsg.result);
expect((gqlMsgs2[0] as any).actionExecutionId).toBe(
gqlMsg.actionExecutionId,
);
});
test("action execution AGUI -> GQL -> AGUI", () => {
const aguiMsg: agui.Message = {
id: "assistant-1",
role: "assistant",
content: "Running action",
toolCalls: [
{
id: "tool-call-1",
type: "function",
function: {
name: "doSomething",
arguments: JSON.stringify({ foo: "bar" }),
},
},
],
};
const gqlMsgs = aguiToGQL(aguiMsg);
const aguiMsgs2 = gqlToAGUI(gqlMsgs);
// Should have an assistant message and an action execution message
expect(aguiMsgs2[0].role).toBe("assistant");
expect(aguiMsgs2[1].role).toBe("assistant");
// Only check toolCalls if present
if ("toolCalls" in aguiMsgs2[1]) {
expect((aguiMsgs2[1] as any).toolCalls[0].function.name).toBe(
"doSomething",
);
}
});
test("action execution GQL -> AGUI -> GQL", () => {
const actionExecMsg = new gql.ActionExecutionMessage({
id: "tool-call-1",
name: "doSomething",
arguments: { foo: "bar" },
parentMessageId: "assistant-1",
});
const aguiMsgs = gqlToAGUI([actionExecMsg]);
const gqlMsgs2 = aguiToGQL(aguiMsgs);
// The ActionExecutionMessage is at index 1, not index 0
expect(gqlMsgs2[1].id).toBe("tool-call-1");
// The name should be extracted from the toolCall function name
expect((gqlMsgs2[1] as any).name).toBe("doSomething");
expect((gqlMsgs2[1] as any).arguments).toEqual({ foo: "bar" });
});
test("agent state GQL -> AGUI -> GQL", () => {
const agentStateMsg = new gql.AgentStateMessage({
id: "agent-state-1",
agentName: "testAgent",
state: { status: "running" },
role: gql.Role.assistant,
});
const aguiMsgs = gqlToAGUI([agentStateMsg]);
const gqlMsgs2 = aguiToGQL(aguiMsgs);
expect(gqlMsgs2[0].id).toBe("agent-state-1");
// The agentName should be preserved in the roundtrip
expect((gqlMsgs2[0] as any).agentName).toBe("testAgent");
});
test("action execution with render function roundtrip", () => {
const mockRender = vi.fn();
const aguiMsg: agui.Message = {
id: "assistant-1",
role: "assistant",
content: "Running action",
toolCalls: [
{
id: "tool-call-1",
type: "function",
function: {
name: "doSomething",
arguments: JSON.stringify({ foo: "bar" }),
},
},
],
generativeUI: mockRender,
};
const actions: Record<string, any> = {
doSomething: { name: "doSomething" },
};
const gqlMsgs = aguiToGQL(aguiMsg, actions);
const aguiMsgs2 = gqlToAGUI(gqlMsgs, actions);
// The render function should be preserved in actions context
expect(typeof actions.doSomething.render).toBe("function");
// The roundtripped message should have the same tool call
if ("toolCalls" in aguiMsgs2[1]) {
expect((aguiMsgs2[1] as any).toolCalls[0].function.name).toBe(
"doSomething",
);
}
});
test("image message GQL -> AGUI -> GQL", () => {
const gqlMsg = new gql.ImageMessage({
id: "img-1",
format: "jpeg",
bytes: "somebase64string",
role: gql.Role.user,
});
const aguiMsgs = gqlToAGUI(gqlMsg);
const gqlMsgs2 = aguiToGQL(aguiMsgs);
expect(gqlMsgs2[0].id).toBe(gqlMsg.id);
expect((gqlMsgs2[0] as any).format).toBe(gqlMsg.format);
expect((gqlMsgs2[0] as any).bytes).toBe(gqlMsg.bytes);
expect((gqlMsgs2[0] as any).role).toBe(gqlMsg.role);
});
test("image message AGUI -> GQL -> AGUI (assistant and user)", () => {
// Assistant image message
const aguiAssistantImageMsg: agui.Message = {
id: "img-assistant-1",
role: "assistant",
image: {
format: "jpeg",
bytes: "assistantbase64data",
},
content: "", // required for type
};
const gqlAssistantMsgs = aguiToGQL(aguiAssistantImageMsg);
const aguiAssistantMsgs2 = gqlToAGUI(gqlAssistantMsgs);
expect(aguiAssistantMsgs2[0].id).toBe(aguiAssistantImageMsg.id);
expect(aguiAssistantMsgs2[0].role).toBe("assistant");
expect((aguiAssistantMsgs2[0] as any).image.format).toBe("jpeg");
expect((aguiAssistantMsgs2[0] as any).image.bytes).toBe(
"assistantbase64data",
);
// User image message
const aguiUserImageMsg = {
id: "img-user-1",
role: "user",
image: {
format: "png",
bytes: "userbase64data",
},
content: "", // required for type
} as agui.Message;
const gqlUserMsgs = aguiToGQL(aguiUserImageMsg);
const aguiUserMsgs2 = gqlToAGUI(gqlUserMsgs);
expect(aguiUserMsgs2[0].id).toBe(aguiUserImageMsg.id);
expect(aguiUserMsgs2[0].role).toBe("user");
expect((aguiUserMsgs2[0] as any).image.format).toBe("png");
expect((aguiUserMsgs2[0] as any).image.bytes).toBe("userbase64data");
});
test("wild card action roundtrip conversion", () => {
const mockRender = vi.fn(
(props) => `Wildcard rendered: ${props.args.test}`,
);
const aguiMsg: agui.Message = {
id: "assistant-wildcard-1",
role: "assistant",
content: "Running wild card action",
toolCalls: [
{
id: "tool-call-wildcard-1",
type: "function",
function: {
name: "unknownAction",
arguments: JSON.stringify({ test: "wildcard-value" }),
},
},
],
generativeUI: mockRender,
};
const actions: Record<string, any> = {
"*": { name: "*" },
};
// AGUI -> GQL -> AGUI roundtrip
const gqlMsgs = aguiToGQL(aguiMsg, actions);
const aguiMsgs2 = gqlToAGUI(gqlMsgs, actions);
// Verify the wild card action preserved the render function
expect(typeof actions["*"].render).toBe("function");
expect(actions["*"].render).toBe(mockRender);
// Verify the roundtripped message structure
expect(aguiMsgs2).toHaveLength(2);
expect(aguiMsgs2[0].role).toBe("assistant");
expect(aguiMsgs2[1].role).toBe("assistant");
// Check that the tool call is preserved
if ("toolCalls" in aguiMsgs2[1]) {
expect((aguiMsgs2[1] as any).toolCalls[0].function.name).toBe(
"unknownAction",
);
expect((aguiMsgs2[1] as any).toolCalls[0].function.arguments).toBe(
'{"test":"wildcard-value"}',
);
}
});
test("wild card action with specific action priority roundtrip", () => {
const mockRender = vi.fn(
(props) => `Specific action rendered: ${props.args.test}`,
);
const aguiMsg: agui.Message = {
id: "assistant-priority-1",
role: "assistant",
content: "Running specific action",
toolCalls: [
{
id: "tool-call-priority-1",
type: "function",
function: {
name: "specificAction",
arguments: JSON.stringify({ test: "specific-value" }),
},
},
],
generativeUI: mockRender,
};
const actions: Record<string, any> = {
specificAction: { name: "specificAction" },
"*": { name: "*" },
};
// AGUI -> GQL -> AGUI roundtrip
const gqlMsgs = aguiToGQL(aguiMsg, actions);
const aguiMsgs2 = gqlToAGUI(gqlMsgs, actions);
// Verify the specific action preserved the render function (not wild card)
expect(typeof actions.specificAction.render).toBe("function");
expect(actions.specificAction.render).toBe(mockRender);
expect(actions["*"].render).toBeUndefined();
// Verify the roundtripped message structure
expect(aguiMsgs2).toHaveLength(2);
expect(aguiMsgs2[0].role).toBe("assistant");
expect(aguiMsgs2[1].role).toBe("assistant");
// Check that the tool call is preserved
if ("toolCalls" in aguiMsgs2[1]) {
expect((aguiMsgs2[1] as any).toolCalls[0].function.name).toBe(
"specificAction",
);
expect((aguiMsgs2[1] as any).toolCalls[0].function.arguments).toBe(
'{"test":"specific-value"}',
);
}
});
test("wild card action GQL -> AGUI -> GQL roundtrip", () => {
const actionExecMsg = new gql.ActionExecutionMessage({
id: "wildcard-action-1",
name: "unknownAction",
arguments: { test: "wildcard-gql-value" },
parentMessageId: "assistant-1",
});
const actions: Record<string, any> = {
"*": {
name: "*",
render: vi.fn((props) => `GQL wildcard rendered: ${props.args.test}`),
},
};
// GQL -> AGUI -> GQL roundtrip
const aguiMsgs = gqlToAGUI([actionExecMsg], actions);
const gqlMsgs2 = aguiToGQL(aguiMsgs, actions);
// When converting ActionExecutionMessage to AGUI and back, we get:
// 1. A TextMessage (assistant message with toolCalls)
// 2. An ActionExecutionMessage (the tool call itself)
expect(gqlMsgs2).toHaveLength(2);
expect(gqlMsgs2[0].id).toBe("wildcard-action-1");
expect((gqlMsgs2[0] as any).role).toBe(gql.Role.assistant);
expect(gqlMsgs2[1].id).toBe("wildcard-action-1");
expect((gqlMsgs2[1] as any).name).toBe("unknownAction");
expect((gqlMsgs2[1] as any).arguments).toEqual({
test: "wildcard-gql-value",
});
});
test("roundtrip conversion with result parsing edge cases", () => {
// Test with a tool result that contains a JSON string
const toolResultMsg: agui.Message = {
id: "tool-result-json",
role: "tool",
content: '{"status": "success", "data": {"value": 42}}',
toolCallId: "tool-call-json",
toolName: "jsonAction",
};
// Convert AGUI -> GQL -> AGUI
const gqlMsgs = aguiToGQL(toolResultMsg);
const aguiMsgs = gqlToAGUI(gqlMsgs);
expect(gqlMsgs).toHaveLength(1);
expect(gqlMsgs[0]).toBeInstanceOf(gql.ResultMessage);
expect((gqlMsgs[0] as any).result).toBe(
'{"status": "success", "data": {"value": 42}}',
);
expect(aguiMsgs).toHaveLength(1);
expect(aguiMsgs[0].role).toBe("tool");
expect(aguiMsgs[0].content).toBe(
'{"status": "success", "data": {"value": 42}}',
);
});
test("roundtrip conversion with object content in tool results", () => {
// Test with a tool result that has object content (edge case)
const toolResultMsg: agui.Message = {
id: "tool-result-object",
role: "tool",
content: { status: "success", data: { value: 42 } } as any,
toolCallId: "tool-call-object",
toolName: "objectAction",
};
// Convert AGUI -> GQL -> AGUI
const gqlMsgs = aguiToGQL(toolResultMsg);
const aguiMsgs = gqlToAGUI(gqlMsgs);
expect(gqlMsgs).toHaveLength(1);
expect(gqlMsgs[0]).toBeInstanceOf(gql.ResultMessage);
expect((gqlMsgs[0] as any).result).toBe(
'{"status":"success","data":{"value":42}}',
);
expect(aguiMsgs).toHaveLength(1);
expect(aguiMsgs[0].role).toBe("tool");
expect(aguiMsgs[0].content).toBe(
'{"status":"success","data":{"value":42}}',
);
});
test("roundtrip conversion with action execution and result parsing", () => {
const mockRender = vi.fn(
(props) => `Rendered: ${JSON.stringify(props.result)}`,
);
// Create action execution message
const actionExecMsg = new gql.ActionExecutionMessage({
id: "action-with-result",
name: "testAction",
arguments: { input: "test-value" },
parentMessageId: "parent-result",
});
// Create result message
const resultMsg = new gql.ResultMessage({
id: "result-with-json",
result: '{"output": "processed", "count": 5}',
actionExecutionId: "action-with-result",
actionName: "testAction",
});
const actions = {
testAction: {
name: "testAction",
render: mockRender,
},
};
// Convert GQL -> AGUI
const aguiMsgs = gqlToAGUI([actionExecMsg, resultMsg], actions);
// The action execution should have a generativeUI function that parses string results
expect(aguiMsgs).toHaveLength(2);
expect(aguiMsgs[0].role).toBe("assistant");
expect("generativeUI" in aguiMsgs[0]).toBe(true);
expect(aguiMsgs[1].role).toBe("tool");
expect(aguiMsgs[1].content).toBe('{"output": "processed", "count": 5}');
// Test that the render function receives parsed results
if ("generativeUI" in aguiMsgs[0] && aguiMsgs[0].generativeUI) {
aguiMsgs[0].generativeUI({ result: '{"parsed": true}' });
expect(mockRender).toHaveBeenCalledWith(
expect.objectContaining({
result: { parsed: true }, // Should be parsed from string
}),
);
}
// Convert back AGUI -> GQL
const gqlMsgs2 = aguiToGQL(aguiMsgs, actions);
// Should have 3 messages: TextMessage, ActionExecutionMessage, ResultMessage
expect(gqlMsgs2).toHaveLength(3);
expect(gqlMsgs2[0]).toBeInstanceOf(gql.TextMessage);
expect(gqlMsgs2[1]).toBeInstanceOf(gql.ActionExecutionMessage);
expect(gqlMsgs2[2]).toBeInstanceOf(gql.ResultMessage);
// Check that arguments roundtripped correctly
expect((gqlMsgs2[1] as any).arguments).toEqual({ input: "test-value" });
expect((gqlMsgs2[2] as any).result).toBe(
'{"output": "processed", "count": 5}',
);
});
test("roundtrip conversion verifies correct property distribution for regular actions", () => {
const mockRender = vi.fn(
(props) => `Regular action: ${JSON.stringify(props.args)}`,
);
const actionExecMsg = new gql.ActionExecutionMessage({
id: "regular-action-test",
name: "regularAction",
arguments: { test: "regular-value" },
parentMessageId: "parent-regular",
});
const actions = {
regularAction: {
name: "regularAction",
render: mockRender,
},
};
// GQL -> AGUI -> GQL roundtrip
const aguiMsgs = gqlToAGUI([actionExecMsg], actions);
const gqlMsgs2 = aguiToGQL(aguiMsgs, actions);
// Verify the roundtrip preserved the action
expect(gqlMsgs2).toHaveLength(2);
expect(gqlMsgs2[1]).toBeInstanceOf(gql.ActionExecutionMessage);
expect((gqlMsgs2[1] as any).name).toBe("regularAction");
// Test that regular actions do NOT receive the name property in render props
if ("generativeUI" in aguiMsgs[0] && aguiMsgs[0].generativeUI) {
aguiMsgs[0].generativeUI();
expect(mockRender).toHaveBeenCalledWith(
expect.objectContaining({
args: { test: "regular-value" },
// name property should NOT be present for regular actions
}),
);
// Verify name property is NOT present
const callArgs = mockRender.mock.calls[0][0];
expect(callArgs).not.toHaveProperty("name");
}
});
test("roundtrip conversion verifies correct property distribution for wildcard actions", () => {
const mockRender = vi.fn(
(props) =>
`Wildcard action: ${props.name} with ${JSON.stringify(props.args)}`,
);
const actionExecMsg = new gql.ActionExecutionMessage({
id: "wildcard-action-test",
name: "unknownAction",
arguments: { test: "wildcard-value" },
parentMessageId: "parent-wildcard",
});
const actions = {
"*": {
name: "*",
render: mockRender,
},
};
// GQL -> AGUI -> GQL roundtrip
const aguiMsgs = gqlToAGUI([actionExecMsg], actions);
const gqlMsgs2 = aguiToGQL(aguiMsgs, actions);
// Verify the roundtrip preserved the action
expect(gqlMsgs2).toHaveLength(2);
expect(gqlMsgs2[1]).toBeInstanceOf(gql.ActionExecutionMessage);
expect((gqlMsgs2[1] as any).name).toBe("unknownAction");
// Test that wildcard actions DO receive the name property in render props
if ("generativeUI" in aguiMsgs[0] && aguiMsgs[0].generativeUI) {
aguiMsgs[0].generativeUI();
expect(mockRender).toHaveBeenCalledWith(
expect.objectContaining({
args: { test: "wildcard-value" },
name: "unknownAction", // name property SHOULD be present for wildcard actions
}),
);
// Verify name property IS present
const callArgs = mockRender.mock.calls[0][0];
expect(callArgs).toHaveProperty("name", "unknownAction");
}
});
});
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { resolveMessageId } from "../resolve-message-id";
describe("resolveMessageId (#2118)", () => {
it("preserves a provided non-empty id verbatim", () => {
expect(resolveMessageId("msg-123")).toBe("msg-123");
});
it.each(["", null, undefined] as const)(
"falls back to a generated id when the event id is %p",
(input) => {
const id = resolveMessageId(input);
// randomId() always produces the "ck-<uuid>" shape; the important
// contract for #2118 is that the returned value is a non-empty string,
// never null/undefined.
expect(id).toMatch(/^ck-[0-9a-f-]{36}$/);
},
);
it("generates a fresh id on each fallback call", () => {
const a = resolveMessageId(undefined);
const b = resolveMessageId(undefined);
expect(a).not.toBe(b);
});
});
@@ -0,0 +1,785 @@
import { Arg, Ctx, Mutation, Query, Resolver } from "type-graphql";
import {
ReplaySubject,
Subject,
Subscription,
filter,
finalize,
firstValueFrom,
shareReplay,
skipWhile,
take,
takeWhile,
tap,
} from "rxjs";
import { GenerateCopilotResponseInput } from "../inputs/generate-copilot-response.input";
import { CopilotResponse } from "../types/copilot-response.type";
import {
CopilotKitLangGraphInterruptEvent,
LangGraphInterruptEvent,
} from "../types/meta-events.type";
import { ActionInputAvailability, MessageRole } from "../types/enums";
import { Repeater } from "graphql-yoga";
import type {
CopilotRequestContextProperties,
GraphQLContext,
} from "../../lib/integrations";
import {
RuntimeEvent,
RuntimeEventTypes,
RuntimeMetaEventName,
} from "../../service-adapters/events";
import {
FailedMessageStatus,
MessageStatusCode,
MessageStatusUnion,
SuccessMessageStatus,
} from "../types/message-status.type";
import {
ResponseStatusUnion,
SuccessResponseStatus,
} from "../types/response-status.type";
import { GraphQLJSONObject } from "graphql-scalars";
import { plainToInstance } from "class-transformer";
import { GuardrailsResult } from "../types/guardrails-result.type";
import { GraphQLError } from "graphql";
import {
GuardrailsValidationFailureResponse,
MessageStreamInterruptedResponse,
UnknownErrorResponse,
} from "../../utils";
import {
ActionExecutionMessage,
AgentStateMessage,
Message,
MessageType,
ResultMessage,
TextMessage,
} from "../types/converted";
import telemetry from "../../lib/telemetry-client";
import { randomId } from "@copilotkit/shared";
import { resolveMessageId } from "./resolve-message-id";
import { AgentsResponse } from "../types/agents-response.type";
import { LangGraphEventTypes } from "../../agents/langgraph/events";
import {
CopilotKitError,
CopilotKitLowLevelError,
isStructuredCopilotKitError,
} from "@copilotkit/shared";
import { CopilotRuntime } from "../../lib";
const invokeGuardrails = async ({
baseUrl,
copilotCloudPublicApiKey,
data,
onResult,
onError,
}: {
baseUrl: string;
copilotCloudPublicApiKey: string;
data: GenerateCopilotResponseInput;
onResult: (result: GuardrailsResult) => void;
onError: (err: Error) => void;
}) => {
if (
data.messages.length &&
data.messages[data.messages.length - 1].textMessage?.role ===
MessageRole.user
) {
const messages = data.messages
.filter(
(m) =>
m.textMessage !== undefined &&
(m.textMessage.role === MessageRole.user ||
m.textMessage.role === MessageRole.assistant),
)
.map((m) => ({
role: m.textMessage!.role,
content: m.textMessage.content,
}));
const lastMessage = messages[messages.length - 1];
const restOfMessages = messages.slice(0, -1);
const body = {
input: lastMessage.content,
validTopics: data.cloud.guardrails.inputValidationRules.allowList,
invalidTopics: data.cloud.guardrails.inputValidationRules.denyList,
messages: restOfMessages,
};
const guardrailsResult = await fetch(`${baseUrl}/guardrails/validate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CopilotCloud-Public-API-Key": copilotCloudPublicApiKey,
},
body: JSON.stringify(body),
});
if (guardrailsResult.ok) {
const resultJson: GuardrailsResult = await guardrailsResult.json();
onResult(resultJson);
} else {
onError(await guardrailsResult.json());
}
}
};
@Resolver(() => CopilotResponse)
export class CopilotResolver {
@Query(() => String)
async hello() {
return "Hello World";
}
@Query(() => AgentsResponse)
async availableAgents(@Ctx() ctx: GraphQLContext) {
let logger = ctx.logger.child({
component: "CopilotResolver.availableAgents",
});
logger.debug("Processing");
const agentsWithEndpoints = [];
logger.debug("Event source created, creating response");
return {
agents: agentsWithEndpoints.map(
({ endpoint, ...agentWithoutEndpoint }) => agentWithoutEndpoint,
),
};
}
@Mutation(() => CopilotResponse)
async generateCopilotResponse(
@Ctx() ctx: GraphQLContext,
@Arg("data") data: GenerateCopilotResponseInput,
@Arg("properties", () => GraphQLJSONObject, { nullable: true })
properties?: CopilotRequestContextProperties,
) {
telemetry.capture("oss.runtime.copilot_request_created", {
"cloud.guardrails.enabled": data.cloud?.guardrails !== undefined,
requestType: data.metadata.requestType,
"cloud.api_key_provided": !!ctx.request.headers.get(
"x-copilotcloud-public-api-key",
),
...(ctx.request.headers.get("x-copilotcloud-public-api-key")
? {
"cloud.public_api_key": ctx.request.headers.get(
"x-copilotcloud-public-api-key",
),
}
: {}),
...(ctx._copilotkit.baseUrl
? {
"cloud.base_url": ctx._copilotkit.baseUrl,
}
: {
"cloud.base_url": "https://api.cloud.copilotkit.ai",
}),
});
let logger = ctx.logger.child({
component: "CopilotResolver.generateCopilotResponse",
});
logger.debug({ data }, "Generating Copilot response");
if (properties) {
logger.debug("Properties provided, merging with context properties");
ctx.properties = { ...ctx.properties, ...properties };
}
const copilotRuntime = ctx._copilotkit.runtime as unknown as CopilotRuntime;
const serviceAdapter = ctx._copilotkit.serviceAdapter;
let copilotCloudPublicApiKey: string | null = null;
let copilotCloudBaseUrl: string;
// Extract publicApiKey from headers for both cloud and non-cloud requests
// This enables onTrace functionality regardless of cloud configuration
const publicApiKeyFromHeaders = ctx.request.headers.get(
"x-copilotcloud-public-api-key",
);
if (publicApiKeyFromHeaders) {
copilotCloudPublicApiKey = publicApiKeyFromHeaders;
}
if (data.cloud) {
logger = logger.child({ cloud: true });
logger.debug(
"Cloud configuration provided, checking for public API key in headers",
);
if (!copilotCloudPublicApiKey) {
logger.error("Public API key not found in headers");
throw new GraphQLError(
"X-CopilotCloud-Public-API-Key header is required",
);
}
if (process.env.COPILOT_CLOUD_BASE_URL) {
copilotCloudBaseUrl = process.env.COPILOT_CLOUD_BASE_URL;
} else if (ctx._copilotkit.cloud?.baseUrl) {
copilotCloudBaseUrl = ctx._copilotkit.cloud?.baseUrl;
} else {
copilotCloudBaseUrl = "https://api.cloud.copilotkit.ai";
}
logger = logger.child({ copilotCloudBaseUrl });
}
logger.debug("Setting up subjects");
const responseStatus$ = new ReplaySubject<typeof ResponseStatusUnion>();
const interruptStreaming$ = new ReplaySubject<{
reason: string;
messageId?: string;
}>();
const guardrailsResult$ = new ReplaySubject<GuardrailsResult>();
let outputMessages: Message[] = [];
let resolveOutputMessagesPromise: (messages: Message[]) => void;
let rejectOutputMessagesPromise: (err: Error) => void;
const outputMessagesPromise = new Promise<Message[]>((resolve, reject) => {
resolveOutputMessagesPromise = resolve;
rejectOutputMessagesPromise = reject;
});
if (copilotCloudPublicApiKey) {
ctx.properties["copilotCloudPublicApiKey"] = copilotCloudPublicApiKey;
}
logger.debug("Processing");
let runtimeResponse;
const {
eventSource,
threadId = randomId(),
runId,
serverSideActions,
actionInputsWithoutAgents,
extensions,
} = runtimeResponse;
logger.debug("Event source created, creating response");
// run and process the event stream
const eventStream = eventSource
.processRuntimeEvents({
serverSideActions,
guardrailsResult$: data.cloud?.guardrails ? guardrailsResult$ : null,
actionInputsWithoutAgents: actionInputsWithoutAgents.filter(
// TODO-AGENTS: do not exclude ALL server side actions
(action) =>
!serverSideActions.find(
(serverSideAction) => serverSideAction.name == action.name,
),
),
threadId,
})
.pipe(
// shareReplay() ensures that later subscribers will see the whole stream instead of
// just the events that were emitted after the subscriber was added.
shareReplay(),
finalize(() => {
logger.debug("Event stream finalized");
}),
);
const response = {
threadId,
runId,
status: firstValueFrom(responseStatus$),
extensions,
metaEvents: new Repeater(async (push, stop) => {
let eventStreamSubscription: Subscription;
eventStreamSubscription = eventStream.subscribe({
next: async (event) => {
if (event.type != RuntimeEventTypes.MetaEvent) {
return;
}
switch (event.name) {
// @ts-ignore
case LangGraphEventTypes.OnInterrupt:
push(
plainToInstance(LangGraphInterruptEvent, {
// @ts-ignore
type: event.type,
// @ts-ignore
name: RuntimeMetaEventName.LangGraphInterruptEvent,
// @ts-ignore
value: event.value,
}),
);
break;
case RuntimeMetaEventName.LangGraphInterruptEvent:
push(
plainToInstance(LangGraphInterruptEvent, {
type: event.type,
name: event.name,
value: event.value,
}),
);
break;
case RuntimeMetaEventName.CopilotKitLangGraphInterruptEvent:
push(
plainToInstance(CopilotKitLangGraphInterruptEvent, {
type: event.type,
name: event.name,
data: {
value: event.data.value,
messages: event.data.messages.map((message) => {
if (
message.type === "TextMessage" ||
("content" in message && "role" in message)
) {
return plainToInstance(TextMessage, {
id: message.id,
createdAt: new Date(),
content: [(message as TextMessage).content],
role: (message as TextMessage).role,
status: new SuccessMessageStatus(),
});
}
if ("arguments" in message) {
return plainToInstance(ActionExecutionMessage, {
name: message.name,
id: message.id,
arguments: [JSON.stringify(message.arguments)],
createdAt: new Date(),
status: new SuccessMessageStatus(),
});
}
throw new Error(
"Unknown message in metaEvents copilot resolver",
);
}),
},
}),
);
break;
}
},
error: (err) => {
// For structured CopilotKit errors, set proper error response status
if (
err?.name?.includes("CopilotKit") ||
err?.extensions?.visibility
) {
responseStatus$.next(
new UnknownErrorResponse({
description: err.message || "Agent error occurred",
}),
);
} else {
responseStatus$.next(
new UnknownErrorResponse({
description: `An unknown error has occurred in the event stream`,
}),
);
}
eventStreamSubscription?.unsubscribe();
stop();
},
complete: async () => {
logger.debug("Meta events stream completed");
responseStatus$.next(new SuccessResponseStatus());
eventStreamSubscription?.unsubscribe();
stop();
},
});
}),
messages: new Repeater(async (pushMessage, stopStreamingMessages) => {
logger.debug("Messages repeater created");
if (data.cloud?.guardrails) {
logger = logger.child({ guardrails: true });
logger.debug("Guardrails is enabled, validating input");
invokeGuardrails({
baseUrl: copilotCloudBaseUrl,
copilotCloudPublicApiKey,
data,
onResult: (result) => {
logger.debug(
{ status: result.status },
"Guardrails validation done",
);
guardrailsResult$.next(result);
// Guardrails validation failed
if (result.status === "denied") {
// send the reason to the client and interrupt streaming
responseStatus$.next(
new GuardrailsValidationFailureResponse({
guardrailsReason: result.reason,
}),
);
interruptStreaming$.next({
reason: `Interrupted due to Guardrails validation failure. Reason: ${result.reason}`,
});
// resolve messages promise to the middleware
outputMessages = [
plainToInstance(TextMessage, {
id: randomId(),
createdAt: new Date(),
content: result.reason,
role: MessageRole.assistant,
}),
];
resolveOutputMessagesPromise(outputMessages);
}
},
onError: (err) => {
logger.error({ err }, "Error in guardrails validation");
responseStatus$.next(
new UnknownErrorResponse({
description: `An unknown error has occurred in the guardrails validation`,
}),
);
interruptStreaming$.next({
reason: `Interrupted due to unknown error in guardrails validation`,
});
// reject the middleware promise
rejectOutputMessagesPromise(err);
},
});
}
let eventStreamSubscription: Subscription;
logger.debug("Event stream created, subscribing to event stream");
eventStreamSubscription = eventStream.subscribe({
next: async (event) => {
switch (event.type) {
case RuntimeEventTypes.MetaEvent:
break;
////////////////////////////////
// TextMessageStart
////////////////////////////////
case RuntimeEventTypes.TextMessageStart:
// create a sub stream that contains the message content
const textMessageContentStream = eventStream.pipe(
// skip until this message start event
skipWhile((e: RuntimeEvent) => e !== event),
// take until the message end event
takeWhile(
(e: RuntimeEvent) =>
!(
e.type === RuntimeEventTypes.TextMessageEnd &&
(e as any).messageId == event.messageId
),
),
// filter out any other message events or message ids
filter(
(e: RuntimeEvent) =>
e.type == RuntimeEventTypes.TextMessageContent &&
(e as any).messageId == event.messageId,
),
);
// signal when we are done streaming
const streamingTextStatus = new Subject<
typeof MessageStatusUnion
>();
const messageId = resolveMessageId(event.messageId);
// push the new message
pushMessage({
id: messageId,
parentMessageId: event.parentMessageId,
status: firstValueFrom(streamingTextStatus),
createdAt: new Date(),
role: MessageRole.assistant,
content: new Repeater(
async (pushTextChunk, stopStreamingText) => {
logger.debug("Text message content repeater created");
const textChunks: string[] = [];
let textSubscription: Subscription;
interruptStreaming$
.pipe(
shareReplay(),
take(1),
tap(({ reason, messageId }) => {
logger.debug(
{ reason, messageId },
"Text streaming interrupted",
);
streamingTextStatus.next(
plainToInstance(FailedMessageStatus, { reason }),
);
responseStatus$.next(
new MessageStreamInterruptedResponse({
messageId,
}),
);
stopStreamingText();
textSubscription?.unsubscribe();
}),
)
.subscribe();
logger.debug(
"Subscribing to text message content stream",
);
textSubscription = textMessageContentStream.subscribe({
next: async (e: RuntimeEvent) => {
if (e.type == RuntimeEventTypes.TextMessageContent) {
await pushTextChunk(e.content);
textChunks.push(e.content);
}
},
error: (err) => {
logger.error(
{ err },
"Error in text message content stream",
);
interruptStreaming$.next({
reason: "Error streaming message content",
messageId,
});
stopStreamingText();
textSubscription?.unsubscribe();
},
complete: () => {
logger.debug("Text message content stream completed");
streamingTextStatus.next(new SuccessMessageStatus());
stopStreamingText();
textSubscription?.unsubscribe();
outputMessages.push(
plainToInstance(TextMessage, {
id: messageId,
createdAt: new Date(),
content: textChunks.join(""),
role: MessageRole.assistant,
}),
);
},
});
},
),
});
break;
////////////////////////////////
// ActionExecutionStart
////////////////////////////////
case RuntimeEventTypes.ActionExecutionStart:
logger.debug("Action execution start event received");
const actionExecutionArgumentStream = eventStream.pipe(
skipWhile((e: RuntimeEvent) => e !== event),
// take until the action execution end event
takeWhile(
(e: RuntimeEvent) =>
!(
e.type === RuntimeEventTypes.ActionExecutionEnd &&
(e as any).actionExecutionId == event.actionExecutionId
),
),
// filter out any other action execution events or action execution ids
filter(
(e: RuntimeEvent) =>
e.type == RuntimeEventTypes.ActionExecutionArgs &&
(e as any).actionExecutionId == event.actionExecutionId,
),
);
const streamingArgumentsStatus = new Subject<
typeof MessageStatusUnion
>();
pushMessage({
id: event.actionExecutionId,
parentMessageId: event.parentMessageId,
status: firstValueFrom(streamingArgumentsStatus),
createdAt: new Date(),
name: event.actionName,
arguments: new Repeater(
async (pushArgumentsChunk, stopStreamingArguments) => {
logger.debug("Action execution argument stream created");
const argumentChunks: string[] = [];
let actionExecutionArgumentSubscription: Subscription;
actionExecutionArgumentSubscription =
actionExecutionArgumentStream.subscribe({
next: async (e: RuntimeEvent) => {
if (
e.type == RuntimeEventTypes.ActionExecutionArgs
) {
await pushArgumentsChunk(e.args);
argumentChunks.push(e.args);
}
},
error: (err) => {
logger.error(
{ err },
"Error in action execution argument stream",
);
streamingArgumentsStatus.next(
plainToInstance(FailedMessageStatus, {
reason:
"An unknown error has occurred in the action execution argument stream",
}),
);
stopStreamingArguments();
actionExecutionArgumentSubscription?.unsubscribe();
},
complete: () => {
logger.debug(
"Action execution argument stream completed",
);
streamingArgumentsStatus.next(
new SuccessMessageStatus(),
);
stopStreamingArguments();
actionExecutionArgumentSubscription?.unsubscribe();
outputMessages.push(
plainToInstance(ActionExecutionMessage, {
id: event.actionExecutionId,
createdAt: new Date(),
name: event.actionName,
arguments: argumentChunks.join(""),
}),
);
},
});
},
),
});
break;
////////////////////////////////
// ActionExecutionResult
////////////////////////////////
case RuntimeEventTypes.ActionExecutionResult:
logger.debug(
{ result: event.result },
"Action execution result event received",
);
pushMessage({
id: "result-" + event.actionExecutionId,
status: new SuccessMessageStatus(),
createdAt: new Date(),
actionExecutionId: event.actionExecutionId,
actionName: event.actionName,
result: event.result,
});
outputMessages.push(
plainToInstance(ResultMessage, {
id: "result-" + event.actionExecutionId,
createdAt: new Date(),
actionExecutionId: event.actionExecutionId,
actionName: event.actionName,
result: event.result,
}),
);
break;
////////////////////////////////
// AgentStateMessage
////////////////////////////////
case RuntimeEventTypes.AgentStateMessage:
logger.debug({ event }, "Agent message event received");
pushMessage({
id: randomId(),
status: new SuccessMessageStatus(),
threadId: event.threadId,
agentName: event.agentName,
nodeName: event.nodeName,
runId: event.runId,
active: event.active,
state: event.state,
running: event.running,
role: MessageRole.assistant,
createdAt: new Date(),
});
outputMessages.push(
plainToInstance(AgentStateMessage, {
id: randomId(),
threadId: event.threadId,
agentName: event.agentName,
nodeName: event.nodeName,
runId: event.runId,
active: event.active,
state: event.state,
running: event.running,
role: MessageRole.assistant,
createdAt: new Date(),
}),
);
break;
}
},
error: (err) => {
// For structured CopilotKit errors, set proper error response status
if (
err instanceof CopilotKitError ||
err instanceof CopilotKitLowLevelError ||
(err instanceof Error &&
err.name &&
err.name.includes("CopilotKit")) ||
err?.extensions?.visibility
) {
responseStatus$.next(
new UnknownErrorResponse({
description: err.message || "Agent error occurred",
// Include original error information for frontend to extract
originalError: {
code: err.code || err.extensions?.code,
statusCode: err.statusCode || err.extensions?.statusCode,
severity: err.severity || err.extensions?.severity,
visibility: err.visibility || err.extensions?.visibility,
originalErrorType:
err.originalErrorType ||
err.extensions?.originalErrorType,
extensions: err.extensions,
},
}),
);
eventStreamSubscription?.unsubscribe();
rejectOutputMessagesPromise(err);
stopStreamingMessages();
return;
}
responseStatus$.next(
new UnknownErrorResponse({
description: `An unknown error has occurred in the event stream`,
}),
);
eventStreamSubscription?.unsubscribe();
stopStreamingMessages();
rejectOutputMessagesPromise(err);
},
complete: async () => {
logger.debug("Event stream completed");
if (data.cloud?.guardrails) {
logger.debug(
"Guardrails is enabled, waiting for guardrails result",
);
await firstValueFrom(guardrailsResult$);
}
responseStatus$.next(new SuccessResponseStatus());
eventStreamSubscription?.unsubscribe();
stopStreamingMessages();
resolveOutputMessagesPromise(outputMessages);
},
});
}),
};
return response;
}
}
@@ -0,0 +1,14 @@
import { randomId } from "@copilotkit/shared";
/**
* Resolve the id to use for a streamed TextMessageOutput.
*
* Upstream events occasionally arrive with `messageId` missing, null, or an
* empty string. We fall back to a freshly generated id in those cases so the
* resulting output never surfaces a null id to the GraphQL client (#2118).
*/
export function resolveMessageId(
eventMessageId: string | null | undefined,
): string {
return eventMessageId || randomId();
}
@@ -0,0 +1,30 @@
import { Arg, Resolver } from "type-graphql";
import { Ctx } from "type-graphql";
import { Query } from "type-graphql";
import { LoadAgentStateResponse } from "../types/load-agent-state-response.type";
import type { GraphQLContext } from "../../lib/integrations";
import { LoadAgentStateInput } from "../inputs/load-agent-state.input";
import { CopilotKitAgentDiscoveryError } from "@copilotkit/shared";
import { CopilotRuntime } from "../../lib";
@Resolver(() => LoadAgentStateResponse)
export class StateResolver {
@Query(() => LoadAgentStateResponse)
async loadAgentState(
@Ctx() ctx: GraphQLContext,
@Arg("data") data: LoadAgentStateInput,
) {
const agents = [];
const hasAgent = agents.some((agent) => agent.name === data.agentName);
if (!hasAgent) {
throw new CopilotKitAgentDiscoveryError({
agentName: data.agentName,
availableAgents: agents.map((a) => ({ name: a.name, id: a.name })),
});
}
const state = {};
return state;
}
}
@@ -0,0 +1,19 @@
import { Field, ObjectType } from "type-graphql";
@ObjectType()
export class Agent {
@Field(() => String)
id: string;
@Field(() => String)
name: string;
@Field(() => String)
description?: string;
}
@ObjectType()
export class AgentsResponse {
@Field(() => [Agent])
agents: Agent[];
}
@@ -0,0 +1,10 @@
import { Field, InputType } from "type-graphql";
@InputType()
export class BaseMessageInput {
@Field(() => String)
id: string;
@Field(() => Date)
createdAt: Date;
}
@@ -0,0 +1,183 @@
import { randomId } from "@copilotkit/shared";
import {
ActionExecutionMessageInput,
ResultMessageInput,
TextMessageInput,
AgentStateMessageInput,
ImageMessageInput,
} from "../../inputs/message.input";
import { BaseMessageInput } from "../base";
import { BaseMessageOutput } from "../copilot-response.type";
import { MessageRole } from "../enums";
import { MessageStatus, MessageStatusCode } from "../message-status.type";
export type MessageType =
| "TextMessage"
| "ActionExecutionMessage"
| "ResultMessage"
| "AgentStateMessage"
| "ImageMessage";
export class Message {
type: MessageType;
id: BaseMessageOutput["id"];
createdAt: BaseMessageOutput["createdAt"];
status: MessageStatus;
constructor(props: any) {
props.id ??= randomId();
props.status ??= { code: MessageStatusCode.Success };
props.createdAt ??= new Date();
Object.assign(this, props);
}
isTextMessage(): this is TextMessage {
return this.type === "TextMessage";
}
isActionExecutionMessage(): this is ActionExecutionMessage {
return this.type === "ActionExecutionMessage";
}
isResultMessage(): this is ResultMessage {
return this.type === "ResultMessage";
}
isAgentStateMessage(): this is AgentStateMessage {
return this.type === "AgentStateMessage";
}
isImageMessage(): this is ImageMessage {
return this.type === "ImageMessage";
}
}
// alias Role to MessageRole
export const Role = MessageRole;
// when constructing any message, the base fields are optional
type MessageConstructorOptions = Partial<Message>;
type TextMessageConstructorOptions = MessageConstructorOptions &
TextMessageInput;
export class TextMessage
extends Message
implements TextMessageConstructorOptions
{
content: TextMessageInput["content"];
parentMessageId: TextMessageInput["parentMessageId"];
role: TextMessageInput["role"];
type = "TextMessage" as const;
constructor(props: TextMessageConstructorOptions) {
super(props);
this.type = "TextMessage";
}
}
export class ActionExecutionMessage
extends Message
implements Omit<ActionExecutionMessageInput, "arguments" | "scope">
{
type: MessageType = "ActionExecutionMessage";
name: string;
arguments: Record<string, any>;
parentMessageId?: string;
}
export class ResultMessage extends Message implements ResultMessageInput {
type: MessageType = "ResultMessage";
actionExecutionId: string;
actionName: string;
result: string;
static encodeResult(
result: any,
error?: { code: string; message: string } | string | Error,
): string {
const errorObj = error
? typeof error === "string"
? { code: "ERROR", message: error }
: error instanceof Error
? { code: "ERROR", message: error.message }
: error
: undefined;
if (errorObj) {
return JSON.stringify({
error: errorObj,
result: result || "",
});
}
if (result === undefined) {
return "";
}
return typeof result === "string" ? result : JSON.stringify(result);
}
static decodeResult(result: string): {
error?: { code: string; message: string };
result: string;
} {
if (!result) {
return { result: "" };
}
try {
const parsed = JSON.parse(result);
if (parsed && typeof parsed === "object") {
if ("error" in parsed) {
return {
error: parsed.error,
result: parsed.result || "",
};
}
return { result: JSON.stringify(parsed) };
}
return { result };
} catch (e) {
return { result };
}
}
hasError(): boolean {
try {
const { error } = ResultMessage.decodeResult(this.result);
return !!error;
} catch {
return false;
}
}
getError(): { code: string; message: string } | undefined {
try {
const { error } = ResultMessage.decodeResult(this.result);
return error;
} catch {
return undefined;
}
}
}
export class AgentStateMessage
extends Message
implements Omit<AgentStateMessageInput, "state">
{
type: MessageType = "AgentStateMessage";
threadId: string;
agentName: string;
nodeName: string;
runId: string;
active: boolean;
role: MessageRole;
state: any;
running: boolean;
}
export class ImageMessage extends Message implements ImageMessageInput {
type: MessageType = "ImageMessage";
format: string;
bytes: string;
role: MessageRole;
parentMessageId?: string;
}
@@ -0,0 +1,141 @@
import { Field, InterfaceType, ObjectType } from "type-graphql";
import { MessageRole } from "./enums";
import { MessageStatusUnion } from "./message-status.type";
import { ResponseStatusUnion } from "./response-status.type";
import { ExtensionsResponse } from "./extensions-response.type";
import { BaseMetaEvent } from "./meta-events.type";
@InterfaceType({
resolveType(value) {
if (value.hasOwnProperty("content")) {
return TextMessageOutput;
} else if (value.hasOwnProperty("name")) {
return ActionExecutionMessageOutput;
} else if (value.hasOwnProperty("result")) {
return ResultMessageOutput;
} else if (value.hasOwnProperty("state")) {
return AgentStateMessageOutput;
} else if (
value.hasOwnProperty("format") &&
value.hasOwnProperty("bytes")
) {
return ImageMessageOutput;
}
return undefined;
},
})
export abstract class BaseMessageOutput {
@Field(() => String)
id: string;
@Field(() => Date)
createdAt: Date;
@Field(() => MessageStatusUnion)
status: typeof MessageStatusUnion;
}
@ObjectType({ implements: BaseMessageOutput })
export class TextMessageOutput {
@Field(() => MessageRole)
role: MessageRole;
@Field(() => [String])
content: string[];
@Field(() => String, { nullable: true })
parentMessageId?: string;
}
@ObjectType({ implements: BaseMessageOutput })
export class ActionExecutionMessageOutput {
@Field(() => String)
name: string;
@Field(() => String, {
nullable: true,
deprecationReason: "This field will be removed in a future version",
})
scope?: string;
@Field(() => [String])
arguments: string[];
@Field(() => String, { nullable: true })
parentMessageId?: string;
}
@ObjectType({ implements: BaseMessageOutput })
export class ResultMessageOutput {
@Field(() => String)
actionExecutionId: string;
@Field(() => String)
actionName: string;
@Field(() => String)
result: string;
}
@ObjectType({ implements: BaseMessageOutput })
export class AgentStateMessageOutput {
@Field(() => String)
threadId: string;
@Field(() => String)
agentName: string;
@Field(() => String)
nodeName: string;
@Field(() => String)
runId: string;
@Field(() => Boolean)
active: boolean;
@Field(() => MessageRole)
role: MessageRole;
@Field(() => String)
state: string;
@Field(() => Boolean)
running: boolean;
}
@ObjectType({ implements: BaseMessageOutput })
export class ImageMessageOutput {
@Field(() => String)
format: string;
@Field(() => String)
bytes: string;
@Field(() => MessageRole)
role: MessageRole;
@Field(() => String, { nullable: true })
parentMessageId?: string;
}
@ObjectType()
export class CopilotResponse {
@Field(() => String)
threadId!: string;
@Field(() => ResponseStatusUnion)
status: typeof ResponseStatusUnion;
@Field({ nullable: true })
runId?: string;
@Field(() => [BaseMessageOutput])
messages: (typeof BaseMessageOutput)[];
@Field(() => ExtensionsResponse, { nullable: true })
extensions?: ExtensionsResponse;
@Field(() => [BaseMetaEvent], { nullable: true })
metaEvents?: (typeof BaseMetaEvent)[];
}
@@ -0,0 +1,38 @@
import { registerEnumType } from "type-graphql";
export enum MessageRole {
assistant = "assistant",
developer = "developer",
system = "system",
tool = "tool",
user = "user",
}
export enum CopilotRequestType {
Chat = "Chat",
Task = "Task",
TextareaCompletion = "TextareaCompletion",
TextareaPopover = "TextareaPopover",
Suggestion = "Suggestion",
}
export enum ActionInputAvailability {
disabled = "disabled",
enabled = "enabled",
remote = "remote",
}
registerEnumType(MessageRole, {
name: "MessageRole",
description: "The role of the message",
});
registerEnumType(CopilotRequestType, {
name: "CopilotRequestType",
description: "The type of Copilot request",
});
registerEnumType(ActionInputAvailability, {
name: "ActionInputAvailability",
description: "The availability of the frontend action",
});
@@ -0,0 +1,23 @@
import { Field, ObjectType } from "type-graphql";
/**
* The extensions response is used to receive additional information from the copilot runtime, specific to a
* service adapter or agent framework.
*
* Next time a request to the runtime is made, the extensions response will be included in the request as input.
*/
@ObjectType()
export class OpenAIApiAssistantAPIResponse {
@Field(() => String, { nullable: true })
runId?: string;
@Field(() => String, { nullable: true })
threadId?: string;
}
@ObjectType()
export class ExtensionsResponse {
@Field(() => OpenAIApiAssistantAPIResponse, { nullable: true })
openaiAssistantAPI?: OpenAIApiAssistantAPIResponse;
}
@@ -0,0 +1,20 @@
import { Field, ObjectType, registerEnumType } from "type-graphql";
export enum GuardrailsResultStatus {
ALLOWED = "allowed",
DENIED = "denied",
}
registerEnumType(GuardrailsResultStatus, {
name: "GuardrailsResultStatus",
description: "The status of the guardrails check",
});
@ObjectType()
export class GuardrailsResult {
@Field(() => GuardrailsResultStatus)
status: GuardrailsResultStatus;
@Field(() => String, { nullable: true })
reason?: string;
}
@@ -0,0 +1,17 @@
import { Field, ObjectType } from "type-graphql";
import { BaseMessageOutput } from "./copilot-response.type";
@ObjectType()
export class LoadAgentStateResponse {
@Field(() => String)
threadId: string;
@Field(() => Boolean)
threadExists: boolean;
@Field(() => String)
state: string;
@Field(() => String)
messages: string;
}
@@ -0,0 +1,48 @@
import {
Field,
ObjectType,
createUnionType,
registerEnumType,
} from "type-graphql";
export enum MessageStatusCode {
Pending = "pending",
Success = "success",
Failed = "failed",
}
registerEnumType(MessageStatusCode, {
name: "MessageStatusCode",
});
@ObjectType()
export class BaseMessageStatus {
@Field(() => MessageStatusCode)
code: MessageStatusCode;
}
@ObjectType()
export class PendingMessageStatus extends BaseMessageStatus {
code: MessageStatusCode = MessageStatusCode.Pending;
}
@ObjectType()
export class SuccessMessageStatus extends BaseMessageStatus {
code: MessageStatusCode = MessageStatusCode.Success;
}
@ObjectType()
export class FailedMessageStatus extends BaseMessageStatus {
code: MessageStatusCode = MessageStatusCode.Failed;
@Field(() => String)
reason: string;
}
export const MessageStatusUnion = createUnionType({
name: "MessageStatus",
types: () =>
[PendingMessageStatus, SuccessMessageStatus, FailedMessageStatus] as const,
});
export type MessageStatus = typeof MessageStatusUnion;
@@ -0,0 +1,78 @@
import {
createUnionType,
Field,
InterfaceType,
ObjectType,
registerEnumType,
} from "type-graphql";
import {
ActionExecutionMessageOutput,
AgentStateMessageOutput,
BaseMessageOutput,
ResultMessageOutput,
TextMessageOutput,
} from "./copilot-response.type";
export enum MetaEventName {
LangGraphInterruptEvent = "LangGraphInterruptEvent",
CopilotKitLangGraphInterruptEvent = "CopilotKitLangGraphInterruptEvent",
}
registerEnumType(MetaEventName, {
name: "MetaEventName",
description: "Meta event types",
});
@InterfaceType({
resolveType(value) {
if (value.name === MetaEventName.LangGraphInterruptEvent) {
return LangGraphInterruptEvent;
} else if (value.name === MetaEventName.CopilotKitLangGraphInterruptEvent) {
return CopilotKitLangGraphInterruptEvent;
}
return undefined;
},
})
@InterfaceType()
export abstract class BaseMetaEvent {
@Field(() => String)
type: "MetaEvent" = "MetaEvent";
@Field(() => MetaEventName)
name: MetaEventName;
}
@ObjectType()
export class CopilotKitLangGraphInterruptEventData {
@Field(() => String)
value: string;
@Field(() => [BaseMessageOutput])
messages: (typeof BaseMessageOutput)[];
}
@ObjectType({ implements: BaseMetaEvent })
export class LangGraphInterruptEvent {
@Field(() => MetaEventName)
name: MetaEventName.LangGraphInterruptEvent =
MetaEventName.LangGraphInterruptEvent;
@Field(() => String)
value: string;
@Field(() => String, { nullable: true })
response?: string;
}
@ObjectType({ implements: BaseMetaEvent })
export class CopilotKitLangGraphInterruptEvent {
@Field(() => MetaEventName)
name: MetaEventName.CopilotKitLangGraphInterruptEvent =
MetaEventName.CopilotKitLangGraphInterruptEvent;
@Field(() => CopilotKitLangGraphInterruptEventData)
data: CopilotKitLangGraphInterruptEventData;
@Field(() => String, { nullable: true })
response?: string;
}
@@ -0,0 +1,77 @@
import { GraphQLJSON } from "graphql-scalars";
import {
Field,
InterfaceType,
ObjectType,
createUnionType,
registerEnumType,
} from "type-graphql";
export enum ResponseStatusCode {
Pending = "pending",
Success = "success",
Failed = "failed",
}
registerEnumType(ResponseStatusCode, {
name: "ResponseStatusCode",
});
@InterfaceType({
resolveType(value) {
if (value.code === ResponseStatusCode.Success) {
return SuccessResponseStatus;
} else if (value.code === ResponseStatusCode.Failed) {
return FailedResponseStatus;
} else if (value.code === ResponseStatusCode.Pending) {
return PendingResponseStatus;
}
return undefined;
},
})
@ObjectType()
abstract class BaseResponseStatus {
@Field(() => ResponseStatusCode)
code: ResponseStatusCode;
}
@ObjectType({ implements: BaseResponseStatus })
export class PendingResponseStatus extends BaseResponseStatus {
code: ResponseStatusCode = ResponseStatusCode.Pending;
}
@ObjectType({ implements: BaseResponseStatus })
export class SuccessResponseStatus extends BaseResponseStatus {
code: ResponseStatusCode = ResponseStatusCode.Success;
}
export enum FailedResponseStatusReason {
GUARDRAILS_VALIDATION_FAILED = "GUARDRAILS_VALIDATION_FAILED",
MESSAGE_STREAM_INTERRUPTED = "MESSAGE_STREAM_INTERRUPTED",
UNKNOWN_ERROR = "UNKNOWN_ERROR",
}
registerEnumType(FailedResponseStatusReason, {
name: "FailedResponseStatusReason",
});
@ObjectType({ implements: BaseResponseStatus })
export class FailedResponseStatus extends BaseResponseStatus {
code: ResponseStatusCode = ResponseStatusCode.Failed;
@Field(() => FailedResponseStatusReason)
reason: FailedResponseStatusReason;
@Field(() => GraphQLJSON, { nullable: true })
details?: Record<string, any> = null;
}
export const ResponseStatusUnion = createUnionType({
name: "ResponseStatus",
types: () =>
[
PendingResponseStatus,
SuccessResponseStatus,
FailedResponseStatus,
] as const,
});
+3
View File
@@ -0,0 +1,3 @@
export * from "./lib";
export * from "./utils";
export * from "./service-adapters";
+1
View File
@@ -0,0 +1 @@
export * from "./lib/runtime/agent-integrations/langgraph";
@@ -0,0 +1,55 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance,
} from "vitest";
import {
_resetRuntimeTelemetryDisclosureForTesting,
logRuntimeTelemetryDisclosure,
} from "../telemetry-disclosure";
let consoleInfoSpy: MockInstance<typeof console.info>;
const originalEnv = { ...process.env };
beforeEach(() => {
_resetRuntimeTelemetryDisclosureForTesting();
consoleInfoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
// Clear opt-out env vars so the disclosure can fire by default.
delete process.env.COPILOTKIT_TELEMETRY_DISABLED;
delete process.env.DO_NOT_TRACK;
});
afterEach(() => {
vi.restoreAllMocks();
process.env = { ...originalEnv };
});
describe("logRuntimeTelemetryDisclosure", () => {
it("logs once per process", () => {
logRuntimeTelemetryDisclosure();
logRuntimeTelemetryDisclosure();
logRuntimeTelemetryDisclosure();
expect(consoleInfoSpy).toHaveBeenCalledTimes(1);
const [message] = consoleInfoSpy.mock.calls[0]!;
expect(message).toMatch(/anonymous telemetry/i);
expect(message).toMatch(/COPILOTKIT_TELEMETRY_DISABLED/);
});
it("does not log when COPILOTKIT_TELEMETRY_DISABLED is set", () => {
process.env.COPILOTKIT_TELEMETRY_DISABLED = "true";
logRuntimeTelemetryDisclosure();
expect(consoleInfoSpy).not.toHaveBeenCalled();
});
it("does not log when DO_NOT_TRACK is set", () => {
process.env.DO_NOT_TRACK = "1";
logRuntimeTelemetryDisclosure();
expect(consoleInfoSpy).not.toHaveBeenCalled();
});
});
+4
View File
@@ -0,0 +1,4 @@
export interface CopilotCloudOptions {
baseUrl?: string;
publicApiKey?: string;
}
+211
View File
@@ -0,0 +1,211 @@
/**
* Error message configuration - Single source of truth for all error messages
*
* This centralized configuration system provides:
*
* 🎯 **Benefits:**
* - Single source of truth for all error messages
* - Easy content management without touching code
* - Consistent error messaging across the application
* - Ready for internationalization (i18n)
* - Type-safe configuration with TypeScript
* - Categorized errors for better handling
*
* 📝 **How to use:**
* 1. Add new error patterns to `errorPatterns` object
* 2. Use {context} placeholder for dynamic context injection
* 3. Specify category, severity, and actionable flags
* 4. Add fallback messages for error categories
*
* 🔧 **How to maintain:**
* - Content teams can update messages here without touching logic
* - Developers add new error patterns as needed
* - Use categories to group similar errors
* - Mark errors as actionable if users can fix them
*
* 🌍 **Future i18n support:**
* - Replace `message` string with `messages: { en: "...", es: "..." }`
* - Add locale parameter to helper functions
*
* @example
* ```typescript
* // Adding a new error pattern:
* "CUSTOM_ERROR": {
* message: "Custom error occurred in {context}. Please try again.",
* category: "unknown",
* severity: "error",
* actionable: true
* }
* ```
*/
export interface ErrorPatternConfig {
message: string;
category:
| "network"
| "connection"
| "authentication"
| "validation"
| "unknown";
severity: "error" | "warning" | "info";
actionable: boolean;
}
export interface ErrorConfig {
errorPatterns: Record<string, ErrorPatternConfig>;
fallbacks: Record<string, string>;
contextTemplates: Record<string, string>;
}
export const errorConfig: ErrorConfig = {
errorPatterns: {
ECONNREFUSED: {
message:
"Connection refused - the agent service is not running or not accessible at the specified address. Please check that your agent is started and listening on the correct port.",
category: "network",
severity: "error",
actionable: true,
},
ENOTFOUND: {
message:
"Host not found - the agent service URL appears to be incorrect or the service is not accessible. Please verify the agent endpoint URL.",
category: "network",
severity: "error",
actionable: true,
},
ETIMEDOUT: {
message:
"Connection timeout - the agent service is taking too long to respond. This could indicate network issues or an overloaded agent service.",
category: "network",
severity: "warning",
actionable: true,
},
terminated: {
message:
"Agent {context} was unexpectedly terminated. This often indicates an error in the agent service (e.g., authentication failures, missing environment variables, or agent crashes). Check the agent logs for the root cause.",
category: "connection",
severity: "error",
actionable: true,
},
UND_ERR_SOCKET: {
message:
"Socket connection was closed unexpectedly. This typically indicates the agent service encountered an error and shut down the connection. Check the agent logs for the underlying cause.",
category: "connection",
severity: "error",
actionable: true,
},
other_side_closed: {
message:
"The agent service closed the connection unexpectedly. This usually indicates an error in the agent service. Check the agent logs for more details.",
category: "connection",
severity: "error",
actionable: true,
},
fetch_failed: {
message:
"Failed to connect to the agent service. Please verify the agent is running and the endpoint URL is correct.",
category: "network",
severity: "error",
actionable: true,
},
// Authentication patterns
"401": {
message:
"Authentication failed. Please check your API keys and ensure they are correctly configured.",
category: "authentication",
severity: "error",
actionable: true,
},
"api key": {
message:
"API key error detected. Please verify your API key is correct and has the necessary permissions.",
category: "authentication",
severity: "error",
actionable: true,
},
unauthorized: {
message:
"Unauthorized access. Please check your authentication credentials.",
category: "authentication",
severity: "error",
actionable: true,
},
// Python-specific error patterns
AuthenticationError: {
message:
"OpenAI authentication failed. Please check your OPENAI_API_KEY environment variable or API key configuration.",
category: "authentication",
severity: "error",
actionable: true,
},
"Incorrect API key provided": {
message:
"OpenAI API key is invalid. Please verify your OPENAI_API_KEY is correct and active.",
category: "authentication",
severity: "error",
actionable: true,
},
RateLimitError: {
message:
"OpenAI rate limit exceeded. Please wait a moment and try again, or check your OpenAI usage limits.",
category: "network",
severity: "warning",
actionable: true,
},
InvalidRequestError: {
message:
"Invalid request to OpenAI API. Please check your request parameters and model configuration.",
category: "validation",
severity: "error",
actionable: true,
},
PermissionDeniedError: {
message:
"Permission denied for OpenAI API. Please check your API key permissions and billing status.",
category: "authentication",
severity: "error",
actionable: true,
},
NotFoundError: {
message:
"OpenAI resource not found. Please check your model name and availability.",
category: "validation",
severity: "error",
actionable: true,
},
},
fallbacks: {
network:
"A network error occurred while connecting to the agent service. Please check your connection and ensure the agent service is running.",
connection:
"The connection to the agent service was lost unexpectedly. This may indicate an issue with the agent service.",
authentication:
"Authentication failed. Please check your API keys and credentials.",
validation:
"Invalid input or configuration. Please check your parameters and try again.",
unknown:
"An unexpected error occurred. Please check the logs for more details.",
default:
"An unexpected error occurred. Please check the logs for more details.",
},
contextTemplates: {
connection: "connection",
event_streaming_connection: "event streaming connection",
agent_streaming_connection: "agent streaming connection",
langgraph_agent_connection: "LangGraph agent connection",
},
};
/**
* Helper function to get error pattern configuration by key
*/
export function getErrorPattern(key: string): ErrorPatternConfig | undefined {
return errorConfig.errorPatterns[key];
}
/**
* Helper function to get fallback message by category
*/
export function getFallbackMessage(category: string): string {
return errorConfig.fallbacks[category] || errorConfig.fallbacks.default;
}
+52
View File
@@ -0,0 +1,52 @@
export * from "../service-adapters/openai/openai-adapter";
export * from "../service-adapters/langchain/langchain-adapter";
export * from "../service-adapters/google/google-genai-adapter";
export * from "../service-adapters/openai/openai-assistant-adapter";
export * from "../service-adapters/unify/unify-adapter";
export * from "../service-adapters/groq/groq-adapter";
export * from "./integrations";
export * from "./logger";
export * from "./runtime/copilot-runtime";
export * from "./runtime/mcp-tools-utils";
export * from "./runtime/telemetry-agent-runner";
// The below re-exports "dummy" classes and types, to get a deprecation warning redirecting the users to import these from the correct, new route
/**
* @deprecated LangGraphAgent import from `@copilotkit/runtime` is deprecated. Please import it from `@copilotkit/runtime/langgraph` instead
*/
export class LangGraphAgent {
constructor() {
throw new Error(
"LangGraphAgent import from @copilotkit/runtime is deprecated. Please import it from @copilotkit/runtime/langgraph instead",
);
}
}
/**
* @deprecated LangGraphHttpAgent import from `@copilotkit/runtime` is deprecated. Please import it from `@copilotkit/runtime/langgraph` instead
*/
export class LangGraphHttpAgent {
constructor() {
throw new Error(
"LangGraphHttpAgent import from @copilotkit/runtime is deprecated. Please import it from @copilotkit/runtime/langgraph instead",
);
}
}
/**
* @deprecated TextMessageEvents import from `@copilotkit/runtime` is deprecated. Please import it from `@copilotkit/runtime/langgraph` instead
*/
export type TextMessageEvents = any;
/**
* @deprecated ToolCallEvents import from `@copilotkit/runtime` is deprecated. Please import it from `@copilotkit/runtime/langgraph` instead
*/
export type ToolCallEvents = any;
/**
* @deprecated CustomEventNames import from `@copilotkit/runtime` is deprecated. Please import it from `@copilotkit/runtime/langgraph` instead
*/
export type CustomEventNames = any;
/**
* @deprecated PredictStateTool import from `@copilotkit/runtime` is deprecated. Please import it from `@copilotkit/runtime/langgraph` instead
*/
export type PredictStateTool = any;
@@ -0,0 +1,6 @@
export * from "./shared";
export * from "./nextjs/app-router";
export * from "./nextjs/pages-router";
export * from "./node-http";
export * from "./node-express";
export * from "./nest";
@@ -0,0 +1,21 @@
import { CreateCopilotRuntimeServerOptions } from "../shared";
import { copilotRuntimeNodeHttpEndpoint } from "../node-http";
import telemetry, {
getRuntimeInstanceTelemetryInfo,
} from "../../telemetry-client";
export function copilotRuntimeNestEndpoint(
options: CreateCopilotRuntimeServerOptions,
) {
telemetry.setGlobalProperties({
runtime: {
framework: "nest",
},
});
telemetry.capture(
"oss.runtime.instance_created",
getRuntimeInstanceTelemetryInfo(options),
);
return copilotRuntimeNodeHttpEndpoint(options);
}
@@ -0,0 +1,47 @@
import { createCopilotEndpointSingleRoute } from "../../../v2/runtime";
import { CreateCopilotRuntimeServerOptions, getCommonConfig } from "../shared";
import telemetry, {
getRuntimeInstanceTelemetryInfo,
} from "../../telemetry-client";
import { handle } from "hono/vercel";
export function copilotRuntimeNextJSAppRouterEndpoint(
options: CreateCopilotRuntimeServerOptions,
) {
const commonConfig = getCommonConfig(options);
telemetry.setGlobalProperties({
runtime: {
framework: "nextjs-app-router",
},
});
if (options.properties?._copilotkit) {
telemetry.setGlobalProperties({
_copilotkit: options.properties._copilotkit,
});
}
telemetry.capture(
"oss.runtime.instance_created",
getRuntimeInstanceTelemetryInfo(options),
);
const logger = commonConfig.logging;
logger.debug("Creating NextJS App Router endpoint");
const serviceAdapter = options.serviceAdapter;
if (serviceAdapter) {
options.runtime.handleServiceAdapter(serviceAdapter);
}
// Note: cors option requires @copilotkit/runtime with credentials support
const copilotRoute = createCopilotEndpointSingleRoute({
runtime: options.runtime.instance,
basePath: options.baseUrl ?? options.endpoint,
...(options.cors && { cors: options.cors }),
} as any);
const handleRequest = handle(copilotRoute as any);
return { handleRequest };
}
@@ -0,0 +1,45 @@
import { CreateCopilotRuntimeServerOptions, getCommonConfig } from "../shared";
import telemetry, {
getRuntimeInstanceTelemetryInfo,
} from "../../telemetry-client";
import { copilotRuntimeNodeHttpEndpoint } from "../node-http";
export const config = {
api: {
bodyParser: false,
},
};
// This import is needed to fix the type error
// Fix is currently in TypeScript 5.5 beta, waiting for stable version
// https://github.com/microsoft/TypeScript/issues/42873#issuecomment-2066874644
// oxlint-disable-next-line unicorn/require-module-specifiers, typescript/no-useless-empty-export
export type {} from "@whatwg-node/server";
export function copilotRuntimeNextJSPagesRouterEndpoint(
options: CreateCopilotRuntimeServerOptions,
) {
const commonConfig = getCommonConfig(options);
telemetry.setGlobalProperties({
runtime: {
framework: "nextjs-pages-router",
},
});
if (options.properties?._copilotkit) {
telemetry.setGlobalProperties({
_copilotkit: options.properties._copilotkit,
});
}
telemetry.capture(
"oss.runtime.instance_created",
getRuntimeInstanceTelemetryInfo(options),
);
const logger = commonConfig.logging;
logger.debug("Creating NextJS Pages Router endpoint");
return copilotRuntimeNodeHttpEndpoint(options);
}
@@ -0,0 +1,21 @@
import { CreateCopilotRuntimeServerOptions } from "../shared";
import { copilotRuntimeNodeHttpEndpoint } from "../node-http";
import telemetry, {
getRuntimeInstanceTelemetryInfo,
} from "../../telemetry-client";
export function copilotRuntimeNodeExpressEndpoint(
options: CreateCopilotRuntimeServerOptions,
) {
telemetry.setGlobalProperties({
runtime: {
framework: "node-express",
},
});
telemetry.capture(
"oss.runtime.instance_created",
getRuntimeInstanceTelemetryInfo(options),
);
return copilotRuntimeNodeHttpEndpoint(options);
}
@@ -0,0 +1,66 @@
import { describe, it, expect } from "vitest";
/**
* Test for #2986: instanceof Request fails with @hono/node-server polyfill
*
* When Hono polyfills the Request class, `instanceof Request` fails because
* the polyfilled Request has a different prototype. We need duck-type checking.
*/
// Simulates a polyfilled Request object that does NOT pass instanceof Request
function createPolyfillRequest(url: string, method: string = "GET"): object {
return {
url,
method,
headers: new Headers({ "content-type": "application/json" }),
body: null,
clone: () => createPolyfillRequest(url, method),
};
}
// This is the duck-type check that should replace instanceof
function isRequestLike(obj: unknown): obj is Request {
return (
typeof obj === "object" &&
obj !== null &&
"url" in obj &&
"method" in obj &&
"headers" in obj &&
typeof (obj as any).url === "string" &&
typeof (obj as any).method === "string"
);
}
describe("Request duck-type detection (#2986)", () => {
it("should detect a native Request object", () => {
const req = new Request("http://localhost:3000/api/copilotkit", {
method: "POST",
});
expect(isRequestLike(req)).toBe(true);
expect(req instanceof Request).toBe(true);
});
it("should detect a polyfilled Request object that fails instanceof", () => {
const polyfilled = createPolyfillRequest(
"http://localhost:3000/api/copilotkit",
"POST",
);
// instanceof fails for polyfilled objects
expect(polyfilled instanceof Request).toBe(false);
// But duck-type check succeeds
expect(isRequestLike(polyfilled)).toBe(true);
});
it("should NOT match null or undefined", () => {
expect(isRequestLike(null)).toBe(false);
expect(isRequestLike(undefined)).toBe(false);
});
it("should NOT match an object missing required properties", () => {
expect(isRequestLike({ url: "http://test.com" })).toBe(false);
expect(isRequestLike({ method: "GET" })).toBe(false);
expect(isRequestLike({})).toBe(false);
});
});
@@ -0,0 +1,187 @@
import { CreateCopilotRuntimeServerOptions, getCommonConfig } from "../shared";
import telemetry, {
getRuntimeInstanceTelemetryInfo,
} from "../../telemetry-client";
import { createCopilotEndpointSingleRoute } from "../../../v2/runtime";
import type { IncomingMessage, ServerResponse } from "node:http";
import {
getFullUrl,
IncomingWithBody,
isDisturbedOrLockedError,
isStreamConsumed,
nodeStreamToReadableStream,
readableStreamToNodeStream,
synthesizeBodyFromParsedBody,
toHeaders,
} from "./request-handler";
export function copilotRuntimeNodeHttpEndpoint(
options: CreateCopilotRuntimeServerOptions,
) {
const commonConfig = getCommonConfig(options);
telemetry.setGlobalProperties({
runtime: {
framework: "node-http",
},
});
if (options.properties?._copilotkit) {
telemetry.setGlobalProperties({
_copilotkit: options.properties._copilotkit,
});
}
telemetry.capture(
"oss.runtime.instance_created",
getRuntimeInstanceTelemetryInfo(options),
);
const logger = commonConfig.logging;
logger.debug("Creating Node HTTP endpoint");
const serviceAdapter = options.serviceAdapter;
if (serviceAdapter) {
options.runtime.handleServiceAdapter(serviceAdapter);
}
// Note: cors option requires @copilotkit/runtime with credentials support
const honoApp = createCopilotEndpointSingleRoute({
runtime: options.runtime.instance,
basePath: options.baseUrl ?? options.endpoint,
...(options.cors && { cors: options.cors }),
} as any);
const handle = async function handler(
req: IncomingWithBody,
res: ServerResponse,
) {
const url = getFullUrl(req);
const hasBody = req.method !== "GET" && req.method !== "HEAD";
const baseHeaders = toHeaders(req.headers);
const parsedBody = req.body;
const streamConsumed = isStreamConsumed(req) || parsedBody !== undefined;
const canStream = hasBody && !streamConsumed;
let requestBody: BodyInit | null | undefined = undefined;
let useDuplex = false;
if (hasBody && canStream) {
requestBody = nodeStreamToReadableStream(req);
useDuplex = true;
}
if (hasBody && streamConsumed) {
if (parsedBody !== undefined) {
const synthesized = synthesizeBodyFromParsedBody(
parsedBody,
baseHeaders,
);
requestBody = synthesized.body ?? undefined;
baseHeaders.delete("content-length");
if (synthesized.contentType) {
baseHeaders.set("content-type", synthesized.contentType);
}
logger.debug(
"Request stream already consumed; using parsed req.body to rebuild request.",
);
} else {
logger.warn(
"Request stream consumed with no available body; sending empty payload.",
);
requestBody = undefined;
}
}
const buildRequest = (
body: BodyInit | null | undefined,
headers: Headers,
duplex: boolean,
) =>
new Request(url, {
method: req.method,
headers,
body,
duplex: duplex ? "half" : undefined,
} as RequestInit);
let response: Response;
try {
response = await honoApp.fetch(
buildRequest(requestBody, baseHeaders, useDuplex),
);
} catch (error) {
if (isDisturbedOrLockedError(error) && hasBody) {
logger.warn(
"Encountered disturbed/locked request body; rebuilding request using parsed body or empty payload.",
);
const fallbackHeaders = new Headers(baseHeaders);
let fallbackBody: BodyInit | null | undefined;
if (parsedBody !== undefined) {
const synthesized = synthesizeBodyFromParsedBody(
parsedBody,
fallbackHeaders,
);
fallbackBody = synthesized.body ?? undefined;
fallbackHeaders.delete("content-length");
if (synthesized.contentType) {
fallbackHeaders.set("content-type", synthesized.contentType);
}
} else {
fallbackBody = undefined;
}
response = await honoApp.fetch(
buildRequest(fallbackBody, fallbackHeaders, false),
);
} else {
throw error;
}
}
res.statusCode = response.status;
response.headers.forEach((value, key) => {
res.setHeader(key, value);
});
if (response.body) {
readableStreamToNodeStream(response.body).pipe(res);
} else {
res.end();
}
};
// Duck-type check for Request-like objects (handles polyfilled Request from @hono/node-server)
function isRequestLike(obj: unknown): obj is Request {
return (
obj instanceof Request ||
(typeof obj === "object" &&
obj !== null &&
"url" in obj &&
"method" in obj &&
"headers" in obj &&
typeof (obj as any).url === "string" &&
typeof (obj as any).method === "string")
);
}
return function (
reqOrRequest: IncomingMessage | Request,
res?: ServerResponse,
): Promise<void> | Promise<Response> | Response {
if (isRequestLike(reqOrRequest) && !res) {
return honoApp.fetch(reqOrRequest as Request);
}
if (!res) {
throw new TypeError("ServerResponse is required for Node HTTP requests");
}
return handle(reqOrRequest as IncomingMessage, res);
};
}
@@ -0,0 +1,130 @@
import type { IncomingMessage } from "http";
import { Readable } from "node:stream";
export type IncomingWithBody = IncomingMessage & {
body?: unknown;
complete?: boolean;
};
export function readableStreamToNodeStream(
webStream: ReadableStream,
): Readable {
const reader = webStream.getReader();
return new Readable({
async read() {
try {
const { done, value } = await reader.read();
if (done) {
this.push(null);
} else {
this.push(Buffer.from(value));
}
} catch (err) {
this.destroy(err as Error);
}
},
});
}
export function nodeStreamToReadableStream(
nodeStream: Readable,
): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
nodeStream.on("data", (chunk) => {
controller.enqueue(
chunk instanceof Buffer ? new Uint8Array(chunk) : chunk,
);
});
nodeStream.on("end", () => {
controller.close();
});
nodeStream.on("error", (err) => {
controller.error(err);
});
},
cancel() {
nodeStream.destroy();
},
});
}
export function getFullUrl(req: IncomingMessage): string {
// Use req.url (path relative to mount point) for Hono routing to work correctly.
// Express sets req.url to the path after the mount point (e.g., "/" when mounted at "/copilotkit").
// Pure Node HTTP sets req.url to the full path.
const path = req.url || "/";
const host =
(req.headers["x-forwarded-host"] as string) ||
(req.headers.host as string) ||
"localhost";
const proto =
(req.headers["x-forwarded-proto"] as string) ||
((req.socket as any).encrypted ? "https" : "http");
return `${proto}://${host}${path}`;
}
export function toHeaders(rawHeaders: IncomingMessage["headers"]): Headers {
const headers = new Headers();
for (const [key, value] of Object.entries(rawHeaders)) {
if (value === undefined) continue;
if (Array.isArray(value)) {
value.forEach((entry) => headers.append(key, entry));
continue;
}
headers.append(key, value);
}
return headers;
}
export function isStreamConsumed(req: IncomingWithBody): boolean {
const readableState = (req as any)._readableState;
return Boolean(
req.readableEnded ||
req.complete ||
readableState?.ended ||
readableState?.endEmitted,
);
}
export function synthesizeBodyFromParsedBody(
parsedBody: unknown,
headers: Headers,
): { body: BodyInit | null; contentType?: string } {
if (parsedBody === null || parsedBody === undefined) {
return { body: null };
}
if (parsedBody instanceof Buffer || parsedBody instanceof Uint8Array) {
// Buffer/Uint8Array<ArrayBufferLike> are valid fetch bodies at runtime,
// but the DOM lib's BodyInit only admits ArrayBuffer-backed views.
return { body: parsedBody as BodyInit };
}
if (typeof parsedBody === "string") {
return {
body: parsedBody,
contentType: headers.get("content-type") ?? "text/plain",
};
}
return {
body: JSON.stringify(parsedBody),
contentType: "application/json",
};
}
export function isDisturbedOrLockedError(error: unknown): boolean {
return (
error instanceof TypeError &&
typeof error.message === "string" &&
(error.message.includes("disturbed") || error.message.includes("locked"))
);
}
@@ -0,0 +1,112 @@
import { YogaInitialContext } from "graphql-yoga";
import { buildSchemaSync } from "type-graphql";
import { CopilotResolver } from "../../graphql/resolvers/copilot.resolver";
import { CopilotRuntime } from "../runtime/copilot-runtime";
import { CopilotServiceAdapter } from "../../service-adapters";
import { CopilotCloudOptions } from "../cloud";
import { LogLevel, createLogger } from "../../lib/logger";
import telemetry from "../telemetry-client";
import { StateResolver } from "../../graphql/resolvers/state.resolver";
/**
* CORS configuration for CopilotKit endpoints.
*/
export interface CopilotEndpointCorsConfig {
/**
* Allowed origin(s). Can be a string, array of strings, or a function that returns the origin.
*/
origin:
| string
| string[]
| ((origin: string, c: any) => string | undefined | null);
/**
* Whether to include credentials (cookies, authorization headers) in CORS requests.
* When true, origin cannot be "*" - must be an explicit origin.
*/
credentials?: boolean;
}
const logger = createLogger();
type AnyPrimitive = string | boolean | number | null;
export type CopilotRequestContextProperties = Record<
string,
AnyPrimitive | Record<string, AnyPrimitive>
>;
export type GraphQLContext = YogaInitialContext & {
_copilotkit: CreateCopilotRuntimeServerOptions;
properties: CopilotRequestContextProperties;
logger: typeof logger;
};
export interface CreateCopilotRuntimeServerOptions {
runtime: CopilotRuntime<any>;
serviceAdapter?: CopilotServiceAdapter;
endpoint: string;
baseUrl?: string;
cloud?: CopilotCloudOptions;
properties?: CopilotRequestContextProperties;
logLevel?: LogLevel;
/**
* Optional CORS configuration. When not provided, defaults to allowing all origins without credentials.
* To support HTTP-only cookies, provide cors config with credentials: true and explicit origin.
*/
cors?: CopilotEndpointCorsConfig;
}
export function buildSchema(
options: {
emitSchemaFile?: string;
} = {},
) {
logger.debug("Building GraphQL schema...");
const schema = buildSchemaSync({
resolvers: [CopilotResolver, StateResolver],
emitSchemaFile: options.emitSchemaFile,
});
logger.debug("GraphQL schema built successfully");
return schema;
}
export type CommonConfig = {
logging: typeof logger;
};
export function getCommonConfig(
options: CreateCopilotRuntimeServerOptions,
): CommonConfig {
const logLevel =
(process.env.LOG_LEVEL as LogLevel) ||
(options.logLevel as LogLevel) ||
"error";
const logger = createLogger({
level: logLevel,
component: "getCommonConfig",
});
if (options.cloud) {
telemetry.setCloudConfiguration({
publicApiKey: options.cloud.publicApiKey,
baseUrl: options.cloud.baseUrl,
});
}
if (options.properties?._copilotkit) {
telemetry.setGlobalProperties({
_copilotkit: {
...(options.properties._copilotkit as Record<string, any>),
},
});
}
telemetry.setGlobalProperties({
runtime: {
serviceAdapter: options.serviceAdapter?.constructor?.name ?? "none",
},
});
return {
logging: createLogger({ component: "CopilotKit Runtime", level: logLevel }),
};
}
+31
View File
@@ -0,0 +1,31 @@
import createPinoLogger from "pino";
import pretty from "pino-pretty";
export type LogLevel = "debug" | "info" | "warn" | "error";
export type CopilotRuntimeLogger = ReturnType<typeof createLogger>;
export function createLogger(options?: {
level?: LogLevel;
component?: string;
}) {
const { level, component } = options || {};
const stream = pretty({ colorize: true });
const logger = createPinoLogger(
{
level: process.env.LOG_LEVEL || level || "error",
redact: {
paths: ["pid", "hostname"],
remove: true,
},
},
stream,
);
if (component) {
return logger.child({ component });
} else {
return logger;
}
}
+165
View File
@@ -0,0 +1,165 @@
import type { RuntimeEventSource } from "../service-adapters/events";
import { RuntimeEventTypes } from "../service-adapters/events";
export interface LLMRequestData {
threadId?: string;
runId?: string;
model?: string;
messages: any[];
actions?: any[];
forwardedParameters?: any;
timestamp: number;
provider?: string;
[key: string]: any;
}
export interface LLMResponseData {
threadId: string;
runId?: string;
model?: string;
output: any;
latency: number;
timestamp: number;
provider?: string;
isProgressiveChunk?: boolean;
isFinalResponse?: boolean;
[key: string]: any;
}
export interface LLMErrorData {
threadId?: string;
runId?: string;
model?: string;
error: Error | string;
timestamp: number;
provider?: string;
[key: string]: any;
}
export interface CopilotObservabilityHooks {
handleRequest: (data: LLMRequestData) => void | Promise<void>;
handleResponse: (data: LLMResponseData) => void | Promise<void>;
handleError: (data: LLMErrorData) => void | Promise<void>;
}
/**
* Configuration for CopilotKit logging functionality.
*
* @remarks
* Custom logging handlers require a valid CopilotKit public API key.
* Sign up at https://docs.copilotkit.ai/built-in-agent/quickstart#create-a-free-account to get your key.
*/
export interface CopilotObservabilityConfig {
/**
* Enable or disable logging functionality.
*
* @default false
*/
enabled: boolean;
/**
* Controls whether logs are streamed progressively or buffered.
* - When true: Each token and update is logged as it's generated (real-time)
* - When false: Complete responses are logged after completion (batched)
*
* @default true
*/
progressive: boolean;
/**
* Custom observability hooks for request, response, and error events.
*
* @remarks
* Using custom observability hooks requires a valid CopilotKit public API key.
*/
hooks: CopilotObservabilityHooks;
}
/**
* Setup progressive logging by wrapping the event stream
*/
function setupProgressiveLogging(
eventSource: RuntimeEventSource,
streamedChunks: any[],
requestStartTime: number,
context: {
threadId?: string;
runId?: string;
model?: string;
provider?: string;
agentName?: string;
nodeName?: string;
},
publicApiKey?: string,
): void {
if (
this.observability?.enabled &&
this.observability.progressive &&
publicApiKey
) {
// Keep reference to original stream function
const originalStream = eventSource.stream.bind(eventSource);
// Wrap the stream function to intercept events
eventSource.stream = async (callback) => {
await originalStream(async (eventStream$) => {
// Create subscription to capture streaming events
eventStream$.subscribe({
next: (event) => {
// Only log content chunks
if (event.type === RuntimeEventTypes.TextMessageContent) {
// Store the chunk
streamedChunks.push(event.content);
// Log each chunk separately for progressive mode
try {
const progressiveData: LLMResponseData = {
threadId: context.threadId || "",
runId: context.runId,
model: context.model,
output: event.content,
latency: Date.now() - requestStartTime,
timestamp: Date.now(),
provider: context.provider,
isProgressiveChunk: true,
agentName: context.agentName,
nodeName: context.nodeName,
};
// Use Promise to handle async logger without awaiting
Promise.resolve()
.then(() => {
this.observability.hooks.handleResponse(progressiveData);
})
.catch((error) => {
console.error("Error in progressive logging:", error);
});
} catch (error) {
console.error("Error preparing progressive log data:", error);
}
}
},
});
// Call the original callback with the event stream
await callback(eventStream$);
});
};
}
}
/**
* Log error if observability is enabled
*/
async function logObservabilityError(
errorData: LLMErrorData,
publicApiKey?: string,
): Promise<void> {
if (this.observability?.enabled && publicApiKey) {
try {
await this.observability.hooks.handleError(errorData);
} catch (logError) {
console.error("Error logging LLM error:", logError);
}
}
}
@@ -0,0 +1,183 @@
import {
CopilotErrorEvent,
CopilotRequestContext,
CopilotErrorHandler,
} from "@copilotkit/shared";
describe("CopilotRuntime onError types", () => {
it("should have correct CopilotTraceEvent type structure", () => {
const errorEvent: CopilotErrorEvent = {
type: "error",
timestamp: Date.now(),
context: {
threadId: "test-123",
source: "runtime",
request: {
operation: "test-operation",
startTime: Date.now(),
},
technical: {},
metadata: {},
},
error: new Error("Test error"),
};
expect(errorEvent.type).toBe("error");
expect(errorEvent.timestamp).toBeGreaterThan(0);
expect(errorEvent.context.threadId).toBe("test-123");
expect(errorEvent.error).toBeInstanceOf(Error);
});
it("should have correct CopilotRequestContext type structure", () => {
const context: CopilotRequestContext = {
threadId: "test-thread-456",
runId: "test-run-789",
source: "runtime",
request: {
operation: "processRuntimeRequest",
method: "POST",
url: "http://localhost:3000/api/copilotkit",
startTime: Date.now(),
},
response: {
status: 200,
endTime: Date.now(),
latency: 1200,
},
agent: {
name: "test-agent",
nodeName: "test-node",
state: { step: 1 },
},
messages: {
input: [],
output: [],
messageCount: 2,
},
technical: {
userAgent: "Mozilla/5.0...",
host: "localhost:3000",
environment: "test",
version: "1.0.0",
stackTrace: "Error: Test\n at test.js:1:1",
},
performance: {
requestDuration: 1200,
streamingDuration: 800,
actionExecutionTime: 400,
memoryUsage: 45.2,
},
metadata: {
testFlag: true,
version: "1.0.0",
},
};
expect(context.threadId).toBe("test-thread-456");
expect(context.agent?.name).toBe("test-agent");
expect(context.messages?.messageCount).toBe(2);
expect(context.technical?.stackTrace).toContain("Error: Test");
expect(context.metadata?.testFlag).toBe(true);
});
it("should support all error event types", () => {
const eventTypes: CopilotErrorEvent["type"][] = [
"error",
"request",
"response",
"agent_state",
"action",
"message",
"performance",
];
eventTypes.forEach((type) => {
const event: CopilotErrorEvent = {
type,
timestamp: Date.now(),
context: {
threadId: `test-${type}`,
source: "runtime",
request: {
operation: "test",
startTime: Date.now(),
},
technical: {},
metadata: {},
},
};
expect(event.type).toBe(type);
});
});
describe("publicApiKey gating logic", () => {
type ShouldHandleError = (
onError?: CopilotErrorHandler,
publicApiKey?: string,
) => boolean;
const shouldHandleError: ShouldHandleError = (onError, publicApiKey) => {
return Boolean(onError && publicApiKey);
};
it("should return true when both onError and publicApiKey are provided", () => {
const onError = vi.fn();
const result = shouldHandleError(onError, "valid-api-key");
expect(result).toBe(true);
});
it("should return false when onError is missing", () => {
const result = shouldHandleError(undefined, "valid-api-key");
expect(result).toBe(false);
});
it("should return false when publicApiKey is missing", () => {
const onError = vi.fn();
const result = shouldHandleError(onError, undefined);
expect(result).toBe(false);
});
it("should return false when publicApiKey is empty string", () => {
const onError = vi.fn();
const result = shouldHandleError(onError, "");
expect(result).toBe(false);
});
it("should return false when both are missing", () => {
const result = shouldHandleError(undefined, undefined);
expect(result).toBe(false);
});
it("should extract publicApiKey from headers for both cloud and non-cloud requests", () => {
// Test the logic we just fixed in the GraphQL resolver
const mockHeaders = new Map([
["x-copilotcloud-public-api-key", "test-key-123"],
]);
// Simulate header extraction logic
const extractPublicApiKey = (
headers: Map<string, string>,
hasCloudConfig: boolean,
) => {
const publicApiKeyFromHeaders = headers.get(
"x-copilotcloud-public-api-key",
);
return publicApiKeyFromHeaders || null;
};
// Should work for cloud requests
const cloudKey = extractPublicApiKey(mockHeaders, true);
expect(cloudKey).toBe("test-key-123");
// Should also work for non-cloud requests (this was the bug)
const nonCloudKey = extractPublicApiKey(mockHeaders, false);
expect(nonCloudKey).toBe("test-key-123");
// Both should enable error handling when onError is present
const onError = vi.fn();
expect(shouldHandleError(onError, cloudKey)).toBe(true);
expect(shouldHandleError(onError, nonCloudKey)).toBe(true);
});
});
});
@@ -0,0 +1,60 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CopilotRuntime } from "../copilot-runtime";
import telemetry from "../../telemetry-client";
/**
* The v1 (GraphQL) CopilotRuntime is a separate endpoint path from the v2
* runtime, with its own constructor. It already forwards the license token to
* telemetry — this pins that behavior so the v1 path can't silently regress
* into anonymous telemetry the way the v2 SSE path did.
*/
describe("v1 CopilotRuntime — telemetry license token", () => {
// Real JWT shape with telemetry_id so the parser doesn't warn.
const TOKEN = `header.${Buffer.from('{"telemetry_id":"abc-123"}').toString(
"base64url",
)}.sig`;
let setLicenseTokenSpy: ReturnType<typeof vi.spyOn>;
let originalEnv: string | undefined;
beforeEach(() => {
setLicenseTokenSpy = vi
.spyOn(telemetry, "setLicenseToken")
.mockImplementation(() => {});
originalEnv = process.env.COPILOTKIT_LICENSE_TOKEN;
delete process.env.COPILOTKIT_LICENSE_TOKEN;
});
afterEach(() => {
setLicenseTokenSpy.mockRestore();
if (originalEnv === undefined) {
delete process.env.COPILOTKIT_LICENSE_TOKEN;
} else {
process.env.COPILOTKIT_LICENSE_TOKEN = originalEnv;
}
});
it("forwards an explicit licenseToken option to telemetry", () => {
const runtime = new CopilotRuntime({ agents: {}, licenseToken: TOKEN });
expect(runtime).toBeInstanceOf(CopilotRuntime);
expect(setLicenseTokenSpy).toHaveBeenCalledWith(TOKEN);
});
it("falls back to COPILOTKIT_LICENSE_TOKEN when no option is given", () => {
process.env.COPILOTKIT_LICENSE_TOKEN = TOKEN;
const runtime = new CopilotRuntime({ agents: {} });
expect(runtime).toBeInstanceOf(CopilotRuntime);
expect(setLicenseTokenSpy).toHaveBeenCalledWith(TOKEN);
});
it("does not set a token when none is provided", () => {
const runtime = new CopilotRuntime({ agents: {} });
expect(runtime).toBeInstanceOf(CopilotRuntime);
expect(setLicenseTokenSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,112 @@
import type { AbstractAgent } from "@ag-ui/client";
import type { LanguageModel } from "ai";
import { CopilotKitMisuseError } from "@copilotkit/shared";
import { describe, expect, it } from "vitest";
import { BuiltInAgent } from "../../../agent";
import type { CopilotServiceAdapter } from "../../../service-adapters";
import { CopilotRuntime } from "../copilot-runtime";
function makeAdapter(
overrides?: Partial<CopilotServiceAdapter>,
): CopilotServiceAdapter {
return {
name: "TestAdapter",
async process() {
throw new Error("process() is not expected to be called in these tests");
},
...overrides,
};
}
async function getDefaultAgent(runtime: CopilotRuntime) {
const agents = (await runtime.instance.agents) as Record<
string,
AbstractAgent
>;
return agents.default;
}
// `BuiltInAgent.config` is private; reading it is the only way to verify the
// correct model was passed through without running the entire agent pipeline.
// This narrow accessor is the Rule 2 exception, documented here once rather
// than inline at each call site.
function getBuiltInAgentModel(agent: BuiltInAgent): unknown {
return (agent as unknown as { config: { model: unknown } }).config.model;
}
describe("CopilotRuntime#handleServiceAdapter (#3217)", () => {
it("uses the adapter's pre-configured LanguageModel when getLanguageModel() returns one", async () => {
const fakeLanguageModel = {
specificationVersion: "v1",
} as unknown as LanguageModel;
const runtime = new CopilotRuntime();
runtime.handleServiceAdapter(
makeAdapter({
name: "OpenAIAdapter",
provider: "openai",
model: "gpt-4o",
getLanguageModel: () => fakeLanguageModel,
}),
);
const agent = await getDefaultAgent(runtime);
expect(agent).toBeInstanceOf(BuiltInAgent);
expect(getBuiltInAgentModel(agent as BuiltInAgent)).toBe(fakeLanguageModel);
});
it("builds a 'provider/model' string when only provider+model are exposed", async () => {
const runtime = new CopilotRuntime();
runtime.handleServiceAdapter(
makeAdapter({
name: "GroqAdapter",
provider: "groq",
model: "llama-3.3-70b-versatile",
}),
);
const agent = await getDefaultAgent(runtime);
expect(agent).toBeInstanceOf(BuiltInAgent);
expect(getBuiltInAgentModel(agent as BuiltInAgent)).toBe(
"groq/llama-3.3-70b-versatile",
);
});
it("throws CopilotKitMisuseError when no model source is available (LangChainAdapter regression)", async () => {
const runtime = new CopilotRuntime();
runtime.handleServiceAdapter(makeAdapter({ name: "LangChainAdapter" }));
await expect(runtime.instance.agents).rejects.toBeInstanceOf(
CopilotKitMisuseError,
);
await expect(runtime.instance.agents).rejects.toThrow(
/Service adapter "LangChainAdapter" does not provide model information/,
);
});
it("falls back to 'unknown' in the thrown error when the adapter has no name", async () => {
const runtime = new CopilotRuntime();
runtime.handleServiceAdapter(makeAdapter({ name: undefined }));
await expect(runtime.instance.agents).rejects.toThrow(
/Service adapter "unknown" does not provide model information/,
);
});
it("does not throw when provider is set without a model — but must not emit 'undefined/undefined'", async () => {
// Guards the specific #3217 regression: when only one half of the pair is
// present, we must NOT synthesize a bogus "provider/undefined" string.
const runtime = new CopilotRuntime();
runtime.handleServiceAdapter(
makeAdapter({ name: "PartialAdapter", provider: "openai" }),
);
await expect(runtime.instance.agents).rejects.toThrow(
CopilotKitMisuseError,
);
});
});
@@ -0,0 +1,499 @@
import {
extractParametersFromSchema,
convertMCPToolsToActions,
generateMcpToolInstructions,
MCPTool,
} from "../mcp-tools-utils";
describe("MCP Tools Utils", () => {
describe("extractParametersFromSchema", () => {
it("should extract parameters from schema.parameters.properties", () => {
const tool: MCPTool = {
description: "Test tool",
schema: {
parameters: {
properties: {
name: { type: "string", description: "A name parameter" },
age: { type: "number", description: "An age parameter" },
},
required: ["name"],
},
},
execute: async () => ({}),
};
const result = extractParametersFromSchema(tool);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
name: "name",
type: "string",
description: "A name parameter",
required: true,
});
expect(result[1]).toEqual({
name: "age",
type: "number",
description: "An age parameter",
required: false,
});
});
it("should extract parameters from schema.parameters.jsonSchema", () => {
const tool: MCPTool = {
description: "Test tool with jsonSchema",
schema: {
parameters: {
jsonSchema: {
properties: {
title: { type: "string", description: "A title parameter" },
count: { type: "number", description: "A count parameter" },
},
required: ["title"],
},
},
},
execute: async () => ({}),
};
const result = extractParametersFromSchema(tool);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
name: "title",
type: "string",
description: "A title parameter",
required: true,
});
expect(result[1]).toEqual({
name: "count",
type: "number",
description: "A count parameter",
required: false,
});
});
it("should handle arrays with items", () => {
const tool: MCPTool = {
description: "Test tool with array parameters",
schema: {
parameters: {
properties: {
simpleArray: {
type: "array",
items: { type: "string" },
description: "Array of strings",
},
objectArray: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string" },
value: { type: "number" },
},
},
description: "Array of objects",
},
},
required: ["simpleArray"],
},
},
execute: async () => ({}),
};
const result = extractParametersFromSchema(tool);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
name: "simpleArray",
type: "array<string>",
description: "Array of strings",
required: true,
});
expect(result[1]).toEqual({
name: "objectArray",
type: "object[]",
description:
"Array of objects Array of objects with properties: name, value",
required: false,
attributes: [
{ name: "name", type: "string", description: "", required: false },
{ name: "value", type: "number", description: "", required: false },
],
});
});
it("should handle enums", () => {
const tool: MCPTool = {
description: "Test tool with enum parameters",
schema: {
parameters: {
properties: {
status: {
type: "string",
enum: ["active", "inactive", "pending"],
description: "Status value",
},
priority: {
type: "number",
enum: [1, 2, 3],
description: "Priority level",
},
},
required: ["status"],
},
},
execute: async () => ({}),
};
const result = extractParametersFromSchema(tool);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
name: "status",
type: "string",
description: "Status value Allowed values: active | inactive | pending",
required: true,
enum: ["active", "inactive", "pending"],
});
expect(result[1]).toEqual({
name: "priority",
type: "number",
description: "Priority level Allowed values: 1 | 2 | 3",
required: false,
});
});
it("should handle nested objects", () => {
const tool: MCPTool = {
description: "Test tool with nested object parameters",
schema: {
parameters: {
properties: {
user: {
type: "object",
properties: {
name: { type: "string" },
email: { type: "string" },
preferences: {
type: "object",
properties: {
theme: { type: "string" },
notifications: { type: "boolean" },
},
},
},
description: "User object",
},
},
required: ["user"],
},
},
execute: async () => ({}),
};
const result = extractParametersFromSchema(tool);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
name: "user",
type: "object",
description:
"User object Object with properties: name, email, preferences",
required: true,
attributes: [
{ name: "name", type: "string", description: "", required: false },
{ name: "email", type: "string", description: "", required: false },
{
name: "preferences",
type: "object",
description: "Object with properties: theme, notifications",
required: false,
attributes: [
{
name: "theme",
type: "string",
description: "",
required: false,
},
{
name: "notifications",
type: "boolean",
description: "",
required: false,
},
],
},
],
});
});
it("should return empty array when no properties", () => {
const tool: MCPTool = {
description: "Test tool without properties",
schema: {
parameters: {},
},
execute: async () => ({}),
};
const result = extractParametersFromSchema(tool);
expect(result).toHaveLength(0);
});
});
describe("generateMcpToolInstructions", () => {
it("should generate instructions with correct parameter schema from schema.parameters.properties", () => {
const toolsMap: Record<string, MCPTool> = {
testTool: {
description: "A test tool",
schema: {
parameters: {
properties: {
name: { type: "string", description: "The name parameter" },
age: { type: "number", description: "The age parameter" },
},
required: ["name"],
},
},
execute: async () => ({}),
},
};
const result = generateMcpToolInstructions(toolsMap);
expect(result).toContain("testTool: A test tool");
expect(result).toContain("- name* (string) - The name parameter");
expect(result).toContain("- age (number) - The age parameter");
});
it("should generate instructions with correct parameter schema from schema.parameters.jsonSchema", () => {
const toolsMap: Record<string, MCPTool> = {
testTool: {
description: "A test tool with jsonSchema",
schema: {
parameters: {
jsonSchema: {
properties: {
title: { type: "string", description: "The title parameter" },
count: { type: "number", description: "The count parameter" },
},
required: ["title"],
},
},
},
execute: async () => ({}),
},
};
const result = generateMcpToolInstructions(toolsMap);
expect(result).toContain("testTool: A test tool with jsonSchema");
expect(result).toContain("- title* (string) - The title parameter");
expect(result).toContain("- count (number) - The count parameter");
});
it("should handle complex schemas with arrays and enums", () => {
const toolsMap: Record<string, MCPTool> = {
complexTool: {
description: "A complex tool",
schema: {
parameters: {
properties: {
items: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string" },
value: { type: "number" },
},
},
description: "Array of items",
},
status: {
type: "string",
enum: ["active", "inactive"],
description: "Status",
},
},
required: ["items"],
},
},
execute: async () => ({}),
},
};
const result = generateMcpToolInstructions(toolsMap);
expect(result).toContain("complexTool: A complex tool");
expect(result).toContain(
"- items* (array<object>) - Array of items Array of objects with properties: name, value",
);
expect(result).toContain(
"- status (string) - Status Allowed values: active | inactive",
);
});
it("should fallback to schema.properties for backward compatibility", () => {
const toolsMap: Record<string, MCPTool> = {
backwardCompatTool: {
description: "A backward compatible tool",
schema: {
// Direct properties without nested parameters
properties: {
name: { type: "string", description: "The name parameter" },
},
required: ["name"],
} as any,
execute: async () => ({}),
},
};
const result = generateMcpToolInstructions(toolsMap);
expect(result).toContain(
"backwardCompatTool: A backward compatible tool",
);
expect(result).toContain("- name* (string) - The name parameter");
});
it("should show 'No parameters required' when no schema properties", () => {
const toolsMap: Record<string, MCPTool> = {
noParamsTool: {
description: "A tool with no parameters",
schema: {
parameters: {},
},
execute: async () => ({}),
},
};
const result = generateMcpToolInstructions(toolsMap);
expect(result).toContain("noParamsTool: A tool with no parameters");
expect(result).toContain("No parameters required");
});
it("should handle tools with no schema", () => {
const toolsMap: Record<string, MCPTool> = {
noSchemaTool: {
description: "A tool with no schema",
execute: async () => ({}),
},
};
const result = generateMcpToolInstructions(toolsMap);
expect(result).toContain("noSchemaTool: A tool with no schema");
expect(result).toContain("No parameters required");
});
it("should return empty string for empty tools map", () => {
const result = generateMcpToolInstructions({});
expect(result).toBe("");
});
});
describe("convertMCPToolsToActions", () => {
it("should convert MCP tools to CopilotKit actions", () => {
const mcpTools: Record<string, MCPTool> = {
testTool: {
description: "A test tool",
schema: {
parameters: {
properties: {
name: { type: "string", description: "The name parameter" },
age: { type: "number", description: "The age parameter" },
},
required: ["name"],
},
},
execute: async () => "test result",
},
};
const result = convertMCPToolsToActions(mcpTools, "http://test-endpoint");
expect(result).toHaveLength(1);
expect(result[0].name).toBe("testTool");
expect(result[0].description).toBe("A test tool");
expect(result[0].parameters).toHaveLength(2);
expect(result[0].parameters[0]).toEqual({
name: "name",
type: "string",
description: "The name parameter",
required: true,
});
expect(result[0].parameters[1]).toEqual({
name: "age",
type: "number",
description: "The age parameter",
required: false,
});
});
it("should handle tool execution correctly", async () => {
const mockExecute = vi.fn().mockResolvedValue("mock result");
const mcpTools: Record<string, MCPTool> = {
testTool: {
description: "A test tool",
schema: {
parameters: {
properties: {
name: { type: "string", description: "The name parameter" },
},
required: ["name"],
},
},
execute: mockExecute,
},
};
const result = convertMCPToolsToActions(mcpTools, "http://test-endpoint");
const action = result[0];
const executeResult = await action.handler({ name: "test" });
expect(executeResult).toBe("mock result");
expect(mockExecute).toHaveBeenCalledWith({ name: "test" });
});
it("should stringify non-string results", async () => {
const mcpTools: Record<string, MCPTool> = {
testTool: {
description: "A test tool",
schema: {
parameters: {
properties: {
name: { type: "string", description: "The name parameter" },
},
required: ["name"],
},
},
execute: async () => ({ result: "complex object" }),
},
};
const result = convertMCPToolsToActions(mcpTools, "http://test-endpoint");
const action = result[0];
const executeResult = await action.handler({ name: "test" });
expect(executeResult).toBe('{"result":"complex object"}');
});
it("should handle execution errors", async () => {
const mcpTools: Record<string, MCPTool> = {
testTool: {
description: "A test tool",
schema: {
parameters: {
properties: {
name: { type: "string", description: "The name parameter" },
},
required: ["name"],
},
},
execute: async () => {
throw new Error("Test error");
},
},
};
const result = convertMCPToolsToActions(mcpTools, "http://test-endpoint");
const action = result[0];
await expect(action.handler({ name: "test" })).rejects.toThrow(
"Execution failed for MCP tool 'testTool': Test error",
);
});
});
});
@@ -0,0 +1,122 @@
import { describe, it, expect, vi } from "vitest";
import { CopilotRuntime } from "../copilot-runtime";
describe("onAfterRequest middleware (#2124)", () => {
it("should pass hookParams to onAfterRequest, not an empty object", async () => {
const onAfterRequest = vi.fn();
const runtime = new CopilotRuntime({
middleware: {
onAfterRequest,
},
});
// Access the internal afterRequestMiddleware function
const afterRequestMw = runtime.instance.afterRequestMiddleware;
expect(afterRequestMw).toBeDefined();
// Simulate calling the middleware with hookParams (as the v2 runtime would)
const fakeHookParams = {
runtime: {} as any,
response: new Response("test"),
path: "/api/copilotkit",
messages: [
{ id: "msg-1", role: "user", content: "Hi there" },
{ id: "msg-2", role: "assistant", content: "Hello" },
{ id: "msg-3", role: "tool", content: "result", toolCallId: "tc-1" },
],
threadId: "thread-123",
runId: "run-456",
};
await (afterRequestMw as Function)(fakeHookParams);
expect(onAfterRequest).toHaveBeenCalledTimes(1);
const callArg = onAfterRequest.mock.calls[0][0];
// Should NOT be called with an empty object
expect(callArg).not.toEqual({});
// Verify all OnAfterRequestOptions fields are present
expect(callArg).toHaveProperty("threadId", "thread-123");
expect(callArg).toHaveProperty("runId", "run-456");
expect(callArg).toHaveProperty("url", "/api/copilotkit");
expect(callArg).toHaveProperty("properties");
expect(callArg.properties).toEqual({});
// Verify message splitting: user messages → inputMessages, others → outputMessages
expect(callArg.inputMessages).toHaveLength(1);
expect(callArg.inputMessages[0]).toMatchObject({
id: "msg-1",
role: "user",
});
expect(callArg.outputMessages).toHaveLength(2);
expect(callArg.outputMessages[0]).toMatchObject({
id: "msg-2",
role: "assistant",
});
expect(callArg.outputMessages[1]).toMatchObject({
id: "msg-3",
role: "tool",
});
});
it("should handle undefined messages gracefully", async () => {
const onAfterRequest = vi.fn();
const runtime = new CopilotRuntime({
middleware: {
onAfterRequest,
},
});
const afterRequestMw = runtime.instance.afterRequestMiddleware;
const fakeHookParams = {
runtime: {} as any,
response: new Response("test"),
path: "/api/copilotkit",
// messages intentionally omitted (undefined)
threadId: "thread-789",
};
await (afterRequestMw as Function)(fakeHookParams);
expect(onAfterRequest).toHaveBeenCalledTimes(1);
const callArg = onAfterRequest.mock.calls[0][0];
expect(callArg.threadId).toBe("thread-789");
expect(callArg.inputMessages).toEqual([]);
expect(callArg.outputMessages).toEqual([]);
});
it("should default threadId to empty string when undefined", async () => {
const onAfterRequest = vi.fn();
const runtime = new CopilotRuntime({
middleware: {
onAfterRequest,
},
});
const afterRequestMw = runtime.instance.afterRequestMiddleware;
const fakeHookParams = {
runtime: {} as any,
response: new Response("test"),
path: "/api/copilotkit",
messages: [],
// threadId intentionally omitted
};
await (afterRequestMw as Function)(fakeHookParams);
expect(onAfterRequest).toHaveBeenCalledTimes(1);
const callArg = onAfterRequest.mock.calls[0][0];
expect(callArg.threadId).toBe("");
expect(callArg.runId).toBeUndefined();
});
});
@@ -0,0 +1,137 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fetchWithRetry, parseRetryAfter, RETRY_CONFIG } from "../retry-utils";
function responseWithRetryAfter(
headerValue: string | null,
status = 429,
): Response {
const headers = new Headers();
if (headerValue !== null) headers.set("Retry-After", headerValue);
return new Response(null, { status, headers });
}
describe("parseRetryAfter", () => {
it("returns undefined when the Retry-After header is absent", () => {
expect(parseRetryAfter(responseWithRetryAfter(null))).toBeUndefined();
});
it("parses integer seconds into milliseconds", () => {
expect(parseRetryAfter(responseWithRetryAfter("5"))).toBe(5000);
});
it("treats zero seconds as zero delay", () => {
expect(parseRetryAfter(responseWithRetryAfter("0"))).toBe(0);
});
it("clamps a negative numeric value to zero", () => {
// `-1` fails the `seconds >= 0` guard and falls through to Date.parse,
// which interprets it as a year-in-the-past timestamp; the past-date
// branch then clamps to 0. The behavior is lenient rather than strict.
expect(parseRetryAfter(responseWithRetryAfter("-1"))).toBe(0);
});
it("parses an HTTP-date in the future as the delta to now", () => {
const now = Date.parse("2026-04-22T12:00:00Z");
vi.useFakeTimers();
vi.setSystemTime(now);
try {
const future = new Date(now + 30_000).toUTCString();
expect(parseRetryAfter(responseWithRetryAfter(future))).toBe(30_000);
} finally {
vi.useRealTimers();
}
});
it("clamps an HTTP-date in the past to zero", () => {
const now = Date.parse("2026-04-22T12:00:00Z");
vi.useFakeTimers();
vi.setSystemTime(now);
try {
const past = new Date(now - 60_000).toUTCString();
expect(parseRetryAfter(responseWithRetryAfter(past))).toBe(0);
} finally {
vi.useRealTimers();
}
});
it("returns undefined for unparseable values", () => {
expect(parseRetryAfter(responseWithRetryAfter("soon"))).toBeUndefined();
});
});
describe("fetchWithRetry Retry-After handling (#3637)", () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.useFakeTimers();
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("honors Retry-After within the allowed maximum on 429", async () => {
fetchMock
.mockResolvedValueOnce(responseWithRetryAfter("2"))
.mockResolvedValueOnce(new Response("ok", { status: 200 }));
const promise = fetchWithRetry("https://example.com", {});
await vi.advanceTimersByTimeAsync(1999);
expect(fetchMock).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
const response = await promise;
expect(response.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("throws when Retry-After exceeds maxRetryAfterSeconds", async () => {
const excessive = RETRY_CONFIG.maxRetryAfterSeconds + 1;
fetchMock.mockResolvedValue(responseWithRetryAfter(String(excessive)));
// The oversized-Retry-After branch throws before sleeping, and the
// resulting Error doesn't match any retryable pattern, so the loop
// breaks out without consuming the remaining attempts.
await expect(fetchWithRetry("https://example.com", {})).rejects.toThrow(
new RegExp(
`Retry-After of ${excessive}s.*exceeds the maximum of ${RETRY_CONFIG.maxRetryAfterSeconds}s`,
),
);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("falls back to exponential backoff when Retry-After is missing on 429", async () => {
fetchMock
.mockResolvedValueOnce(new Response(null, { status: 429 }))
.mockResolvedValueOnce(new Response("ok", { status: 200 }));
const promise = fetchWithRetry("https://example.com", {});
// calculateDelay(0) === RETRY_CONFIG.baseDelayMs
await vi.advanceTimersByTimeAsync(RETRY_CONFIG.baseDelayMs - 1);
expect(fetchMock).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
const response = await promise;
expect(response.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("ignores Retry-After on non-429 retryable responses (e.g. 503)", async () => {
const longRetryAfter = String(RETRY_CONFIG.maxRetryAfterSeconds + 600);
fetchMock
.mockResolvedValueOnce(responseWithRetryAfter(longRetryAfter, 503))
.mockResolvedValueOnce(new Response("ok", { status: 200 }));
const promise = fetchWithRetry("https://example.com", {});
// Exponential backoff applies, not the header value — otherwise this
// would wait 10 minutes and the test would time out.
await vi.advanceTimersByTimeAsync(RETRY_CONFIG.baseDelayMs);
const response = await promise;
expect(response.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,109 @@
import { describe, it, expect, vi } from "vitest";
import { HttpAgent } from "@ag-ui/client";
import { CopilotRuntime } from "../copilot-runtime";
import {
resolveAgents,
type AgentsConfig,
} from "../../../v2/runtime/core/runtime";
function createMockAgent(name = "test") {
return new HttpAgent({ url: `https://example.com/${name}` });
}
function createMockRequest(headers?: Record<string, string>) {
return new Request("https://example.com/agent/test/run", {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify({ threadId: "thread-1", messages: [], state: {} }),
});
}
describe("V1 CopilotRuntime with agent factory function", () => {
it("preserves factory function through constructor (does not spread to {})", () => {
const factory = vi.fn().mockReturnValue({
default: createMockAgent("default"),
});
const runtime = new CopilotRuntime({ agents: factory });
// The V2 instance should receive a function (factory), not an empty object.
// Before the fix, spreading a function produced {}, losing all agents.
const v2Agents = runtime.instance.agents;
expect(typeof v2Agents).toBe("function");
});
it("factory function resolves agents on each request", async () => {
const agentA = createMockAgent("tenant-a");
const agentB = createMockAgent("tenant-b");
const factory: AgentsConfig = ({ request }) => {
const tenantId = request.headers.get("x-tenant-id");
if (tenantId === "a") return { default: agentA };
return { default: agentB };
};
const runtime = new CopilotRuntime({ agents: factory });
const v2Agents = runtime.instance.agents;
const requestA = createMockRequest({ "x-tenant-id": "a" });
const resolvedA = await resolveAgents(v2Agents, requestA);
expect(resolvedA.default).toBe(agentA);
const requestB = createMockRequest({ "x-tenant-id": "b" });
const resolvedB = await resolveAgents(v2Agents, requestB);
expect(resolvedB.default).toBe(agentB);
});
it("merges endpoint agents with factory-resolved agents", async () => {
const factoryAgent = createMockAgent("factory-agent");
const factory = vi.fn().mockReturnValue({
dynamic: factoryAgent,
});
// Use remoteEndpoints to generate endpoint agents that should be merged
const runtime = new CopilotRuntime({
agents: factory,
remoteEndpoints: [
{
url: "https://example.com/endpoint",
onBeforeRequest: undefined,
},
],
});
const v2Agents = runtime.instance.agents;
expect(typeof v2Agents).toBe("function");
const request = createMockRequest();
const resolved = await resolveAgents(v2Agents, request);
// Factory agent should be present
expect(resolved.dynamic).toBe(factoryAgent);
// Factory should have been called with request context
expect(factory).toHaveBeenCalledWith({ request });
});
it("static agents record still works after fix", async () => {
const agent = createMockAgent("static");
const runtime = new CopilotRuntime({
agents: { myAgent: agent },
});
const v2Agents = runtime.instance.agents;
const resolved = await resolveAgents(v2Agents);
expect(resolved.myAgent).toBe(agent);
});
it("promised agents record still works after fix", async () => {
const agent = createMockAgent("promised");
const runtime = new CopilotRuntime({
agents: Promise.resolve({ myAgent: agent }),
});
const v2Agents = runtime.instance.agents;
const resolved = await resolveAgents(v2Agents);
expect(resolved.myAgent).toBe(agent);
});
});
@@ -0,0 +1,345 @@
import { EventType } from "@ag-ui/client";
import { LangGraphAgent } from "../agent";
import { CustomEventNames } from "../consts";
import { vi } from "vitest";
function createAgent() {
const agent = new LangGraphAgent({
graphId: "test-graph",
deploymentUrl: "http://localhost:8000",
});
const events: any[] = [];
(agent as any).subscriber = { next: (e: any) => events.push(e) };
(agent as any).activeRun = {
id: "run-1",
threadId: "thread-1",
hasFunctionStreaming: false,
};
(agent as any).messagesInProcess = {};
return { agent, events };
}
function makeTextEvent(
type: EventType,
metadata: Record<string, any>,
messageId = "msg-1",
) {
return {
type,
messageId,
...(type === EventType.TEXT_MESSAGE_CONTENT ? { delta: "hello" } : {}),
...(type === EventType.TEXT_MESSAGE_START ? { role: "assistant" } : {}),
rawEvent: { metadata },
};
}
function makeCustomEvent(name: string, value: any) {
return {
type: EventType.CUSTOM,
name,
value,
} as any;
}
/**
* Mock the parent class's langGraphDefaultMergeState for a single test,
* using vi.spyOn for automatic cleanup.
*/
function withMockedParentMerge(agent: LangGraphAgent, returnValue: any) {
const parentProto = Object.getPrototypeOf(Object.getPrototypeOf(agent));
return vi
.spyOn(parentProto, "langGraphDefaultMergeState")
.mockReturnValue(returnValue);
}
describe("dispatchEvent emit-messages filtering", () => {
it("suppresses message events when copilotkit:emit-messages is false", () => {
const { agent, events } = createAgent();
const result = agent.dispatchEvent(
makeTextEvent(EventType.TEXT_MESSAGE_START, {
"copilotkit:emit-messages": false,
}) as any,
);
expect(result).toBe(false);
expect(events).toHaveLength(0);
});
it("passes message events when copilotkit:emit-messages is true", () => {
const { agent, events } = createAgent();
const result = agent.dispatchEvent(
makeTextEvent(EventType.TEXT_MESSAGE_START, {
"copilotkit:emit-messages": true,
}) as any,
);
expect(result).toBe(true);
expect(events).toHaveLength(1);
});
it("clears messagesInProcess when suppressing message events", () => {
const { agent } = createAgent();
// Simulate parent class having set a stale message record
(agent as any).messagesInProcess["run-1"] = {
id: "msg-1",
toolCallId: null,
toolCallName: null,
};
agent.dispatchEvent(
makeTextEvent(EventType.TEXT_MESSAGE_START, {
"copilotkit:emit-messages": false,
}) as any,
);
expect((agent as any).messagesInProcess["run-1"]).toBeNull();
});
it("does NOT clear messagesInProcess when emit-messages is true", () => {
const { agent } = createAgent();
const staleRecord = {
id: "msg-1",
toolCallId: null,
toolCallName: null,
};
(agent as any).messagesInProcess["run-1"] = staleRecord;
agent.dispatchEvent(
makeTextEvent(EventType.TEXT_MESSAGE_CONTENT, {
"copilotkit:emit-messages": true,
}) as any,
);
// Record should still be there (not cleared)
expect((agent as any).messagesInProcess["run-1"]).toBe(staleRecord);
});
it("clears messagesInProcess on TEXT_MESSAGE_END suppression (the cross-node leak scenario)", () => {
const { agent } = createAgent();
// Orchestrator node set a message in progress, then its events get suppressed.
// The END event must also clear the tracking state.
(agent as any).messagesInProcess["run-1"] = {
id: "msg-orchestrator",
toolCallId: null,
toolCallName: null,
};
agent.dispatchEvent(
makeTextEvent(
EventType.TEXT_MESSAGE_END,
{ "copilotkit:emit-messages": false },
"msg-orchestrator",
) as any,
);
expect((agent as any).messagesInProcess["run-1"]).toBeNull();
});
});
describe("dispatchEvent emit-tool-calls filtering", () => {
it("suppresses tool events when copilotkit:emit-tool-calls is false", () => {
const { agent, events } = createAgent();
const result = agent.dispatchEvent({
type: EventType.TOOL_CALL_START,
toolCallId: "tc-1",
toolCallName: "search",
parentMessageId: "msg-1",
rawEvent: { metadata: { "copilotkit:emit-tool-calls": false } },
} as any);
expect(result).toBe(false);
expect(events).toHaveLength(0);
});
it("passes tool events when copilotkit:emit-tool-calls is true", () => {
const { agent, events } = createAgent();
const result = agent.dispatchEvent({
type: EventType.TOOL_CALL_START,
toolCallId: "tc-1",
toolCallName: "search",
parentMessageId: "msg-1",
rawEvent: { metadata: { "copilotkit:emit-tool-calls": true } },
} as any);
expect(result).toBe(true);
expect(events).toHaveLength(1);
});
});
// ---------- CopilotKit custom event dispatch ----------
describe("dispatchEvent custom CopilotKit events", () => {
it("manually_emit_message produces TextMessage event sequence", () => {
const { agent, events } = createAgent();
const result = agent.dispatchEvent(
makeCustomEvent(CustomEventNames.CopilotKitManuallyEmitMessage, {
message_id: "msg-manual-1",
message: "Hello from agent",
role: "assistant",
}),
);
expect(result).toBe(true);
expect(events).toHaveLength(3);
expect(events[0].type).toBe(EventType.TEXT_MESSAGE_START);
expect(events[0].messageId).toBe("msg-manual-1");
expect(events[0].role).toBe("assistant");
expect(events[1].type).toBe(EventType.TEXT_MESSAGE_CONTENT);
expect(events[1].delta).toBe("Hello from agent");
expect(events[2].type).toBe(EventType.TEXT_MESSAGE_END);
expect(events[2].messageId).toBe("msg-manual-1");
});
it("manually_emit_tool_call produces ToolCall event sequence", () => {
const { agent, events } = createAgent();
const result = agent.dispatchEvent(
makeCustomEvent(CustomEventNames.CopilotKitManuallyEmitToolCall, {
id: "tc-manual-1",
name: "SearchTool",
args: { query: "test" },
}),
);
expect(result).toBe(true);
expect(events).toHaveLength(3);
expect(events[0].type).toBe(EventType.TOOL_CALL_START);
expect(events[0].toolCallId).toBe("tc-manual-1");
expect(events[0].toolCallName).toBe("SearchTool");
expect(events[1].type).toBe(EventType.TOOL_CALL_ARGS);
expect(events[1].toolCallId).toBe("tc-manual-1");
expect(events[1].delta).toEqual({ query: "test" });
expect(events[2].type).toBe(EventType.TOOL_CALL_END);
expect(events[2].toolCallId).toBe("tc-manual-1");
});
it("manually_emit_state produces StateSnapshot event", () => {
const { agent, events } = createAgent();
// Mock getStateSnapshot since it depends on thread state
(agent as any).getStateSnapshot = (state: any) => ({
values: state.values,
});
const result = agent.dispatchEvent(
makeCustomEvent(
CustomEventNames.CopilotKitManuallyEmitIntermediateState,
{
progress: 75,
},
),
);
expect(result).toBe(true);
expect((agent as any).activeRun.manuallyEmittedState).toEqual({
progress: 75,
});
const snapshotEvents = events.filter(
(e) => e.type === EventType.STATE_SNAPSHOT,
);
expect(snapshotEvents.length).toBeGreaterThanOrEqual(1);
});
it("copilotkit_exit produces Exit custom event", () => {
const { agent, events } = createAgent();
const result = agent.dispatchEvent(
makeCustomEvent(CustomEventNames.CopilotKitExit, {}),
);
expect(result).toBe(true);
expect(events).toHaveLength(1);
expect(events[0].type).toBe(EventType.CUSTOM);
// "Exit" is the hardcoded downstream name in agent.ts — not a constant,
// because it's the value the frontend listens for, not an internal enum.
expect(events[0].name).toBe("Exit");
expect(events[0].value).toBe(true);
});
it("events without rawEvent pass through to subscriber", () => {
const { agent, events } = createAgent();
const result = agent.dispatchEvent({
type: EventType.TEXT_MESSAGE_START,
messageId: "msg-no-raw",
role: "assistant",
} as any);
expect(result).toBe(true);
expect(events).toHaveLength(1);
expect(events[0].messageId).toBe("msg-no-raw");
});
});
// ---------- langGraphDefaultMergeState ----------
describe("langGraphDefaultMergeState", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("merges copilotkit actions from ag-ui tools", () => {
const { agent } = createAgent();
const tools = [{ name: "tool1" }, { name: "tool2" }];
withMockedParentMerge(agent, {
"ag-ui": { tools, context: [] },
tools: [],
messages: [],
});
const result = agent.langGraphDefaultMergeState({} as any, [], {} as any);
expect(result.copilotkit).toBeDefined();
expect(result.copilotkit.actions).toEqual(expect.arrayContaining(tools));
});
it("merges copilotkit context from ag-ui", () => {
const { agent } = createAgent();
const context = [{ description: "user info", value: "test" }];
withMockedParentMerge(agent, {
"ag-ui": { tools: [], context },
tools: [],
messages: [],
});
const result = agent.langGraphDefaultMergeState({} as any, [], {} as any);
expect(result.copilotkit.context).toEqual(context);
});
it("handles missing ag-ui key without crashing", () => {
const { agent } = createAgent();
withMockedParentMerge(agent, { messages: [] });
const result = agent.langGraphDefaultMergeState({} as any, [], {} as any);
expect(result.copilotkit).toBeDefined();
expect(result.copilotkit.actions).toEqual([]);
expect(result.copilotkit.context).toEqual([]);
});
it("deduplicates tools from returnedTools and ag-ui tools", () => {
const { agent } = createAgent();
const tool = { name: "SharedTool", id: "shared-1" };
withMockedParentMerge(agent, {
"ag-ui": { tools: [tool], context: [] },
tools: [tool],
messages: [],
});
const result = agent.langGraphDefaultMergeState({} as any, [], {} as any);
expect(result.copilotkit.actions).toHaveLength(1);
expect(result.copilotkit.actions[0].name).toBe("SharedTool");
});
});
@@ -0,0 +1,156 @@
import { of } from "rxjs";
import { vi } from "vitest";
import { LangGraphAgent } from "../agent";
// REGRESSION: `@ag-ui/langgraph`'s message converter only handles
// {user, assistant, system, tool} and throws "message role is not
// supported." on anything else. Reasoning-stream agents (OpenAI
// Responses API with `reasoning={summary:"detailed"}`) emit AG-UI
// messages with `role:"reasoning"` that the AG-UI client persists in
// the thread and replays on the NEXT turn's `input.messages` — which
// crashes the converter before the model is called.
//
// CopilotKit's LangGraphAgent.run subclass strips those reasoning
// messages from the inbound `input.messages` before delegating to
// super. This file verifies that filter:
// - `role:"reasoning"` is dropped.
// - All other roles (user, assistant, system, tool, plus unknown
// roles we don't recognize) are preserved in order.
// - The pre-existing forwardedProps enrichment (`streamSubgraphs`)
// still applies.
function createAgent() {
return new LangGraphAgent({
graphId: "test-graph",
deploymentUrl: "http://localhost:8000",
});
}
/**
* Mock the parent class's `run` method for a single test so we can
* capture the `input` it receives without actually opening a stream.
* The harness spies on the prototype TWO levels up because the
* CopilotKit `LangGraphAgent` extends `@ag-ui/langgraph`'s
* `LangGraphAgent`. Mirror the pattern from
* `dispatch-event-filtering.test.ts`'s `withMockedParentMerge`.
*/
function withMockedParentRun(agent: LangGraphAgent) {
const parentProto = Object.getPrototypeOf(Object.getPrototypeOf(agent));
const calls: any[] = [];
const spy = vi.spyOn(parentProto, "run").mockImplementation(function (
this: unknown,
input: unknown,
) {
calls.push(input);
// Return an empty Observable — none of our assertions consume it,
// we only care about what was passed in.
return of();
});
return { spy, calls };
}
function makeInput(messages: Array<{ role: string; id?: string }>) {
return {
runId: "run-1",
threadId: "thread-1",
messages,
tools: [],
context: [],
state: {},
forwardedProps: {},
} as any;
}
describe("LangGraphAgent.run reasoning-role message filtering", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("strips role:'reasoning' messages from input before delegating to super.run", () => {
const agent = createAgent();
const { calls } = withMockedParentRun(agent);
agent.run(
makeInput([
{ role: "user", id: "u1" },
{ role: "reasoning", id: "r1" },
{ role: "assistant", id: "a1" },
{ role: "tool", id: "t1" },
{ role: "reasoning", id: "r2" },
]),
);
expect(calls).toHaveLength(1);
expect(calls[0].messages.map((m: { id: string }) => m.id)).toEqual([
"u1",
"a1",
"t1",
]);
expect(
calls[0].messages.some((m: { role: string }) => m.role === "reasoning"),
).toBe(false);
});
it("preserves user/assistant/system/tool messages in their original order", () => {
const agent = createAgent();
const { calls } = withMockedParentRun(agent);
agent.run(
makeInput([
{ role: "system", id: "s1" },
{ role: "user", id: "u1" },
{ role: "assistant", id: "a1" },
{ role: "tool", id: "t1" },
{ role: "user", id: "u2" },
]),
);
expect(calls[0].messages.map((m: { id: string }) => m.id)).toEqual([
"s1",
"u1",
"a1",
"t1",
"u2",
]);
});
it("tolerates an empty messages array without throwing", () => {
const agent = createAgent();
const { calls } = withMockedParentRun(agent);
agent.run(makeInput([]));
expect(calls[0].messages).toEqual([]);
});
it("tolerates omitted messages (defaults to empty array)", () => {
const agent = createAgent();
const { calls } = withMockedParentRun(agent);
const input = makeInput([{ role: "user", id: "u1" }]);
delete input.messages;
agent.run(input);
expect(calls[0].messages).toEqual([]);
});
it("preserves the pre-existing forwardedProps.streamSubgraphs default", () => {
const agent = createAgent();
const { calls } = withMockedParentRun(agent);
agent.run(makeInput([{ role: "user", id: "u1" }]));
expect(calls[0].forwardedProps).toMatchObject({ streamSubgraphs: true });
});
it("respects an explicit forwardedProps.streamSubgraphs=false", () => {
const agent = createAgent();
const { calls } = withMockedParentRun(agent);
const input = makeInput([{ role: "user", id: "u1" }]);
input.forwardedProps = { streamSubgraphs: false };
agent.run(input);
expect(calls[0].forwardedProps.streamSubgraphs).toBe(false);
});
});
@@ -0,0 +1,263 @@
import type { Observable } from "rxjs";
import { map } from "rxjs";
import { LangGraphEventTypes } from "../../../../agents/langgraph/events";
import type { BaseEvent, RawEvent } from "@ag-ui/core";
import type {
ProcessedEvents,
SchemaKeys,
StateEnrichment,
} from "@ag-ui/langgraph";
import {
LangGraphAgent as AGUILangGraphAgent,
LangGraphHttpAgent,
type LangGraphAgentConfig,
type State,
} from "@ag-ui/langgraph";
import type { Message as LangGraphMessage } from "@langchain/langgraph-sdk/dist/types.messages";
import type { ThreadState } from "@langchain/langgraph-sdk";
interface CopilotKitStateEnrichment {
copilotkit: {
actions: StateEnrichment["ag-ui"]["tools"];
context: StateEnrichment["ag-ui"]["context"];
};
}
import type { RunAgentInput, CustomEvent } from "@ag-ui/client";
import { EventType } from "@ag-ui/client";
// Import and re-export from separate file to maintain API compatibility
import type {
TextMessageEvents,
ToolCallEvents,
PredictStateTool,
} from "./consts";
import { CustomEventNames } from "./consts";
export { CustomEventNames };
export class LangGraphAgent extends AGUILangGraphAgent {
constructor(config: LangGraphAgentConfig) {
super(config);
}
dispatchEvent(event: ProcessedEvents) {
if (event.type === EventType.CUSTOM) {
// const event = processedEvent as unknown as CustomEvent;
const customEvent = event as unknown as CustomEvent;
if (customEvent.name === CustomEventNames.CopilotKitManuallyEmitMessage) {
this.subscriber.next({
type: EventType.TEXT_MESSAGE_START,
role: "assistant",
messageId: customEvent.value.message_id,
rawEvent: event,
});
this.subscriber.next({
type: EventType.TEXT_MESSAGE_CONTENT,
messageId: customEvent.value.message_id,
delta: customEvent.value.message,
rawEvent: event,
});
this.subscriber.next({
type: EventType.TEXT_MESSAGE_END,
messageId: customEvent.value.message_id,
rawEvent: event,
});
return true;
}
if (
customEvent.name === CustomEventNames.CopilotKitManuallyEmitToolCall
) {
this.subscriber.next({
type: EventType.TOOL_CALL_START,
toolCallId: customEvent.value.id,
toolCallName: customEvent.value.name,
parentMessageId: customEvent.value.id,
rawEvent: event,
});
this.subscriber.next({
type: EventType.TOOL_CALL_ARGS,
toolCallId: customEvent.value.id,
delta: customEvent.value.args,
rawEvent: event,
});
this.subscriber.next({
type: EventType.TOOL_CALL_END,
toolCallId: customEvent.value.id,
rawEvent: event,
});
return true;
}
if (
customEvent.name ===
CustomEventNames.CopilotKitManuallyEmitIntermediateState
) {
this.activeRun.manuallyEmittedState = customEvent.value;
this.dispatchEvent({
type: EventType.STATE_SNAPSHOT,
snapshot: this.getStateSnapshot({
values: this.activeRun.manuallyEmittedState,
} as ThreadState<State>),
rawEvent: event,
});
return true;
}
if (customEvent.name === CustomEventNames.CopilotKitExit) {
this.subscriber.next({
type: EventType.CUSTOM,
name: "Exit",
value: true,
});
return true;
}
}
// Intercept all text message and tool call events and check if should disable
const rawEvent = (event as ToolCallEvents | TextMessageEvents).rawEvent;
if (!rawEvent) {
this.subscriber.next(event);
return true;
}
const isMessageEvent =
event.type === EventType.TEXT_MESSAGE_START ||
event.type === EventType.TEXT_MESSAGE_CONTENT ||
event.type === EventType.TEXT_MESSAGE_END;
const isToolEvent =
event.type === EventType.TOOL_CALL_START ||
event.type === EventType.TOOL_CALL_ARGS ||
event.type === EventType.TOOL_CALL_END;
if ("copilotkit:emit-tool-calls" in (rawEvent.metadata || {})) {
if (
rawEvent.metadata["copilotkit:emit-tool-calls"] === false &&
isToolEvent
) {
return false;
}
}
if ("copilotkit:emit-messages" in (rawEvent.metadata || {})) {
if (
rawEvent.metadata["copilotkit:emit-messages"] === false &&
isMessageEvent
) {
// Clean up tracked message state to prevent stale records from
// leaking into subsequent nodes that have emit-messages enabled.
if (this.activeRun?.id) {
this.messagesInProcess[this.activeRun.id] = null;
}
return false;
}
}
this.subscriber.next(event);
return true;
}
// @ts-ignore
run(input: RunAgentInput): Observable<BaseEvent> {
// @ag-ui/langgraph's message converter throws "message role is not
// supported." on any role it doesn't enumerate (user|assistant|system|tool).
// Reasoning-stream agents (e.g. OpenAI Responses API with reasoning summaries)
// emit AG-UI messages with role:"reasoning" that the client then replays on
// subsequent turns; without this filter the second turn crashes before the
// model is ever called.
const messages = (input.messages ?? []).filter(
(m: { role?: string }) => m?.role !== "reasoning",
);
const enrichedInput = {
...input,
messages,
forwardedProps: {
...input.forwardedProps,
streamSubgraphs: input.forwardedProps?.streamSubgraphs ?? true,
},
};
return super.run(enrichedInput).pipe(
map((processedEvent) => {
// Turn raw event into emit state snapshot from tool call event
if (processedEvent.type === EventType.RAW) {
// Get the LangGraph event from the AGUI event.
const event =
(processedEvent as RawEvent).event ??
(processedEvent as RawEvent).rawEvent;
const eventType = event.event;
const toolCallData = event.data?.chunk?.tool_call_chunks?.[0];
const toolCallUsedToPredictState = event.metadata?.[
"copilotkit:emit-intermediate-state"
]?.some(
(predictStateTool: PredictStateTool) =>
predictStateTool.tool === toolCallData?.name,
);
if (
eventType === LangGraphEventTypes.OnChatModelStream &&
toolCallUsedToPredictState
) {
return {
type: EventType.CUSTOM,
name: "PredictState",
value: event.metadata["copilotkit:emit-intermediate-state"],
};
}
}
return processedEvent;
}),
);
}
langGraphDefaultMergeState(
state: State,
messages: LangGraphMessage[],
input: RunAgentInput,
): State<StateEnrichment & CopilotKitStateEnrichment> {
const aguiMergedState = super.langGraphDefaultMergeState(
state,
messages,
input,
);
const { tools: returnedTools, "ag-ui": agui } = aguiMergedState;
// tolerate undefined and de-duplicate by stable key (id | name | key)
const rawCombinedTools = [
...((returnedTools as any[]) ?? []),
...((agui?.tools as any[]) ?? []),
];
const combinedTools = Array.from(
new Map(
rawCombinedTools.map((t: any) => [
t?.id ?? t?.name ?? t?.key ?? JSON.stringify(t),
t,
]),
).values(),
);
return {
...aguiMergedState,
copilotkit: {
actions: combinedTools,
context: agui?.context ?? [],
},
};
}
async getSchemaKeys(): Promise<SchemaKeys> {
const CONSTANT_KEYS = ["copilotkit"];
const schemaKeys = await super.getSchemaKeys();
return {
config: schemaKeys.config,
input: schemaKeys.input ? [...schemaKeys.input, ...CONSTANT_KEYS] : null,
output: schemaKeys.output
? [...schemaKeys.output, ...CONSTANT_KEYS]
: null,
context: schemaKeys.context
? [...schemaKeys.context, ...CONSTANT_KEYS]
: null,
};
}
}
export { LangGraphHttpAgent };
@@ -0,0 +1,37 @@
/**
* Constants for LangGraph integration.
* This file is separate from langgraph.agent.ts to avoid pulling in @ag-ui/langgraph
* when only these constants are needed.
*/
import {
TextMessageStartEvent,
TextMessageContentEvent,
TextMessageEndEvent,
ToolCallStartEvent,
ToolCallArgsEvent,
ToolCallEndEvent,
} from "@ag-ui/client";
export type TextMessageEvents =
| TextMessageStartEvent
| TextMessageContentEvent
| TextMessageEndEvent;
export type ToolCallEvents =
| ToolCallStartEvent
| ToolCallArgsEvent
| ToolCallEndEvent;
export enum CustomEventNames {
CopilotKitManuallyEmitMessage = "copilotkit_manually_emit_message",
CopilotKitManuallyEmitToolCall = "copilotkit_manually_emit_tool_call",
CopilotKitManuallyEmitIntermediateState = "copilotkit_manually_emit_intermediate_state",
CopilotKitExit = "copilotkit_exit",
}
export interface PredictStateTool {
tool: string;
state_key: string;
tool_argument: string;
}
@@ -0,0 +1,2 @@
export * from "./consts";
export * from "./agent";
@@ -0,0 +1,871 @@
/**
* <Callout type="info">
* This is the reference for the `CopilotRuntime` class. For more information and example code snippets, please see [Concept: Copilot Runtime](/concepts/copilot-runtime).
* </Callout>
*
* ## Usage
*
* ```tsx
* import { CopilotRuntime } from "@copilotkit/runtime";
*
* const copilotKit = new CopilotRuntime();
* ```
*/
import {
CopilotKitMisuseError,
readBody,
getZodParameters,
isTelemetryDisabled,
} from "@copilotkit/shared";
import type {
Action,
CopilotErrorHandler,
MaybePromise,
NonEmptyRecord,
Parameter,
PartialBy,
DebugConfig,
} from "@copilotkit/shared";
import type { RunAgentInput } from "@ag-ui/core";
import { aguiToGQL } from "../../graphql/message-conversion/agui-to-gql";
import type {
CopilotServiceAdapter,
RemoteChainParameters,
} from "../../service-adapters";
import {
CopilotRuntime as CopilotRuntimeVNext,
InMemoryAgentRunner,
} from "../../v2/runtime";
import type {
CopilotRuntimeOptions,
CopilotRuntimeOptions as CopilotRuntimeOptionsVNext,
AgentRunner,
AgentsConfig,
AgentsFactory,
AgentFactoryContext,
} from "../../v2/runtime";
export type { AgentsConfig, AgentsFactory, AgentFactoryContext };
import { TelemetryAgentRunner } from "./telemetry-agent-runner";
import telemetry from "../telemetry-client";
import { logRuntimeTelemetryDisclosure } from "../telemetry-disclosure";
import type { MessageInput } from "../../graphql/inputs/message.input";
import type { Message } from "../../graphql/types/converted";
import { EndpointType } from "./types";
import type {
EndpointDefinition,
CopilotKitEndpoint,
LangGraphPlatformEndpoint,
} from "./types";
import type {
CopilotObservabilityConfig,
LLMRequestData,
LLMResponseData,
} from "../observability";
import type { AbstractAgent } from "@ag-ui/client";
// +++ MCP Imports +++
import { extractParametersFromSchema } from "./mcp-tools-utils";
import type { MCPClient, MCPEndpointConfig, MCPTool } from "./mcp-tools-utils";
import { BuiltInAgent } from "../../agent";
import type { BuiltInAgentClassicConfig } from "../../agent";
// Define the function type alias here or import if defined elsewhere
type CreateMCPClientFunction = (
config: MCPEndpointConfig,
) => Promise<MCPClient>;
type ActionsConfiguration<T extends Parameter[] | [] = []> =
| Action<T>[]
| ((ctx: { properties: any; url?: string }) => Action<T>[]);
interface OnBeforeRequestOptions {
threadId?: string;
runId?: string;
inputMessages: Message[];
properties: any;
url?: string;
}
type OnBeforeRequestHandler = (
options: OnBeforeRequestOptions,
) => void | Promise<void>;
interface OnAfterRequestOptions {
threadId: string;
runId?: string;
inputMessages: Message[];
outputMessages: Message[];
properties: any;
url?: string;
}
type OnAfterRequestHandler = (
options: OnAfterRequestOptions,
) => void | Promise<void>;
interface OnStopGenerationOptions {
threadId: string;
runId?: string;
url?: string;
agentName?: string;
lastMessage: MessageInput;
}
type OnStopGenerationHandler = (
options: OnStopGenerationOptions,
) => void | Promise<void>;
interface Middleware {
/**
* A function that is called before the request is processed.
*/
/**
* @deprecated This middleware hook is deprecated and will be removed in a future version.
* Use updated middleware integration methods in CopilotRuntimeVNext instead.
*/
onBeforeRequest?: OnBeforeRequestHandler;
/**
* A function that is called after the request is processed.
*/
/**
* @deprecated This middleware hook is deprecated and will be removed in a future version.
* Use updated middleware integration methods in CopilotRuntimeVNext instead.
*/
onAfterRequest?: OnAfterRequestHandler;
}
export interface CopilotRuntimeConstructorParams_BASE<
T extends Parameter[] | [] = [],
> {
/**
* Middleware to be used by the runtime.
*
* ```ts
* onBeforeRequest: (options: {
* threadId?: string;
* runId?: string;
* inputMessages: Message[];
* properties: any;
* }) => void | Promise<void>;
* ```
*
* ```ts
* onAfterRequest: (options: {
* threadId?: string;
* runId?: string;
* inputMessages: Message[];
* outputMessages: Message[];
* properties: any;
* }) => void | Promise<void>;
* ```
*/
/**
* @deprecated This middleware hook is deprecated and will be removed in a future version.
* Use updated middleware integration methods in CopilotRuntimeVNext instead.
*/
middleware?: Middleware;
/*
* A list of server side actions that can be executed. Will be ignored when remoteActions are set
*/
actions?: ActionsConfiguration<T>;
/*
* Deprecated: Use `remoteEndpoints`.
*/
remoteActions?: CopilotKitEndpoint[];
/*
* A list of remote actions that can be executed.
*/
remoteEndpoints?: EndpointDefinition[];
/*
* An array of LangServer URLs.
*/
langserve?: RemoteChainParameters[];
/**
* Optional agent runner to use for SSE runtime.
*/
runner?: AgentRunner;
/*
* A map of agent names to AGUI agents.
* Example agent config:
* ```ts
* import { AbstractAgent } from "@ag-ui/client";
* // ...
* agents: {
* "support": new CustomerSupportAgent(),
* "technical": new TechnicalAgent()
* }
* ```
*/
agents?: Record<string, AbstractAgent>;
/*
* Delegates agent state processing to the service adapter.
*
* When enabled, individual agent state requests will not be processed by the agent itself.
* Instead, all processing will be handled by the service adapter.
*/
delegateAgentProcessingToServiceAdapter?: boolean;
/**
* Configuration for LLM request/response logging.
*
* Example logging config:
* ```ts
* logging: {
* enabled: true, // Enable or disable logging
* progressive: true, // Set to false for buffered logging
* logger: {
* logRequest: (data) => langfuse.trace({ name: "LLM Request", input: data }),
* logResponse: (data) => langfuse.trace({ name: "LLM Response", output: data }),
* logError: (errorData) => langfuse.trace({ name: "LLM Error", metadata: errorData }),
* },
* }
* ```
*/
observability_c?: CopilotObservabilityConfig;
/**
* Configuration for connecting to Model Context Protocol (MCP) servers.
* Allows fetching and using tools defined on external MCP-compliant servers.
* Requires providing the `createMCPClient` function during instantiation.
* @experimental
*/
mcpServers?: MCPEndpointConfig[];
/**
* A function that creates an MCP client instance for a given endpoint configuration.
* This function is responsible for using the appropriate MCP client library
* (e.g., `@copilotkit/runtime`, `ai`) to establish a connection.
* Required if `mcpServers` is provided.
*
* ```typescript
* import { experimental_createMCPClient } from "ai"; // Import from vercel ai library
* // ...
* const runtime = new CopilotRuntime({
* mcpServers: [{ endpoint: "..." }],
* async createMCPClient(config) {
* return await experimental_createMCPClient({
* transport: {
* type: "sse",
* url: config.endpoint,
* headers: config.apiKey
* ? { Authorization: `Bearer ${config.apiKey}` }
* : undefined,
* },
* });
* }
* });
* ```
*/
createMCPClient?: CreateMCPClientFunction;
/**
* Optional error handler for comprehensive debugging and observability.
*
* @param errorEvent - Structured error event with rich debugging context
*
* @example
* ```typescript
* const runtime = new CopilotRuntime({
* onError: (errorEvent) => {
* debugDashboard.capture(errorEvent);
* }
* });
* ```
*/
onError?: CopilotErrorHandler;
onStopGeneration?: OnStopGenerationHandler;
/**
* Enable debug logging for the runtime event pipeline.
* Pass `true` for full output, or an object for granular control:
*
* ```ts
* const runtime = new CopilotRuntime({
* debug: true,
* // or: debug: { events: true, lifecycle: true, verbose: false }
* });
* ```
*/
debug?: DebugConfig;
// /** Optional transcription service for audio processing. */
// transcriptionService?: CopilotRuntimeOptionsVNext["transcriptionService"];
// /** Optional *before* middleware callback function or webhook URL. */
// beforeRequestMiddleware?: CopilotRuntimeOptionsVNext["beforeRequestMiddleware"];
// /** Optional *after* middleware callback function or webhook URL. */
// afterRequestMiddleware?: CopilotRuntimeOptionsVNext["afterRequestMiddleware"];
}
type BeforeRequestMiddleware =
CopilotRuntimeOptionsVNext["beforeRequestMiddleware"];
type AfterRequestMiddleware =
CopilotRuntimeOptionsVNext["afterRequestMiddleware"];
type BeforeRequestMiddlewareFn = Exclude<BeforeRequestMiddleware, string>;
type BeforeRequestMiddlewareFnParameters =
Parameters<BeforeRequestMiddlewareFn>;
type BeforeRequestMiddlewareFnResult = ReturnType<BeforeRequestMiddlewareFn>;
type AfterRequestMiddlewareFn = Exclude<AfterRequestMiddleware, string>;
type AfterRequestMiddlewareFnParameters = Parameters<AfterRequestMiddlewareFn>;
interface CopilotRuntimeConstructorParams<T extends Parameter[] | [] = []>
extends
Omit<CopilotRuntimeConstructorParams_BASE<T>, "agents">,
Omit<CopilotRuntimeOptionsVNext, "agents" | "transcriptionService"> {
/**
* TODO: un-omit `transcriptionService` above once it's supported
*
* This satisfies...
* the optional constraint in `CopilotRuntimeConstructorParams_BASE`
* the `MaybePromise<NonEmptyRecord<T>>` constraint in `CopilotRuntimeOptionsVNext`
* the `Record<string, AbstractAgent>` constraint in `both
*/
agents?: AgentsConfig;
}
/**
* Central runtime object passed to all request handlers.
*/
export class CopilotRuntime<const T extends Parameter[] | [] = []> {
params?: CopilotRuntimeConstructorParams<T>;
private observability?: CopilotObservabilityConfig;
// Cache MCP tools per endpoint to avoid re-fetching repeatedly
private mcpToolsCache: Map<string, BuiltInAgentClassicConfig["tools"]> =
new Map();
private runtimeArgs: CopilotRuntimeOptions;
private _instance: CopilotRuntimeVNext;
constructor(
params?: CopilotRuntimeConstructorParams<T> &
PartialBy<CopilotRuntimeOptions, "agents">,
) {
logRuntimeTelemetryDisclosure();
const agents = params?.agents ?? {};
const endpointAgents = this.assignEndpointsToAgents(
params?.remoteEndpoints ?? [],
);
// Merge endpoint agents with user-provided agents.
// When agents is a factory function, wrap it so endpoint agents are merged
// at resolution time (spreading a function produces {} — silent data loss).
let mergedAgents: AgentsConfig;
if (typeof agents === "function") {
mergedAgents = async (ctx) => {
const resolved = await agents(ctx);
return { ...endpointAgents, ...resolved };
};
} else {
mergedAgents = Promise.resolve(agents).then((resolved) => ({
...endpointAgents,
...resolved,
}));
}
// Determine the base runner (user-provided or default)
const baseRunner = params?.runner ?? new InMemoryAgentRunner();
// Wrap with TelemetryAgentRunner unless telemetry is disabled
// This ensures we always capture agent execution telemetry when enabled,
// even if the user provides their own custom runner
const runner = isTelemetryDisabled()
? baseRunner
: new TelemetryAgentRunner({ runner: baseRunner });
// Match license-verifier's env fallback so telemetry attribution
// resolves the same way as feature gating — otherwise customers who
// set only COPILOTKIT_LICENSE_TOKEN would get a working license but
// anonymous telemetry. Only used here for the telemetry setter; the
// v2 runtime applies the same fallback to runtimeArgs.licenseToken
// on its own.
const resolvedLicenseToken =
params?.licenseToken ?? process.env.COPILOTKIT_LICENSE_TOKEN;
if (resolvedLicenseToken) {
telemetry.setLicenseToken(resolvedLicenseToken);
}
this.runtimeArgs = {
agents: mergedAgents,
runner,
licenseToken: params?.licenseToken,
debug: params?.debug,
// TODO: add support for transcriptionService from CopilotRuntimeOptionsVNext once it is ready
// transcriptionService: params?.transcriptionService,
beforeRequestMiddleware:
this.createOnBeforeRequestHandler(params).bind(this),
...(params?.afterRequestMiddleware || params?.middleware?.onAfterRequest
? {
afterRequestMiddleware:
this.createOnAfterRequestHandler(params).bind(this),
}
: {}),
a2ui: params?.a2ui,
mcpApps: params?.mcpApps,
openGenerativeUI: params?.openGenerativeUI,
};
this.params = params;
this.observability = params?.observability_c;
}
get instance() {
if (!this._instance) {
this._instance = new CopilotRuntimeVNext(this.runtimeArgs);
}
return this._instance;
}
private assignEndpointsToAgents(
endpoints: CopilotRuntimeConstructorParams<T>["remoteEndpoints"],
): Record<string, AbstractAgent> {
let result: Record<string, AbstractAgent> = {};
if (
endpoints.some(
(endpoint) =>
resolveEndpointType(endpoint) == EndpointType.LangGraphPlatform,
)
) {
throw new CopilotKitMisuseError({
message:
"LangGraphPlatformEndpoint in remoteEndpoints is deprecated. " +
'Please use the "agents" option instead with LangGraphAgent from "@copilotkit/runtime/langgraph". ' +
'Example: agents: { myAgent: new LangGraphAgent({ deploymentUrl: "...", graphId: "..." }) }',
});
}
return result;
}
handleServiceAdapter(serviceAdapter: CopilotServiceAdapter) {
this.runtimeArgs.agents = Promise.resolve(
this.runtimeArgs.agents ?? {},
).then(async (agents) => {
// An AgentsFactory function has no enumerable keys, so it flows through
// this path the same way an empty record does.
let agentsList = agents as Record<string, AbstractAgent>;
const isAgentsListEmpty = !Object.keys(agents).length;
const hasServiceAdapter = Boolean(serviceAdapter);
const illegalServiceAdapterNames = ["EmptyAdapter"];
const serviceAdapterCanBeUsedForAgent =
!illegalServiceAdapterNames.includes(serviceAdapter.name);
if (
isAgentsListEmpty &&
(!hasServiceAdapter || !serviceAdapterCanBeUsedForAgent)
) {
throw new CopilotKitMisuseError({
message:
"No default agent provided. Please provide a default agent in the runtime config.",
});
}
if (isAgentsListEmpty) {
const languageModel = serviceAdapter.getLanguageModel?.();
if (languageModel) {
// Adapter exposes a pre-configured LanguageModel (e.g. OpenAI/Anthropic adapters)
agentsList.default = new BuiltInAgent({ model: languageModel });
} else if (serviceAdapter.provider && serviceAdapter.model) {
// Adapter exposes provider/model strings
agentsList.default = new BuiltInAgent({
model: `${serviceAdapter.provider}/${serviceAdapter.model}`,
});
} else {
throw new CopilotKitMisuseError({
message:
`Service adapter "${serviceAdapter.name ?? "unknown"}" does not provide model information. ` +
`When using adapters like LangChainAdapter without an explicit agents list, ` +
`please provide a default agent in the runtime config. Example:\n` +
` new CopilotRuntime({\n` +
` agents: { default: new BuiltInAgent({ model: "openai/gpt-4o" }) }\n` +
` })`,
});
}
}
const actions = this.params?.actions;
if (actions) {
const mcpTools = await this.getToolsFromMCP();
agentsList = this.assignToolsToAgents(agentsList, [
...this.getToolsFromActions(actions),
...mcpTools,
]);
}
return agentsList;
});
}
// Receive this.params.action and turn it into the AbstractAgent tools
private getToolsFromActions(
actions: ActionsConfiguration<any>,
): BuiltInAgentClassicConfig["tools"] {
// Resolve actions to an array (handle function case)
const actionsArray =
typeof actions === "function"
? actions({ properties: {}, url: undefined })
: actions;
// Convert each Action to a ToolDefinition
return actionsArray.map((action) => {
// Convert JSON schema to Zod schema
const zodSchema = getZodParameters(action.parameters || []);
return {
name: action.name,
description: action.description || "",
parameters: zodSchema,
execute: () => Promise.resolve(),
};
});
}
private assignToolsToAgents(
agents: Record<string, AbstractAgent>,
tools: BuiltInAgentClassicConfig["tools"],
): Record<string, AbstractAgent> {
if (!tools?.length) {
return agents;
}
const enrichedAgents: Record<string, AbstractAgent> = { ...agents };
for (const [agentId, agent] of Object.entries(enrichedAgents)) {
const existingConfig = (Reflect.get(agent, "config") ?? {}) as Record<
string,
unknown
>;
// Skip factory-mode agents — they don't have a tools property
if ("factory" in existingConfig) {
continue;
}
const classicConfig =
existingConfig as unknown as BuiltInAgentClassicConfig;
const existingTools = classicConfig.tools ?? [];
const updatedConfig: BuiltInAgentClassicConfig = {
...classicConfig,
tools: [...existingTools, ...tools],
};
Reflect.set(agent, "config", updatedConfig);
enrichedAgents[agentId] = agent;
}
return enrichedAgents;
}
private createOnBeforeRequestHandler(
params?: CopilotRuntimeConstructorParams<T> &
PartialBy<CopilotRuntimeOptions, "agents">,
) {
return async (hookParams: BeforeRequestMiddlewareFnParameters[0]) => {
const { request } = hookParams;
// Capture telemetry for copilot request creation
const publicApiKey = request.headers.get("x-copilotcloud-public-api-key");
const body = (await readBody(request)) as RunAgentInput;
const forwardedProps = body?.forwardedProps as
| {
cloud?: { guardrails?: unknown };
metadata?: { requestType?: string };
}
| undefined;
// Get cloud base URL from environment or default
const cloudBaseUrl =
process.env.COPILOT_CLOUD_BASE_URL || "https://api.cloud.copilotkit.ai";
telemetry.capture("oss.runtime.copilot_request_created", {
"cloud.guardrails.enabled":
forwardedProps?.cloud?.guardrails !== undefined,
requestType: forwardedProps?.metadata?.requestType ?? "unknown",
"cloud.api_key_provided": !!publicApiKey,
...(publicApiKey ? { "cloud.public_api_key": publicApiKey } : {}),
"cloud.base_url": cloudBaseUrl,
});
// We do not process middleware for the internal GET requests
if (request.method === "GET" || !body) return;
// TODO: get public api key and run with expected data
// if (this.observability?.enabled && this.params.publicApiKey) {
// this.logObservabilityBeforeRequest()
// }
// TODO: replace hooksParams top argument type with BeforeRequestMiddlewareParameters when exported
const middlewareResult =
await params?.beforeRequestMiddleware?.(hookParams);
if (params?.middleware?.onBeforeRequest) {
const { request, runtime, path } = hookParams;
const gqlMessages = (aguiToGQL(body.messages) as Message[]).reduce(
(acc, msg) => {
if ("role" in msg && msg.role === "user") {
acc.inputMessages.push(msg);
} else {
acc.outputMessages.push(msg);
}
return acc;
},
{ inputMessages: [] as Message[], outputMessages: [] as Message[] },
);
const { inputMessages, outputMessages } = gqlMessages;
params.middleware.onBeforeRequest({
threadId: body.threadId,
runId: body.runId,
inputMessages,
properties: body.forwardedProps,
url: request.url,
} satisfies OnBeforeRequestOptions);
}
return middlewareResult;
};
}
private createOnAfterRequestHandler(
params?: CopilotRuntimeConstructorParams<T> &
PartialBy<CopilotRuntimeOptions, "agents">,
) {
return async (hookParams: AfterRequestMiddlewareFnParameters[0]) => {
// TODO: get public api key and run with expected data
// if (this.observability?.enabled && publicApiKey) {
// this.logObservabilityAfterRequest()
// }
// TODO: replace hooksParams top argument type with AfterRequestMiddlewareParameters when exported
params?.afterRequestMiddleware?.(hookParams);
if (params?.middleware?.onAfterRequest) {
const messages = hookParams.messages ?? [];
params.middleware.onAfterRequest({
threadId: hookParams.threadId ?? "",
runId: hookParams.runId,
inputMessages: messages.filter(
(m): m is typeof m & { role: string } =>
"role" in m && m.role === "user",
) as unknown as Message[],
outputMessages: messages.filter(
(m): m is typeof m & { role: string } =>
"role" in m && m.role !== "user",
) as unknown as Message[],
// TODO: forward actual properties once the after-request hook has access to the request body
properties: {},
url: hookParams.path,
} satisfies OnAfterRequestOptions);
}
};
}
// Observability Methods
/**
* Log LLM request if observability is enabled
*/
private async logObservabilityBeforeRequest(
requestData: LLMRequestData,
): Promise<void> {
try {
await this.observability.hooks.handleRequest(requestData);
} catch (error) {
console.error("Error logging LLM request:", error);
}
}
/**
* Log final LLM response after request completes
*/
private logObservabilityAfterRequest(
outputMessagesPromise: Promise<Message[]>,
baseData: {
threadId: string;
runId?: string;
model?: string;
provider?: string;
agentName?: string;
nodeName?: string;
},
streamedChunks: any[],
requestStartTime: number,
publicApiKey?: string,
): void {
try {
outputMessagesPromise
.then((outputMessages) => {
const responseData: LLMResponseData = {
threadId: baseData.threadId,
runId: baseData.runId,
model: baseData.model,
// Use collected chunks for progressive mode or outputMessages for regular mode
output: this.observability.progressive
? streamedChunks
: outputMessages,
latency: Date.now() - requestStartTime,
timestamp: Date.now(),
provider: baseData.provider,
isFinalResponse: true,
agentName: baseData.agentName,
nodeName: baseData.nodeName,
};
try {
this.observability.hooks.handleResponse(responseData);
} catch (logError) {
console.error("Error logging LLM response:", logError);
}
})
.catch((error) => {
console.error("Failed to get output messages for logging:", error);
});
} catch (error) {
console.error("Error setting up logging for LLM response:", error);
}
}
// Resolve MCP tools to BuiltInAgent tool definitions
// Optionally accepts request-scoped properties to merge request-provided mcpServers
private async getToolsFromMCP(options?: {
properties?: Record<string, unknown>;
}): Promise<BuiltInAgentClassicConfig["tools"]> {
const runtimeMcpServers = (this.params?.mcpServers ??
[]) as MCPEndpointConfig[];
const createMCPClient = this.params?.createMCPClient as
| CreateMCPClientFunction
| undefined;
// If no runtime config and no request overrides, nothing to do
const requestMcpServers = ((
options?.properties as { mcpServers?: MCPEndpointConfig[] } | undefined
)?.mcpServers ??
(
options?.properties as
| { mcpEndpoints?: MCPEndpointConfig[] }
| undefined
)?.mcpEndpoints ??
[]) as MCPEndpointConfig[];
const hasAnyServers =
(runtimeMcpServers?.length ?? 0) > 0 ||
(requestMcpServers?.length ?? 0) > 0;
if (!hasAnyServers) {
return [];
}
if (!createMCPClient) {
// Mirror legacy behavior: when servers are provided without a factory, treat as misconfiguration
throw new CopilotKitMisuseError({
message:
"MCP Integration Error: `mcpServers` were provided, but the `createMCPClient` function was not passed to the CopilotRuntime constructor. Please provide an implementation for `createMCPClient`.",
});
}
// Merge and dedupe endpoints by URL; request-level overrides take precedence
const effectiveEndpoints = (() => {
const byUrl = new Map<string, MCPEndpointConfig>();
for (const ep of runtimeMcpServers) {
if (ep?.endpoint) byUrl.set(ep.endpoint, ep);
}
for (const ep of requestMcpServers) {
if (ep?.endpoint) byUrl.set(ep.endpoint, ep);
}
return Array.from(byUrl.values());
})();
const allTools: BuiltInAgentClassicConfig["tools"] = [];
for (const config of effectiveEndpoints) {
const endpointUrl = config.endpoint;
// Return cached tool definitions when available
const cached = this.mcpToolsCache.get(endpointUrl);
if (cached) {
allTools.push(...cached);
continue;
}
try {
const client = await createMCPClient(config);
const toolsMap = await client.tools();
const toolDefs: BuiltInAgentClassicConfig["tools"] = Object.entries(
toolsMap,
).map(([toolName, tool]: [string, MCPTool]) => {
const params: Parameter[] = extractParametersFromSchema(tool);
const zodSchema = getZodParameters(params);
return {
name: toolName,
description:
tool.description || `MCP tool: ${toolName} (from ${endpointUrl})`,
parameters: zodSchema,
execute: () => Promise.resolve(),
};
});
// Cache per endpoint and add to aggregate
this.mcpToolsCache.set(endpointUrl, toolDefs);
allTools.push(...toolDefs);
} catch (error) {
console.error(
`MCP: Failed to fetch tools from endpoint ${endpointUrl}. Skipping. Error:`,
error,
);
// Cache empty to prevent repeated attempts within lifecycle
this.mcpToolsCache.set(endpointUrl, []);
}
}
// Dedupe tools by name while preserving last-in wins (request overrides)
const dedupedByName = new Map<string, (typeof allTools)[number]>();
for (const tool of allTools) {
dedupedByName.set(tool.name, tool);
}
return Array.from(dedupedByName.values());
}
}
// The two functions below are "factory functions", meant to create the action objects that adhere to the expected interfaces
export function copilotKitEndpoint(
config: Omit<CopilotKitEndpoint, "type">,
): CopilotKitEndpoint {
return {
...config,
type: EndpointType.CopilotKit,
};
}
export function langGraphPlatformEndpoint(
config: Omit<LangGraphPlatformEndpoint, "type">,
): LangGraphPlatformEndpoint {
return {
...config,
type: EndpointType.LangGraphPlatform,
};
}
export function resolveEndpointType(endpoint: EndpointDefinition) {
if (!endpoint.type) {
if ("deploymentUrl" in endpoint && "agents" in endpoint) {
return EndpointType.LangGraphPlatform;
} else {
return EndpointType.CopilotKit;
}
}
return endpoint.type;
}
@@ -0,0 +1,313 @@
import { Action, Parameter } from "@copilotkit/shared";
/**
* Represents a tool provided by an MCP server.
*/
export interface MCPTool {
description?: string;
/** Schema defining parameters, mirroring the MCP structure. */
schema?: {
parameters?: {
properties?: Record<string, any>;
required?: string[];
jsonSchema?: Record<string, any>;
};
};
/** The function to call to execute the tool on the MCP server. */
execute(params: any): Promise<any>;
}
/**
* Defines the contract for *any* MCP client implementation the user might provide.
*/
export interface MCPClient {
/** A method that returns a map of tool names to MCPTool objects available from the connected MCP server. */
tools(): Promise<Record<string, MCPTool>>;
/** An optional method for cleanup if the underlying client requires explicit disconnection. */
close?(): Promise<void>;
}
/**
* Configuration for connecting to an MCP endpoint.
*/
export interface MCPEndpointConfig {
endpoint: string;
apiKey?: string;
}
/**
* Extracts CopilotKit-compatible parameters from an MCP tool schema.
* @param toolOrSchema The schema object from an MCPTool or the full MCPTool object.
* @returns An array of Parameter objects.
*/
export function extractParametersFromSchema(
toolOrSchema?: MCPTool | MCPTool["schema"],
): Parameter[] {
const parameters: Parameter[] = [];
// Handle either full tool object or just schema
const schema =
"schema" in (toolOrSchema || {})
? (toolOrSchema as MCPTool).schema
: (toolOrSchema as MCPTool["schema"]);
const toolParameters = schema?.parameters?.jsonSchema || schema?.parameters;
const properties = toolParameters?.properties;
const requiredParams = new Set(toolParameters?.required || []);
if (!properties) {
return parameters;
}
for (const paramName in properties) {
if (Object.prototype.hasOwnProperty.call(properties, paramName)) {
const paramDef = properties[paramName];
// Enhanced type extraction with support for complex types
let type = paramDef.type || "string";
let description = paramDef.description || "";
// Handle arrays with items
if (type === "array" && paramDef.items) {
const itemType = paramDef.items.type || "object";
if (itemType === "object" && paramDef.items.properties) {
// For arrays of objects, describe the structure
const itemProperties = Object.keys(paramDef.items.properties).join(
", ",
);
description =
description +
(description ? " " : "") +
`Array of objects with properties: ${itemProperties}`;
} else {
// For arrays of primitives
type = `array<${itemType}>`;
}
}
// Handle enums — preserve as structured data for Zod conversion
let enumValues: string[] | undefined;
if (paramDef.enum && Array.isArray(paramDef.enum)) {
enumValues = paramDef.enum.map(String);
const enumDisplay = enumValues.join(" | ");
description =
description +
(description ? " " : "") +
`Allowed values: ${enumDisplay}`;
}
// Handle objects with properties — recurse to preserve nested structure
let attributes: Parameter[] | undefined;
if (type === "object" && paramDef.properties) {
const objectProperties = Object.keys(paramDef.properties).join(", ");
description =
description +
(description ? " " : "") +
`Object with properties: ${objectProperties}`;
// Recursively extract nested parameters
attributes = extractParametersFromSchema({
parameters: {
properties: paramDef.properties,
required: paramDef.required || [],
},
});
}
// Handle object arrays — recurse into item schema
if (type === "array" && paramDef.items?.type === "object" && paramDef.items?.properties) {
attributes = extractParametersFromSchema({
parameters: {
properties: paramDef.items.properties,
required: paramDef.items.required || [],
},
});
}
const param: any = {
name: paramName,
type: type,
description: description,
required: requiredParams.has(paramName),
};
// Preserve enum values for string parameters
if (type === "string" && enumValues) {
param.enum = enumValues;
}
// Preserve nested attributes for object and object[] types
if (attributes && attributes.length > 0) {
param.attributes = attributes;
if (type === "array") {
param.type = "object[]";
}
}
parameters.push(param);
}
}
return parameters;
}
/**
* Converts a map of MCPTools into an array of CopilotKit Actions.
* @param mcpTools A record mapping tool names to MCPTool objects.
* @param mcpEndpoint The endpoint URL from which these tools were fetched.
* @returns An array of Action<any> objects.
*/
export function convertMCPToolsToActions(
mcpTools: Record<string, MCPTool>,
mcpEndpoint: string,
): Action<any>[] {
const actions: Action<any>[] = [];
for (const [toolName, tool] of Object.entries(mcpTools)) {
const parameters = extractParametersFromSchema(tool);
const handler = async (params: any): Promise<any> => {
try {
const result = await tool.execute(params);
// Ensure the result is a string or stringify it, as required by many LLMs.
// This might need adjustment depending on how different LLMs handle tool results.
return typeof result === "string" ? result : JSON.stringify(result);
} catch (error) {
console.error(
`Error executing MCP tool '${toolName}' from endpoint ${mcpEndpoint}:`,
error,
);
// Re-throw or format the error for the LLM
throw new Error(
`Execution failed for MCP tool '${toolName}': ${
error instanceof Error ? error.message : String(error)
}`, { cause: error },
);
}
};
actions.push({
name: toolName,
description:
tool.description || `MCP tool: ${toolName} (from ${mcpEndpoint})`,
parameters: parameters,
handler: handler,
// Add metadata for easier identification/debugging
_isMCPTool: true,
_mcpEndpoint: mcpEndpoint,
} as Action<any> & { _isMCPTool: boolean; _mcpEndpoint: string }); // Type assertion for metadata
}
return actions;
}
/**
* Generate better instructions for using MCP tools
* This is used to enhance the system prompt with tool documentation
*/
export function generateMcpToolInstructions(
toolsMap: Record<string, MCPTool>,
): string {
if (!toolsMap || Object.keys(toolsMap).length === 0) {
return "";
}
const toolEntries = Object.entries(toolsMap);
// Generate documentation for each tool
const toolsDoc = toolEntries
.map(([name, tool]) => {
// Extract schema information if available
let paramsDoc = " No parameters required";
try {
if (tool.schema && typeof tool.schema === "object") {
const schema = tool.schema as any;
// Extract parameters from JSON Schema - check both schema.parameters.properties and schema.properties
const toolParameters =
schema.parameters?.jsonSchema || schema.parameters;
const properties = toolParameters?.properties || schema.properties;
const requiredParams =
toolParameters?.required || schema.required || [];
if (properties) {
// Build parameter documentation from properties with enhanced type information
const paramsList = Object.entries(properties).map(
([paramName, propSchema]) => {
const propDetails = propSchema as any;
const requiredMark = requiredParams.includes(paramName)
? "*"
: "";
let typeInfo = propDetails.type || "any";
let description = propDetails.description
? ` - ${propDetails.description}`
: "";
// Enhanced type display for complex schemas
if (typeInfo === "array" && propDetails.items) {
const itemType = propDetails.items.type || "object";
if (itemType === "object" && propDetails.items.properties) {
const itemProps = Object.keys(
propDetails.items.properties,
).join(", ");
typeInfo = `array<object>`;
description =
description +
(description ? " " : " - ") +
`Array of objects with properties: ${itemProps}`;
} else {
typeInfo = `array<${itemType}>`;
}
}
// Handle enums
if (propDetails.enum && Array.isArray(propDetails.enum)) {
const enumValues = propDetails.enum.join(" | ");
description =
description +
(description ? " " : " - ") +
`Allowed values: ${enumValues}`;
}
// Handle objects
if (typeInfo === "object" && propDetails.properties) {
const objectProps = Object.keys(propDetails.properties).join(
", ",
);
description =
description +
(description ? " " : " - ") +
`Object with properties: ${objectProps}`;
}
return ` - ${paramName}${requiredMark} (${typeInfo})${description}`;
},
);
if (paramsList.length > 0) {
paramsDoc = paramsList.join("\n");
}
}
}
} catch (e) {
console.error(`Error parsing schema for tool ${name}:`, e);
}
return `- ${name}: ${tool.description || ""}
${paramsDoc}`;
})
.join("\n\n");
return `You have access to the following external tools provided by Model Context Protocol (MCP) servers:
${toolsDoc}
When using these tools:
1. Only provide valid parameters according to their type requirements
2. Required parameters are marked with *
3. For array parameters, provide data in the correct array format
4. For object parameters, include all required nested properties
5. For enum parameters, use only the allowed values listed
6. Format API calls correctly with the expected parameter structure
7. Always check tool responses to determine your next action`;
}
@@ -0,0 +1,141 @@
import { Logger } from "pino";
// Retry configuration for network requests
export const RETRY_CONFIG = {
maxRetries: 3,
baseDelayMs: 1000,
maxDelayMs: 5000,
// HTTP status codes that should be retried
retryableStatusCodes: [502, 503, 504, 408, 429],
// Maximum Retry-After value (in seconds) we're willing to honor
maxRetryAfterSeconds: 60,
// Network error patterns that should be retried
retryableErrorMessages: [
"fetch failed",
"network error",
"connection timeout",
"ECONNREFUSED",
"ETIMEDOUT",
"ENOTFOUND",
"ECONNRESET",
],
};
// Helper function to check if an error/response is retryable
export function isRetryableError(error: any, response?: Response): boolean {
// Check HTTP response status
if (response && RETRY_CONFIG.retryableStatusCodes.includes(response.status)) {
return true;
}
// Check error codes (for connection errors like ECONNREFUSED)
const errorCode = error?.cause?.code || error?.code;
if (errorCode && RETRY_CONFIG.retryableErrorMessages.includes(errorCode)) {
return true;
}
// Check error messages
const errorMessage = error?.message?.toLowerCase() || "";
return RETRY_CONFIG.retryableErrorMessages.some((msg) =>
errorMessage.includes(msg),
);
}
// Helper function to sleep for a given duration
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Parse the Retry-After header value into milliseconds.
// Returns undefined if the header is missing or unparseable.
export function parseRetryAfter(response: Response): number | undefined {
const retryAfter = response.headers.get("Retry-After");
if (!retryAfter) return undefined;
// Try as seconds (integer)
const seconds = Number(retryAfter);
if (!Number.isNaN(seconds) && seconds >= 0) {
return seconds * 1000;
}
// Try as HTTP-date
const date = Date.parse(retryAfter);
if (!Number.isNaN(date)) {
const delayMs = date - Date.now();
return delayMs > 0 ? delayMs : 0;
}
return undefined;
}
// Calculate exponential backoff delay
export function calculateDelay(attempt: number): number {
const delay = RETRY_CONFIG.baseDelayMs * Math.pow(2, attempt);
return Math.min(delay, RETRY_CONFIG.maxDelayMs);
}
// Retry wrapper for fetch requests
export async function fetchWithRetry(
url: string,
options: RequestInit,
logger?: Logger,
): Promise<Response> {
let lastError: any;
for (let attempt = 0; attempt <= RETRY_CONFIG.maxRetries; attempt++) {
try {
const response = await fetch(url, options);
// If response is retryable, treat as error and retry
if (
isRetryableError(null, response) &&
attempt < RETRY_CONFIG.maxRetries
) {
let delay = calculateDelay(attempt);
// Honor Retry-After header on 429 responses
if (response.status === 429) {
const retryAfterMs = parseRetryAfter(response);
if (retryAfterMs !== undefined) {
const maxMs = RETRY_CONFIG.maxRetryAfterSeconds * 1000;
if (retryAfterMs > maxMs) {
throw new Error(
`Server requested Retry-After of ${Math.ceil(retryAfterMs / 1000)}s ` +
`which exceeds the maximum of ${RETRY_CONFIG.maxRetryAfterSeconds}s`,
);
}
delay = retryAfterMs;
}
}
logger?.warn(
`Request to ${url} failed with status ${response.status}. ` +
`Retrying attempt ${attempt + 1}/${RETRY_CONFIG.maxRetries + 1} in ${delay}ms.`,
);
await sleep(delay);
continue;
}
return response; // Success or non-retryable error
} catch (error) {
lastError = error;
// Check if this is a retryable network error
if (isRetryableError(error) && attempt < RETRY_CONFIG.maxRetries) {
const delay = calculateDelay(attempt);
logger?.warn(
`Request to ${url} failed with network error. ` +
`Retrying attempt ${attempt + 1}/${RETRY_CONFIG.maxRetries + 1} in ${delay}ms. Error: ${error?.message || String(error)}`,
);
await sleep(delay);
continue;
}
// Not retryable or max retries exceeded
break;
}
}
// Re-throw the last error after retries exhausted
throw lastError;
}
@@ -0,0 +1,151 @@
/**
* TelemetryAgentRunner - A wrapper around AgentRunner that adds telemetry
* for agent execution streams.
*
* This captures the following telemetry events:
* - oss.runtime.agent_execution_stream_started - when an agent execution starts
* - oss.runtime.agent_execution_stream_ended - when an agent execution completes
* - oss.runtime.agent_execution_stream_errored - when an agent execution fails
*/
import { type AgentRunner, InMemoryAgentRunner } from "../../v2/runtime";
import { createHash } from "node:crypto";
import { tap, catchError, finalize } from "rxjs";
import telemetry from "../telemetry-client";
import type { AgentExecutionResponseInfo } from "@copilotkit/shared/src/telemetry/events";
/**
* Configuration options for TelemetryAgentRunner
*/
export interface TelemetryAgentRunnerConfig {
/**
* The underlying runner to delegate to
* If not provided, defaults to InMemoryAgentRunner
*/
runner?: AgentRunner;
/**
* Optional LangSmith API key (will be hashed for telemetry)
*/
langsmithApiKey?: string;
}
/**
* An AgentRunner wrapper that adds telemetry tracking for agent executions.
*
* Usage:
* ```ts
* const runtime = new CopilotRuntime({
* runner: new TelemetryAgentRunner(),
* // or with custom runner:
* runner: new TelemetryAgentRunner({ runner: customRunner }),
* });
* ```
*/
export class TelemetryAgentRunner implements AgentRunner {
private readonly _runner: AgentRunner;
private readonly hashedLgcKey: string | undefined;
constructor(config?: TelemetryAgentRunnerConfig) {
this._runner = config?.runner ?? new InMemoryAgentRunner();
this.hashedLgcKey = config?.langsmithApiKey
? createHash("sha256").update(config.langsmithApiKey).digest("hex")
: undefined;
}
/**
* Runs an agent with telemetry tracking.
* Wraps the underlying runner's Observable stream with telemetry events.
*/
run(...args: Parameters<AgentRunner["run"]>): ReturnType<AgentRunner["run"]> {
const streamInfo: AgentExecutionResponseInfo = {
hashedLgcKey: this.hashedLgcKey,
};
let streamErrored = false;
// Capture stream started event
telemetry.capture("oss.runtime.agent_execution_stream_started", {
hashedLgcKey: this.hashedLgcKey,
});
// Delegate to the underlying runner and wrap with telemetry
return this._runner.run(...args).pipe(
// Extract metadata from events if available
tap((event) => {
// Try to extract provider/model info from raw events
const rawEvent = (
event as {
rawEvent?: {
metadata?: Record<string, unknown>;
data?: Record<string, unknown>;
};
}
).rawEvent;
if (rawEvent?.data) {
const data = rawEvent.data as { output?: { model?: string } };
if (data?.output?.model) {
streamInfo.model = data.output.model;
streamInfo.provider = data.output.model;
}
}
if (rawEvent?.metadata) {
const metadata = rawEvent.metadata as {
langgraph_host?: string;
langgraph_version?: string;
};
if (metadata?.langgraph_host) {
streamInfo.langGraphHost = metadata.langgraph_host;
}
if (metadata?.langgraph_version) {
streamInfo.langGraphVersion = metadata.langgraph_version;
}
}
}),
catchError((error) => {
// Capture stream error event
streamErrored = true;
telemetry.capture("oss.runtime.agent_execution_stream_errored", {
...streamInfo,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}),
finalize(() => {
// Capture stream ended event (only if not errored)
if (!streamErrored) {
telemetry.capture(
"oss.runtime.agent_execution_stream_ended",
streamInfo,
);
}
}),
);
}
/**
* Delegates to the underlying runner's connect method
*/
connect(
...args: Parameters<AgentRunner["connect"]>
): ReturnType<AgentRunner["connect"]> {
return this._runner.connect(...args);
}
/**
* Delegates to the underlying runner's isRunning method
*/
isRunning(
...args: Parameters<AgentRunner["isRunning"]>
): ReturnType<AgentRunner["isRunning"]> {
return this._runner.isRunning(...args);
}
/**
* Delegates to the underlying runner's stop method
*/
stop(
...args: Parameters<AgentRunner["stop"]>
): ReturnType<AgentRunner["stop"]> {
return this._runner.stop(...args);
}
}
+48
View File
@@ -0,0 +1,48 @@
import { GraphQLContext } from "../integrations";
import { ActionInput } from "../../graphql/inputs/action.input";
import { Message } from "../../graphql/types/converted";
import { MetaEventInput } from "../../graphql/inputs/meta-event.input";
export interface BaseEndpointDefinition<TActionType extends EndpointType> {
type?: TActionType;
}
export interface CopilotKitEndpoint extends BaseEndpointDefinition<EndpointType.CopilotKit> {
url: string;
onBeforeRequest?: ({ ctx }: { ctx: GraphQLContext }) => {
headers?: Record<string, string> | undefined;
};
}
export interface LangGraphPlatformAgent {
name: string;
description: string;
assistantId?: string;
}
export interface LangGraphPlatformEndpoint extends BaseEndpointDefinition<EndpointType.LangGraphPlatform> {
deploymentUrl: string;
langsmithApiKey?: string | null;
agents: LangGraphPlatformAgent[];
}
export type RemoteActionInfoResponse = {
actions: any[];
agents: any[];
};
export type RemoteAgentHandlerParams = {
name: string;
actionInputsWithoutAgents: ActionInput[];
threadId?: string;
nodeName?: string;
additionalMessages?: Message[];
metaEvents?: MetaEventInput[];
};
export type EndpointDefinition = CopilotKitEndpoint | LangGraphPlatformEndpoint;
export enum EndpointType {
CopilotKit = "copilotKit",
LangGraphPlatform = "langgraph-platform",
}

Some files were not shown because too many files have changed in this diff Show More