chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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,
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./lib";
|
||||
export * from "./utils";
|
||||
export * from "./service-adapters";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface CopilotCloudOptions {
|
||||
baseUrl?: string;
|
||||
publicApiKey?: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+345
@@ -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");
|
||||
});
|
||||
});
|
||||
+156
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user