chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
@@ -0,0 +1,683 @@
/**
* Shared test utilities for BuiltInAgent factory-mode tests.
*
* Re-exports everything from the existing test-helpers module and adds
* BuiltInAgent-specific factories, mock stream builders, and assertion helpers.
*/
import { EventType } from "@ag-ui/client";
import type { BaseEvent, RunAgentInput } from "@ag-ui/client";
import type { Observable } from "rxjs";
import { MockLanguageModelV3, simulateReadableStream } from "ai/test";
import { BuiltInAgent } from "../index";
import type {
AgentFactoryContext,
BuiltInAgentFactoryConfig,
ToolDefinition,
} from "../index";
import type { MockStreamEvent } from "./test-helpers";
// Re-export everything from existing test helpers
export {
type MockStreamEvent,
mockStreamTextResponse,
textStart,
textDelta,
toolCallStreamingStart,
toolCallDelta,
toolCall,
toolResult,
reasoningStart,
reasoningDelta,
reasoningEnd,
finish,
abort,
error,
collectEvents,
} from "./test-helpers";
// Re-export for test files that need to construct agents directly
export {
BuiltInAgent,
type AgentFactoryContext,
type BuiltInAgentFactoryConfig,
};
// ---------------------------------------------------------------------------
// Default input factory
// ---------------------------------------------------------------------------
/**
* Returns a valid `RunAgentInput` with sensible defaults.
* Any field can be overridden via the `overrides` parameter.
*/
export function createDefaultInput(overrides?: Partial<RunAgentInput>) {
return {
threadId: "test-thread",
runId: "test-run",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: {},
...overrides,
} as RunAgentInput;
}
// ---------------------------------------------------------------------------
// AI SDK native human-in-the-loop stream part
// ---------------------------------------------------------------------------
/**
* AI SDK `fullStream` part emitted when a tool declared `needsApproval: true`
* is called. In the real SDK the tool name + args arrive in a preceding
* `tool-call` part; this part only carries the id to pause on.
*/
export function aisdkToolApprovalRequest(toolCallId: string): MockStreamEvent {
return {
type: "tool-approval-request",
toolCallId,
approvalId: `approval-${toolCallId}`,
};
}
// ---------------------------------------------------------------------------
// TanStack mock stream chunk builders
// ---------------------------------------------------------------------------
/** TanStack text content chunk */
export function tanstackTextChunk(delta: string) {
return { type: "TEXT_MESSAGE_CONTENT", delta } as const;
}
/** TanStack tool call start chunk */
export function tanstackToolCallStart(
toolCallId: string,
toolCallName: string,
) {
return { type: "TOOL_CALL_START", toolCallId, toolCallName } as const;
}
/** TanStack tool call args chunk */
export function tanstackToolCallArgs(toolCallId: string, delta: string) {
return { type: "TOOL_CALL_ARGS", toolCallId, delta } as const;
}
/** TanStack tool call end chunk */
export function tanstackToolCallEnd(toolCallId: string) {
return { type: "TOOL_CALL_END", toolCallId } as const;
}
/** TanStack tool-call result chunk. `content` is what the tool's `execute()` returned. */
export function tanstackToolCallResult(toolCallId: string, content: unknown) {
return { type: "TOOL_CALL_RESULT", toolCallId, content } as const;
}
/**
* TanStack native human-in-the-loop chunk: the CUSTOM "approval-requested"
* event emitted by `chat()` when a tool declared `needsApproval: true` is called.
*/
export function tanstackApprovalRequested(
toolCallId: string,
toolName: string,
input?: unknown,
) {
return {
type: "CUSTOM",
name: "approval-requested",
value: {
toolCallId,
toolName,
input,
approval: { id: `approval-${toolCallId}`, needsApproval: true },
},
} as const;
}
/** TanStack reasoning lifecycle chunk builders */
export function tanstackReasoningStart(messageId: string) {
return { type: "REASONING_START", messageId } as const;
}
export function tanstackReasoningMessageStart(messageId: string) {
return {
type: "REASONING_MESSAGE_START",
messageId,
role: "reasoning",
} as const;
}
export function tanstackReasoningMessageContent(
messageId: string,
delta: string,
) {
return { type: "REASONING_MESSAGE_CONTENT", messageId, delta } as const;
}
export function tanstackReasoningMessageEnd(messageId: string) {
return { type: "REASONING_MESSAGE_END", messageId } as const;
}
export function tanstackReasoningEnd(messageId: string) {
return { type: "REASONING_END", messageId } as const;
}
// ---------------------------------------------------------------------------
// Mock async iterable builders
// ---------------------------------------------------------------------------
/**
* Creates an `AsyncIterable<unknown>` from an array of TanStack-style chunks.
*/
export function mockTanStackStream(chunks: Record<string, unknown>[]) {
return {
[Symbol.asyncIterator]: async function* () {
for (const chunk of chunks) {
yield chunk;
}
},
};
}
/**
* Creates an `AsyncIterable<BaseEvent>` from an array of AG-UI events.
*/
export function mockCustomStream(
events: BaseEvent[],
): AsyncIterable<BaseEvent> {
return {
[Symbol.asyncIterator]: async function* () {
for (const event of events) {
yield event;
}
},
};
}
// ---------------------------------------------------------------------------
// BuiltInAgent factories
// ---------------------------------------------------------------------------
export type AgentType = "aisdk" | "tanstack" | "custom";
/**
* Creates a BuiltInAgent backed by a mock factory that yields the given stream data.
*
* Overloaded for each supported agent type:
* - `"aisdk"` expects `MockStreamEvent[]` (AI SDK fullStream events)
* - `"tanstack"` expects `Record<string, unknown>[]` (TanStack chunks)
* - `"custom"` expects `BaseEvent[]` (AG-UI events directly)
*/
export function createAgent(
type: "aisdk",
streamData: MockStreamEvent[],
): BuiltInAgent;
export function createAgent(
type: "tanstack",
streamData: Record<string, unknown>[],
): BuiltInAgent;
export function createAgent(
type: "custom",
streamData: BaseEvent[],
): BuiltInAgent;
export function createAgent(
type: AgentType,
streamData: MockStreamEvent[] | Record<string, unknown>[] | BaseEvent[],
): BuiltInAgent;
export function createAgent(
type: AgentType,
streamData: MockStreamEvent[] | Record<string, unknown>[] | BaseEvent[],
): BuiltInAgent {
switch (type) {
case "aisdk": {
// Cast needed: TypeScript's control-flow narrowing doesn't propagate
// through overload signatures to narrow the union parameter type.
const events = streamData as MockStreamEvent[];
return new BuiltInAgent({
type: "aisdk",
factory: () => ({
fullStream: (async function* () {
for (const event of events) {
yield event;
}
})(),
}),
});
}
case "tanstack": {
// Cast needed: same overload-narrowing limitation as above.
const chunks = streamData as Record<string, unknown>[];
return new BuiltInAgent({
type: "tanstack",
factory: () => mockTanStackStream(chunks),
});
}
case "custom": {
// Cast needed: same overload-narrowing limitation as above.
const events = streamData as BaseEvent[];
return new BuiltInAgent({
type: "custom",
factory: () => mockCustomStream(events),
});
}
}
}
// ---------------------------------------------------------------------------
// Classic (model-based) agent factory with a capturing mock model
// ---------------------------------------------------------------------------
/**
* The element type of the `ReadableStream` returned by a V3 model's `doStream`.
* Derived from `MockLanguageModelV3` so we never need a direct (non-resolvable
* from this package) `@ai-sdk/provider` import.
*/
type V3StreamResult = Awaited<ReturnType<MockLanguageModelV3["doStream"]>>;
type V3StreamPart =
V3StreamResult["stream"] extends ReadableStream<infer P> ? P : never;
type V3FinishPart = Extract<V3StreamPart, { type: "finish" }>;
/** The shape of the options object the model's `doStream` is invoked with. */
type V3CallOptions = MockLanguageModelV3["doStreamCalls"][number];
function toV3FinishReason(value: unknown): V3FinishPart["finishReason"] {
if (value && typeof value === "object" && "unified" in value) {
return value as V3FinishPart["finishReason"];
}
const raw = typeof value === "string" ? value : undefined;
switch (raw) {
case "length":
case "content-filter":
case "tool-calls":
case "error":
case "other":
case "stop":
return { unified: raw, raw };
default:
return { unified: "stop", raw };
}
}
/** A BuiltInAgent augmented with a test-only seam exposing the messages the
* model received on its most recent `doStream` invocation. */
export type ClassicAgentWithCapture = BuiltInAgent & {
/** The `prompt` (provider-level message list) passed to the mock model on its
* most recent `doStream` call, or `undefined` if the model was never run. */
readonly __lastModelMessages: V3CallOptions["prompt"] | undefined;
};
/**
* Translates the existing fullStream-style `MockStreamEvent` chunks (built via
* `textDelta`, `toolCall`, `finish`, …) into the provider-level
* `LanguageModelV3` stream parts that the REAL `streamText` consumes from a
* model's `doStream`.
*
* Only the chunk kinds needed by the classic-path interrupt tests are
* translated; anything else is passed through unchanged so it can be extended
* later without breaking existing callers.
*/
function toV3StreamParts(chunks: MockStreamEvent[]): V3StreamPart[] {
const parts: V3StreamPart[] = [];
let openTextId: string | undefined;
const closeTextIfOpen = () => {
if (openTextId !== undefined) {
parts.push({ type: "text-end", id: openTextId } as V3StreamPart);
openTextId = undefined;
}
};
let autoId = 0;
for (const chunk of chunks) {
switch (chunk.type) {
case "text-start": {
const id = (chunk.id as string | undefined) ?? `text-${autoId++}`;
openTextId = id;
parts.push({ type: "text-start", id } as V3StreamPart);
break;
}
case "text-delta": {
if (openTextId === undefined) {
openTextId = `text-${autoId++}`;
parts.push({ type: "text-start", id: openTextId } as V3StreamPart);
}
parts.push({
type: "text-delta",
id: openTextId,
delta: String(chunk.text ?? ""),
} as V3StreamPart);
break;
}
case "tool-call": {
closeTextIfOpen();
// Provider-level tool calls carry a stringified-JSON `input`; the
// fullStream builder accepts an object, so stringify it here.
const input =
typeof chunk.input === "string"
? chunk.input
: JSON.stringify(chunk.input ?? {});
parts.push({
type: "tool-call",
toolCallId: String(chunk.toolCallId),
toolName: String(chunk.toolName),
input,
} as V3StreamPart);
break;
}
case "finish": {
closeTextIfOpen();
const finishPart: V3FinishPart = {
type: "finish",
finishReason: toV3FinishReason(chunk.finishReason),
usage: {
inputTokens: {
total: 0,
noCache: 0,
cacheRead: 0,
cacheWrite: 0,
},
outputTokens: { total: 0, text: 0, reasoning: 0 },
},
};
parts.push(finishPart);
break;
}
default: {
// Pass through any already-provider-shaped part unchanged.
parts.push(chunk as unknown as V3StreamPart);
}
}
}
closeTextIfOpen();
return parts;
}
/**
* Creates a CLASSIC (model-based) `BuiltInAgent` driven by a mock
* `LanguageModelV3`, wired with the given `tools`.
*
* The mock model surfaces `streamChunks` (the same fullStream-style chunks used
* by the AI-SDK factory tests — `toolCall`, `textDelta`, `finish`, …) through
* the real `streamText` pipeline, and RECORDS the messages it was invoked with.
* Read them via `agent.__lastModelMessages` after running the agent.
*/
export function createClassicAgentWithTools(
streamChunks: MockStreamEvent[],
tools: ToolDefinition[],
): ClassicAgentWithCapture {
const model = new MockLanguageModelV3({
doStream: async () => ({
stream: simulateReadableStream({
chunks: toV3StreamParts(streamChunks),
}),
}),
});
const agent = new BuiltInAgent({ model, tools }) as ClassicAgentWithCapture;
Object.defineProperty(agent, "__lastModelMessages", {
configurable: true,
enumerable: false,
get() {
const calls = model.doStreamCalls;
return calls.length > 0 ? calls[calls.length - 1].prompt : undefined;
},
});
return agent;
}
// ---------------------------------------------------------------------------
// Error agent factories
// ---------------------------------------------------------------------------
/**
* Creates a BuiltInAgent whose factory immediately throws.
*/
export function createThrowingAgent(
type: AgentType,
errorMessage: string,
): BuiltInAgent {
// All three factory signatures accept (ctx) and can throw before returning.
// Since the factory throws, the return type is irrelevant — TypeScript's
// `never` return satisfies all three config shapes.
const thrower = (): never => {
throw new Error(errorMessage);
};
switch (type) {
case "aisdk":
return new BuiltInAgent({ type: "aisdk", factory: thrower });
case "tanstack":
return new BuiltInAgent({ type: "tanstack", factory: thrower });
case "custom":
return new BuiltInAgent({ type: "custom", factory: thrower });
}
}
/**
* Creates a BuiltInAgent that yields one partial event and then throws.
*
* - `"aisdk"`: yields `{ type: "text-delta", text: "partial" }` then throws
* - `"tanstack"`: yields `{ type: "TEXT_MESSAGE_CONTENT", delta: "partial" }` then throws
* - `"custom"`: yields a `TEXT_MESSAGE_CHUNK` BaseEvent then throws
*/
export function createMidStreamErrorAgent(
type: AgentType,
errorMessage: string,
): BuiltInAgent {
switch (type) {
case "aisdk": {
return new BuiltInAgent({
type: "aisdk",
factory: () => ({
fullStream: (async function* () {
yield { type: "text-delta", text: "partial" };
throw new Error(errorMessage);
})(),
}),
});
}
case "tanstack": {
return new BuiltInAgent({
type: "tanstack",
factory: () => ({
[Symbol.asyncIterator]: async function* () {
yield {
type: "TEXT_MESSAGE_CONTENT",
delta: "partial",
};
throw new Error(errorMessage);
},
}),
});
}
case "custom": {
return new BuiltInAgent({
type: "custom",
factory: () => ({
[Symbol.asyncIterator]: async function* () {
yield {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: "partial",
} as const as BaseEvent;
throw new Error(errorMessage);
},
}),
});
}
}
}
// ---------------------------------------------------------------------------
// Event collection utilities
// ---------------------------------------------------------------------------
/**
* Result of collecting events from an observable that may error.
*/
export interface CollectedEventsResult {
events: BaseEvent[];
/** Whether the observable completed via error (true) or normal completion (false) */
errored: boolean;
/** Whether the safety timeout fired (indicates a hung observable) */
timedOut: boolean;
}
/**
* Like `collectEvents` but resolves (instead of rejecting) when the
* observable errors. Returns the events collected up to and including
* the error point, along with whether it errored or completed normally.
*/
export async function collectEventsIncludingErrors(
observable: Observable<BaseEvent>,
): Promise<CollectedEventsResult> {
return new Promise((resolve) => {
const events: BaseEvent[] = [];
const timeoutId = setTimeout(() => {
subscription.unsubscribe();
resolve({ events, errored: false, timedOut: true });
}, 5000);
const subscription = observable.subscribe({
next: (event) => events.push(event),
error: () => {
clearTimeout(timeoutId);
resolve({ events, errored: true, timedOut: false });
},
complete: () => {
clearTimeout(timeoutId);
resolve({ events, errored: false, timedOut: false });
},
});
});
}
// ---------------------------------------------------------------------------
// Typed event field accessors (avoids `as any` casts in tests)
// ---------------------------------------------------------------------------
/**
* Reads a field from a BaseEvent. AG-UI's BaseEvent uses `.passthrough()` so
* extra fields exist at runtime but aren't in the static type. This helper
* provides typed access without casts.
*/
export function eventField<T = unknown>(event: BaseEvent, field: string): T {
return (event as Record<string, unknown>)[field] as T;
}
// ---------------------------------------------------------------------------
// Assertion helpers
// ---------------------------------------------------------------------------
/**
* Asserts that the events are wrapped with RUN_STARTED as the first event
* and RUN_FINISHED as the last event. Optionally checks threadId and runId.
*/
export function expectLifecycleWrapped(
events: BaseEvent[],
threadId?: string,
runId?: string,
): void {
if (events.length < 2) {
throw new Error(
`Expected at least 2 events (RUN_STARTED + RUN_FINISHED), got ${events.length}`,
);
}
const first = events[0];
const last = events[events.length - 1];
if (first.type !== EventType.RUN_STARTED) {
throw new Error(
`Expected first event to be RUN_STARTED, got ${first.type}`,
);
}
if (last.type !== EventType.RUN_FINISHED) {
throw new Error(`Expected last event to be RUN_FINISHED, got ${last.type}`);
}
if (threadId !== undefined) {
if (eventField<string>(first, "threadId") !== threadId) {
throw new Error(
`Expected RUN_STARTED threadId to be "${threadId}", got "${eventField(first, "threadId")}"`,
);
}
if (eventField<string>(last, "threadId") !== threadId) {
throw new Error(
`Expected RUN_FINISHED threadId to be "${threadId}", got "${eventField(last, "threadId")}"`,
);
}
}
if (runId !== undefined) {
if (eventField<string>(first, "runId") !== runId) {
throw new Error(
`Expected RUN_STARTED runId to be "${runId}", got "${eventField(first, "runId")}"`,
);
}
if (eventField<string>(last, "runId") !== runId) {
throw new Error(
`Expected RUN_FINISHED runId to be "${runId}", got "${eventField(last, "runId")}"`,
);
}
}
}
/**
* Asserts that the event types match the expected sequence exactly.
*/
export function expectEventSequence(
events: BaseEvent[],
expectedTypes: EventType[],
): void {
const actualTypes = events.map((e) => e.type);
if (actualTypes.length !== expectedTypes.length) {
throw new Error(
`Event count mismatch: expected ${expectedTypes.length}, got ${actualTypes.length}.\n` +
`Expected: [${expectedTypes.join(", ")}]\n` +
`Actual: [${actualTypes.join(", ")}]`,
);
}
for (let i = 0; i < expectedTypes.length; i++) {
if (actualTypes[i] !== expectedTypes[i]) {
throw new Error(
`Event type mismatch at index ${i}: expected ${expectedTypes[i]}, got ${actualTypes[i]}.\n` +
`Expected: [${expectedTypes.join(", ")}]\n` +
`Actual: [${actualTypes.join(", ")}]`,
);
}
}
}
/**
* Asserts that no content events appear after the specified terminal event type.
*
* "Content events" are everything except RUN_STARTED, RUN_FINISHED, and RUN_ERROR.
*/
export function expectNoEventAfter(
events: BaseEvent[],
terminalType: EventType,
): void {
const terminalIndex = events.findIndex((e) => e.type === terminalType);
if (terminalIndex === -1) {
throw new Error(`Terminal event type ${terminalType} not found in events`);
}
const lifecycleTypes = new Set([
EventType.RUN_STARTED,
EventType.RUN_FINISHED,
EventType.RUN_ERROR,
]);
const eventsAfter = events.slice(terminalIndex + 1);
const contentEventsAfter = eventsAfter.filter(
(e) => !lifecycleTypes.has(e.type),
);
if (contentEventsAfter.length > 0) {
throw new Error(
`Found ${contentEventsAfter.length} content event(s) after ${terminalType}: ` +
`[${contentEventsAfter.map((e) => e.type).join(", ")}]`,
);
}
}
@@ -0,0 +1,987 @@
import { describe, it, expect } from "vitest";
import { EventType } from "@ag-ui/client";
import type {
BaseEvent,
Interrupt,
ResumeEntry,
RunAgentInput,
} from "@ag-ui/client";
import { z } from "zod";
import {
BuiltInAgent,
createDefaultInput,
createAgent,
createThrowingAgent,
createMidStreamErrorAgent,
createClassicAgentWithTools,
collectEvents,
collectEventsIncludingErrors,
expectLifecycleWrapped,
eventField,
textDelta,
finish,
toolCall,
tanstackTextChunk,
tanstackToolCallStart,
tanstackToolCallEnd,
tanstackApprovalRequested,
aisdkToolApprovalRequest,
} from "./agent-test-helpers";
import type {
AgentFactoryContext,
BuiltInAgentFactoryConfig,
AgentType,
MockStreamEvent,
} from "./agent-test-helpers";
// ---------------------------------------------------------------------------
// Local helpers for parameterized tests
// ---------------------------------------------------------------------------
const allTypes: AgentType[] = ["aisdk", "tanstack", "custom"];
function minimalStreamData(
type: AgentType,
): MockStreamEvent[] | Record<string, unknown>[] | BaseEvent[] {
switch (type) {
case "aisdk":
return [textDelta("hi"), finish()];
case "tanstack":
return [tanstackTextChunk("hi")];
case "custom":
return [
{
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: "hi",
} as BaseEvent,
];
}
}
function emptyStreamData(
type: AgentType,
): MockStreamEvent[] | Record<string, unknown>[] | BaseEvent[] {
switch (type) {
case "aisdk":
return [finish()];
case "tanstack":
return [];
case "custom":
return [];
}
}
// ---------------------------------------------------------------------------
// Parameterized test suites
// ---------------------------------------------------------------------------
describe.each(allTypes)("Agent [%s]", (type) => {
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
describe("lifecycle", () => {
it("emits RUN_STARTED as the first event with correct threadId/runId", async () => {
const agent = createAgent(type, minimalStreamData(type));
const input = createDefaultInput({ threadId: "t1", runId: "r1" });
const events = await collectEvents(agent.run(input));
expect(events.length).toBeGreaterThanOrEqual(2);
const first = events[0];
expect(first.type).toBe(EventType.RUN_STARTED);
expect(eventField<string>(first, "threadId")).toBe("t1");
expect(eventField<string>(first, "runId")).toBe("r1");
});
it("emits RUN_FINISHED as the last event with correct threadId/runId", async () => {
const agent = createAgent(type, minimalStreamData(type));
const input = createDefaultInput({ threadId: "t2", runId: "r2" });
const events = await collectEvents(agent.run(input));
const last = events[events.length - 1];
expect(last.type).toBe(EventType.RUN_FINISHED);
expect(eventField<string>(last, "threadId")).toBe("t2");
expect(eventField<string>(last, "runId")).toBe("r2");
});
it("emits RUN_FINISHED for an empty stream", async () => {
const agent = createAgent(type, emptyStreamData(type));
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expect(events.length).toBeGreaterThanOrEqual(2);
expect(events[0].type).toBe(EventType.RUN_STARTED);
expect(events[events.length - 1].type).toBe(EventType.RUN_FINISHED);
});
it("wraps content with lifecycle events", async () => {
const agent = createAgent(type, minimalStreamData(type));
const input = createDefaultInput({ threadId: "wrap-t", runId: "wrap-r" });
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events, "wrap-t", "wrap-r");
// There should be content events between the lifecycle bookends
const contentEvents = events.slice(1, -1);
expect(contentEvents.length).toBeGreaterThan(0);
for (const e of contentEvents) {
expect(e.type).not.toBe(EventType.RUN_STARTED);
expect(e.type).not.toBe(EventType.RUN_FINISHED);
}
});
});
// -------------------------------------------------------------------------
// RUN_ERROR
// -------------------------------------------------------------------------
describe("RUN_ERROR", () => {
it("emits RUN_ERROR when factory throws", async () => {
const agent = createThrowingAgent(type, "factory-boom");
const input = createDefaultInput();
const { events, errored } = await collectEventsIncludingErrors(
agent.run(input),
);
expect(errored).toBe(true);
const errorEvents = events.filter((e) => e.type === EventType.RUN_ERROR);
expect(errorEvents.length).toBe(1);
expect(eventField<string>(errorEvents[0], "message")).toBe(
"factory-boom",
);
});
it("emits RUN_ERROR when stream throws mid-iteration", async () => {
const agent = createMidStreamErrorAgent(type, "mid-stream-boom");
const input = createDefaultInput();
const { events, errored } = await collectEventsIncludingErrors(
agent.run(input),
);
expect(errored).toBe(true);
const errorEvents = events.filter((e) => e.type === EventType.RUN_ERROR);
expect(errorEvents.length).toBe(1);
expect(eventField<string>(errorEvents[0], "message")).toBe(
"mid-stream-boom",
);
});
it("does not emit RUN_FINISHED after RUN_ERROR", async () => {
const agent = createThrowingAgent(type, "no-finish");
const input = createDefaultInput();
const { events } = await collectEventsIncludingErrors(agent.run(input));
const errorIdx = events.findIndex((e) => e.type === EventType.RUN_ERROR);
expect(errorIdx).toBeGreaterThanOrEqual(0);
const eventsAfterError = events.slice(errorIdx + 1);
const finishAfterError = eventsAfterError.filter(
(e) => e.type === EventType.RUN_FINISHED,
);
expect(finishAfterError.length).toBe(0);
});
});
// -------------------------------------------------------------------------
// Abort
// -------------------------------------------------------------------------
describe("abort", () => {
it("completes without error after abortRun()", async () => {
// Use a signal to synchronize: abort after the first chunk is emitted
let emittedFirstChunk: () => void;
const firstChunkEmitted = new Promise<void>(
(r) => (emittedFirstChunk = r),
);
let config: BuiltInAgentFactoryConfig;
switch (type) {
case "aisdk":
config = {
type: "aisdk",
factory: ({ abortSignal }: AgentFactoryContext) => ({
fullStream: (async function* () {
yield { type: "text-delta", text: "tick" };
emittedFirstChunk();
// Wait for abort — use a promise that resolves on abort
await new Promise<void>((r) => {
if (abortSignal.aborted) return r();
abortSignal.addEventListener("abort", () => r(), {
once: true,
});
});
})(),
}),
};
break;
case "tanstack":
config = {
type: "tanstack",
factory: ({ abortSignal }: AgentFactoryContext) => ({
[Symbol.asyncIterator]: async function* () {
yield { type: "TEXT_MESSAGE_CONTENT", delta: "tick" };
emittedFirstChunk();
await new Promise<void>((r) => {
if (abortSignal.aborted) return r();
abortSignal.addEventListener("abort", () => r(), {
once: true,
});
});
},
}),
};
break;
case "custom":
config = {
type: "custom",
factory: ({ abortSignal }: AgentFactoryContext) => ({
[Symbol.asyncIterator]: async function* () {
yield {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: "tick",
} as BaseEvent;
emittedFirstChunk();
await new Promise<void>((r) => {
if (abortSignal.aborted) return r();
abortSignal.addEventListener("abort", () => r(), {
once: true,
});
});
},
}),
};
break;
}
const agent = new BuiltInAgent(config);
const input = createDefaultInput();
const completed = await new Promise<boolean>((resolve) => {
agent.run(input).subscribe({
next: () => {},
error: () => resolve(false),
complete: () => resolve(true),
});
// Wait for the first chunk to be emitted, then abort
firstChunkEmitted.then(() => agent.abortRun());
});
expect(completed).toBe(true);
});
});
// -------------------------------------------------------------------------
// Factory Context
// -------------------------------------------------------------------------
describe("factory context", () => {
it("receives correct input with threadId, runId, and forwardedProps", async () => {
let capturedCtx: AgentFactoryContext | null = null;
let config: BuiltInAgentFactoryConfig;
switch (type) {
case "aisdk":
config = {
type: "aisdk",
factory: (ctx: AgentFactoryContext) => {
capturedCtx = ctx;
return {
fullStream: (async function* () {
yield { type: "finish", finishReason: "stop" };
})(),
};
},
};
break;
case "tanstack":
config = {
type: "tanstack",
factory: (ctx: AgentFactoryContext) => {
capturedCtx = ctx;
return (async function* () {
// empty stream
})();
},
};
break;
case "custom":
config = {
type: "custom",
factory: (ctx: AgentFactoryContext) => {
capturedCtx = ctx;
return (async function* () {
// empty stream
})();
},
};
break;
}
const agent = new BuiltInAgent(config);
const input = createDefaultInput({
threadId: "ctx-thread",
runId: "ctx-run",
forwardedProps: { model: "gpt-4" },
});
await collectEvents(agent.run(input));
expect(capturedCtx).not.toBeNull();
expect(capturedCtx!.input.threadId).toBe("ctx-thread");
expect(capturedCtx!.input.runId).toBe("ctx-run");
expect(capturedCtx!.input.forwardedProps).toEqual({ model: "gpt-4" });
});
it("receives abortController and abortSignal", async () => {
let capturedCtx: AgentFactoryContext | null = null;
let config: BuiltInAgentFactoryConfig;
switch (type) {
case "aisdk":
config = {
type: "aisdk",
factory: (ctx: AgentFactoryContext) => {
capturedCtx = ctx;
return {
fullStream: (async function* () {
yield { type: "finish", finishReason: "stop" };
})(),
};
},
};
break;
case "tanstack":
config = {
type: "tanstack",
factory: (ctx: AgentFactoryContext) => {
capturedCtx = ctx;
return (async function* () {
// empty
})();
},
};
break;
case "custom":
config = {
type: "custom",
factory: (ctx: AgentFactoryContext) => {
capturedCtx = ctx;
return (async function* () {
// empty
})();
},
};
break;
}
const agent = new BuiltInAgent(config);
const input = createDefaultInput();
await collectEvents(agent.run(input));
expect(capturedCtx!.abortController).toBeInstanceOf(AbortController);
expect(capturedCtx!.abortSignal).toBe(
capturedCtx!.abortController.signal,
);
});
});
// -------------------------------------------------------------------------
// clone()
// -------------------------------------------------------------------------
describe("clone()", () => {
it("returns a new Agent instance (not the same reference)", () => {
const agent = createAgent(type, minimalStreamData(type));
const cloned = agent.clone();
expect(cloned).toBeInstanceOf(BuiltInAgent);
expect(cloned).not.toBe(agent);
});
it("produces correct lifecycle events from a cloned agent", async () => {
const agent = createAgent(type, minimalStreamData(type));
const cloned = agent.clone();
const input = createDefaultInput({
threadId: "clone-t",
runId: "clone-r",
});
const events = await collectEvents(cloned.run(input));
expectLifecycleWrapped(events, "clone-t", "clone-r");
});
});
});
// ---------------------------------------------------------------------------
// Type Discrimination (NOT parameterized)
// ---------------------------------------------------------------------------
describe("Agent type discrimination", () => {
it('"aisdk" routes to AI SDK converter and produces text content', async () => {
const agent = createAgent("aisdk", [
textDelta("hello from aisdk"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents.length).toBe(1);
expect(eventField<string>(textEvents[0], "delta")).toBe("hello from aisdk");
});
it('"tanstack" routes to TanStack converter and produces text content', async () => {
const agent = createAgent("tanstack", [
tanstackTextChunk("hello from tanstack"),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents.length).toBe(1);
expect(eventField<string>(textEvents[0], "delta")).toBe(
"hello from tanstack",
);
});
it('"custom" forwards events directly without conversion', async () => {
const customEvent: BaseEvent = {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: "hello from custom",
} as BaseEvent;
const agent = createAgent("custom", [customEvent]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents.length).toBe(1);
expect(eventField<string>(textEvents[0], "delta")).toBe(
"hello from custom",
);
});
});
// ---------------------------------------------------------------------------
// Async Factory (Promise-returning)
// ---------------------------------------------------------------------------
describe("Async factory (Promise-returning)", () => {
it("aisdk: async factory resolves and streams correctly", async () => {
const agent = new BuiltInAgent({
type: "aisdk",
factory: async () => {
// Simulate async setup (e.g., fetching API key)
await new Promise((r) => setTimeout(r, 5));
return {
fullStream: (async function* () {
yield { type: "text-delta", text: "async-aisdk" };
yield { type: "finish", finishReason: "stop" };
})(),
};
},
});
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents).toHaveLength(1);
expect(eventField<string>(textEvents[0], "delta")).toBe("async-aisdk");
});
it("tanstack: async factory resolves and streams correctly", async () => {
const agent = new BuiltInAgent({
type: "tanstack",
factory: async () => {
await new Promise((r) => setTimeout(r, 5));
return (async function* () {
yield { type: "TEXT_MESSAGE_CONTENT", delta: "async-tanstack" };
})();
},
});
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents).toHaveLength(1);
expect(eventField<string>(textEvents[0], "delta")).toBe("async-tanstack");
});
it("custom: async factory resolves and streams correctly", async () => {
const agent = new BuiltInAgent({
type: "custom",
factory: async () => {
await new Promise((r) => setTimeout(r, 5));
return (async function* () {
yield {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: "async-custom",
} as BaseEvent;
})();
},
});
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents).toHaveLength(1);
expect(eventField<string>(textEvents[0], "delta")).toBe("async-custom");
});
});
// ---------------------------------------------------------------------------
// RUN_ERROR includes threadId and runId
// ---------------------------------------------------------------------------
describe("RUN_ERROR correlation fields", () => {
it("RUN_ERROR includes threadId and runId for run correlation", async () => {
const agent = new BuiltInAgent({
type: "aisdk",
factory: () => {
throw new Error("test-error");
},
});
const input = createDefaultInput({
threadId: "err-thread",
runId: "err-run",
});
const { events, errored } = await collectEventsIncludingErrors(
agent.run(input),
);
expect(errored).toBe(true);
const errorEvents = events.filter((e) => e.type === EventType.RUN_ERROR);
expect(errorEvents).toHaveLength(1);
expect(eventField<string>(errorEvents[0], "threadId")).toBe("err-thread");
expect(eventField<string>(errorEvents[0], "runId")).toBe("err-run");
});
});
// ---------------------------------------------------------------------------
// Concurrent run guard
// ---------------------------------------------------------------------------
describe("Concurrent run guard", () => {
it("throws when run() is called while another run is in progress", async () => {
let resolveFactory: () => void;
const factoryBlocked = new Promise<void>((r) => (resolveFactory = r));
const agent = new BuiltInAgent({
type: "custom",
factory: async function* ({ abortSignal }) {
// Block until resolved externally
await new Promise<void>((r) => {
if (abortSignal.aborted) return r();
abortSignal.addEventListener("abort", () => r(), { once: true });
factoryBlocked.then(() => r());
});
},
});
const input = createDefaultInput();
// Start first run — abortController is now set synchronously in run()
const sub = agent.run(input).subscribe({ next: () => {} });
// Second run should throw immediately (no timing dependency)
expect(() => agent.run(input)).toThrow("Agent is already running");
// Cleanup
resolveFactory!();
sub.unsubscribe();
});
});
// ---------------------------------------------------------------------------
// BuiltInAgent factory interrupt() primitive
// ---------------------------------------------------------------------------
describe("BuiltInAgent factory interrupt() primitive", () => {
const INT: Interrupt = {
id: "int-1",
reason: "confirmation",
message: "Approve?",
};
function makeCustomInterruptAgent() {
return new BuiltInAgent({
type: "custom",
factory: async function* (ctx) {
const responses = await ctx.interrupt([INT]); // pauses on fresh run
// resume run continues here:
yield {
type: EventType.TEXT_MESSAGE_START,
messageId: "m1",
role: "assistant",
} as any;
yield {
type: EventType.TEXT_MESSAGE_CONTENT,
messageId: "m1",
delta: `resolved:${JSON.stringify(responses)}`,
} as any;
yield { type: EventType.TEXT_MESSAGE_END, messageId: "m1" } as any;
},
});
}
it("emits RUN_FINISHED with outcome:interrupt on a fresh run", async () => {
const agent = makeCustomInterruptAgent();
const events = await collectEvents(agent.run(createDefaultInput()));
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
) as any;
expect(finished).toBeDefined();
expect(finished.outcome).toEqual({ type: "interrupt", interrupts: [INT] });
// No text emitted on the paused run:
expect(events.some((e) => e.type === EventType.TEXT_MESSAGE_CONTENT)).toBe(
false,
);
});
it("continues past interrupt() on a resume run, returning resume payloads", async () => {
const agent = makeCustomInterruptAgent();
const resume: ResumeEntry[] = [
{ interruptId: "int-1", status: "resolved", payload: { ok: true } },
];
const events = await collectEvents(
agent.run(createDefaultInput({ resume })),
);
const content = events.find(
(e) => e.type === EventType.TEXT_MESSAGE_CONTENT,
) as any;
expect(content.delta).toContain("resolved:");
expect(content.delta).toContain('"ok":true');
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
) as any;
expect(finished.outcome).toBeUndefined(); // normal completion
});
});
// ---------------------------------------------------------------------------
// BuiltInAgent classic interrupt tools
// ---------------------------------------------------------------------------
describe("BuiltInAgent classic interrupt tools", () => {
const interruptTool = {
name: "confirm_action",
description: "Ask the human to confirm",
parameters: z.object({ summary: z.string() }),
interrupt: true as const,
interruptReason: "confirmation",
interruptMessage: "Please confirm",
};
it("pauses + emits outcome:interrupt (keyed by toolCallId), withholding the tool result", async () => {
// mock stream: assistant calls the interrupt tool, then 'finish'
const agent = createClassicAgentWithTools(
[toolCall("tc-1", "confirm_action", { summary: "delete X" }), finish()],
[interruptTool],
);
const events = await collectEvents(
agent.run(
createDefaultInput({
messages: [{ id: "u1", role: "user", content: "do it" }] as any,
}),
),
);
expect(events.some((e) => e.type === EventType.TOOL_CALL_START)).toBe(true);
expect(events.some((e) => e.type === EventType.TOOL_CALL_END)).toBe(true);
// result withheld:
expect(events.some((e) => e.type === EventType.TOOL_CALL_RESULT)).toBe(
false,
);
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
) as any;
expect(finished.outcome.type).toBe("interrupt");
expect(finished.outcome.interrupts[0]).toMatchObject({
id: "tc-1",
toolCallId: "tc-1",
reason: "confirmation",
message: "Please confirm",
});
});
it("on resume, injects the resume payload as the interrupt tool's result and continues", async () => {
// Resume run: the model just replies after the injected tool result.
const agent = createClassicAgentWithTools(
[textDelta("done"), finish()],
[interruptTool],
);
const input = createDefaultInput({
resume: [
{
interruptId: "tc-1",
status: "resolved",
payload: { approved: true },
},
],
messages: [
{ id: "u1", role: "user", content: "do it" },
{
id: "a1",
role: "assistant",
content: "",
toolCalls: [
{
id: "tc-1",
type: "function",
function: {
name: "confirm_action",
arguments: '{"summary":"delete X"}',
},
},
],
},
] as any,
});
const events = await collectEvents(agent.run(input));
// The run completes normally (no interrupt outcome on the resume run):
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
) as any;
expect(finished.outcome).toBeUndefined();
// The synthesized tool result reached the model: a tool-role message
// addressing tc-1 with the resume payload must be present.
expect(agent.__lastModelMessages).toEqual(
expect.arrayContaining([expect.objectContaining({ role: "tool" })]),
);
});
});
// ---------------------------------------------------------------------------
// BuiltInAgent factory-mode NATIVE approval interrupts (aisdk + tanstack)
//
// A tool declared with the SDK's native `needsApproval: true` pauses the run:
// AI SDK emits a `tool-approval-request` fullStream part; TanStack emits a
// CUSTOM `approval-requested` chunk. Both surface as RUN_FINISHED
// outcome:interrupt, and on resume the payload is injected as that tool call's
// native tool-result so the factory continues.
// ---------------------------------------------------------------------------
describe("BuiltInAgent factory native approval interrupts", () => {
it("aisdk: tool-approval-request → outcome:interrupt keyed by toolCallId", async () => {
const agent = createAgent("aisdk", [
toolCall("tc-1", "bookFlight", { dest: "NRT" }),
aisdkToolApprovalRequest("tc-1"),
finish(),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
// The tool call is surfaced so the client knows what it's approving.
expect(events.some((e) => e.type === EventType.TOOL_CALL_START)).toBe(true);
expect(events.some((e) => e.type === EventType.TOOL_CALL_END)).toBe(true);
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
) as any;
expect(finished.outcome.type).toBe("interrupt");
expect(finished.outcome.interrupts[0]).toMatchObject({
id: "tc-1",
toolCallId: "tc-1",
});
});
it("aisdk: multiple needsApproval tool calls → one interrupt each", async () => {
const agent = createAgent("aisdk", [
toolCall("tc-1", "bookFlight", { dest: "NRT" }),
aisdkToolApprovalRequest("tc-1"),
toolCall("tc-2", "bookFlight", { dest: "CDG" }),
aisdkToolApprovalRequest("tc-2"),
finish(),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
) as any;
expect(finished.outcome.type).toBe("interrupt");
expect(finished.outcome.interrupts.map((i: any) => i.toolCallId)).toEqual([
"tc-1",
"tc-2",
]);
});
it("multiple resume entries each inject their own tool-role message", async () => {
let captured: RunAgentInput | undefined;
const agent = new BuiltInAgent({
type: "aisdk",
factory: (ctx) => {
captured = ctx.input;
return {
fullStream: (async function* () {
yield textDelta("done");
yield finish();
})(),
};
},
});
const resume: ResumeEntry[] = [
{ interruptId: "tc-1", status: "resolved", payload: { approved: true } },
{ interruptId: "tc-2", status: "cancelled" },
];
await collectEvents(agent.run(createDefaultInput({ resume })));
const toolMsgs = captured!.messages.filter(
(m) => m.role === "tool",
) as any[];
expect(toolMsgs.map((m) => m.toolCallId)).toEqual(["tc-1", "tc-2"]);
expect(toolMsgs[0].content).toContain("approved");
expect(toolMsgs[1].content).toContain("cancelled");
});
it("resume injection is idempotent — does not double-answer a tool call the client already recorded", async () => {
// The client (useInterrupt) persists the resolution as a tool message so the
// thread stays well-formed across turns. The runtime must NOT then inject a
// second tool-result for the same toolCallId.
let captured: RunAgentInput | undefined;
const agent = new BuiltInAgent({
type: "aisdk",
factory: (ctx) => {
captured = ctx.input;
return {
fullStream: (async function* () {
yield textDelta("ok");
yield finish();
})(),
};
},
});
const resume: ResumeEntry[] = [
{ interruptId: "tc-1", status: "resolved", payload: { approved: true } },
];
await collectEvents(
agent.run(
createDefaultInput({
resume,
messages: [
{ id: "u1", role: "user", content: "go" },
{
id: "a1",
role: "assistant",
content: "",
toolCalls: [
{
id: "tc-1",
type: "function",
function: { name: "bookFlight", arguments: "{}" },
},
],
},
{
id: "t1",
role: "tool",
toolCallId: "tc-1",
content: JSON.stringify({ approved: true }),
},
] as RunAgentInput["messages"],
}),
),
);
const toolMsgs = captured!.messages.filter(
(m) => m.role === "tool" && (m as any).toolCallId === "tc-1",
);
expect(toolMsgs).toHaveLength(1);
});
it("tanstack: CUSTOM approval-requested (even after RUN_FINISHED) → outcome:interrupt", async () => {
// The approval chunk is built from the finish event and can arrive AFTER
// RUN_FINISHED — assert it's still captured (ordering-robust).
const agent = createAgent("tanstack", [
tanstackToolCallStart("tc-9", "bookFlight"),
tanstackToolCallEnd("tc-9"),
{ type: "RUN_FINISHED" },
tanstackApprovalRequested("tc-9", "bookFlight", { dest: "NRT" }),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
) as any;
expect(finished.outcome.type).toBe("interrupt");
expect(finished.outcome.interrupts[0]).toMatchObject({
id: "tc-9",
toolCallId: "tc-9",
message: 'Approve "bookFlight"?',
});
});
it("aisdk: resume injects the payload as a tool-role message the factory sees", async () => {
let captured: RunAgentInput | undefined;
const agent = new BuiltInAgent({
type: "aisdk",
factory: (ctx) => {
captured = ctx.input;
return {
fullStream: (async function* () {
yield textDelta("booked");
yield finish();
})(),
};
},
});
const resume: ResumeEntry[] = [
{ interruptId: "tc-1", status: "resolved", payload: { approved: true } },
];
const events = await collectEvents(
agent.run(createDefaultInput({ resume })),
);
// Normal completion on the resume run (no re-interrupt).
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
) as any;
expect(finished.outcome).toBeUndefined();
const toolMsg = captured!.messages.find(
(m) => m.role === "tool" && (m as any).toolCallId === "tc-1",
) as any;
expect(toolMsg).toBeDefined();
expect(toolMsg.content).toContain("approved");
});
it("tanstack: cancelled resume injects a cancellation tool-role message", async () => {
let captured: RunAgentInput | undefined;
const agent = new BuiltInAgent({
type: "tanstack",
factory: (ctx) => {
captured = ctx.input;
return {
[Symbol.asyncIterator]: async function* () {
yield tanstackTextChunk("ok");
},
};
},
});
const resume: ResumeEntry[] = [
{ interruptId: "tc-9", status: "cancelled" },
];
await collectEvents(agent.run(createDefaultInput({ resume })));
const toolMsg = captured!.messages.find(
(m) => m.role === "tool" && (m as any).toolCallId === "tc-9",
) as any;
expect(toolMsg).toBeDefined();
expect(toolMsg.content).toContain("cancelled");
});
});
@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { BuiltInAgent } from "../index";
import { EventType, type RunAgentInput } from "@ag-ui/client";
import { streamText } from "ai";
import {
mockStreamTextResponse,
textDelta,
finish,
collectEvents,
} from "./test-helpers";
// Mock the ai module
vi.mock("ai", () => ({
streamText: vi.fn(),
tool: vi.fn((config) => config),
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
}));
// Mock SDK providers (not used directly, but resolveModel imports them)
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
specificationVersion: "v3",
modelId,
provider: "openai",
})),
}));
vi.mock("@ai-sdk/anthropic", () => ({
createAnthropic: vi.fn(() => (modelId: string) => ({
specificationVersion: "v3",
modelId,
provider: "anthropic",
})),
}));
vi.mock("@ai-sdk/google", () => ({
createGoogleGenerativeAI: vi.fn(() => (modelId: string) => ({
specificationVersion: "v3",
modelId,
provider: "google",
})),
}));
describe("AI SDK v6 Compatibility", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
});
afterEach(() => {
process.env = originalEnv;
});
it("should accept a LanguageModelV3 instance (specificationVersion 'v3')", async () => {
// Simulate what @ai-sdk/openai@^3 (AI SDK v6) returns:
// a model object with specificationVersion: "v3"
const v3Model = {
specificationVersion: "v3" as const,
modelId: "gpt-4o-mini",
provider: "openai",
supportedUrls: {},
doGenerate: vi.fn(),
doStream: vi.fn(),
};
// After upgrading to ai@^6, LanguageModel = string | LanguageModelV2 | LanguageModelV3.
// No 'as any' cast needed — the type accepts V3 models natively.
const agent = new BuiltInAgent({
model: v3Model,
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
textDelta("Hello from V3 model"),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread-v3",
runId: "run-v3",
messages: [{ id: "1", role: "user", content: "Hi" }],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
// Verify the model was passed through to streamText
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.model).toBe(v3Model);
// Verify normal event emission still works
expect(events[0]).toMatchObject({
type: EventType.RUN_STARTED,
threadId: "thread-v3",
runId: "run-v3",
});
const textEvents = events.filter(
(e: any) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents).toHaveLength(1);
expect(textEvents[0]).toMatchObject({
delta: "Hello from V3 model",
});
expect(events[events.length - 1]).toMatchObject({
type: EventType.RUN_FINISHED,
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
import { describe, it, expect } from "vitest";
import { BuiltInAgent } from "../index";
describe("BuiltInAgent.getCapabilities", () => {
it("should return default inferred capabilities", async () => {
const agent = new BuiltInAgent({
model: "openai/gpt-5.5",
});
const capabilities = await agent.getCapabilities();
expect(capabilities).toEqual({
tools: {
supported: true,
clientProvided: true,
},
transport: {
streaming: true,
},
humanInTheLoop: {
interrupts: true,
},
});
});
it("should merge explicit overrides with inferred defaults", async () => {
const agent = new BuiltInAgent({
model: "openai/gpt-5.5",
capabilities: {
reasoning: {
supported: true,
streaming: true,
},
identity: {
name: "my-agent",
type: "custom",
},
},
});
const capabilities = await agent.getCapabilities();
expect(capabilities).toEqual({
tools: {
supported: true,
clientProvided: true,
},
transport: {
streaming: true,
},
humanInTheLoop: {
interrupts: true,
},
reasoning: {
supported: true,
streaming: true,
},
identity: {
name: "my-agent",
type: "custom",
},
});
});
it("should allow overrides to replace entire categories", async () => {
const agent = new BuiltInAgent({
model: "openai/gpt-5.5",
capabilities: {
tools: {
supported: true,
clientProvided: true,
parallelCalls: true,
},
},
});
const capabilities = await agent.getCapabilities();
expect(capabilities.tools).toEqual({
supported: true,
clientProvided: true,
parallelCalls: true,
});
// transport still inferred
expect(capabilities.transport).toEqual({ streaming: true });
});
});
@@ -0,0 +1,519 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { z } from "zod";
import { BasicAgent, defineTool } from "../index";
import { EventType } from "@ag-ui/client";
import type { RunAgentInput } from "@ag-ui/client";
import { streamText } from "ai";
import {
mockStreamTextResponse,
toolCallStreamingStart,
toolCall,
toolResult,
finish,
collectEvents,
} from "./test-helpers";
// Mock the ai module
vi.mock("ai", () => ({
streamText: vi.fn(),
tool: vi.fn((config) => config),
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
}));
// Mock the SDK clients
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
modelId,
provider: "openai",
})),
}));
describe("Config Tools Server-Side Execution", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
});
afterEach(() => {
process.env = originalEnv;
});
describe("Tool Definition with Execute", () => {
it("should pass execute function to streamText tools", async () => {
const executeFn = vi.fn().mockResolvedValue({ result: "executed" });
const weatherTool = defineTool({
name: "getWeather",
description: "Get weather for a city",
parameters: z.object({
city: z.string().describe("The city name"),
}),
execute: executeFn,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
tools: [weatherTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
// Verify streamText was called with tools that have execute functions
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("getWeather");
expect(callArgs.tools.getWeather).toHaveProperty("execute");
expect(typeof callArgs.tools.getWeather.execute).toBe("function");
});
it("should include all tool properties in the Vercel AI SDK tool", async () => {
const executeFn = vi.fn().mockResolvedValue({ temperature: 72 });
const weatherTool = defineTool({
name: "getWeather",
description: "Get weather for a city",
parameters: z.object({
city: z.string(),
units: z.enum(["celsius", "fahrenheit"]).optional(),
}),
execute: executeFn,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
tools: [weatherTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
const tool = callArgs.tools.getWeather;
expect(tool.description).toBe("Get weather for a city");
expect(tool.inputSchema).toBeDefined();
expect(tool.execute).toBe(executeFn);
});
it("should handle multiple config tools with execute functions", async () => {
const weatherExecute = vi.fn().mockResolvedValue({ temp: 72 });
const searchExecute = vi.fn().mockResolvedValue({ results: [] });
const weatherTool = defineTool({
name: "getWeather",
description: "Get weather",
parameters: z.object({ city: z.string() }),
execute: weatherExecute,
});
const searchTool = defineTool({
name: "search",
description: "Search the web",
parameters: z.object({ query: z.string() }),
execute: searchExecute,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
tools: [weatherTool, searchTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools.getWeather.execute).toBe(weatherExecute);
expect(callArgs.tools.search.execute).toBe(searchExecute);
});
});
describe("Config Tools vs Input Tools", () => {
it("config tools should have execute, input tools should not", async () => {
const configExecute = vi.fn().mockResolvedValue({ result: "server" });
const configTool = defineTool({
name: "serverTool",
description: "Runs on server",
parameters: z.object({ data: z.string() }),
execute: configExecute,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
tools: [configTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [
{
name: "clientTool",
description: "Runs on client",
parameters: {
type: "object",
properties: { input: { type: "string" } },
},
},
],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
// Config tool has execute
expect(callArgs.tools.serverTool.execute).toBe(configExecute);
// Input tool does NOT have execute (client-side execution)
expect(callArgs.tools.clientTool.execute).toBeUndefined();
});
});
describe("Execute Function Invocation", () => {
it("execute function can be called with correct arguments", async () => {
const executeFn = vi
.fn()
.mockResolvedValue({ weather: "sunny", temp: 72 });
const weatherTool = defineTool({
name: "getWeather",
description: "Get weather",
parameters: z.object({
city: z.string(),
units: z.enum(["celsius", "fahrenheit"]),
}),
execute: executeFn,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
tools: [weatherTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
// Get the execute function that was passed to streamText
const callArgs = vi.mocked(streamText).mock.calls[0][0];
const passedExecute = callArgs.tools.getWeather.execute;
// Manually invoke it to verify it works correctly
const result = await passedExecute(
{ city: "New York", units: "fahrenheit" },
{ toolCallId: "tool-call-1", messages: [] },
);
expect(executeFn).toHaveBeenCalledWith(
{ city: "New York", units: "fahrenheit" },
{ toolCallId: "tool-call-1", messages: [] },
);
expect(result).toEqual({ weather: "sunny", temp: 72 });
});
it("execute function errors are propagated", async () => {
const executeFn = vi.fn().mockRejectedValue(new Error("API unavailable"));
const failingTool = defineTool({
name: "failingTool",
description: "A tool that fails",
parameters: z.object({}),
execute: executeFn,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
tools: [failingTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
const passedExecute = callArgs.tools.failingTool.execute;
await expect(
passedExecute({}, { toolCallId: "tool-call-1", messages: [] }),
).rejects.toThrow("API unavailable");
});
});
describe("Built-in State Tools Still Work", () => {
it("AGUISendStateSnapshot should have execute alongside config tools", async () => {
const configExecute = vi.fn().mockResolvedValue({});
const configTool = defineTool({
name: "myTool",
description: "My tool",
parameters: z.object({}),
execute: configExecute,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
tools: [configTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: { value: 1 },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
// Both config tool and state tools should have execute
expect(callArgs.tools.myTool.execute).toBe(configExecute);
expect(callArgs.tools.AGUISendStateSnapshot.execute).toBeDefined();
expect(callArgs.tools.AGUISendStateDelta.execute).toBeDefined();
});
});
describe("Message ID Generation", () => {
it("should use messageId from text-start event", async () => {
const executeFn = vi.fn().mockResolvedValue({ result: "ok" });
const tool = defineTool({
name: "myTool",
description: "My tool",
parameters: z.object({}),
execute: executeFn,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
tools: [tool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
{ type: "text-start", id: "msg-1" },
{ type: "text-delta", text: "Before " },
{ type: "text-delta", text: "tool" },
toolCallStreamingStart("call1", "myTool"),
toolCall("call1", "myTool"),
toolResult("call1", "myTool", { result: "ok" }),
{ type: "text-start", id: "msg-2" },
{ type: "text-delta", text: "After " },
{ type: "text-delta", text: "tool" },
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
const textEvents = events.filter(
(e: any) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
// First two text chunks should have messageId from first text-start
expect(textEvents[0].messageId).toBe("msg-1");
expect(textEvents[1].messageId).toBe("msg-1");
// After tool result, text chunks should have messageId from second text-start
expect(textEvents[2].messageId).toBe("msg-2");
expect(textEvents[3].messageId).toBe("msg-2");
});
});
describe("Multi-Step Execution (maxSteps)", () => {
it("should pass stopWhen with stepCountIs when maxSteps is configured", async () => {
const executeFn = vi.fn().mockResolvedValue({ result: "ok" });
const tool = defineTool({
name: "myTool",
description: "My tool",
parameters: z.object({}),
execute: executeFn,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
maxSteps: 5,
tools: [tool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
// stopWhen should be set with stepCountIs(5)
expect(callArgs.stopWhen).toEqual({ type: "stepCount", count: 5 });
});
it("should not set stopWhen when maxSteps is not configured", async () => {
const executeFn = vi.fn().mockResolvedValue({ result: "ok" });
const tool = defineTool({
name: "myTool",
description: "My tool",
parameters: z.object({}),
execute: executeFn,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
// maxSteps not set
tools: [tool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
// stopWhen should be undefined (defaults to stepCountIs(1) in SDK)
expect(callArgs.stopWhen).toBeUndefined();
});
it("should allow high maxSteps for complex tool chains", async () => {
const executeFn = vi.fn().mockResolvedValue({});
const tool = defineTool({
name: "chainTool",
description: "Tool for chaining",
parameters: z.object({}),
execute: executeFn,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
maxSteps: 10,
tools: [tool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.stopWhen).toEqual({ type: "stepCount", count: 10 });
});
});
});
@@ -0,0 +1,692 @@
import { describe, it, expect } from "vitest";
import { EventType } from "@ag-ui/client";
import {
createAgent,
createDefaultInput,
collectEvents,
collectEventsIncludingErrors,
expectLifecycleWrapped,
expectEventSequence,
eventField,
textStart,
textDelta,
toolCallStreamingStart,
toolCallDelta,
toolCall,
toolResult,
reasoningStart,
reasoningDelta,
reasoningEnd,
finish,
} from "./agent-test-helpers";
// ---------------------------------------------------------------------------
// Basic Event Emission
// ---------------------------------------------------------------------------
describe("AI SDK Converter", () => {
describe("Basic Event Emission", () => {
it("text delta emits TEXT_MESSAGE_CHUNK with correct role, messageId, and delta", async () => {
const agent = createAgent("aisdk", [textDelta("Hello"), finish()]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
const textChunks = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textChunks).toHaveLength(1);
expect(eventField<string>(textChunks[0], "role")).toBe("assistant");
expect(eventField<string>(textChunks[0], "delta")).toBe("Hello");
expect(eventField<string>(textChunks[0], "messageId")).toBeDefined();
expect(typeof eventField<string>(textChunks[0], "messageId")).toBe(
"string",
);
});
it("text-start with provider id uses that id as messageId", async () => {
const agent = createAgent("aisdk", [
textStart("custom-msg-id"),
textDelta("Hi"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const chunk = events.find(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
)!;
expect(eventField<string>(chunk, "messageId")).toBe("custom-msg-id");
});
it('text-start with "0" generates a unique messageId (not "0")', async () => {
const agent = createAgent("aisdk", [
textStart("0"),
textDelta("Hi"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const chunk = events.find(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
)!;
expect(eventField<string>(chunk, "messageId")).not.toBe("0");
expect(eventField<string>(chunk, "messageId")).toBeDefined();
expect(eventField<string>(chunk, "messageId").length).toBeGreaterThan(0);
});
it("multiple text deltas share the same messageId", async () => {
const agent = createAgent("aisdk", [
textDelta("Hello "),
textDelta("world"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const textChunks = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textChunks).toHaveLength(2);
expect(eventField<string>(textChunks[0], "messageId")).toBe(
eventField<string>(textChunks[1], "messageId"),
);
});
it("empty stream (only finish) emits only RUN_STARTED + RUN_FINISHED", async () => {
const agent = createAgent("aisdk", [finish()]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.RUN_FINISHED,
]);
});
});
// ---------------------------------------------------------------------------
// Tool Call Events
// ---------------------------------------------------------------------------
describe("Tool Call Events", () => {
it("streamed tool call emits correct START/ARGS/END/RESULT events", async () => {
const agent = createAgent("aisdk", [
toolCallStreamingStart("tc-1", "myTool"),
toolCallDelta("tc-1", '{"key":'),
toolCallDelta("tc-1", '"value"}'),
toolCall("tc-1", "myTool"),
toolResult("tc-1", "myTool", { result: "ok" }),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
// Check the sequence of tool events
const toolEvents = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_START ||
e.type === EventType.TOOL_CALL_ARGS ||
e.type === EventType.TOOL_CALL_END ||
e.type === EventType.TOOL_CALL_RESULT,
);
expectEventSequence(toolEvents, [
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_END,
EventType.TOOL_CALL_RESULT,
]);
// Verify TOOL_CALL_START details
expect(eventField<string>(toolEvents[0], "toolCallId")).toBe("tc-1");
expect(eventField<string>(toolEvents[0], "toolCallName")).toBe("myTool");
// Verify TOOL_CALL_ARGS deltas
const argsEvts = toolEvents.filter(
(e) => e.type === EventType.TOOL_CALL_ARGS,
);
expect(eventField<string>(argsEvts[0], "delta")).toBe('{"key":');
expect(eventField<string>(argsEvts[1], "delta")).toBe('"value"}');
// Verify TOOL_CALL_END
expect(eventField<string>(toolEvents[2 + 1], "toolCallId")).toBe("tc-1");
// Verify TOOL_CALL_RESULT
expect(eventField<string>(toolEvents[4], "toolCallId")).toBe("tc-1");
expect(JSON.parse(eventField<string>(toolEvents[4], "content"))).toEqual({
result: "ok",
});
});
it("non-streamed tool call (tool-call with input, no prior tool-input-start) emits START + ARGS + END", async () => {
const agent = createAgent("aisdk", [
toolCall("tc-2", "directTool", { foo: "bar" }),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
const toolEvents = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_START ||
e.type === EventType.TOOL_CALL_ARGS ||
e.type === EventType.TOOL_CALL_END,
);
expectEventSequence(toolEvents, [
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_END,
]);
expect(eventField<string>(toolEvents[0], "toolCallId")).toBe("tc-2");
expect(eventField<string>(toolEvents[0], "toolCallName")).toBe(
"directTool",
);
expect(JSON.parse(eventField<string>(toolEvents[1], "delta"))).toEqual({
foo: "bar",
});
});
it("no duplicate START after tool-input-start followed by tool-call", async () => {
const agent = createAgent("aisdk", [
toolCallStreamingStart("tc-3", "myTool"),
toolCallDelta("tc-3", "{}"),
toolCall("tc-3", "myTool"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const startEvents = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_START &&
eventField<string>(e, "toolCallId") === "tc-3",
);
expect(startEvents).toHaveLength(1);
});
it("multiple concurrent tool calls have events correctly paired by toolCallId", async () => {
const agent = createAgent("aisdk", [
toolCallStreamingStart("tc-a", "toolA"),
toolCallStreamingStart("tc-b", "toolB"),
toolCallDelta("tc-a", '{"a":1}'),
toolCallDelta("tc-b", '{"b":2}'),
toolCall("tc-a", "toolA"),
toolCall("tc-b", "toolB"),
toolResult("tc-a", "toolA", "resultA"),
toolResult("tc-b", "toolB", "resultB"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
// Verify each tool call has its own START
const startsA = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_START &&
eventField<string>(e, "toolCallId") === "tc-a",
);
const startsB = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_START &&
eventField<string>(e, "toolCallId") === "tc-b",
);
expect(startsA).toHaveLength(1);
expect(startsB).toHaveLength(1);
// Verify args are correctly paired
const argsA = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_ARGS &&
eventField<string>(e, "toolCallId") === "tc-a",
);
const argsB = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_ARGS &&
eventField<string>(e, "toolCallId") === "tc-b",
);
expect(eventField<string>(argsA[0], "delta")).toBe('{"a":1}');
expect(eventField<string>(argsB[0], "delta")).toBe('{"b":2}');
// Verify results are correctly paired
const resultsA = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_RESULT &&
eventField<string>(e, "toolCallId") === "tc-a",
);
const resultsB = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_RESULT &&
eventField<string>(e, "toolCallId") === "tc-b",
);
expect(JSON.parse(eventField<string>(resultsA[0], "content"))).toBe(
"resultA",
);
expect(JSON.parse(eventField<string>(resultsB[0], "content"))).toBe(
"resultB",
);
});
});
// ---------------------------------------------------------------------------
// Reasoning Events
// ---------------------------------------------------------------------------
describe("Reasoning Events", () => {
it("full reasoning lifecycle emits correct REASONING_START/MESSAGE_START/CONTENT/MESSAGE_END/END events", async () => {
const agent = createAgent("aisdk", [
reasoningStart("r-1"),
reasoningDelta("thinking..."),
reasoningEnd(),
textDelta("Answer"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
const reasoningEvents = events.filter(
(e) =>
e.type === EventType.REASONING_START ||
e.type === EventType.REASONING_MESSAGE_START ||
e.type === EventType.REASONING_MESSAGE_CONTENT ||
e.type === EventType.REASONING_MESSAGE_END ||
e.type === EventType.REASONING_END,
);
expectEventSequence(reasoningEvents, [
EventType.REASONING_START,
EventType.REASONING_MESSAGE_START,
EventType.REASONING_MESSAGE_CONTENT,
EventType.REASONING_MESSAGE_END,
EventType.REASONING_END,
]);
// Verify messageId consistency
expect(eventField<string>(reasoningEvents[0], "messageId")).toBe("r-1");
expect(eventField<string>(reasoningEvents[1], "messageId")).toBe("r-1");
expect(eventField<string>(reasoningEvents[1], "role")).toBe("reasoning");
expect(eventField<string>(reasoningEvents[2], "messageId")).toBe("r-1");
expect(eventField<string>(reasoningEvents[2], "delta")).toBe(
"thinking...",
);
expect(eventField<string>(reasoningEvents[3], "messageId")).toBe("r-1");
expect(eventField<string>(reasoningEvents[4], "messageId")).toBe("r-1");
});
it("empty reasoning deltas are skipped", async () => {
const agent = createAgent("aisdk", [
reasoningStart(),
reasoningDelta(""),
reasoningDelta("actual content"),
reasoningDelta(""),
reasoningEnd(),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const contentEvents = events.filter(
(e) => e.type === EventType.REASONING_MESSAGE_CONTENT,
);
expect(contentEvents).toHaveLength(1);
expect(eventField<string>(contentEvents[0], "delta")).toBe(
"actual content",
);
});
it("auto-close reasoning before text-delta", async () => {
// No explicit reasoning-end — the converter should auto-close
const agent = createAgent("aisdk", [
reasoningStart("r-auto"),
reasoningDelta("thinking"),
textDelta("Answer"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
// Reasoning should be closed before the text event
const types = events.map((e) => e.type);
const msgEndIdx = types.indexOf(EventType.REASONING_MESSAGE_END);
const reasoningEndIdx = types.indexOf(EventType.REASONING_END);
const textIdx = types.indexOf(EventType.TEXT_MESSAGE_CHUNK);
expect(msgEndIdx).toBeGreaterThan(-1);
expect(reasoningEndIdx).toBeGreaterThan(-1);
expect(textIdx).toBeGreaterThan(reasoningEndIdx);
});
it("auto-close reasoning before tool-input-start", async () => {
const agent = createAgent("aisdk", [
reasoningStart(),
reasoningDelta("thinking about tools"),
toolCallStreamingStart("tc-r", "someTool"),
toolCallDelta("tc-r", "{}"),
toolCall("tc-r", "someTool"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
const types = events.map((e) => e.type);
const reasoningEndIdx = types.indexOf(EventType.REASONING_END);
const toolStartIdx = types.indexOf(EventType.TOOL_CALL_START);
expect(reasoningEndIdx).toBeGreaterThan(-1);
expect(toolStartIdx).toBeGreaterThan(reasoningEndIdx);
});
it("auto-close reasoning before finish", async () => {
const agent = createAgent("aisdk", [
reasoningStart(),
reasoningDelta("deep thought"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
// Should contain reasoning close events
const types = events.map((e) => e.type);
expect(types).toContain(EventType.REASONING_MESSAGE_END);
expect(types).toContain(EventType.REASONING_END);
// They should appear before RUN_FINISHED
const reasoningEndIdx = types.indexOf(EventType.REASONING_END);
const runFinishedIdx = types.indexOf(EventType.RUN_FINISHED);
expect(reasoningEndIdx).toBeLessThan(runFinishedIdx);
});
});
// ---------------------------------------------------------------------------
// State Management Tool Results
// ---------------------------------------------------------------------------
describe("State Management Tool Results", () => {
it("AGUISendStateSnapshot tool result emits STATE_SNAPSHOT event", async () => {
const agent = createAgent("aisdk", [
toolCallStreamingStart("tc-state", "AGUISendStateSnapshot"),
toolCallDelta("tc-state", '{"snapshot":{"count":1}}'),
toolCall("tc-state", "AGUISendStateSnapshot"),
toolResult("tc-state", "AGUISendStateSnapshot", {
snapshot: { count: 1 },
}),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const stateSnapshots = events.filter(
(e) => e.type === EventType.STATE_SNAPSHOT,
);
expect(stateSnapshots).toHaveLength(1);
expect(eventField(stateSnapshots[0], "snapshot")).toEqual({ count: 1 });
});
it("AGUISendStateDelta tool result emits STATE_DELTA event", async () => {
const delta = [{ op: "replace", path: "/count", value: 2 }];
const agent = createAgent("aisdk", [
toolCallStreamingStart("tc-delta", "AGUISendStateDelta"),
toolCallDelta("tc-delta", JSON.stringify({ delta })),
toolCall("tc-delta", "AGUISendStateDelta"),
toolResult("tc-delta", "AGUISendStateDelta", { delta }),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const stateDeltas = events.filter(
(e) => e.type === EventType.STATE_DELTA,
);
expect(stateDeltas).toHaveLength(1);
expect(eventField(stateDeltas[0], "delta")).toEqual(delta);
});
it("state tool result also emits TOOL_CALL_RESULT event", async () => {
const agent = createAgent("aisdk", [
toolCallStreamingStart("tc-state2", "AGUISendStateSnapshot"),
toolCallDelta("tc-state2", "{}"),
toolCall("tc-state2", "AGUISendStateSnapshot"),
toolResult("tc-state2", "AGUISendStateSnapshot", {
snapshot: { x: 1 },
}),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const toolResults = events.filter(
(e) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(toolResults).toHaveLength(1);
expect(eventField<string>(toolResults[0], "toolCallId")).toBe(
"tc-state2",
);
});
it("does not emit STATE_SNAPSHOT when snapshot field is undefined", async () => {
const agent = createAgent("aisdk", [
toolCallStreamingStart("tc-no-snap", "AGUISendStateSnapshot"),
toolCallDelta("tc-no-snap", "{}"),
toolCall("tc-no-snap", "AGUISendStateSnapshot"),
toolResult("tc-no-snap", "AGUISendStateSnapshot", {
success: true,
}),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const stateSnapshots = events.filter(
(e) => e.type === EventType.STATE_SNAPSHOT,
);
expect(stateSnapshots).toHaveLength(0);
// Should still emit the TOOL_CALL_RESULT
const toolResults = events.filter(
(e) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(toolResults).toHaveLength(1);
});
it("does not emit STATE_DELTA when delta field is undefined", async () => {
const agent = createAgent("aisdk", [
toolCallStreamingStart("tc-no-delta", "AGUISendStateDelta"),
toolCallDelta("tc-no-delta", "{}"),
toolCall("tc-no-delta", "AGUISendStateDelta"),
toolResult("tc-no-delta", "AGUISendStateDelta", {
success: true,
}),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const stateDeltas = events.filter(
(e) => e.type === EventType.STATE_DELTA,
);
expect(stateDeltas).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Tool Result Property Compatibility
// ---------------------------------------------------------------------------
describe("Tool Result Property Compatibility", () => {
it("reads tool result from 'result' property when 'output' is absent", async () => {
// Simulate older AI SDK that uses "result" instead of "output"
const agent = createAgent("aisdk", [
toolCall("tc-compat", "myTool"),
{
type: "tool-result",
toolCallId: "tc-compat",
toolName: "myTool",
result: { data: "from-result-prop" },
},
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const resultEvents = events.filter(
(e) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(resultEvents).toHaveLength(1);
expect(
JSON.parse(eventField<string>(resultEvents[0], "content")),
).toEqual({ data: "from-result-prop" });
});
it("prefers 'output' over 'result' when both are present", async () => {
const agent = createAgent("aisdk", [
toolCall("tc-both", "myTool"),
{
type: "tool-result",
toolCallId: "tc-both",
toolName: "myTool",
output: { source: "output" },
result: { source: "result" },
},
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const resultEvents = events.filter(
(e) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(
JSON.parse(eventField<string>(resultEvents[0], "content")),
).toEqual({ source: "output" });
});
});
// ---------------------------------------------------------------------------
// Error Event Handling
// ---------------------------------------------------------------------------
describe("Error Event Handling", () => {
it("wraps undefined error in a descriptive Error", async () => {
const agent = createAgent("aisdk", [
textDelta("before error"),
{ type: "error" }, // no .error property
]);
const input = createDefaultInput();
const { events, errored } = await collectEventsIncludingErrors(
agent.run(input),
);
expect(errored).toBe(true);
const errorEvents = events.filter((e) => e.type === EventType.RUN_ERROR);
expect(errorEvents).toHaveLength(1);
// Should contain a useful message, not just "undefined"
expect(eventField<string>(errorEvents[0], "message")).not.toBe(
"undefined",
);
expect(
eventField<string>(errorEvents[0], "message").length,
).toBeGreaterThan(5);
});
it("preserves Error instances from error event", async () => {
const agent = createAgent("aisdk", [
{ type: "error", error: new Error("rate limit exceeded") },
]);
const input = createDefaultInput();
const { events, errored } = await collectEventsIncludingErrors(
agent.run(input),
);
expect(errored).toBe(true);
const errorEvents = events.filter((e) => e.type === EventType.RUN_ERROR);
expect(eventField<string>(errorEvents[0], "message")).toBe(
"rate limit exceeded",
);
});
it("handles string error from error event", async () => {
const agent = createAgent("aisdk", [
{ type: "error", message: "auth failed" },
]);
const input = createDefaultInput();
const { events, errored } = await collectEventsIncludingErrors(
agent.run(input),
);
expect(errored).toBe(true);
const errorEvents = events.filter((e) => e.type === EventType.RUN_ERROR);
expect(eventField<string>(errorEvents[0], "message")).toBe("auth failed");
});
});
// ---------------------------------------------------------------------------
// Edge Cases
// ---------------------------------------------------------------------------
describe("Edge Cases", () => {
it("unknown event types are silently ignored", async () => {
const agent = createAgent("aisdk", [
{ type: "some-unknown-event", data: "hello" },
textDelta("text after unknown"),
{ type: "another-mystery-event" },
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
// Should still have the text chunk
const textChunks = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textChunks).toHaveLength(1);
// No events for unknown types — only RUN_STARTED, TEXT_MESSAGE_CHUNK, RUN_FINISHED
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TEXT_MESSAGE_CHUNK,
EventType.RUN_FINISHED,
]);
});
it("large text deltas (100k chars) are passed through", async () => {
const largeText = "x".repeat(100_000);
const agent = createAgent("aisdk", [textDelta(largeText), finish()]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
const chunk = events.find(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
)!;
expect(eventField<string>(chunk, "delta")).toBe(largeText);
expect(eventField<string>(chunk, "delta").length).toBe(100_000);
});
});
});
@@ -0,0 +1,319 @@
import { describe, it, expect } from "vitest";
import { EventType, type BaseEvent } from "@ag-ui/client";
import { BuiltInAgent } from "../index";
import {
createAgent,
createDefaultInput,
collectEvents,
expectLifecycleWrapped,
expectEventSequence,
eventField,
mockCustomStream,
} from "./agent-test-helpers";
describe("Custom Converter (passthrough)", () => {
// -----------------------------------------------------------------------
// Event Forwarding
// -----------------------------------------------------------------------
describe("Event Forwarding", () => {
it("should forward a single TEXT_MESSAGE_CHUNK as-is between lifecycle events", async () => {
const chunk: BaseEvent = {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: "Hello world",
} as BaseEvent;
const agent = createAgent("custom", [chunk]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events, "test-thread", "test-run");
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TEXT_MESSAGE_CHUNK,
EventType.RUN_FINISHED,
]);
expect(eventField<string>(events[1], "delta")).toBe("Hello world");
expect(eventField<string>(events[1], "role")).toBe("assistant");
});
it("should forward multiple event types in order", async () => {
const userEvents: BaseEvent[] = [
{ type: EventType.TEXT_MESSAGE_START, role: "assistant" } as BaseEvent,
{
type: EventType.TEXT_MESSAGE_CONTENT,
role: "assistant",
content: "Hi",
} as BaseEvent,
{ type: EventType.TEXT_MESSAGE_END } as BaseEvent,
];
const agent = createAgent("custom", userEvents);
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TEXT_MESSAGE_START,
EventType.TEXT_MESSAGE_CONTENT,
EventType.TEXT_MESSAGE_END,
EventType.RUN_FINISHED,
]);
});
it("should forward TOOL_CALL_START + TOOL_CALL_ARGS + TOOL_CALL_END", async () => {
const toolEvents: BaseEvent[] = [
{
type: EventType.TOOL_CALL_START,
toolCallId: "tc-1",
toolCallName: "myTool",
} as BaseEvent,
{
type: EventType.TOOL_CALL_ARGS,
toolCallId: "tc-1",
delta: '{"key":"value"}',
} as BaseEvent,
{
type: EventType.TOOL_CALL_END,
toolCallId: "tc-1",
} as BaseEvent,
];
const agent = createAgent("custom", toolEvents);
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_END,
EventType.RUN_FINISHED,
]);
expect(eventField<string>(events[1], "toolCallId")).toBe("tc-1");
expect(eventField<string>(events[1], "toolCallName")).toBe("myTool");
expect(eventField<string>(events[2], "toolCallId")).toBe("tc-1");
expect(eventField<string>(events[2], "delta")).toBe('{"key":"value"}');
expect(eventField<string>(events[3], "toolCallId")).toBe("tc-1");
});
it("should forward a STATE_SNAPSHOT event", async () => {
const snapshot: BaseEvent = {
type: EventType.STATE_SNAPSHOT,
snapshot: { counter: 42, items: ["a", "b"] },
} as BaseEvent;
const agent = createAgent("custom", [snapshot]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.STATE_SNAPSHOT,
EventType.RUN_FINISHED,
]);
expect(
eventField<Record<string, unknown>>(events[1], "snapshot"),
).toEqual({ counter: 42, items: ["a", "b"] });
});
it("should forward a STATE_DELTA event", async () => {
const delta: BaseEvent = {
type: EventType.STATE_DELTA,
delta: [{ op: "replace", path: "/counter", value: 43 }],
} as BaseEvent;
const agent = createAgent("custom", [delta]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.STATE_DELTA,
EventType.RUN_FINISHED,
]);
expect(eventField<unknown[]>(events[1], "delta")).toEqual([
{ op: "replace", path: "/counter", value: 43 },
]);
});
it("should forward reasoning events in order", async () => {
const reasoningEvents: BaseEvent[] = [
{ type: EventType.REASONING_START } as BaseEvent,
{ type: EventType.REASONING_MESSAGE_START } as BaseEvent,
{
type: EventType.REASONING_MESSAGE_CONTENT,
content: "Thinking step 1",
} as BaseEvent,
{
type: EventType.REASONING_MESSAGE_CONTENT,
content: "Thinking step 2",
} as BaseEvent,
{ type: EventType.REASONING_MESSAGE_END } as BaseEvent,
{ type: EventType.REASONING_END } as BaseEvent,
];
const agent = createAgent("custom", reasoningEvents);
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.REASONING_START,
EventType.REASONING_MESSAGE_START,
EventType.REASONING_MESSAGE_CONTENT,
EventType.REASONING_MESSAGE_CONTENT,
EventType.REASONING_MESSAGE_END,
EventType.REASONING_END,
EventType.RUN_FINISHED,
]);
expect(eventField<string>(events[3], "content")).toBe("Thinking step 1");
expect(eventField<string>(events[4], "content")).toBe("Thinking step 2");
});
});
// -----------------------------------------------------------------------
// Lifecycle Boundary
// -----------------------------------------------------------------------
describe("Lifecycle Boundary", () => {
it("should result in duplicate RUN_STARTED when user emits one in custom stream", async () => {
const userEvents: BaseEvent[] = [
{
type: EventType.RUN_STARTED,
threadId: "user-thread",
runId: "user-run",
} as BaseEvent,
{
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: "Hello",
} as BaseEvent,
];
const agent = createAgent("custom", userEvents);
const events = await collectEvents(agent.run(createDefaultInput()));
// Agent emits its own RUN_STARTED, then the user's RUN_STARTED is forwarded
const runStartedEvents = events.filter(
(e) => e.type === EventType.RUN_STARTED,
);
expect(runStartedEvents).toHaveLength(2);
// First is from the Agent lifecycle
expect(runStartedEvents[0]).toMatchObject({
type: EventType.RUN_STARTED,
threadId: "test-thread",
runId: "test-run",
});
// Second is the user-emitted one, forwarded as-is
expect(eventField<string>(runStartedEvents[1], "threadId")).toBe(
"user-thread",
);
expect(eventField<string>(runStartedEvents[1], "runId")).toBe("user-run");
});
});
// -----------------------------------------------------------------------
// Edge Cases
// -----------------------------------------------------------------------
describe("Edge Cases", () => {
it("should emit only lifecycle events for an empty async iterable", async () => {
const agent = createAgent("custom", []);
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.RUN_FINISHED,
]);
expectLifecycleWrapped(events, "test-thread", "test-run");
});
it("should work correctly with an async generator factory", async () => {
const agent = new BuiltInAgent({
type: "custom",
factory: async function* () {
yield {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: "from generator",
} as BaseEvent;
yield {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: " factory",
} as BaseEvent;
},
});
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TEXT_MESSAGE_CHUNK,
EventType.TEXT_MESSAGE_CHUNK,
EventType.RUN_FINISHED,
]);
expect(eventField<string>(events[1], "delta")).toBe("from generator");
expect(eventField<string>(events[2], "delta")).toBe(" factory");
});
it("should pass through events with extra/unknown fields", async () => {
const eventWithExtras: BaseEvent = {
type: EventType.CUSTOM,
customField: "custom-value",
nestedData: { deep: { value: 123 } },
arrayField: [1, 2, 3],
} as BaseEvent;
const agent = createAgent("custom", [eventWithExtras]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.CUSTOM,
EventType.RUN_FINISHED,
]);
expect(eventField<string>(events[1], "customField")).toBe("custom-value");
expect(
eventField<{ deep: { value: number } }>(events[1], "nestedData"),
).toEqual({ deep: { value: 123 } });
expect(eventField<number[]>(events[1], "arrayField")).toEqual([1, 2, 3]);
});
it("should forward 1000+ events without loss", async () => {
const count = 1500;
const manyEvents: BaseEvent[] = Array.from({ length: count }, (_, i) => ({
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: `chunk-${i}`,
})) as BaseEvent[];
const agent = createAgent("custom", manyEvents);
const events = await collectEvents(agent.run(createDefaultInput()));
// Total = RUN_STARTED + 1500 chunks + RUN_FINISHED
expect(events).toHaveLength(count + 2);
// First and last are lifecycle
expect(events[0].type).toBe(EventType.RUN_STARTED);
expect(events[events.length - 1].type).toBe(EventType.RUN_FINISHED);
// All content events are TEXT_MESSAGE_CHUNK
const contentEvents = events.slice(1, -1);
expect(contentEvents).toHaveLength(count);
// Verify order preservation
for (let i = 0; i < count; i++) {
expect(contentEvents[i].type).toBe(EventType.TEXT_MESSAGE_CHUNK);
expect(eventField<string>(contentEvents[i], "delta")).toBe(
`chunk-${i}`,
);
}
});
});
});
@@ -0,0 +1,291 @@
import { describe, it, expect } from "vitest";
import { convertInputToTanStackAI } from "../converters/tanstack";
import { createDefaultInput } from "./agent-test-helpers";
describe("convertInputToTanStackAI", () => {
// -------------------------------------------------------------------------
// Message filtering
// -------------------------------------------------------------------------
describe("message filtering", () => {
it("filters out system messages from the messages array", () => {
const input = createDefaultInput({
messages: [
{ id: "msg-1", role: "system", content: "You are helpful" },
{ id: "msg-2", role: "user", content: "Hello" },
],
});
const { messages } = convertInputToTanStackAI(input);
expect(messages).toHaveLength(1);
expect(messages[0].role).toBe("user");
expect(messages[0].content).toBe("Hello");
});
it("filters out developer messages from the messages array", () => {
const input = createDefaultInput({
messages: [
{ id: "msg-3", role: "developer", content: "Internal instruction" },
{ id: "msg-4", role: "user", content: "Hi" },
],
});
const { messages } = convertInputToTanStackAI(input);
expect(messages).toHaveLength(1);
expect(messages[0].role).toBe("user");
});
it("extracts system and developer messages into systemPrompts", () => {
const input = createDefaultInput({
messages: [
{ id: "msg-5", role: "system", content: "System prompt" },
{ id: "msg-6", role: "developer", content: "Dev instruction" },
{ id: "msg-7", role: "user", content: "Hello" },
],
});
const { systemPrompts } = convertInputToTanStackAI(input);
expect(systemPrompts).toContain("System prompt");
expect(systemPrompts).toContain("Dev instruction");
});
it("preserves user and assistant messages in order", () => {
const input = createDefaultInput({
messages: [
{ id: "msg-8", role: "user", content: "Question 1" },
{ id: "msg-9", role: "assistant", content: "Answer 1" },
{ id: "msg-10", role: "user", content: "Question 2" },
],
});
const { messages } = convertInputToTanStackAI(input);
expect(messages).toHaveLength(3);
expect(messages[0]).toMatchObject({
role: "user",
content: "Question 1",
});
expect(messages[1]).toMatchObject({
role: "assistant",
content: "Answer 1",
});
expect(messages[2]).toMatchObject({
role: "user",
content: "Question 2",
});
});
});
// -------------------------------------------------------------------------
// Tool call mapping
// -------------------------------------------------------------------------
describe("tool call mapping", () => {
it("maps assistant message toolCalls to TanStack format", () => {
const input = createDefaultInput({
messages: [
{
id: "msg-14",
role: "assistant",
content: null,
toolCalls: [
{
id: "tc-1",
type: "function",
function: { name: "getWeather", arguments: '{"city":"NYC"}' },
},
],
},
],
});
const { messages } = convertInputToTanStackAI(input);
expect(messages).toHaveLength(1);
expect(messages[0].toolCalls).toHaveLength(1);
expect(messages[0].toolCalls![0]).toEqual({
id: "tc-1",
type: "function",
function: { name: "getWeather", arguments: '{"city":"NYC"}' },
});
});
it("maps tool messages with toolCallId", () => {
const input = createDefaultInput({
messages: [
{
id: "msg-15",
role: "tool",
content: '{"temp": 72}',
toolCallId: "tc-1",
},
],
});
const { messages } = convertInputToTanStackAI(input);
expect(messages).toHaveLength(1);
expect(messages[0].role).toBe("tool");
expect(messages[0].toolCallId).toBe("tc-1");
});
});
// -------------------------------------------------------------------------
// Client tools (frontend-provided tools → TanStack client tools)
// -------------------------------------------------------------------------
describe("client tools", () => {
it("converts input.tools to TanStack client tools (no executor)", () => {
const params = {
type: "object" as const,
properties: { issues: { type: "array" as const } },
required: ["issues"],
};
const input = createDefaultInput({
tools: [
{
name: "issue_list",
description: "Render a list of issues",
parameters: params,
},
],
});
const { tools } = convertInputToTanStackAI(input);
expect(tools).toHaveLength(1);
expect(tools[0]).toEqual({
__toolSide: "client",
name: "issue_list",
description: "Render a list of issues",
inputSchema: params,
});
// Client tools must NOT carry an executor — TanStack pauses and hands
// the call back to the AG-UI client instead of running it.
expect("execute" in tools[0]).toBe(false);
});
it("returns an empty tools array when input has no tools", () => {
const { tools } = convertInputToTanStackAI(createDefaultInput());
expect(tools).toEqual([]);
});
it("closes open objects (additionalProperties → false) for OpenAI compatibility", () => {
const input = createDefaultInput({
tools: [
{
name: "render_chart",
description: "Render a chart",
parameters: {
type: "object",
properties: {
// z.record(z.string(), z.any()) → additionalProperties: {}
options: { type: "object", additionalProperties: {} },
data: {
type: "array",
// .passthrough() → additionalProperties: true on items
items: {
type: "object",
properties: { v: { type: "number" } },
additionalProperties: true,
},
},
},
additionalProperties: false,
},
},
],
});
const { tools } = convertInputToTanStackAI(input);
const schema = tools[0].inputSchema as Record<string, any>;
expect(schema.properties.options.additionalProperties).toBe(false);
expect(schema.properties.data.items.additionalProperties).toBe(false);
// Nested defined properties are preserved.
expect(schema.properties.data.items.properties.v).toEqual({
type: "number",
});
});
});
// -------------------------------------------------------------------------
// Context injection
// -------------------------------------------------------------------------
describe("context injection", () => {
it("appends context entries to systemPrompts", () => {
const input = createDefaultInput({
context: [
{ description: "User preferences", value: "Dark mode enabled" },
{ description: "Current page", value: "/dashboard" },
],
});
const { systemPrompts } = convertInputToTanStackAI(input);
expect(systemPrompts).toContain("User preferences:\nDark mode enabled");
expect(systemPrompts).toContain("Current page:\n/dashboard");
});
it("does not add context when context array is empty", () => {
const input = createDefaultInput({ context: [] });
const { systemPrompts } = convertInputToTanStackAI(input);
expect(systemPrompts).toHaveLength(0);
});
});
// -------------------------------------------------------------------------
// State serialization
// -------------------------------------------------------------------------
describe("state serialization", () => {
it("serializes non-empty state into systemPrompts", () => {
const input = createDefaultInput({
state: { count: 42, items: ["a", "b"] },
});
const { systemPrompts } = convertInputToTanStackAI(input);
const statePrompt = systemPrompts.find((p) =>
p.startsWith("Application State:"),
);
expect(statePrompt).toBeDefined();
expect(statePrompt).toContain('"count": 42');
});
it("does not add state when state is empty object", () => {
const input = createDefaultInput({ state: {} });
const { systemPrompts } = convertInputToTanStackAI(input);
expect(
systemPrompts.find((p) => p.startsWith("Application State:")),
).toBeUndefined();
});
it("does not add state when state is null", () => {
const input = createDefaultInput({ state: null });
const { systemPrompts } = convertInputToTanStackAI(input);
expect(
systemPrompts.find((p) => p.startsWith("Application State:")),
).toBeUndefined();
});
});
// -------------------------------------------------------------------------
// Empty input
// -------------------------------------------------------------------------
describe("empty input", () => {
it("returns empty messages and systemPrompts for default input", () => {
const input = createDefaultInput();
const { messages, systemPrompts } = convertInputToTanStackAI(input);
expect(messages).toHaveLength(0);
expect(systemPrompts).toHaveLength(0);
});
});
});
@@ -0,0 +1,688 @@
import { describe, it, expect } from "vitest";
import { EventType } from "@ag-ui/client";
import {
createAgent,
createDefaultInput,
collectEvents,
collectEventsIncludingErrors,
expectLifecycleWrapped,
expectEventSequence,
eventField,
tanstackTextChunk,
tanstackToolCallStart,
tanstackToolCallArgs,
tanstackToolCallEnd,
tanstackToolCallResult,
tanstackReasoningStart,
tanstackReasoningMessageStart,
tanstackReasoningMessageContent,
tanstackReasoningMessageEnd,
tanstackReasoningEnd,
} from "./agent-test-helpers";
describe("TanStack AI converter (via Agent)", () => {
// -------------------------------------------------------------------------
// Text Events
// -------------------------------------------------------------------------
describe("Text Events", () => {
it("TEXT_MESSAGE_CONTENT chunk produces TEXT_MESSAGE_CHUNK with role assistant and correct delta", async () => {
const agent = createAgent("tanstack", [tanstackTextChunk("Hello world")]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents).toHaveLength(1);
expect(eventField<string>(textEvents[0], "role")).toBe("assistant");
expect(eventField<string>(textEvents[0], "delta")).toBe("Hello world");
expect(eventField<string>(textEvents[0], "messageId")).toBeDefined();
expect(typeof eventField<string>(textEvents[0], "messageId")).toBe(
"string",
);
expect(
eventField<string>(textEvents[0], "messageId").length,
).toBeGreaterThan(0);
});
it("multiple text chunks share the same messageId", async () => {
const agent = createAgent("tanstack", [
tanstackTextChunk("Hello "),
tanstackTextChunk("world"),
tanstackTextChunk("!"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents).toHaveLength(3);
const messageIds = new Set(
textEvents.map((e) => eventField<string>(e, "messageId")),
);
expect(messageIds.size).toBe(1);
});
it("empty stream produces only RUN_STARTED + RUN_FINISHED", async () => {
const agent = createAgent("tanstack", []);
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.RUN_FINISHED,
]);
});
});
// -------------------------------------------------------------------------
// Tool Call Events
// -------------------------------------------------------------------------
describe("Tool Call Events", () => {
it("full tool call lifecycle produces START, ARGS, END events in order", async () => {
const agent = createAgent("tanstack", [
tanstackToolCallStart("tc-1", "myTool"),
tanstackToolCallArgs("tc-1", '{"key":'),
tanstackToolCallArgs("tc-1", '"value"}'),
tanstackToolCallEnd("tc-1"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_END,
EventType.RUN_FINISHED,
]);
expect(eventField<string>(events[1], "toolCallId")).toBe("tc-1");
expect(eventField<string>(events[1], "toolCallName")).toBe("myTool");
expect(eventField<string>(events[2], "toolCallId")).toBe("tc-1");
expect(eventField<string>(events[2], "delta")).toBe('{"key":');
expect(eventField<string>(events[3], "toolCallId")).toBe("tc-1");
expect(eventField<string>(events[3], "delta")).toBe('"value"}');
expect(eventField<string>(events[4], "toolCallId")).toBe("tc-1");
});
it("TOOL_CALL_START sets parentMessageId", async () => {
const agent = createAgent("tanstack", [
tanstackTextChunk("before"),
tanstackToolCallStart("tc-1", "myTool"),
tanstackToolCallEnd("tc-1"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const textEvent = events.find(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
)!;
const toolStartEvent = events.find(
(e) => e.type === EventType.TOOL_CALL_START,
)!;
expect(
eventField<string>(toolStartEvent, "parentMessageId"),
).toBeDefined();
expect(eventField<string>(toolStartEvent, "parentMessageId")).toBe(
eventField<string>(textEvent, "messageId"),
);
});
it("multiple tool calls in sequence each get correct events", async () => {
const agent = createAgent("tanstack", [
tanstackToolCallStart("tc-1", "toolA"),
tanstackToolCallArgs("tc-1", '{"a":1}'),
tanstackToolCallEnd("tc-1"),
tanstackToolCallStart("tc-2", "toolB"),
tanstackToolCallArgs("tc-2", '{"b":2}'),
tanstackToolCallEnd("tc-2"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_END,
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_END,
EventType.RUN_FINISHED,
]);
// Verify first tool call
expect(eventField<string>(events[1], "toolCallId")).toBe("tc-1");
expect(eventField<string>(events[1], "toolCallName")).toBe("toolA");
expect(eventField<string>(events[2], "toolCallId")).toBe("tc-1");
expect(eventField<string>(events[3], "toolCallId")).toBe("tc-1");
// Verify second tool call
expect(eventField<string>(events[4], "toolCallId")).toBe("tc-2");
expect(eventField<string>(events[4], "toolCallName")).toBe("toolB");
expect(eventField<string>(events[5], "toolCallId")).toBe("tc-2");
expect(eventField<string>(events[6], "toolCallId")).toBe("tc-2");
});
it("tool call with no ARGS chunks produces only START + END", async () => {
const agent = createAgent("tanstack", [
tanstackToolCallStart("tc-1", "noArgsTool"),
tanstackToolCallEnd("tc-1"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_END,
EventType.RUN_FINISHED,
]);
});
});
// -------------------------------------------------------------------------
// Tool Call Result Events
// -------------------------------------------------------------------------
describe("Tool Call Result Events", () => {
it("TOOL_CALL_RESULT chunk produces TOOL_CALL_RESULT event with correct content", async () => {
const agent = createAgent("tanstack", [
tanstackToolCallStart("tc-1", "myTool"),
tanstackToolCallArgs("tc-1", '{"key":"value"}'),
tanstackToolCallEnd("tc-1"),
{
type: "TOOL_CALL_RESULT",
toolCallId: "tc-1",
content: JSON.stringify({ result: "ok" }),
},
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
const resultEvents = events.filter(
(e) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(resultEvents).toHaveLength(1);
expect(eventField<string>(resultEvents[0], "toolCallId")).toBe("tc-1");
expect(eventField<string>(resultEvents[0], "role")).toBe("tool");
expect(
JSON.parse(eventField<string>(resultEvents[0], "content")),
).toEqual({ result: "ok" });
});
it("TOOL_CALL_RESULT with object content serializes to JSON", async () => {
const agent = createAgent("tanstack", [
tanstackToolCallStart("tc-2", "myTool"),
tanstackToolCallEnd("tc-2"),
{
type: "TOOL_CALL_RESULT",
toolCallId: "tc-2",
result: { data: 42 },
},
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const resultEvents = events.filter(
(e) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(resultEvents).toHaveLength(1);
expect(
JSON.parse(eventField<string>(resultEvents[0], "content")),
).toEqual({ data: 42 });
});
});
// -------------------------------------------------------------------------
// Mixed Content
// -------------------------------------------------------------------------
describe("Mixed Content", () => {
it("text interleaved with tool calls produces correct event types and order", async () => {
const agent = createAgent("tanstack", [
tanstackTextChunk("Let me help. "),
tanstackToolCallStart("tc-1", "search"),
tanstackToolCallArgs("tc-1", '{"q":"test"}'),
tanstackToolCallEnd("tc-1"),
tanstackTextChunk("Here are the results."),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TEXT_MESSAGE_CHUNK,
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_END,
EventType.TEXT_MESSAGE_CHUNK,
EventType.RUN_FINISHED,
]);
// Verify content of text events
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(eventField<string>(textEvents[0], "delta")).toBe("Let me help. ");
expect(eventField<string>(textEvents[1], "delta")).toBe(
"Here are the results.",
);
});
});
// -------------------------------------------------------------------------
// Edge Cases
// -------------------------------------------------------------------------
describe("Edge Cases", () => {
it("unknown chunk types are silently ignored", async () => {
const agent = createAgent("tanstack", [
tanstackTextChunk("hello"),
{ type: "SOME_UNKNOWN_TYPE", data: "foo" },
{ type: "ANOTHER_MYSTERY", value: 42 },
tanstackTextChunk(" world"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TEXT_MESSAGE_CHUNK,
EventType.TEXT_MESSAGE_CHUNK,
EventType.RUN_FINISHED,
]);
});
it("large deltas (100k chars) are passed through", async () => {
const largeDelta = "x".repeat(100_000);
const agent = createAgent("tanstack", [tanstackTextChunk(largeDelta)]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
const textEvent = events.find(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
)!;
expect(eventField<string>(textEvent, "delta")).toBe(largeDelta);
expect(eventField<string>(textEvent, "delta").length).toBe(100_000);
});
});
});
describe("TanStack AI converter — multi-turn agent loop", () => {
// chat() emits a RUN_STARTED / RUN_FINISHED pair PER model turn and executes
// server-side tools (MCP / provider tools) itself between turns. The run
// lifecycle is owned by the Agent wrapper, so per-turn markers must be
// dropped and every turn's content converted — a regression here truncated
// the run at the first tool turn, dropping the tool result and final answer.
it("does NOT truncate at a per-turn RUN_FINISHED — tool result + final text still convert", async () => {
const agent = createAgent("tanstack", [
// Turn 1: model emits a tool call, then the turn finishes.
tanstackToolCallStart("tc-1", "list_issues"),
tanstackToolCallArgs("tc-1", '{"team":"CPK"}'),
tanstackToolCallEnd("tc-1"),
{ type: "RUN_FINISHED" },
// Between turns: chat() executes the MCP tool itself.
tanstackToolCallResult("tc-1", { issues: [] }),
// Turn 2: model produces the final answer.
{ type: "RUN_STARTED" },
tanstackTextChunk("No open issues."),
{ type: "RUN_FINISHED" },
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_END,
EventType.TOOL_CALL_RESULT,
EventType.TEXT_MESSAGE_CHUNK,
EventType.RUN_FINISHED,
]);
// Exactly one RUN_FINISHED — the outer wrapper's, not TanStack's per-turn ones.
expect(
events.filter((e) => e.type === EventType.RUN_FINISHED),
).toHaveLength(1);
// The final answer survived the per-turn RUN_FINISHED.
const textEvent = events.find(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
)!;
expect(eventField<string>(textEvent, "delta")).toBe("No open issues.");
});
it("de-duplicates a tool call re-announced after execution", async () => {
const agent = createAgent("tanstack", [
tanstackToolCallStart("tc-1", "search"),
tanstackToolCallEnd("tc-1"),
{ type: "RUN_FINISHED" },
tanstackToolCallResult("tc-1", { ok: true }),
// chat() re-announces the same call when it re-prompts — must be dropped.
tanstackToolCallStart("tc-1", "search"),
tanstackToolCallArgs("tc-1", '{"q":"x"}'),
tanstackToolCallEnd("tc-1"),
{ type: "RUN_STARTED" },
tanstackTextChunk("done"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_END,
EventType.TOOL_CALL_RESULT,
EventType.TEXT_MESSAGE_CHUNK,
EventType.RUN_FINISHED,
]);
});
it("surfaces a RUN_ERROR chunk as a terminal RUN_ERROR event", async () => {
const agent = createAgent("tanstack", [
tanstackTextChunk("partial"),
{ type: "RUN_ERROR", message: "provider 400" },
// Anything after the error must never be converted.
tanstackTextChunk("should not appear"),
]);
const { events, errored } = await collectEventsIncludingErrors(
agent.run(createDefaultInput()),
);
expect(errored).toBe(true);
const errorEvent = events.find((e) => e.type === EventType.RUN_ERROR)!;
expect(errorEvent).toBeDefined();
expect(eventField<string>(errorEvent, "message")).toBe("provider 400");
// The post-error text chunk was not emitted.
const texts = events.filter((e) => e.type === EventType.TEXT_MESSAGE_CHUNK);
expect(texts).toHaveLength(1);
expect(eventField<string>(texts[0], "delta")).toBe("partial");
});
});
describe("TanStack AI converter — state tools", () => {
it("emits STATE_SNAPSHOT before TOOL_CALL_RESULT for AGUISendStateSnapshot", async () => {
const snapshot = { counter: 5, items: ["x", "y"] };
const agent = createAgent("tanstack", [
tanstackToolCallStart("call1", "AGUISendStateSnapshot"),
tanstackToolCallEnd("call1"),
tanstackToolCallResult("call1", { success: true, snapshot }),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
const snapshotIdx = events.findIndex(
(e) => e.type === EventType.STATE_SNAPSHOT,
);
const resultIdx = events.findIndex(
(e) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(snapshotIdx).toBeGreaterThanOrEqual(0);
expect(resultIdx).toBeGreaterThanOrEqual(0);
expect(snapshotIdx).toBeLessThan(resultIdx);
expect(eventField<unknown>(events[snapshotIdx], "snapshot")).toEqual(
snapshot,
);
});
it("emits STATE_DELTA before TOOL_CALL_RESULT for AGUISendStateDelta", async () => {
const delta = [{ op: "replace", path: "/counter", value: 7 }];
const agent = createAgent("tanstack", [
tanstackToolCallStart("call1", "AGUISendStateDelta"),
tanstackToolCallEnd("call1"),
tanstackToolCallResult("call1", { success: true, delta }),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
const deltaIdx = events.findIndex((e) => e.type === EventType.STATE_DELTA);
const resultIdx = events.findIndex(
(e) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(deltaIdx).toBeGreaterThanOrEqual(0);
expect(deltaIdx).toBeLessThan(resultIdx);
expect(eventField<unknown>(events[deltaIdx], "delta")).toEqual(delta);
});
it("emits STATE_SNAPSHOT when payload arrives in raw.result instead of raw.content", async () => {
// Regression: serialization fell back to raw.result (`?? raw.result ?? null`)
// but state-tool detection only inspected raw.content, so STATE_* events
// were silently dropped if upstream used `result` for the state-tool body.
// See the "TOOL_CALL_RESULT with object content serializes to JSON" test
// above which already exercises the `result` field on a non-state tool.
const snapshot = { counter: 99 };
const agent = createAgent("tanstack", [
tanstackToolCallStart("call1", "AGUISendStateSnapshot"),
tanstackToolCallEnd("call1"),
{
type: "TOOL_CALL_RESULT",
toolCallId: "call1",
result: { success: true, snapshot },
},
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
const snapshotIdx = events.findIndex(
(e) => e.type === EventType.STATE_SNAPSHOT,
);
expect(snapshotIdx).toBeGreaterThanOrEqual(0);
expect(eventField<unknown>(events[snapshotIdx], "snapshot")).toEqual(
snapshot,
);
});
it("does NOT emit STATE_* for non-state tool results", async () => {
const agent = createAgent("tanstack", [
tanstackToolCallStart("call1", "getWeather"),
tanstackToolCallEnd("call1"),
tanstackToolCallResult("call1", {
snapshot: { spoofed: true },
delta: [{ op: "x" }],
}),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
expect(
events.find(
(e) =>
e.type === EventType.STATE_SNAPSHOT ||
e.type === EventType.STATE_DELTA,
),
).toBeUndefined();
expect(
events.find((e) => e.type === EventType.TOOL_CALL_RESULT),
).toBeDefined();
});
});
describe("TanStack AI converter — reasoning", () => {
it("emits the full REASONING lifecycle for reasoning chunks", async () => {
const agent = createAgent("tanstack", [
tanstackReasoningStart("r1"),
tanstackReasoningMessageStart("r1"),
tanstackReasoningMessageContent("r1", "thinking"),
tanstackReasoningMessageEnd("r1"),
tanstackReasoningEnd("r1"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
// Strip the lifecycle wrap and inspect the inner sequence.
const inner = events.slice(1, -1).map((e) => e.type);
expect(inner).toEqual([
EventType.REASONING_START,
EventType.REASONING_MESSAGE_START,
EventType.REASONING_MESSAGE_CONTENT,
EventType.REASONING_MESSAGE_END,
EventType.REASONING_END,
]);
});
it("auto-closes an open reasoning lifecycle when text starts", async () => {
const agent = createAgent("tanstack", [
tanstackReasoningStart("r1"),
tanstackReasoningMessageStart("r1"),
tanstackReasoningMessageContent("r1", "thinking"),
// No REASONING_MESSAGE_END / REASONING_END before text
tanstackTextChunk("Hi"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const types = events.map((e) => e.type);
const reasonEndIdx = types.indexOf(EventType.REASONING_END);
const reasonMsgEndIdx = types.indexOf(EventType.REASONING_MESSAGE_END);
const textIdx = types.indexOf(EventType.TEXT_MESSAGE_CHUNK);
expect(reasonMsgEndIdx).toBeGreaterThan(-1);
expect(reasonEndIdx).toBeGreaterThan(-1);
expect(reasonEndIdx).toBeLessThan(textIdx);
});
it("auto-closes an open reasoning lifecycle when a tool call starts", async () => {
const agent = createAgent("tanstack", [
tanstackReasoningStart("r1"),
tanstackReasoningMessageStart("r1"),
tanstackReasoningMessageContent("r1", "..."),
tanstackToolCallStart("t1", "x"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const types = events.map((e) => e.type);
expect(types).toContain(EventType.REASONING_MESSAGE_END);
expect(types).toContain(EventType.REASONING_END);
expect(types.indexOf(EventType.REASONING_END)).toBeLessThan(
types.indexOf(EventType.TOOL_CALL_START),
);
});
it("auto-closes when the stream ends without explicit reasoning end", async () => {
const agent = createAgent("tanstack", [
tanstackReasoningStart("r1"),
tanstackReasoningMessageStart("r1"),
tanstackReasoningMessageContent("r1", "x"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const types = events.map((e) => e.type);
expect(types).toContain(EventType.REASONING_MESSAGE_END);
expect(types).toContain(EventType.REASONING_END);
});
it("emits REASONING_MESSAGE_END before REASONING_END when upstream sends END with message still open", async () => {
// Regression: if the converter received REASONING_END while a message
// was still open, it cleared run-open and emitted END only — leaving
// message-open true. The next non-reasoning chunk then triggered
// closeReasoningIfOpen, which emitted MSG_END AFTER END (wrong order).
// Fix: REASONING_END handler closes any open message first.
const agent = createAgent("tanstack", [
tanstackReasoningStart("r1"),
tanstackReasoningMessageStart("r1"),
tanstackReasoningMessageContent("r1", "thinking"),
// Upstream skips MSG_END and goes straight to END
tanstackReasoningEnd("r1"),
tanstackTextChunk("Hi"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const types = events.map((e) => e.type);
const msgEndIdx = types.indexOf(EventType.REASONING_MESSAGE_END);
const endIdx = types.indexOf(EventType.REASONING_END);
expect(msgEndIdx).toBeGreaterThan(-1);
expect(endIdx).toBeGreaterThan(-1);
expect(msgEndIdx).toBeLessThan(endIdx);
// No duplicate MSG_END or END from auto-close on the text chunk
expect(
types.filter((t) => t === EventType.REASONING_MESSAGE_END),
).toHaveLength(1);
expect(types.filter((t) => t === EventType.REASONING_END)).toHaveLength(1);
});
it("auto-closes prior reasoning run when a new REASONING_START arrives without END", async () => {
// Regression: REASONING_START used to overwrite reasoningMessageId
// unconditionally, orphaning the prior run's MSG_END / END.
// Fix: REASONING_START handler calls closeReasoningIfOpen() first.
const agent = createAgent("tanstack", [
tanstackReasoningStart("r1"),
tanstackReasoningMessageStart("r1"),
tanstackReasoningMessageContent("r1", "first"),
// Second START with no intervening END
tanstackReasoningStart("r2"),
tanstackReasoningMessageStart("r2"),
tanstackReasoningMessageContent("r2", "second"),
tanstackReasoningMessageEnd("r2"),
tanstackReasoningEnd("r2"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const types = events.map((e) => e.type);
// Two complete START → MSG_START → ... → MSG_END → END sequences
expect(types.filter((t) => t === EventType.REASONING_START)).toHaveLength(
2,
);
expect(types.filter((t) => t === EventType.REASONING_END)).toHaveLength(2);
expect(
types.filter((t) => t === EventType.REASONING_MESSAGE_END),
).toHaveLength(2);
// First START's prior message gets closed BEFORE the second START
const firstEndIdx = types.indexOf(EventType.REASONING_END);
const secondStartIdx = types.indexOf(
EventType.REASONING_START,
firstEndIdx + 1,
);
expect(firstEndIdx).toBeLessThan(secondStartIdx);
});
it("does NOT duplicate REASONING_MESSAGE_END when upstream emits it explicitly before text", async () => {
// Regression: a single isInReasoning flag conflated message-open with
// run-open, so closeReasoningIfOpen on TEXT_MESSAGE_CONTENT emitted a
// second REASONING_MESSAGE_END after upstream's own. Track message-open
// and run-open separately so closeReasoningIfOpen owes only what's still
// open.
const agent = createAgent("tanstack", [
tanstackReasoningStart("r1"),
tanstackReasoningMessageStart("r1"),
tanstackReasoningMessageContent("r1", "thinking"),
tanstackReasoningMessageEnd("r1"),
// No explicit REASONING_END before text — closeReasoningIfOpen should
// emit REASONING_END but NOT a second REASONING_MESSAGE_END.
tanstackTextChunk("Hi"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const types = events.map((e) => e.type);
const msgEndCount = types.filter(
(t) => t === EventType.REASONING_MESSAGE_END,
).length;
const endCount = types.filter((t) => t === EventType.REASONING_END).length;
expect(msgEndCount).toBe(1);
expect(endCount).toBe(1);
expect(types.indexOf(EventType.REASONING_END)).toBeLessThan(
types.indexOf(EventType.TEXT_MESSAGE_CHUNK),
);
});
});
@@ -0,0 +1,160 @@
/**
* Integration tests for factory-mode native interrupts driven by the REAL
* AI SDK `streamText` (via a mock LanguageModelV3 — no network). These verify
* the two things synthetic-stream tests can't:
* 1. a tool declared `needsApproval: true` actually makes `streamText` emit a
* `tool-approval-request` part → our converter turns it into outcome:interrupt;
* 2. on resume, the tool-result the runtime injects from the resume payload is
* accepted by `streamText` so the run continues (no re-approval loop).
*/
import { describe, it, expect } from "vitest";
import { EventType } from "@ag-ui/client";
import type { RunAgentInput } from "@ag-ui/client";
import { streamText, tool } from "ai";
import type { FlexibleSchema } from "ai";
import { MockLanguageModelV3, simulateReadableStream } from "ai/test";
import { z } from "zod";
import { BuiltInAgent, convertMessagesToVercelAISDKMessages } from "../index";
import { collectEvents, createDefaultInput } from "./agent-test-helpers";
const USAGE = {
inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 0, text: 0, reasoning: 0 },
} as const;
type BookFlightInput = { destination: string };
const bookFlightInputSchema = z.object({
destination: z.string(),
}) as unknown as FlexibleSchema<BookFlightInput>;
function finishReason(unified: "stop" | "tool-calls") {
return { unified, raw: unified };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function modelEmitting(chunks: any[]): MockLanguageModelV3 {
return new MockLanguageModelV3({
doStream: async () => ({
stream: simulateReadableStream({ chunks }),
}),
});
}
function aisdkApprovalAgent(model: MockLanguageModelV3): BuiltInAgent {
return new BuiltInAgent({
type: "aisdk",
factory: ({ input, abortSignal }) =>
streamText({
model,
messages: convertMessagesToVercelAISDKMessages(input.messages),
tools: {
bookFlight: tool<BookFlightInput, never>({
description: "Book a flight. Requires approval.",
inputSchema: bookFlightInputSchema,
needsApproval: true,
}),
},
abortSignal,
}),
});
}
describe("factory aisdk native interrupt (real streamText)", () => {
it("a needsApproval tool call → RUN_FINISHED outcome:interrupt", async () => {
const model = modelEmitting([
{
type: "tool-call",
toolCallId: "tc-1",
toolName: "bookFlight",
input: JSON.stringify({ destination: "Tokyo" }),
},
{
type: "finish",
finishReason: finishReason("tool-calls"),
usage: USAGE,
},
]);
const agent = aisdkApprovalAgent(model);
const events = await collectEvents(
agent.run(
createDefaultInput({
messages: [
{ id: "u1", role: "user", content: "Book a flight to Tokyo" },
] as RunAgentInput["messages"],
}),
),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any;
expect(finished.outcome?.type).toBe("interrupt");
expect(finished.outcome.interrupts[0]).toMatchObject({
id: "tc-1",
toolCallId: "tc-1",
});
});
it("on resume, the injected tool-result is accepted and the run completes", async () => {
// Resume run: model now just replies (the tool call is already answered by
// the injected result, so streamText must NOT re-request approval).
const model = modelEmitting([
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: "Booked your flight to Tokyo!" },
{ type: "text-end", id: "t1" },
{ type: "finish", finishReason: finishReason("stop"), usage: USAGE },
]);
const agent = aisdkApprovalAgent(model);
const events = await collectEvents(
agent.run(
createDefaultInput({
resume: [
{
interruptId: "tc-1",
status: "resolved",
payload: { approved: true },
},
],
messages: [
{ id: "u1", role: "user", content: "Book a flight to Tokyo" },
{
id: "a1",
role: "assistant",
content: "",
toolCalls: [
{
id: "tc-1",
type: "function",
function: {
name: "bookFlight",
arguments: JSON.stringify({ destination: "Tokyo" }),
},
},
],
},
] as RunAgentInput["messages"],
}),
),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any;
// Normal completion — no re-interrupt.
expect(finished.outcome).toBeUndefined();
const text = events
.filter((e) => e.type === EventType.TEXT_MESSAGE_CHUNK)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map((e) => (e as any).delta)
.join("");
expect(text).toContain("Booked your flight");
});
});
@@ -0,0 +1,252 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { BasicAgent } from "../index";
import type { MCPClientProvider } from "../index";
import { EventType } from "@ag-ui/client";
import type { RunAgentInput } from "@ag-ui/client";
import { streamText } from "ai";
import type { ToolSet } from "ai";
import {
mockStreamTextResponse,
textDelta,
finish,
collectEvents,
} from "./test-helpers";
// Mock the ai module
vi.mock("ai", () => ({
streamText: vi.fn(),
tool: vi.fn((config) => config),
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
}));
// Mock the SDK clients
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
modelId,
provider: "openai",
})),
}));
// Mock MCP imports so mcpServers code path doesn't fail when tested alongside mcpClients
vi.mock("@ai-sdk/mcp", () => ({
createMCPClient: vi.fn(),
}));
describe("mcpClients — user-managed MCP clients", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
});
afterEach(() => {
process.env = originalEnv;
});
const baseInput: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
function makeMockProvider(
tools: Record<string, any>,
): MCPClientProvider & { close: ReturnType<typeof vi.fn> } {
return {
tools: vi.fn().mockResolvedValue(tools),
close: vi.fn(),
};
}
it("tools from mcpClients are passed to streamText", async () => {
const mockTools = {
listEnvelopes: { description: "List envelopes", execute: vi.fn() },
};
const provider = makeMockProvider(mockTools);
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpClients: [provider],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("Hello"), finish()]) as any,
);
await collectEvents(agent["run"](baseInput));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("listEnvelopes");
expect(callArgs.tools.listEnvelopes.description).toBe("List envelopes");
expect(provider.tools).toHaveBeenCalledOnce();
});
it("mcpClients are NOT closed after run completes", async () => {
const provider = makeMockProvider({
myTool: { description: "A tool", execute: vi.fn() },
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpClients: [provider],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
await collectEvents(agent["run"](baseInput));
expect(provider.close).not.toHaveBeenCalled();
});
it("mcpServers tools override mcpClients tools on name collision", async () => {
const clientExecute = vi.fn();
const serverExecute = vi.fn();
const provider = makeMockProvider({
sharedTool: { description: "from client", execute: clientExecute },
});
// Mock mcpServers flow: createMCPClient returns a client with tools()
const { createMCPClient } = await import("@ai-sdk/mcp");
vi.mocked(createMCPClient).mockResolvedValue({
tools: vi.fn().mockResolvedValue({
sharedTool: { description: "from server", execute: serverExecute },
}),
close: vi.fn(),
} as any);
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpClients: [provider],
mcpServers: [{ type: "http", url: "http://localhost:9999" }],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
await collectEvents(agent["run"](baseInput));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
// mcpServers runs after mcpClients, so "from server" should win
expect(callArgs.tools.sharedTool.description).toBe("from server");
});
it("multiple mcpClients merge in order (later overrides earlier)", async () => {
const provider1 = makeMockProvider({
toolA: { description: "from provider 1", execute: vi.fn() },
shared: { description: "from provider 1", execute: vi.fn() },
});
const provider2 = makeMockProvider({
toolB: { description: "from provider 2", execute: vi.fn() },
shared: { description: "from provider 2", execute: vi.fn() },
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpClients: [provider1, provider2],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
await collectEvents(agent["run"](baseInput));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("toolA");
expect(callArgs.tools).toHaveProperty("toolB");
expect(callArgs.tools.shared.description).toBe("from provider 2");
});
it("empty mcpClients array is a no-op", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpClients: [],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("Hi"), finish()]) as any,
);
const events = await collectEvents(agent["run"](baseInput));
// Should still work normally
const textEvents = events.filter(
(e: any) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents.length).toBeGreaterThan(0);
});
it("mcpClients .tools() rejection emits RUN_ERROR", async () => {
const failingProvider: MCPClientProvider = {
tools: vi.fn().mockRejectedValue(new Error("MCP connection lost")),
};
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpClients: [failingProvider],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
// Collect events manually so we can capture RUN_ERROR before the rejection
const events: any[] = [];
try {
await new Promise((resolve, reject) => {
agent["run"](baseInput).subscribe({
next: (event: any) => events.push(event),
error: (err: any) => reject(err),
complete: () => resolve(events),
});
});
} catch {
// Expected — Observable errors after emitting RUN_ERROR
}
// streamText should NOT have been called (error before reaching it)
expect(streamText).not.toHaveBeenCalled();
// A RUN_ERROR event should have been emitted
expect(events.some((e) => e.type === EventType.RUN_ERROR)).toBe(true);
});
it("clone() shares the same mcpClients references", () => {
const provider = makeMockProvider({});
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpClients: [provider],
});
const cloned = agent.clone();
// Access the config to verify same reference
// Both agents share the same config object (by reference)
expect((cloned as any).config.mcpClients[0]).toBe(provider);
});
it("type compatibility: @ai-sdk/mcp MCPClient satisfies MCPClientProvider", async () => {
// Compile-time check that `MCPClientProvider` is structurally compatible
// with `@ai-sdk/mcp`'s `MCPClient`. After the refactor `MCPClientProvider`
// is an alias for `Pick<MCPClient, "tools">`, so this is trivially true —
// but keeping the test guards against future divergence.
type MCPClient = Awaited<
ReturnType<typeof import("@ai-sdk/mcp").createMCPClient>
>;
const client = {} as MCPClient;
const _assignable: MCPClientProvider = {
tools: () => client.tools() as unknown as Promise<ToolSet>,
};
void _assignable;
expect(true).toBe(true);
});
});
@@ -0,0 +1,381 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { BasicAgent } from "../index";
import { EventType } from "@ag-ui/client";
import { streamText } from "ai";
import { LLMock, MCPMock } from "@copilotkit/aimock";
import {
mockStreamTextResponse,
textDelta,
finish,
collectEvents,
toolCall,
toolResult,
} from "./test-helpers";
// Mock the ai module — we don't want real LLM calls
vi.mock("ai", () => ({
streamText: vi.fn(),
tool: vi.fn((config) => config),
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
}));
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
modelId,
provider: "openai",
})),
}));
// Do NOT mock @ai-sdk/mcp or @modelcontextprotocol/sdk transports —
// we want real HTTP connections to the MCPMock server.
describe("mcpServers — real MCP server integration", () => {
const originalEnv = process.env;
let llm: LLMock;
let mcpMock: MCPMock;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
});
afterEach(async () => {
process.env = originalEnv;
if (llm) {
await llm.stop().catch(() => {});
}
});
const baseInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
/**
* Start an LLMock with an MCPMock mounted at /mcp.
* Returns the full MCP endpoint URL.
*/
async function startMcpServer(
tools: Array<{ name: string; description?: string }>,
): Promise<{ mcpUrl: string; llm: LLMock; mcpMock: MCPMock }> {
const mock = new MCPMock();
for (const t of tools) {
mock.addTool({
name: t.name,
description: t.description ?? `${t.name} tool`,
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
},
});
mock.onToolCall(t.name, () => `result from ${t.name}`);
}
const server = new LLMock({ port: 0 });
server.mount("/mcp", mock);
await server.start();
return {
mcpUrl: `${server.url}/mcp`,
llm: server,
mcpMock: mock,
};
}
it("HTTP transport fetches tools from MCPMock", async () => {
const result = await startMcpServer([
{ name: "get_weather", description: "Get the weather" },
]);
llm = result.llm;
mcpMock = result.mcpMock;
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [{ type: "http", url: result.mcpUrl }],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("Hello"), finish()]) as any,
);
await collectEvents(agent["run"](baseInput));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("get_weather");
});
it("an unreachable MCP server (SSE vs HTTP-only mock) is skipped, not fatal", async () => {
// MCPMock only supports Streamable HTTP, not SSE — so the SSE client
// fails to connect. The run must degrade gracefully: no RUN_ERROR, and
// streamText still runs (just without that server's tools).
const result = await startMcpServer([
{ name: "get_weather", description: "Get the weather" },
]);
llm = result.llm;
mcpMock = result.mcpMock;
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [{ type: "sse", url: result.mcpUrl }],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("ok"), finish()]) as any,
);
const events = await collectEvents(agent["run"](baseInput));
expect(events.some((e: any) => e.type === EventType.RUN_ERROR)).toBe(false);
expect(vi.mocked(streamText).mock.calls.length).toBeGreaterThan(0);
// The failed server contributed no tools.
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools ?? {}).not.toHaveProperty("get_weather");
});
it("a down MCP server is skipped while a healthy one still loads its tools", async () => {
const result = await startMcpServer([
{ name: "get_weather", description: "Get the weather" },
]);
llm = result.llm;
mcpMock = result.mcpMock;
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [
{ type: "http", url: "http://127.0.0.1:1/mcp" }, // unreachable
{ type: "http", url: result.mcpUrl }, // healthy
],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("ok"), finish()]) as any,
);
const events = await collectEvents(agent["run"](baseInput));
// The dead server is skipped; the run completes and the healthy server's
// tools are present.
expect(events.some((e: any) => e.type === EventType.RUN_ERROR)).toBe(false);
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("get_weather");
});
it("tool call round-trip emits TOOL_CALL_START, TOOL_CALL_RESULT, and TEXT_MESSAGE_CHUNK", async () => {
const result = await startMcpServer([
{ name: "get_weather", description: "Get the weather" },
]);
llm = result.llm;
mcpMock = result.mcpMock;
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [{ type: "http", url: result.mcpUrl }],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCall("tc1", "get_weather", { query: "NYC" }),
toolResult("tc1", "get_weather", "Sunny 72F"),
textDelta("The weather is sunny."),
finish(),
]) as any,
);
const events = await collectEvents(agent["run"](baseInput));
const types = events.map((e: any) => e.type);
expect(types).toContain(EventType.TOOL_CALL_START);
expect(types).toContain(EventType.TOOL_CALL_RESULT);
expect(types).toContain(EventType.TEXT_MESSAGE_CHUNK);
// Verify the tool call result content
const resultEvent = events.find(
(e: any) => e.type === EventType.TOOL_CALL_RESULT,
) as any;
expect(resultEvent.toolCallId).toBe("tc1");
expect(resultEvent.content).toContain("Sunny 72F");
});
it("MCP clients are cleaned up after completion — second run creates fresh connections", async () => {
const result = await startMcpServer([
{ name: "get_weather", description: "Get the weather" },
]);
llm = result.llm;
mcpMock = result.mcpMock;
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [{ type: "http", url: result.mcpUrl }],
});
// First run
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("Run 1"), finish()]) as any,
);
const events1 = await collectEvents(agent["run"](baseInput));
expect(events1.some((e: any) => e.type === EventType.RUN_FINISHED)).toBe(
true,
);
// Second run — should succeed with fresh MCP client connections
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("Run 2"), finish()]) as any,
);
const events2 = await collectEvents(agent["run"](baseInput));
expect(events2.some((e: any) => e.type === EventType.RUN_FINISHED)).toBe(
true,
);
// streamText was called twice (once per run), each time with MCP tools
expect(vi.mocked(streamText).mock.calls).toHaveLength(2);
expect(vi.mocked(streamText).mock.calls[0][0].tools).toHaveProperty(
"get_weather",
);
expect(vi.mocked(streamText).mock.calls[1][0].tools).toHaveProperty(
"get_weather",
);
});
it("the only MCP server being unreachable does NOT fail the run", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [{ type: "http", url: "http://localhost:59999" }],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("ok"), finish()]) as any,
);
const events = await collectEvents(agent["run"](baseInput));
// Graceful degradation: the unreachable server is skipped, no RUN_ERROR,
// and the run still proceeds (just with no MCP tools).
expect(events.some((e: any) => e.type === EventType.RUN_ERROR)).toBe(false);
expect(vi.mocked(streamText).mock.calls.length).toBeGreaterThan(0);
});
it("MCP clients are cleaned up after streamText error — subsequent run still works", async () => {
const result = await startMcpServer([
{ name: "get_weather", description: "Get the weather" },
]);
llm = result.llm;
mcpMock = result.mcpMock;
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [{ type: "http", url: result.mcpUrl }],
});
// First run — streamText throws an error
vi.mocked(streamText).mockImplementation(() => {
throw new Error("LLM connection failed");
});
const events1: any[] = [];
try {
await new Promise((resolve, reject) => {
agent["run"](baseInput).subscribe({
next: (event: any) => events1.push(event),
error: (err: any) => reject(err),
complete: () => resolve(events1),
});
});
} catch {
// Expected — streamText threw
}
// Should have emitted RUN_ERROR
expect(events1.some((e) => e.type === EventType.RUN_ERROR)).toBe(true);
// Second run — streamText works normally, proving MCP cleanup happened
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("Recovery"), finish()]) as any,
);
const events2 = await collectEvents(agent["run"](baseInput));
expect(events2.some((e: any) => e.type === EventType.RUN_FINISHED)).toBe(
true,
);
// The second run should have MCP tools available
const secondCallArgs = vi.mocked(streamText).mock.calls[1][0];
expect(secondCallArgs.tools).toHaveProperty("get_weather");
});
it("MCP tool descriptions are passed to streamText tools config", async () => {
const result = await startMcpServer([
{ name: "get_weather", description: "Get the weather" },
]);
llm = result.llm;
mcpMock = result.mcpMock;
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [{ type: "http", url: result.mcpUrl }],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("Hello"), finish()]) as any,
);
await collectEvents(agent["run"](baseInput));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("get_weather");
// The MCP tool should include the description from the MCPMock server
expect(callArgs.tools.get_weather.description).toBe("Get the weather");
});
it("multiple MCP servers merge tools from both", async () => {
// First server with get_weather
const result1 = await startMcpServer([
{ name: "get_weather", description: "Get the weather" },
]);
llm = result1.llm;
// Second server with search_docs
const mock2 = new MCPMock();
mock2.addTool({
name: "search_docs",
description: "Search documentation",
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
},
});
mock2.onToolCall("search_docs", () => "doc results");
const llm2 = new LLMock({ port: 0 });
llm2.mount("/mcp", mock2);
await llm2.start();
try {
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [
{ type: "http", url: result1.mcpUrl },
{ type: "http", url: `${llm2.url}/mcp` },
],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
textDelta("Both tools available"),
finish(),
]) as any,
);
await collectEvents(agent["run"](baseInput));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("get_weather");
expect(callArgs.tools).toHaveProperty("search_docs");
} finally {
await llm2.stop().catch(() => {});
}
});
});
@@ -0,0 +1,284 @@
import { describe, it, expect } from "vitest";
import { convertInputToTanStackAI } from "../converters/tanstack";
import { createDefaultInput } from "./agent-test-helpers";
import type { Message, InputContent } from "@ag-ui/client";
/**
* Helper: build a user message with the given content parts, run through
* convertInputToTanStackAI, and return the first converted message for assertion.
*/
function convertUserContent(content: string | InputContent[]) {
const input = createDefaultInput({
messages: [{ role: "user", content } as Message],
});
const { messages } = convertInputToTanStackAI(input);
return messages[0];
}
/** Shorthand factories for AG-UI input parts */
function dataSource(value: string, mimeType: string) {
return { type: "data" as const, value, mimeType };
}
function urlSource(value: string, mimeType?: string) {
return { type: "url" as const, value, ...(mimeType ? { mimeType } : {}) };
}
describe("convertInputToTanStackAI — multimodal", () => {
it("passes through plain string user content", () => {
const result = convertUserContent("Hello");
expect(result.content).toBe("Hello");
});
it("converts text-only InputContent[] to TanStack TextPart array", () => {
const result = convertUserContent([{ type: "text", text: "Hello world" }]);
expect(result.content).toEqual([{ type: "text", content: "Hello world" }]);
});
it("converts ImageInputPart with data source", () => {
const result = convertUserContent([
{ type: "text", text: "What is this?" },
{ type: "image", source: dataSource("iVBORw0KGgo=", "image/png") },
]);
expect(result.content).toEqual([
{ type: "text", content: "What is this?" },
{
type: "image",
source: { type: "data", value: "iVBORw0KGgo=", mimeType: "image/png" },
},
]);
});
it("converts ImageInputPart with url source", () => {
const result = convertUserContent([
{
type: "image",
source: urlSource("https://example.com/photo.jpg", "image/jpeg"),
},
]);
expect(result.content).toEqual([
{
type: "image",
source: {
type: "url",
value: "https://example.com/photo.jpg",
mimeType: "image/jpeg",
},
},
]);
});
it("converts AudioInputPart", () => {
const result = convertUserContent([
{ type: "audio", source: dataSource("base64audiodata", "audio/mp3") },
]);
expect(result.content).toEqual([
{
type: "audio",
source: {
type: "data",
value: "base64audiodata",
mimeType: "audio/mp3",
},
},
]);
});
it("converts VideoInputPart with url source", () => {
const result = convertUserContent([
{
type: "video",
source: urlSource("https://example.com/video.mp4", "video/mp4"),
},
]);
expect(result.content).toEqual([
{
type: "video",
source: {
type: "url",
value: "https://example.com/video.mp4",
mimeType: "video/mp4",
},
},
]);
});
it("converts DocumentInputPart", () => {
const result = convertUserContent([
{
type: "document",
source: dataSource("base64pdfdata", "application/pdf"),
},
]);
expect(result.content).toEqual([
{
type: "document",
source: {
type: "data",
value: "base64pdfdata",
mimeType: "application/pdf",
},
},
]);
});
it("handles mixed text and multimodal parts", () => {
const result = convertUserContent([
{ type: "text", text: "Analyze these:" },
{ type: "image", source: dataSource("imgdata", "image/png") },
{
type: "document",
source: dataSource("docdata", "application/pdf"),
},
]);
expect(result.content).toEqual([
{ type: "text", content: "Analyze these:" },
{
type: "image",
source: { type: "data", value: "imgdata", mimeType: "image/png" },
},
{
type: "document",
source: {
type: "data",
value: "docdata",
mimeType: "application/pdf",
},
},
]);
});
it("returns empty string for empty content array", () => {
const result = convertUserContent([]);
expect(result.content).toBe("");
});
it("preserves empty string text parts", () => {
const result = convertUserContent([{ type: "text", text: "" }]);
expect(result.content).toEqual([{ type: "text", content: "" }]);
});
it("skips parts with missing source without crashing", () => {
const result = convertUserContent([
{ type: "text", text: "check this" },
{ type: "image" } as any,
]);
expect(result.content).toEqual([{ type: "text", content: "check this" }]);
});
it("silently skips unknown part types", () => {
const result = convertUserContent([
{ type: "text", text: "hello" },
{ type: "spreadsheet", source: dataSource("data", "text/csv") } as any,
]);
expect(result.content).toEqual([{ type: "text", content: "hello" }]);
});
it("returns null for null or undefined content", () => {
const nullInput = createDefaultInput({
messages: [{ role: "user", content: null } as unknown as Message],
});
const { messages: nullMessages } = convertInputToTanStackAI(nullInput);
expect(nullMessages[0].content).toBeNull();
const undefinedInput = createDefaultInput({
messages: [{ role: "user", content: undefined } as unknown as Message],
});
const { messages: undefinedMessages } =
convertInputToTanStackAI(undefinedInput);
expect(undefinedMessages[0].content).toBeNull();
});
describe("legacy BinaryInputContent backward compat", () => {
it("converts binary with image mimeType and data", () => {
const legacyPart = {
type: "binary",
mimeType: "image/jpeg",
data: "legacybase64",
};
const input = createDefaultInput({
messages: [
{
role: "user",
content: [legacyPart] as unknown as InputContent[],
} as Message,
],
});
const { messages } = convertInputToTanStackAI(input);
expect(messages[0].content).toEqual([
{
type: "image",
source: {
type: "data",
value: "legacybase64",
mimeType: "image/jpeg",
},
},
]);
});
it("skips binary with neither data nor url", () => {
const legacyPart = {
type: "binary",
mimeType: "image/png",
};
const input = createDefaultInput({
messages: [
{
role: "user",
content: [legacyPart] as unknown as InputContent[],
} as Message,
],
});
const { messages } = convertInputToTanStackAI(input);
expect(messages[0].content).toBe("");
});
it("converts binary with non-image mimeType and url", () => {
const legacyPart = {
type: "binary",
mimeType: "application/pdf",
url: "https://example.com/doc.pdf",
};
const input = createDefaultInput({
messages: [
{
role: "user",
content: [legacyPart] as unknown as InputContent[],
} as Message,
],
});
const { messages } = convertInputToTanStackAI(input);
expect(messages[0].content).toEqual([
{
type: "document",
source: {
type: "url",
value: "https://example.com/doc.pdf",
mimeType: "application/pdf",
},
},
]);
});
});
it("only converts user message content, not assistant messages", () => {
const input = createDefaultInput({
messages: [
{
role: "user",
content: [
{ type: "text", text: "Look at this" },
{ type: "image", source: dataSource("imgdata", "image/png") },
],
} as Message,
{ role: "assistant", content: "I see an image" } as Message,
],
});
const { messages } = convertInputToTanStackAI(input);
// User message should have array content
expect(Array.isArray(messages[0].content)).toBe(true);
// Assistant message should keep string content
expect(messages[1].content).toBe("I see an image");
});
});
@@ -0,0 +1,176 @@
import { describe, it, expect } from "vitest";
import { convertMessagesToVercelAISDKMessages } from "../index";
import type { Message, InputContent } from "@ag-ui/client";
import type { UserModelMessage } from "ai";
/**
* Helper: build a user message with the given content parts and convert it.
* Returns the converted UserModelMessage for assertion.
*/
function convertUserContent(content: string | InputContent[]) {
const messages: Message[] = [{ id: "1", role: "user", content }];
const result = convertMessagesToVercelAISDKMessages(messages);
return result[0] as UserModelMessage;
}
/** Shorthand factories for AG-UI input parts */
function dataSource(value: string, mimeType: string) {
return { type: "data" as const, value, mimeType };
}
function urlSource(value: string, mimeType?: string) {
return { type: "url" as const, value, ...(mimeType ? { mimeType } : {}) };
}
describe("convertMessagesToVercelAISDKMessages — multimodal", () => {
it("passes through plain string user content", () => {
const result = convertUserContent("Hello");
expect(result).toEqual({ role: "user", content: "Hello" });
});
it("converts text-only InputContent[] to parts array", () => {
const result = convertUserContent([{ type: "text", text: "Hello world" }]);
expect(result.role).toBe("user");
expect(result.content).toEqual([{ type: "text", text: "Hello world" }]);
});
it("converts ImageInputPart with data source to ImagePart", () => {
const result = convertUserContent([
{ type: "text", text: "What is this?" },
{ type: "image", source: dataSource("iVBORw0KGgo=", "image/png") },
]);
expect(result.content).toEqual([
{ type: "text", text: "What is this?" },
{ type: "image", image: "iVBORw0KGgo=", mediaType: "image/png" },
]);
});
it("converts ImageInputPart with url source to ImagePart with URL", () => {
const result = convertUserContent([
{
type: "image",
source: urlSource("https://example.com/photo.jpg", "image/jpeg"),
},
]);
expect(result.content).toEqual([
{
type: "image",
image: new URL("https://example.com/photo.jpg"),
mediaType: "image/jpeg",
},
]);
});
it("converts AudioInputPart to FilePart", () => {
const result = convertUserContent([
{ type: "audio", source: dataSource("base64audiodata", "audio/mp3") },
]);
expect(result.content).toEqual([
{ type: "file", data: "base64audiodata", mediaType: "audio/mp3" },
]);
});
it("converts VideoInputPart with url source to FilePart", () => {
const result = convertUserContent([
{
type: "video",
source: urlSource("https://example.com/video.mp4", "video/mp4"),
},
]);
expect(result.content).toEqual([
{
type: "file",
data: new URL("https://example.com/video.mp4"),
mediaType: "video/mp4",
},
]);
});
it("converts DocumentInputPart to FilePart", () => {
const result = convertUserContent([
{
type: "document",
source: dataSource("base64pdfdata", "application/pdf"),
},
]);
expect(result.content).toEqual([
{ type: "file", data: "base64pdfdata", mediaType: "application/pdf" },
]);
});
it("handles mixed text and multimodal parts", () => {
const result = convertUserContent([
{ type: "text", text: "Analyze these:" },
{ type: "image", source: dataSource("imgdata", "image/png") },
{ type: "document", source: dataSource("docdata", "application/pdf") },
]);
expect(result.content).toEqual([
{ type: "text", text: "Analyze these:" },
{ type: "image", image: "imgdata", mediaType: "image/png" },
{ type: "file", data: "docdata", mediaType: "application/pdf" },
]);
});
it("returns empty string for empty content array", () => {
const result = convertUserContent([]);
expect(result.content).toBe("");
});
it("skips image parts with malformed URLs without crashing", () => {
const result = convertUserContent([
{ type: "text", text: "check this" },
{ type: "image", source: { type: "url", value: "not-a-url" } },
]);
// Malformed URL part is skipped, text part preserved
expect(result.content).toEqual([{ type: "text", text: "check this" }]);
});
// Legacy backward compat — BinaryInputContent is not in the current schema
// but older clients may still send it. We intentionally construct untyped
// objects here to simulate that scenario.
describe("legacy BinaryInputContent backward compat", () => {
it("converts binary with image mimeType and data to ImagePart", () => {
const legacyPart = {
type: "binary",
mimeType: "image/jpeg",
data: "legacybase64",
};
const messages: Message[] = [
{
id: "1",
role: "user",
content: [legacyPart] as unknown as InputContent[],
},
];
const result = convertMessagesToVercelAISDKMessages(messages);
const userMsg = result[0] as UserModelMessage;
expect(userMsg.content).toEqual([
{ type: "image", image: "legacybase64", mediaType: "image/jpeg" },
]);
});
it("converts binary with non-image mimeType and url to FilePart", () => {
const legacyPart = {
type: "binary",
mimeType: "application/pdf",
url: "https://example.com/doc.pdf",
};
const messages: Message[] = [
{
id: "1",
role: "user",
content: [legacyPart] as unknown as InputContent[],
},
];
const result = convertMessagesToVercelAISDKMessages(messages);
const userMsg = result[0] as UserModelMessage;
expect(userMsg.content).toEqual([
{
type: "file",
data: new URL("https://example.com/doc.pdf"),
mediaType: "application/pdf",
},
]);
});
});
});
@@ -0,0 +1,599 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { BasicAgent } from "../index";
import type { RunAgentInput } from "@ag-ui/client";
import { streamText } from "ai";
import { mockStreamTextResponse, finish, collectEvents } from "./test-helpers";
// Mock the ai module
vi.mock("ai", () => ({
streamText: vi.fn(),
tool: vi.fn((config) => config),
}));
// Mock the SDK clients
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
modelId,
provider: "openai",
})),
}));
vi.mock("@ai-sdk/anthropic", () => ({
createAnthropic: vi.fn(() => (modelId: string) => ({
modelId,
provider: "anthropic",
})),
}));
describe("Property Overrides - Edge Cases", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
process.env.ANTHROPIC_API_KEY = "test-key";
});
afterEach(() => {
process.env = originalEnv;
});
describe("Model Override", () => {
it("should override model when allowed", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
overridableProperties: ["model"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { model: "anthropic/claude-sonnet-4.5" },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
const model = callArgs.model as { provider: string; modelId: string };
expect(model.provider).toBe("anthropic");
expect(model.modelId).toBe("claude-sonnet-4.5");
});
it("should accept LanguageModel instance for override", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
overridableProperties: ["model"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const customModel = {
modelId: "custom-model",
provider: "custom",
};
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { model: customModel },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.model).toBe(customModel);
});
it("should ignore invalid model override types", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
overridableProperties: ["model"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { model: 123 }, // Invalid type
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect((callArgs.model as { modelId: string }).modelId).toBe("gpt-4o"); // Original value
});
});
describe("ToolChoice Override", () => {
it("should override with 'auto'", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
toolChoice: "required",
overridableProperties: ["toolChoice"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { toolChoice: "auto" },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.toolChoice).toBe("auto");
});
it("should override with 'required'", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
toolChoice: "auto",
overridableProperties: ["toolChoice"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { toolChoice: "required" },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.toolChoice).toBe("required");
});
it("should override with 'none'", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
toolChoice: "auto",
overridableProperties: ["toolChoice"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { toolChoice: "none" },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.toolChoice).toBe("none");
});
it("should override with specific tool selection", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
toolChoice: "auto",
overridableProperties: ["toolChoice"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: {
toolChoice: { type: "tool", toolName: "specificTool" },
},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.toolChoice).toEqual({
type: "tool",
toolName: "specificTool",
});
});
it("should ignore invalid toolChoice values", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
toolChoice: "auto",
overridableProperties: ["toolChoice"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { toolChoice: "invalid" },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.toolChoice).toBe("auto"); // Original value
});
});
describe("StopSequences Override", () => {
it("should override stopSequences with valid array", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
stopSequences: ["STOP"],
overridableProperties: ["stopSequences"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { stopSequences: ["END", "FINISH"] },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.stopSequences).toEqual(["END", "FINISH"]);
});
it("should ignore stopSequences with non-string elements", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
stopSequences: ["STOP"],
overridableProperties: ["stopSequences"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { stopSequences: ["STOP", 123, "END"] as any },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.stopSequences).toEqual(["STOP"]); // Original value
});
it("should ignore non-array stopSequences", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
stopSequences: ["STOP"],
overridableProperties: ["stopSequences"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { stopSequences: "STOP" as any },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.stopSequences).toEqual(["STOP"]); // Original value
});
});
describe("Numeric Property Overrides", () => {
it("should override all numeric properties when allowed", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
maxOutputTokens: 100,
temperature: 0.5,
topP: 0.9,
topK: 50,
presencePenalty: 0.0,
frequencyPenalty: 0.0,
seed: 123,
maxRetries: 3,
overridableProperties: [
"maxOutputTokens",
"temperature",
"topP",
"topK",
"presencePenalty",
"frequencyPenalty",
"seed",
"maxRetries",
],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: {
maxOutputTokens: 500,
temperature: 0.8,
topP: 0.95,
topK: 100,
presencePenalty: 0.5,
frequencyPenalty: 0.5,
seed: 456,
maxRetries: 5,
},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.maxOutputTokens).toBe(500);
expect(callArgs.temperature).toBe(0.8);
expect(callArgs.topP).toBe(0.95);
expect(callArgs.topK).toBe(100);
expect(callArgs.presencePenalty).toBe(0.5);
expect(callArgs.frequencyPenalty).toBe(0.5);
expect(callArgs.seed).toBe(456);
expect(callArgs.maxRetries).toBe(5);
});
it("should ignore non-numeric values for numeric properties", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
temperature: 0.5,
overridableProperties: ["temperature"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { temperature: "high" as any },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.temperature).toBe(0.5); // Original value
});
});
describe("Multiple Property Overrides", () => {
it("should only override allowed properties", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
temperature: 0.5,
topP: 0.9,
maxOutputTokens: 100,
overridableProperties: ["temperature"], // Only temperature is overridable
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: {
temperature: 0.9,
topP: 0.5,
maxOutputTokens: 500,
},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.temperature).toBe(0.9); // Overridden
expect(callArgs.topP).toBe(0.9); // Original
expect(callArgs.maxOutputTokens).toBe(100); // Original
});
it("should handle undefined forwardedProps", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
temperature: 0.5,
overridableProperties: ["temperature"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
// No forwardedProps
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.temperature).toBe(0.5); // Original value
});
it("should handle non-object forwardedProps", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
temperature: 0.5,
overridableProperties: ["temperature"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: "not an object" as any,
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.temperature).toBe(0.5); // Original value
});
it("should handle undefined property values in forwardedProps", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
temperature: 0.5,
overridableProperties: ["temperature"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { temperature: undefined },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.temperature).toBe(0.5); // Original value (undefined is ignored)
});
});
describe("canOverride method", () => {
it("should return true for overridable properties", () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
overridableProperties: ["temperature", "topP"],
});
expect(agent.canOverride("temperature")).toBe(true);
expect(agent.canOverride("topP")).toBe(true);
});
it("should return false for non-overridable properties", () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
overridableProperties: ["temperature"],
});
expect(agent.canOverride("topP")).toBe(false);
expect(agent.canOverride("seed")).toBe(false);
});
it("should return false when overridableProperties is undefined", () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
expect(agent.canOverride("temperature")).toBe(false);
expect(agent.canOverride("topP")).toBe(false);
});
it("should return false when overridableProperties is empty array", () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
overridableProperties: [],
});
expect(agent.canOverride("temperature")).toBe(false);
});
});
});
@@ -0,0 +1,195 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { BuiltInAgent } from "../index";
import { EventType, type RunAgentInput } from "@ag-ui/client";
import { streamText } from "ai";
import {
mockStreamTextResponse,
textDelta,
finish,
collectEvents,
} from "./test-helpers";
// Mock the ai module
vi.mock("ai", () => ({
streamText: vi.fn(),
tool: vi.fn((config) => config),
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
}));
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
specificationVersion: "v3",
modelId,
provider: "openai",
})),
}));
vi.mock("@ai-sdk/anthropic", () => ({
createAnthropic: vi.fn(() => (modelId: string) => ({
specificationVersion: "v3",
modelId,
provider: "anthropic",
})),
}));
vi.mock("@ai-sdk/google", () => ({
createGoogleGenerativeAI: vi.fn(() => (modelId: string) => ({
specificationVersion: "v3",
modelId,
provider: "google",
})),
}));
describe("Provider ID collision (#3410, #3623)", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
});
afterEach(() => {
process.env = originalEnv;
});
it('should replace text-start providedId "txt-0" with a UUID', async () => {
const agent = new BuiltInAgent({ model: "openai:gpt-4o-mini" });
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
{ type: "text-start", id: "txt-0" },
textDelta("Hello"),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread-1",
runId: "run-1",
messages: [{ id: "1", role: "user", content: "Hi" }],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
// Find the TEXT_MESSAGE_CHUNK event and check its messageId
const textChunks = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textChunks.length).toBeGreaterThan(0);
const messageId = (textChunks[0] as any).messageId;
// The messageId should NOT be "txt-0" — it should be a UUID
expect(messageId).not.toBe("txt-0");
// UUID v4 pattern
expect(messageId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
});
it('should replace reasoning-start providedId "reasoning-0" with a UUID', async () => {
const agent = new BuiltInAgent({ model: "openai:gpt-4o-mini" });
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
{ type: "reasoning-start", id: "reasoning-0" },
{ type: "reasoning-delta", text: "Thinking..." },
{ type: "reasoning-end" },
{ type: "text-start", id: "txt-0" },
textDelta("Answer"),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread-2",
runId: "run-2",
messages: [{ id: "1", role: "user", content: "Hi" }],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
// Find REASONING_START event
const reasoningStarts = events.filter(
(e) => e.type === EventType.REASONING_START,
);
expect(reasoningStarts.length).toBeGreaterThan(0);
const reasoningId = (reasoningStarts[0] as any).messageId;
// Should NOT be "reasoning-0"
expect(reasoningId).not.toBe("reasoning-0");
expect(reasoningId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
});
it('should replace providedId "msg-0" with a UUID', async () => {
const agent = new BuiltInAgent({ model: "openai:gpt-4o-mini" });
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
{ type: "text-start", id: "msg-0" },
textDelta("Hello"),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread-3",
runId: "run-3",
messages: [{ id: "1", role: "user", content: "Hi" }],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
const textChunks = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textChunks.length).toBeGreaterThan(0);
const messageId = (textChunks[0] as any).messageId;
expect(messageId).not.toBe("msg-0");
expect(messageId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
});
it("should preserve legitimate provider IDs", async () => {
const agent = new BuiltInAgent({ model: "openai:gpt-4o-mini" });
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
{ type: "text-start", id: "custom-msg-id-123" },
textDelta("Hello"),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread-4",
runId: "run-4",
messages: [{ id: "1", role: "user", content: "Hi" }],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
const textChunks = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textChunks.length).toBeGreaterThan(0);
// Legitimate IDs should be preserved
expect((textChunks[0] as any).messageId).toBe("custom-msg-id-123");
});
});
@@ -0,0 +1,317 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { z } from "zod";
import * as v from "valibot";
import { toStandardJsonSchema } from "@valibot/to-json-schema";
import { type } from "arktype";
import type { StandardSchemaV1 } from "@copilotkit/shared";
import {
defineTool,
convertToolDefinitionsToVercelAITools,
BuiltInAgent,
} from "../index";
import type { RunAgentInput } from "@ag-ui/client";
import { streamText } from "ai";
import { mockStreamTextResponse, finish, collectEvents } from "./test-helpers";
const arktype = <Out>(def: Record<string, string>) =>
(type as unknown as (d: unknown) => StandardSchemaV1<unknown, Out>)(def);
// Mock the ai module — keep jsonSchema real for non-Zod path
vi.mock("ai", async () => {
const actual = await vi.importActual("ai");
return {
...actual,
streamText: vi.fn(),
tool: vi.fn((config) => config),
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
};
});
// Mock the SDK clients
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
modelId,
provider: "openai",
})),
}));
describe("Standard Schema support in agent tools", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
});
afterEach(() => {
process.env = originalEnv;
});
describe("defineTool with different schema libraries", () => {
it("accepts a Zod schema (existing behavior)", () => {
const tool = defineTool({
name: "zodTool",
description: "A tool with Zod schema",
parameters: z.object({
city: z.string(),
units: z.enum(["celsius", "fahrenheit"]).optional(),
}),
execute: async (args) => {
return { city: args.city };
},
});
expect(tool.name).toBe("zodTool");
expect(tool.parameters["~standard"].vendor).toBe("zod");
});
it("accepts a Valibot schema (via toStandardJsonSchema)", () => {
const tool = defineTool({
name: "valibotTool",
description: "A tool with Valibot schema",
parameters: toStandardJsonSchema(
v.object({
query: v.string(),
limit: v.optional(v.number()),
}),
),
execute: async (args) => {
return { query: args.query };
},
});
expect(tool.name).toBe("valibotTool");
expect(tool.parameters["~standard"].vendor).toBe("valibot");
});
it("accepts an ArkType schema", () => {
const tool = defineTool({
name: "arktypeTool",
description: "A tool with ArkType schema",
parameters: arktype<{ query: string; limit: number }>({
query: "string",
limit: "number",
}),
execute: async (args) => {
return { query: args.query };
},
});
expect(tool.name).toBe("arktypeTool");
expect(tool.parameters["~standard"].vendor).toBe("arktype");
});
});
describe("convertToolDefinitionsToVercelAITools", () => {
it("converts Zod tool definitions to AI SDK tools (passes Zod directly)", () => {
const tools = [
defineTool({
name: "zodTool",
description: "Zod test",
parameters: z.object({ name: z.string() }),
execute: async () => ({}),
}),
];
const aiTools = convertToolDefinitionsToVercelAITools(tools);
expect(aiTools).toHaveProperty("zodTool");
expect(aiTools.zodTool).toHaveProperty("description", "Zod test");
expect(aiTools.zodTool).toHaveProperty("execute");
expect(aiTools.zodTool.inputSchema).toBeDefined();
});
it("converts Valibot tool definitions to AI SDK tools", () => {
const tools = [
defineTool({
name: "valibotTool",
description: "Valibot test",
parameters: toStandardJsonSchema(v.object({ name: v.string() })),
execute: async () => ({}),
}),
];
const aiTools = convertToolDefinitionsToVercelAITools(tools);
expect(aiTools).toHaveProperty("valibotTool");
expect(aiTools.valibotTool).toHaveProperty("description", "Valibot test");
expect(aiTools.valibotTool).toHaveProperty("execute");
expect(aiTools.valibotTool.inputSchema).toBeDefined();
});
it("converts ArkType tool definitions to AI SDK tools", () => {
const tools = [
defineTool({
name: "arktypeTool",
description: "ArkType test",
parameters: arktype<{ name: string }>({ name: "string" }),
execute: async () => ({}),
}),
];
const aiTools = convertToolDefinitionsToVercelAITools(tools);
expect(aiTools).toHaveProperty("arktypeTool");
expect(aiTools.arktypeTool).toHaveProperty("description", "ArkType test");
expect(aiTools.arktypeTool).toHaveProperty("execute");
expect(aiTools.arktypeTool.inputSchema).toBeDefined();
});
it("converts mixed schema types in the same tool set", () => {
const tools = [
defineTool({
name: "zodTool",
description: "Zod",
parameters: z.object({ a: z.string() }),
execute: async () => ({}),
}),
defineTool({
name: "valibotTool",
description: "Valibot",
parameters: toStandardJsonSchema(v.object({ b: v.string() })),
execute: async () => ({}),
}),
defineTool({
name: "arktypeTool",
description: "ArkType",
parameters: arktype<{ c: string }>({ c: "string" }),
execute: async () => ({}),
}),
];
const aiTools = convertToolDefinitionsToVercelAITools(tools);
expect(Object.keys(aiTools)).toEqual([
"zodTool",
"valibotTool",
"arktypeTool",
]);
for (const key of Object.keys(aiTools)) {
expect(aiTools[key]).toHaveProperty("execute");
expect(aiTools[key]).toHaveProperty("inputSchema");
}
});
});
describe("BuiltInAgent with non-Zod schemas", () => {
it("includes Valibot-based config tools alongside input tools", async () => {
const executeFn = vi.fn().mockResolvedValue({ result: "ok" });
const valibotTool = defineTool({
name: "searchValibot",
description: "Search with Valibot schema",
parameters: toStandardJsonSchema(v.object({ query: v.string() })),
execute: executeFn,
});
const agent = new BuiltInAgent({
model: "openai/gpt-4o",
tools: [valibotTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("searchValibot");
expect(typeof callArgs.tools?.searchValibot.execute).toBe("function");
});
it("includes ArkType-based config tools alongside input tools", async () => {
const executeFn = vi.fn().mockResolvedValue({ result: "ok" });
const arktypeTool = defineTool({
name: "searchArktype",
description: "Search with ArkType schema",
parameters: arktype<{ query: string }>({ query: "string" }),
execute: executeFn,
});
const agent = new BuiltInAgent({
model: "openai/gpt-4o",
tools: [arktypeTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("searchArktype");
expect(typeof callArgs.tools?.searchArktype.execute).toBe("function");
});
it("mixes Zod, Valibot, and ArkType tools in one agent", async () => {
const agent = new BuiltInAgent({
model: "openai/gpt-4o",
tools: [
defineTool({
name: "zodTool",
description: "Zod",
parameters: z.object({ a: z.string() }),
execute: async () => ({}),
}),
defineTool({
name: "valibotTool",
description: "Valibot",
parameters: toStandardJsonSchema(v.object({ b: v.string() })),
execute: async () => ({}),
}),
defineTool({
name: "arktypeTool",
description: "ArkType",
parameters: arktype<{ c: string }>({ c: "string" }),
execute: async () => ({}),
}),
],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("zodTool");
expect(callArgs.tools).toHaveProperty("valibotTool");
expect(callArgs.tools).toHaveProperty("arktypeTool");
// State tools should also be present
expect(callArgs.tools).toHaveProperty("AGUISendStateSnapshot");
expect(callArgs.tools).toHaveProperty("AGUISendStateDelta");
});
});
});
@@ -0,0 +1,162 @@
import { describe, it, expectTypeOf } from "vitest";
import { z } from "zod";
import * as v from "valibot";
import { type } from "arktype";
import type { StandardSchemaV1 } from "@copilotkit/shared";
import { defineTool } from "../index";
import type { ToolDefinition } from "../index";
const arktype = <Out>(def: Record<string, string>) =>
(type as unknown as (d: unknown) => StandardSchemaV1<unknown, Out>)(def);
describe("ToolDefinition type inference", () => {
describe("defineTool with Zod", () => {
it("infers execute args from Zod schema", () => {
const tool = defineTool({
name: "weather",
description: "Get weather",
parameters: z.object({
city: z.string(),
units: z.enum(["celsius", "fahrenheit"]),
}),
execute: async (args) => {
// args should be fully typed
expectTypeOf(args).toEqualTypeOf<{
city?: string;
units?: "celsius" | "fahrenheit";
}>();
return {};
},
});
expectTypeOf(tool.name).toBeString();
expectTypeOf(tool.description).toBeString();
expectTypeOf(tool.parameters).toMatchTypeOf<StandardSchemaV1>();
});
it("infers execute args with optional Zod fields", () => {
defineTool({
name: "search",
description: "Search",
parameters: z.object({
query: z.string(),
limit: z.number().optional(),
}),
execute: async (args) => {
expectTypeOf(args).toEqualTypeOf<{
query?: string;
limit?: number;
}>();
return {};
},
});
});
});
describe("defineTool with Valibot", () => {
it("infers execute args from Valibot schema", () => {
defineTool({
name: "search",
description: "Search",
parameters: v.object({
query: v.string(),
limit: v.number(),
}),
execute: async (args) => {
expectTypeOf(args).toEqualTypeOf<{
query: string;
limit: number;
}>();
return {};
},
});
});
it("infers execute args with optional Valibot fields", () => {
defineTool({
name: "search",
description: "Search",
parameters: v.object({
query: v.string(),
limit: v.optional(v.number()),
}),
execute: async (args) => {
expectTypeOf(args).toEqualTypeOf<{
query: string;
limit?: number | undefined;
}>();
return {};
},
});
});
});
describe("defineTool with ArkType", () => {
it("infers execute args from ArkType schema", () => {
defineTool({
name: "search",
description: "Search",
parameters: arktype<{ query: string; limit: number }>({
query: "string",
limit: "number",
}),
execute: async (args) => {
expectTypeOf(args).toEqualTypeOf<{
query: string;
limit: number;
}>();
return {};
},
});
});
it("infers execute args with optional ArkType fields", () => {
defineTool({
name: "profile",
description: "Profile",
parameters: arktype<{ name: string; age?: number }>({
name: "string",
"age?": "number",
}),
execute: async (args) => {
expectTypeOf(args).toEqualTypeOf<{
name: string;
age?: number;
}>();
return {};
},
});
});
});
describe("ToolDefinition interface", () => {
it("accepts Zod schema as generic parameter", () => {
type ZodTool = ToolDefinition<z.ZodObject<{ city: z.ZodString }>>;
expectTypeOf<ZodTool["execute"]>().toBeFunction();
expectTypeOf<Parameters<ZodTool["execute"]>[0]>().toEqualTypeOf<{
city?: string;
}>();
});
it("default generic parameter is StandardSchemaV1", () => {
type DefaultTool = ToolDefinition;
expectTypeOf<
DefaultTool["parameters"]
>().toMatchTypeOf<StandardSchemaV1>();
});
it("preserves schema type through defineTool return", () => {
const schema = z.object({ x: z.number() });
const tool = defineTool({
name: "t",
description: "d",
parameters: schema,
execute: async () => ({}),
});
expectTypeOf(tool.parameters).toEqualTypeOf<typeof schema>();
});
});
});
@@ -0,0 +1,436 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { BasicAgent } from "../index";
import { EventType, type RunAgentInput } from "@ag-ui/client";
import { streamText } from "ai";
import {
mockStreamTextResponse,
toolCallStreamingStart,
toolCall,
toolResult,
finish,
collectEvents,
} from "./test-helpers";
// Mock the ai module
vi.mock("ai", () => ({
streamText: vi.fn(),
tool: vi.fn((config) => config),
}));
// Mock the SDK clients
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
modelId,
provider: "openai",
})),
}));
describe("State Update Tools", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
});
afterEach(() => {
process.env = originalEnv;
});
describe("AGUISendStateSnapshot", () => {
it("should emit STATE_SNAPSHOT event when tool is called", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
const newState = { counter: 5, items: ["x", "y"] };
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateSnapshot"),
toolCall("call1", "AGUISendStateSnapshot"),
toolResult("call1", "AGUISendStateSnapshot", {
success: true,
snapshot: newState,
}),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: { counter: 0 },
};
const events = await collectEvents(agent["run"](input));
// Find STATE_SNAPSHOT event
const snapshotEvent = events.find(
(e: any) => e.type === EventType.STATE_SNAPSHOT,
);
expect(snapshotEvent).toBeDefined();
expect(snapshotEvent).toMatchObject({
type: EventType.STATE_SNAPSHOT,
snapshot: newState,
});
});
it("should still emit TOOL_CALL_RESULT for the LLM", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateSnapshot"),
toolCall("call1", "AGUISendStateSnapshot"),
toolResult("call1", "AGUISendStateSnapshot", {
success: true,
snapshot: { value: 1 },
}),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
// Should have both STATE_SNAPSHOT and TOOL_CALL_RESULT
const snapshotEvent = events.find(
(e: any) => e.type === EventType.STATE_SNAPSHOT,
);
const toolResultEvent = events.find(
(e: any) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(snapshotEvent).toBeDefined();
expect(toolResultEvent).toBeDefined();
expect(toolResultEvent).toMatchObject({
type: EventType.TOOL_CALL_RESULT,
toolCallId: "call1",
});
});
});
describe("AGUISendStateDelta", () => {
it("should emit STATE_DELTA event when tool is called", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
const delta = [
{ op: "replace", path: "/counter", value: 10 },
{ op: "add", path: "/newField", value: "test" },
];
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateDelta"),
toolCall("call1", "AGUISendStateDelta"),
toolResult("call1", "AGUISendStateDelta", { success: true, delta }),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: { counter: 0 },
};
const events = await collectEvents(agent["run"](input));
// Find STATE_DELTA event
const deltaEvent = events.find(
(e: any) => e.type === EventType.STATE_DELTA,
);
expect(deltaEvent).toBeDefined();
expect(deltaEvent).toMatchObject({
type: EventType.STATE_DELTA,
delta,
});
});
it("should handle add operations", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
const delta = [{ op: "add", path: "/items/0", value: "new item" }];
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateDelta"),
toolCall("call1", "AGUISendStateDelta"),
toolResult("call1", "AGUISendStateDelta", { success: true, delta }),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: { items: [] },
};
const events = await collectEvents(agent["run"](input));
const deltaEvent = events.find(
(e: any) => e.type === EventType.STATE_DELTA,
);
expect(deltaEvent?.delta).toEqual(delta);
});
it("should handle replace operations", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
const delta = [{ op: "replace", path: "/status", value: "active" }];
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateDelta"),
toolCall("call1", "AGUISendStateDelta"),
toolResult("call1", "AGUISendStateDelta", { success: true, delta }),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: { status: "inactive" },
};
const events = await collectEvents(agent["run"](input));
const deltaEvent = events.find(
(e: any) => e.type === EventType.STATE_DELTA,
);
expect(deltaEvent?.delta).toEqual(delta);
});
it("should handle remove operations", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
const delta = [{ op: "remove", path: "/oldField" }];
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateDelta"),
toolCall("call1", "AGUISendStateDelta"),
toolResult("call1", "AGUISendStateDelta", { success: true, delta }),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: { oldField: "value", keepField: "keep" },
};
const events = await collectEvents(agent["run"](input));
const deltaEvent = events.find(
(e: any) => e.type === EventType.STATE_DELTA,
);
expect(deltaEvent?.delta).toEqual(delta);
});
it("should handle multiple operations in a single delta", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
const delta = [
{ op: "replace", path: "/counter", value: 5 },
{ op: "add", path: "/items/-", value: "new" },
{ op: "remove", path: "/temp" },
];
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateDelta"),
toolCall("call1", "AGUISendStateDelta"),
toolResult("call1", "AGUISendStateDelta", { success: true, delta }),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: { counter: 0, items: [], temp: "remove me" },
};
const events = await collectEvents(agent["run"](input));
const deltaEvent = events.find(
(e: any) => e.type === EventType.STATE_DELTA,
);
expect(deltaEvent?.delta).toEqual(delta);
});
it("should still emit TOOL_CALL_RESULT for the LLM", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
const delta = [{ op: "replace", path: "/value", value: 1 }];
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateDelta"),
toolCall("call1", "AGUISendStateDelta"),
toolResult("call1", "AGUISendStateDelta", { success: true, delta }),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
// Should have both STATE_DELTA and TOOL_CALL_RESULT
const deltaEvent = events.find(
(e: any) => e.type === EventType.STATE_DELTA,
);
const toolResultEvent = events.find(
(e: any) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(deltaEvent).toBeDefined();
expect(toolResultEvent).toBeDefined();
expect(toolResultEvent).toMatchObject({
type: EventType.TOOL_CALL_RESULT,
toolCallId: "call1",
});
});
});
describe("State Tools Integration", () => {
it("should handle both snapshot and delta in same run", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateSnapshot"),
toolCall("call1", "AGUISendStateSnapshot"),
toolResult("call1", "AGUISendStateSnapshot", {
success: true,
snapshot: { value: 1 },
}),
toolCallStreamingStart("call2", "AGUISendStateDelta"),
toolCall("call2", "AGUISendStateDelta"),
toolResult("call2", "AGUISendStateDelta", {
success: true,
delta: [{ op: "replace", path: "/value", value: 2 }],
}),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
const snapshotEvents = events.filter(
(e: any) => e.type === EventType.STATE_SNAPSHOT,
);
const deltaEvents = events.filter(
(e: any) => e.type === EventType.STATE_DELTA,
);
expect(snapshotEvents).toHaveLength(1);
expect(deltaEvents).toHaveLength(1);
});
it("should not emit state events for non-state tools", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "otherTool"),
toolCall("call1", "otherTool"),
toolResult("call1", "otherTool", { result: "data" }),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [
{
name: "otherTool",
description: "Other tool",
parameters: { type: "object", properties: {} },
},
],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
const stateEvents = events.filter(
(e: any) =>
e.type === EventType.STATE_SNAPSHOT ||
e.type === EventType.STATE_DELTA,
);
expect(stateEvents).toHaveLength(0);
});
});
});
@@ -0,0 +1,197 @@
/**
* Test helpers for mocking streamText responses
*/
import type { streamText } from "ai";
import type { Observable } from "rxjs";
import type { BaseEvent } from "@ag-ui/client";
export interface MockStreamEvent {
type: string;
[key: string]: unknown;
}
/**
* Creates a mock streamText response with controlled events.
*/
export function mockStreamTextResponse(
events: MockStreamEvent[],
): ReturnType<typeof streamText> {
return {
fullStream: (async function* () {
for (const event of events) {
yield event;
}
})(),
} as unknown as ReturnType<typeof streamText>;
}
/**
* Helper to create a text-start event
*/
export function textStart(id?: string): MockStreamEvent {
const event: MockStreamEvent = {
type: "text-start",
};
if (id !== undefined) {
event.id = id;
}
return event;
}
/**
* Helper to create a text delta event
*/
export function textDelta(text: string): MockStreamEvent {
return {
type: "text-delta",
text,
};
}
/**
* Helper to create a tool call streaming start event
*/
export function toolCallStreamingStart(
toolCallId: string,
toolName: string,
): MockStreamEvent {
return {
type: "tool-input-start",
id: toolCallId,
toolName,
};
}
/**
* Helper to create a tool call delta event
*/
export function toolCallDelta(
toolCallId: string,
argsTextDelta: string,
): MockStreamEvent {
return {
type: "tool-input-delta",
id: toolCallId,
delta: argsTextDelta,
};
}
/**
* Helper to create a tool call event
*/
export function toolCall(
toolCallId: string,
toolName: string,
input: unknown = {},
): MockStreamEvent {
return {
type: "tool-call",
toolCallId,
toolName,
input,
};
}
/**
* Helper to create a tool result event
*/
export function toolResult(
toolCallId: string,
toolName: string,
output: unknown,
): MockStreamEvent {
return {
type: "tool-result",
toolCallId,
toolName,
output,
};
}
/**
* Helper to create a reasoning-start event
*/
export function reasoningStart(id?: string): MockStreamEvent {
const event: MockStreamEvent = {
type: "reasoning-start",
};
if (id !== undefined) {
event.id = id;
}
return event;
}
/**
* Helper to create a reasoning-delta event
*/
export function reasoningDelta(text: string): MockStreamEvent {
return {
type: "reasoning-delta",
text,
};
}
/**
* Helper to create a reasoning-end event
*/
export function reasoningEnd(): MockStreamEvent {
return {
type: "reasoning-end",
};
}
/**
* Helper to create a finish event
*/
export function finish(): MockStreamEvent {
return {
type: "finish",
finishReason: "stop",
};
}
/**
* Helper to create an abort event
*/
export function abort(): MockStreamEvent {
return {
type: "abort",
};
}
/**
* Helper to create an error event
*/
export function error(errorMessage: string): MockStreamEvent {
return {
type: "error",
error: new Error(errorMessage),
};
}
/**
* Collects all events from an Observable<BaseEvent> into an array.
*/
export async function collectEvents(
observable: Observable<BaseEvent>,
): Promise<BaseEvent[]> {
return new Promise((resolve, reject) => {
const events: BaseEvent[] = [];
const timeoutId = setTimeout(() => {
subscription.unsubscribe();
reject(new Error("Observable did not complete within timeout"));
}, 5000);
const subscription = observable.subscribe({
next: (event) => events.push(event),
error: (err: unknown) => {
clearTimeout(timeoutId);
reject(err);
},
complete: () => {
clearTimeout(timeoutId);
resolve(events);
},
});
});
}
@@ -0,0 +1,542 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { z } from "zod";
import {
resolveModel,
convertMessagesToVercelAISDKMessages,
convertJsonSchemaToZodSchema,
convertToolsToVercelAITools,
convertToolDefinitionsToVercelAITools,
defineTool,
} from "../index";
import type { ToolDefinition } from "../index";
import type { Message } from "@ag-ui/client";
describe("resolveModel", () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-openai-key";
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";
process.env.GOOGLE_API_KEY = "test-google-key";
});
afterEach(() => {
process.env = originalEnv;
});
it("should resolve OpenAI models with / separator", () => {
const model = resolveModel("openai/gpt-4o");
expect(model).toBeDefined();
expect((model as { modelId: string }).modelId).toBe("gpt-4o");
});
it("should resolve OpenAI models with : separator", () => {
const model = resolveModel("openai:gpt-4o-mini");
expect(model).toBeDefined();
expect((model as { modelId: string }).modelId).toBe("gpt-4o-mini");
});
it("should resolve Anthropic models", () => {
const model = resolveModel("anthropic/claude-sonnet-4.5");
expect(model).toBeDefined();
expect((model as { modelId: string }).modelId).toBe("claude-sonnet-4.5");
});
it("should resolve Google models", () => {
const model = resolveModel("google/gemini-2.5-pro");
expect(model).toBeDefined();
expect((model as { modelId: string }).modelId).toBe("gemini-2.5-pro");
});
it("should handle gemini provider alias", () => {
const model = resolveModel("gemini/gemini-2.5-flash");
expect(model).toBeDefined();
expect((model as { modelId: string }).modelId).toBe("gemini-2.5-flash");
});
it("should throw error for invalid format", () => {
expect(() => resolveModel("invalid")).toThrow("Invalid model string");
});
it("should throw error for unknown provider", () => {
expect(() => resolveModel("unknown/model")).toThrow("Unknown provider");
});
it("should pass through LanguageModel instances", () => {
const mockModel = resolveModel("openai/gpt-4o");
const result = resolveModel(mockModel as any);
expect(result).toBe(mockModel);
});
});
describe("convertMessagesToVercelAISDKMessages", () => {
it("should convert user messages", () => {
const messages: Message[] = [
{
id: "1",
role: "user",
content: "Hello",
},
];
const result = convertMessagesToVercelAISDKMessages(messages);
expect(result).toEqual([
{
role: "user",
content: "Hello",
},
]);
});
it("should convert assistant messages with text content", () => {
const messages: Message[] = [
{
id: "1",
role: "assistant",
content: "Hello there",
},
];
const result = convertMessagesToVercelAISDKMessages(messages);
expect(result).toEqual([
{
role: "assistant",
content: [{ type: "text", text: "Hello there" }],
},
]);
});
it("should convert assistant messages with tool calls", () => {
const messages: Message[] = [
{
id: "1",
role: "assistant",
content: "Let me help",
toolCalls: [
{
id: "call1",
type: "function",
function: {
name: "getTool",
arguments: '{"arg":"value"}',
},
},
],
},
];
const result = convertMessagesToVercelAISDKMessages(messages);
expect(result[0]).toEqual({
role: "assistant",
content: [
{ type: "text", text: "Let me help" },
{
type: "tool-call",
toolCallId: "call1",
toolName: "getTool",
input: { arg: "value" },
},
],
});
});
it("should convert tool messages", () => {
const messages: Message[] = [
{
id: "1",
role: "assistant",
toolCalls: [
{
id: "call1",
type: "function",
function: {
name: "getTool",
arguments: "{}",
},
},
],
},
{
id: "2",
role: "tool",
content: "result",
toolCallId: "call1",
},
];
const result = convertMessagesToVercelAISDKMessages(messages);
expect(result[1]).toEqual({
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call1",
toolName: "getTool",
output: {
type: "text",
value: "result",
},
},
],
});
});
it("should handle multiple messages", () => {
const messages: Message[] = [
{ id: "1", role: "user", content: "Hi" },
{ id: "2", role: "assistant", content: "Hello" },
{ id: "3", role: "user", content: "How are you?" },
];
const result = convertMessagesToVercelAISDKMessages(messages);
expect(result).toHaveLength(3);
expect(result[0].role).toBe("user");
expect(result[1].role).toBe("assistant");
expect(result[2].role).toBe("user");
});
});
describe("convertJsonSchemaToZodSchema", () => {
it("should convert string schema", () => {
const jsonSchema = {
type: "string" as const,
description: "A string field",
};
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, true);
expect(zodSchema.parse("test")).toBe("test");
expect(() => zodSchema.parse(123)).toThrow();
});
it("should convert number schema", () => {
const jsonSchema = {
type: "number" as const,
description: "A number field",
};
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, true);
expect(zodSchema.parse(123)).toBe(123);
expect(() => zodSchema.parse("test")).toThrow();
});
it("should convert boolean schema", () => {
const jsonSchema = {
type: "boolean" as const,
description: "A boolean field",
};
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, true);
expect(zodSchema.parse(true)).toBe(true);
expect(() => zodSchema.parse("true")).toThrow();
});
it("should convert array schema", () => {
const jsonSchema = {
type: "array" as const,
description: "An array field",
items: {
type: "string" as const,
},
};
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, true);
expect(zodSchema.parse(["a", "b"])).toEqual(["a", "b"]);
expect(() => zodSchema.parse([1, 2])).toThrow();
});
it("should convert object schema with properties", () => {
const jsonSchema = {
type: "object" as const,
description: "An object",
properties: {
name: { type: "string" as const },
age: { type: "number" as const },
},
required: ["name"],
};
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, true);
const valid = zodSchema.parse({ name: "John", age: 30 });
expect(valid).toEqual({ name: "John", age: 30 });
expect(() => zodSchema.parse({ age: 30 })).toThrow();
});
it("should make schema optional when required is false", () => {
const jsonSchema = {
type: "string" as const,
description: "Optional string",
};
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, false);
expect(zodSchema.parse(undefined)).toBeUndefined();
expect(zodSchema.parse("test")).toBe("test");
});
it("should preserve string enum constraints", () => {
const jsonSchema = {
type: "object" as const,
properties: {
status: {
type: "string" as const,
description: "The status",
enum: ["todo", "done"],
},
},
required: ["status"],
};
const result = convertJsonSchemaToZodSchema(jsonSchema, true);
const statusSchema = (result as z.ZodObject<any>).shape.status;
expect(statusSchema._def.typeName).toBe("ZodEnum");
expect(statusSchema._def.values).toEqual(["todo", "done"]);
});
it("should handle nested object schemas", () => {
const jsonSchema = {
type: "object" as const,
properties: {
user: {
type: "object" as const,
properties: {
name: { type: "string" as const },
},
required: ["name"],
},
},
required: ["user"],
};
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, true);
const valid = zodSchema.parse({ user: { name: "John" } });
expect(valid).toEqual({ user: { name: "John" } });
});
});
describe("convertToolsToVercelAITools", () => {
it("should convert AG-UI tools to Vercel AI tools", () => {
const tools = [
{
name: "testTool",
description: "A test tool",
parameters: {
type: "object" as const,
properties: {
input: { type: "string" as const },
},
required: ["input"],
},
},
];
const result = convertToolsToVercelAITools(tools);
expect(result).toHaveProperty("testTool");
expect(result.testTool).toBeDefined();
});
it("should throw error for invalid JSON schema", () => {
const tools = [
{
name: "testTool",
description: "A test tool",
parameters: { invalid: "schema" } as any,
},
];
expect(() => convertToolsToVercelAITools(tools)).toThrow(
"Invalid JSON schema",
);
});
it("should handle multiple tools", () => {
const tools = [
{
name: "tool1",
description: "First tool",
parameters: {
type: "object" as const,
properties: {},
},
},
{
name: "tool2",
description: "Second tool",
parameters: {
type: "object" as const,
properties: {},
},
},
];
const result = convertToolsToVercelAITools(tools);
expect(result).toHaveProperty("tool1");
expect(result).toHaveProperty("tool2");
});
});
describe("convertToolDefinitionsToVercelAITools", () => {
it("should convert ToolDefinitions to Vercel AI tools", () => {
const tools: ToolDefinition[] = [
{
name: "testTool",
description: "A test tool",
parameters: z.object({
input: z.string(),
}),
execute: async () => undefined,
},
];
const result = convertToolDefinitionsToVercelAITools(tools);
expect(result).toHaveProperty("testTool");
expect(result.testTool).toBeDefined();
});
it("should handle multiple tool definitions", () => {
const tools: ToolDefinition[] = [
{
name: "tool1",
description: "First tool",
parameters: z.object({}),
execute: async () => undefined,
},
{
name: "tool2",
description: "Second tool",
parameters: z.object({ value: z.number() }),
execute: async () => undefined,
},
];
const result = convertToolDefinitionsToVercelAITools(tools);
expect(result).toHaveProperty("tool1");
expect(result).toHaveProperty("tool2");
expect(Object.keys(result)).toHaveLength(2);
});
});
describe("ensureObjectArgs via convertMessagesToVercelAISDKMessages", () => {
function makeAssistantWithToolArgs(argsJson: string): Message[] {
return [
{
id: "1",
role: "assistant",
content: "calling tool",
toolCalls: [
{
id: "call1",
type: "function",
function: {
name: "myTool",
arguments: argsJson,
},
},
],
},
];
}
it("should pass through valid object arguments", () => {
const result = convertMessagesToVercelAISDKMessages(
makeAssistantWithToolArgs('{"key":"value"}'),
);
const toolCall = (result[0] as any).content[1];
expect(toolCall.input).toEqual({ key: "value" });
});
it("should replace a string argument with an empty object", () => {
const result = convertMessagesToVercelAISDKMessages(
makeAssistantWithToolArgs('""'),
);
const toolCall = (result[0] as any).content[1];
expect(toolCall.input).toEqual({});
});
it("should replace an array argument with an empty object", () => {
const result = convertMessagesToVercelAISDKMessages(
makeAssistantWithToolArgs("[1,2,3]"),
);
const toolCall = (result[0] as any).content[1];
expect(toolCall.input).toEqual({});
});
it("should replace a null argument with an empty object", () => {
const result = convertMessagesToVercelAISDKMessages(
makeAssistantWithToolArgs("null"),
);
const toolCall = (result[0] as any).content[1];
expect(toolCall.input).toEqual({});
});
it("should replace a numeric argument with an empty object", () => {
const result = convertMessagesToVercelAISDKMessages(
makeAssistantWithToolArgs("42"),
);
const toolCall = (result[0] as any).content[1];
expect(toolCall.input).toEqual({});
});
it("should replace a boolean argument with an empty object", () => {
const result = convertMessagesToVercelAISDKMessages(
makeAssistantWithToolArgs("true"),
);
const toolCall = (result[0] as any).content[1];
expect(toolCall.input).toEqual({});
});
it("should replace unparseable JSON with an empty object", () => {
const result = convertMessagesToVercelAISDKMessages(
makeAssistantWithToolArgs("{broken"),
);
const toolCall = (result[0] as any).content[1];
expect(toolCall.input).toEqual({});
});
});
describe("defineTool", () => {
it("should create a ToolDefinition", () => {
const tool = defineTool({
name: "myTool",
description: "My test tool",
parameters: z.object({
input: z.string(),
}),
execute: async () => undefined,
});
expect(tool).toEqual({
name: "myTool",
description: "My test tool",
parameters: expect.any(Object),
execute: expect.any(Function),
});
expect(tool.name).toBe("myTool");
expect(tool.description).toBe("My test tool");
});
it("should preserve type information", () => {
const tool = defineTool({
name: "calc",
description: "Calculate",
parameters: z.object({
a: z.number(),
b: z.number(),
}),
execute: async () => undefined,
});
// Should be able to parse valid input
const result = tool.parameters.parse({ a: 1, b: 2 });
expect(result).toEqual({ a: 1, b: 2 });
});
});
@@ -0,0 +1,356 @@
/**
* Regression tests proving that the agent package's defineTool and
* convertToolDefinitionsToVercelAITools still work identically with
* Zod schemas after the Standard Schema migration.
*
* Covers:
* 1. defineTool with complex Zod schemas
* 2. convertToolDefinitionsToVercelAITools passes Zod schemas directly
* (not through JSON Schema conversion — critical behavioral regression)
* 3. BuiltInAgent with Zod tools still works end-to-end
* 4. execute callback receives correctly shaped args
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { z } from "zod";
import {
defineTool,
convertToolDefinitionsToVercelAITools,
BuiltInAgent,
} from "../index";
import type { RunAgentInput } from "@ag-ui/client";
import { streamText } from "ai";
import { mockStreamTextResponse, finish, collectEvents } from "./test-helpers";
vi.mock("ai", async () => {
const actual = await vi.importActual("ai");
return {
...actual,
streamText: vi.fn(),
tool: vi.fn((config) => config),
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
};
});
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
modelId,
provider: "openai",
})),
}));
describe("defineTool Zod regression", () => {
it("complex Zod schema with nested objects, arrays, unions", () => {
const tool = defineTool({
name: "complexTool",
description: "A complex tool",
parameters: z.object({
query: z.string().describe("Search query"),
filters: z
.object({
category: z.enum(["books", "movies", "music"]).optional(),
minRating: z.number().min(0).max(5).optional(),
tags: z.array(z.string()).optional(),
})
.optional(),
pagination: z
.object({
page: z.number().int().positive().default(1),
perPage: z.number().int().positive().default(20),
})
.optional(),
}),
execute: async (args) => {
return { query: args.query };
},
});
expect(tool.name).toBe("complexTool");
expect(tool.parameters["~standard"].vendor).toBe("zod");
expect(tool.parameters["~standard"].version).toBe(1);
});
it("Zod schema with discriminated union", () => {
const tool = defineTool({
name: "actionTool",
description: "An action tool",
parameters: z.object({
action: z.discriminatedUnion("type", [
z.object({ type: z.literal("search"), query: z.string() }),
z.object({ type: z.literal("navigate"), url: z.string().url() }),
z.object({
type: z.literal("execute"),
code: z.string(),
language: z.enum(["javascript", "python"]),
}),
]),
}),
execute: async (args) => {
return { action: args.action.type };
},
});
expect(tool.name).toBe("actionTool");
expect(tool.parameters["~standard"].vendor).toBe("zod");
});
it("Zod schema with nullable and record fields", () => {
const tool = defineTool({
name: "flexTool",
description: "Flexible tool",
parameters: z.object({
title: z.string(),
description: z.string().nullable(),
metadata: z.record(z.string(), z.unknown()).optional(),
}),
execute: async (args) => {
return { title: args.title };
},
});
expect(tool.name).toBe("flexTool");
expect(tool.parameters["~standard"].vendor).toBe("zod");
});
it("execute callback receives correctly typed args", async () => {
const receivedArgs: unknown[] = [];
const tool = defineTool({
name: "argCapture",
description: "Captures args",
parameters: z.object({
city: z.string(),
units: z.enum(["celsius", "fahrenheit"]),
}),
execute: async (args) => {
receivedArgs.push(args);
return { temp: 22 };
},
});
await tool.execute({ city: "Berlin", units: "celsius" });
expect(receivedArgs).toHaveLength(1);
expect(receivedArgs[0]).toEqual({ city: "Berlin", units: "celsius" });
});
});
describe("convertToolDefinitionsToVercelAITools Zod regression", () => {
it("Zod schemas are passed directly to AI SDK (not converted via JSON Schema)", () => {
const zodSchema = z.object({
city: z.string(),
units: z.enum(["celsius", "fahrenheit"]).optional(),
});
const tools = [
defineTool({
name: "weather",
description: "Get weather",
parameters: zodSchema,
execute: async () => ({ temp: 22 }),
}),
];
const aiTools = convertToolDefinitionsToVercelAITools(tools);
expect(aiTools).toHaveProperty("weather");
// The inputSchema should be the original Zod schema (passed directly),
// NOT a jsonSchema() wrapper. We verify by checking the Zod-specific
// ~standard.vendor property is preserved on the schema.
const inputSchema = aiTools.weather.inputSchema;
expect(inputSchema).toBeDefined();
// For Zod schemas, the AI SDK receives the raw Zod object (which has ~standard.vendor === "zod")
// If it went through jsonSchema(), it would lose the ~standard property
expect(inputSchema["~standard"]?.vendor).toBe("zod");
});
it("multiple Zod tools all get direct pass-through", () => {
const tools = [
defineTool({
name: "tool1",
description: "Tool 1",
parameters: z.object({ a: z.string() }),
execute: async () => ({}),
}),
defineTool({
name: "tool2",
description: "Tool 2",
parameters: z.object({ b: z.number(), c: z.boolean().optional() }),
execute: async () => ({}),
}),
defineTool({
name: "tool3",
description: "Tool 3",
parameters: z.object({
nested: z.object({ x: z.number(), y: z.number() }),
}),
execute: async () => ({}),
}),
];
const aiTools = convertToolDefinitionsToVercelAITools(tools);
for (const name of ["tool1", "tool2", "tool3"]) {
expect(aiTools).toHaveProperty(name);
expect(aiTools[name].inputSchema["~standard"]?.vendor).toBe("zod");
expect(typeof aiTools[name].execute).toBe("function");
}
});
it("preserves tool description and execute function", () => {
const executeFn = vi.fn().mockResolvedValue({ result: "ok" });
const tools = [
defineTool({
name: "myTool",
description: "My custom tool description",
parameters: z.object({ input: z.string() }),
execute: executeFn,
}),
];
const aiTools = convertToolDefinitionsToVercelAITools(tools);
expect(aiTools.myTool.description).toBe("My custom tool description");
expect(aiTools.myTool.execute).toBeDefined();
});
});
describe("BuiltInAgent Zod regression", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
});
afterEach(() => {
process.env = originalEnv;
});
it("Zod tools are included in streamText call", async () => {
const agent = new BuiltInAgent({
model: "openai/gpt-4o",
tools: [
defineTool({
name: "weather",
description: "Get weather",
parameters: z.object({
city: z.string(),
units: z.enum(["celsius", "fahrenheit"]).optional(),
}),
execute: async () => ({ temp: 22 }),
}),
],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("weather");
expect(typeof callArgs.tools?.weather.execute).toBe("function");
});
it("multiple Zod tools coexist with built-in state tools", async () => {
const agent = new BuiltInAgent({
model: "openai/gpt-4o",
tools: [
defineTool({
name: "search",
description: "Search",
parameters: z.object({ query: z.string() }),
execute: async () => ({}),
}),
defineTool({
name: "calculate",
description: "Calculate",
parameters: z.object({
expression: z.string(),
precision: z.number().int().optional(),
}),
execute: async () => ({}),
}),
],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
// User-defined Zod tools
expect(callArgs.tools).toHaveProperty("search");
expect(callArgs.tools).toHaveProperty("calculate");
// Built-in state tools
expect(callArgs.tools).toHaveProperty("AGUISendStateSnapshot");
expect(callArgs.tools).toHaveProperty("AGUISendStateDelta");
});
it("Zod tool execute is invocable after conversion", async () => {
const executeFn = vi.fn().mockResolvedValue({ temp: 22 });
const agent = new BuiltInAgent({
model: "openai/gpt-4o",
tools: [
defineTool({
name: "weather",
description: "Get weather",
parameters: z.object({ city: z.string() }),
execute: executeFn,
}),
],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
const weatherTool = callArgs.tools?.weather;
// Invoke the execute function to verify it's wired correctly
await weatherTool.execute(
{ city: "Berlin" },
{ toolCallId: "tool-call-1", messages: [] },
);
expect(executeFn).toHaveBeenCalledWith(
{ city: "Berlin" },
{ toolCallId: "tool-call-1", messages: [] },
);
});
});
@@ -0,0 +1,405 @@
import type {
BaseEvent,
Interrupt,
ReasoningEndEvent,
ReasoningMessageContentEvent,
ReasoningMessageEndEvent,
ReasoningMessageStartEvent,
ReasoningStartEvent,
TextMessageChunkEvent,
ToolCallArgsEvent,
ToolCallEndEvent,
ToolCallStartEvent,
ToolCallResultEvent,
StateSnapshotEvent,
StateDeltaEvent,
} from "@ag-ui/client";
import { EventType } from "@ag-ui/client";
import { randomUUID } from "@copilotkit/shared";
/**
* Converts an AI SDK `fullStream` into AG-UI `BaseEvent` objects.
*
* This is a pure converter — it does NOT emit lifecycle events
* (RUN_STARTED / RUN_FINISHED / RUN_ERROR). The caller (Agent class)
* is responsible for those.
*
* Terminal stream events (finish, error, abort) cause the generator to
* return so the caller can handle lifecycle appropriately.
*
* `pendingInterrupts`, when provided, is filled with one AG-UI Interrupt per
* AI SDK `tool-approval-request` part (a tool declared `needsApproval: true`).
* The caller turns a non-empty array into a RUN_FINISHED `outcome:interrupt`.
*/
export async function* convertAISDKStream(
fullStream: AsyncIterable<unknown>,
abortSignal: AbortSignal,
pendingInterrupts?: Interrupt[],
): AsyncGenerator<BaseEvent> {
let messageId = randomUUID();
let reasoningMessageId = randomUUID();
let isInReasoning = false;
const toolCallStates = new Map<
string,
{
started: boolean;
hasArgsDelta: boolean;
ended: boolean;
toolName?: string;
}
>();
const ensureToolCallState = (toolCallId: string) => {
let state = toolCallStates.get(toolCallId);
if (!state) {
state = { started: false, hasArgsDelta: false, ended: false };
toolCallStates.set(toolCallId, state);
}
return state;
};
/**
* Auto-close an open reasoning lifecycle.
* Some AI SDK providers (notably @ai-sdk/anthropic) never emit "reasoning-end",
* which leaves downstream state machines stuck. This helper emits the
* missing REASONING_MESSAGE_END + REASONING_END events so the stream
* can transition to text, tool-call, or finish phases.
*/
function* closeReasoningIfOpen(): Generator<BaseEvent> {
if (!isInReasoning) return;
isInReasoning = false;
const reasoningMsgEnd: ReasoningMessageEndEvent = {
type: EventType.REASONING_MESSAGE_END,
messageId: reasoningMessageId,
};
yield reasoningMsgEnd;
const reasoningEnd: ReasoningEndEvent = {
type: EventType.REASONING_END,
messageId: reasoningMessageId,
};
yield reasoningEnd;
}
try {
for await (const part of fullStream) {
const p = part as Record<string, unknown>;
// Close any open reasoning lifecycle on every event except
// reasoning-delta, which arrives mid-block and must not interrupt it.
if (p.type !== "reasoning-delta") {
yield* closeReasoningIfOpen();
}
switch (p.type) {
case "abort": {
// Terminal — let the caller handle lifecycle
return;
}
case "reasoning-start": {
// Use SDK-provided id, or generate a fresh UUID if id is falsy/"0"
// to prevent consecutive reasoning blocks from sharing a messageId
const providedId = "id" in p ? p.id : undefined;
reasoningMessageId =
providedId && providedId !== "0"
? (providedId as string)
: randomUUID();
const reasoningStartEvent: ReasoningStartEvent = {
type: EventType.REASONING_START,
messageId: reasoningMessageId,
};
yield reasoningStartEvent;
const reasoningMessageStart: ReasoningMessageStartEvent = {
type: EventType.REASONING_MESSAGE_START,
messageId: reasoningMessageId,
role: "reasoning",
};
yield reasoningMessageStart;
isInReasoning = true;
break;
}
case "reasoning-delta": {
const delta = (p.text as string) ?? "";
if (!delta) break; // skip — @ag-ui/core schema requires delta to be non-empty
const reasoningDeltaEvent: ReasoningMessageContentEvent = {
type: EventType.REASONING_MESSAGE_CONTENT,
messageId: reasoningMessageId,
delta,
};
yield reasoningDeltaEvent;
break;
}
case "reasoning-end": {
// closeReasoningIfOpen() already called before the switch — no-op here
// if the SDK never emits this event (e.g. @ai-sdk/anthropic).
break;
}
case "tool-input-start": {
const toolCallId = p.id as string;
const state = ensureToolCallState(toolCallId);
state.toolName = p.toolName as string;
if (!state.started) {
state.started = true;
const startEvent: ToolCallStartEvent = {
type: EventType.TOOL_CALL_START,
parentMessageId: messageId,
toolCallId,
toolCallName: p.toolName as string,
};
yield startEvent;
}
break;
}
case "tool-input-delta": {
const toolCallId = p.id as string;
const state = ensureToolCallState(toolCallId);
state.hasArgsDelta = true;
const argsEvent: ToolCallArgsEvent = {
type: EventType.TOOL_CALL_ARGS,
toolCallId,
delta: p.delta as string,
};
yield argsEvent;
break;
}
case "tool-input-end": {
// No direct event the subsequent "tool-call" part marks completion.
break;
}
case "text-start": {
// New text message starting - use the SDK-provided id
// Use randomUUID() if part.id is falsy or "0" to prevent message merging issues
const providedId = "id" in p ? p.id : undefined;
messageId =
providedId && providedId !== "0"
? (providedId as string)
: randomUUID();
break;
}
case "text-delta": {
// AI SDK text-delta events use 'text' (not 'delta')
const textDelta = "text" in p ? (p.text as string) : "";
const textEvent: TextMessageChunkEvent = {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
messageId,
delta: textDelta,
};
yield textEvent;
break;
}
case "tool-call": {
const toolCallId = p.toolCallId as string;
const state = ensureToolCallState(toolCallId);
state.toolName = (p.toolName as string) ?? state.toolName;
if (!state.started) {
state.started = true;
const startEvent: ToolCallStartEvent = {
type: EventType.TOOL_CALL_START,
parentMessageId: messageId,
toolCallId,
toolCallName: p.toolName as string,
};
yield startEvent;
}
if (!state.hasArgsDelta && "input" in p && p.input !== undefined) {
let serializedInput = "";
if (typeof p.input === "string") {
serializedInput = p.input;
} else {
try {
serializedInput = JSON.stringify(p.input);
} catch {
serializedInput = String(p.input);
}
}
if (serializedInput.length > 0) {
const argsEvent: ToolCallArgsEvent = {
type: EventType.TOOL_CALL_ARGS,
toolCallId,
delta: serializedInput,
};
yield argsEvent;
state.hasArgsDelta = true;
}
}
if (!state.ended) {
state.ended = true;
const endEvent: ToolCallEndEvent = {
type: EventType.TOOL_CALL_END,
toolCallId,
};
yield endEvent;
}
break;
}
case "tool-approval-request": {
// AI SDK native human-in-the-loop: a tool declared `needsApproval`
// was called. The preceding "tool-call" part already streamed the
// tool name + args; this part carries the toolCallId to pause on.
// (Some shapes nest the call under `toolCall` — read both.)
const nested = (p.toolCall ?? {}) as {
toolCallId?: string;
toolName?: string;
input?: unknown;
};
const toolCallId =
(p.toolCallId as string | undefined) ?? nested.toolCallId;
if (!toolCallId) {
throw new Error(
"AI SDK tool-approval-request is missing toolCallId",
);
}
const state = ensureToolCallState(toolCallId);
const toolName = state.toolName ?? nested.toolName;
// Defensive: emit the tool-call lifecycle if the "tool-call" part
// didn't precede this one, so the assistant tool-call is in history
// for the resume run (which keys the injected result by toolCallId).
if (!state.started) {
state.started = true;
const startEvent: ToolCallStartEvent = {
type: EventType.TOOL_CALL_START,
parentMessageId: messageId,
toolCallId,
toolCallName: toolName ?? "",
};
yield startEvent;
}
if (!state.hasArgsDelta && nested.input !== undefined) {
let serialized = "";
try {
serialized =
typeof nested.input === "string"
? nested.input
: JSON.stringify(nested.input);
} catch {
serialized = String(nested.input);
}
if (serialized.length > 0) {
const argsEvent: ToolCallArgsEvent = {
type: EventType.TOOL_CALL_ARGS,
toolCallId,
delta: serialized,
};
yield argsEvent;
state.hasArgsDelta = true;
}
}
if (!state.ended) {
state.ended = true;
const endEvent: ToolCallEndEvent = {
type: EventType.TOOL_CALL_END,
toolCallId,
};
yield endEvent;
}
pendingInterrupts?.push({
id: toolCallId,
toolCallId,
reason: "tool_approval",
message: toolName ? `Approve "${toolName}"?` : undefined,
...(toolName ? { metadata: { toolName } } : {}),
});
break;
}
case "tool-result": {
// AI SDK tool-result uses "output"; older versions used "result" — check both
const toolResult =
"output" in p ? p.output : "result" in p ? p.result : null;
const toolName = "toolName" in p ? (p.toolName as string) : "";
toolCallStates.delete(p.toolCallId as string);
// Check if this is a state update tool
if (
toolName === "AGUISendStateSnapshot" &&
toolResult &&
typeof toolResult === "object"
) {
const snapshot = (toolResult as Record<string, unknown>).snapshot;
if (snapshot !== undefined) {
const stateSnapshotEvent: StateSnapshotEvent = {
type: EventType.STATE_SNAPSHOT,
snapshot,
};
yield stateSnapshotEvent;
}
} else if (
toolName === "AGUISendStateDelta" &&
toolResult &&
typeof toolResult === "object"
) {
const delta = (toolResult as { delta?: StateDeltaEvent["delta"] })
.delta;
if (delta !== undefined) {
const stateDeltaEvent: StateDeltaEvent = {
type: EventType.STATE_DELTA,
delta,
};
yield stateDeltaEvent;
}
}
// Always emit the tool result event for the LLM
let serializedResult: string;
try {
serializedResult = JSON.stringify(toolResult);
} catch {
serializedResult = `[Unserializable tool result from ${toolName || "unknown tool"}]`;
}
const resultEvent: ToolCallResultEvent = {
type: EventType.TOOL_CALL_RESULT,
role: "tool",
messageId: randomUUID(),
toolCallId: p.toolCallId as string,
content: serializedResult,
};
yield resultEvent;
break;
}
case "finish": {
// Terminal — let the caller handle lifecycle
return;
}
case "error": {
if (abortSignal.aborted) {
return;
}
// Re-throw so the caller can emit RUN_ERROR
const err = p.error ?? p.message ?? p.cause;
if (err instanceof Error) throw err;
throw new Error(
typeof err === "string"
? err
: `AI SDK stream error: ${JSON.stringify(p)}`,
);
}
default:
// Unknown event types are silently ignored
break;
}
}
} finally {
// Always close reasoning on exit (normal or exceptional)
yield* closeReasoningIfOpen();
}
}
@@ -0,0 +1,7 @@
export { convertAISDKStream } from "./aisdk";
export {
convertTanStackStream,
convertInputToTanStackAI,
type TanStackChatMessage,
type TanStackInputResult,
} from "./tanstack";
@@ -0,0 +1,594 @@
import type {
BaseEvent,
Interrupt,
RunAgentInput,
Message,
TextMessageChunkEvent,
ToolCallArgsEvent,
ToolCallEndEvent,
ToolCallStartEvent,
ToolCallResultEvent,
StateSnapshotEvent,
StateDeltaEvent,
ReasoningStartEvent,
ReasoningMessageStartEvent,
ReasoningMessageContentEvent,
ReasoningMessageEndEvent,
ReasoningEndEvent,
} from "@ag-ui/client";
import { EventType } from "@ag-ui/client";
import { randomUUID } from "@copilotkit/shared";
type ContentPartSource =
| { type: "data"; value: string; mimeType: string }
| { type: "url"; value: string; mimeType?: string };
/**
* A TanStack AI content part (text, image, audio, video, or document).
*/
export type TanStackContentPart =
| { type: "text"; content: string }
| { type: "image"; source: ContentPartSource }
| { type: "audio"; source: ContentPartSource }
| { type: "video"; source: ContentPartSource }
| { type: "document"; source: ContentPartSource };
/**
* Message format expected by TanStack AI's `chat()`.
*
* Content is typed as `any[]` for the multimodal case so messages are directly
* passable to any adapter without casts — different adapters constrain which
* modalities they accept (e.g. OpenAI only allows text + image).
* Use `TanStackContentPart` to inspect individual parts if needed.
*/
export interface TanStackChatMessage {
role: "user" | "assistant" | "tool";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
content: string | null | any[];
name?: string;
toolCalls?: Array<{
id: string;
type: "function";
function: { name: string; arguments: string };
}>;
toolCallId?: string;
}
/**
* A TanStack AI client-side tool, derived from a frontend-provided AG-UI tool.
*
* Shaped to match `@tanstack/ai`'s `ClientTool` (`__toolSide: "client"`, no
* `execute`): the model may CALL it, but TanStack does not run it — it pauses
* the run and hands the call back to the AG-UI client (the CopilotKit frontend
* / bot) to execute, mirroring CopilotKit's client-tool round-trip. `chat()`
* accepts a JSON Schema directly as `inputSchema`, so the AG-UI tool's
* `parameters` pass through unchanged.
*/
export interface TanStackClientTool {
__toolSide: "client";
name: string;
description: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
inputSchema: any;
}
/**
* Result of converting RunAgentInput to TanStack AI format.
*/
export interface TanStackInputResult {
/** Chat messages (only user/assistant/tool roles; all others excluded) */
messages: TanStackChatMessage[];
/** System prompts extracted from system/developer messages, context, and state */
systemPrompts: string[];
/**
* Client-side tools derived from `input.tools` (the frontend-provided tools
* the CopilotKit client forwards on every run). Pass these into `chat()`
* alongside any server/provider tools so the model can call the frontend's
* generative-UI and human-in-the-loop tools; TanStack pauses the run on a
* client-tool call and the client executes it.
*/
tools: TanStackClientTool[];
}
/**
* Converts AG-UI user message content to TanStack AI format.
* Handles plain strings, multimodal parts (image/audio/video/document),
* and legacy BinaryInputContent for backward compatibility.
*/
function convertUserContent(
content: unknown,
): string | null | TanStackContentPart[] {
if (!content) return null;
if (typeof content === "string") return content;
if (!Array.isArray(content)) return null;
if (content.length === 0) return "";
const parts: TanStackContentPart[] = [];
for (const part of content) {
if (!part || typeof part !== "object" || !("type" in part)) continue;
switch ((part as { type: string }).type) {
case "text": {
const text = (part as { text?: string }).text;
if (text != null) parts.push({ type: "text", content: text });
break;
}
case "image":
case "audio":
case "video":
case "document": {
const source = (part as { source?: any }).source;
if (!source) break;
const partType = (part as { type: string }).type as
| "image"
| "audio"
| "video"
| "document";
if (source.type === "data") {
parts.push({
type: partType,
source: {
type: "data",
value: source.value,
mimeType: source.mimeType,
},
});
} else if (source.type === "url") {
parts.push({
type: partType,
source: {
type: "url",
value: source.value,
...(source.mimeType ? { mimeType: source.mimeType } : {}),
},
});
}
break;
}
// Legacy BinaryInputContent backward compatibility
case "binary": {
const legacy = part as {
mimeType?: string;
data?: string;
url?: string;
};
const mimeType = legacy.mimeType ?? "application/octet-stream";
const isImage = mimeType.startsWith("image/");
if (legacy.data) {
const partType = isImage ? "image" : "document";
parts.push({
type: partType,
source: { type: "data", value: legacy.data, mimeType },
});
} else if (legacy.url) {
const partType = isImage ? "image" : "document";
parts.push({
type: partType,
source: { type: "url", value: legacy.url, mimeType },
});
}
break;
}
}
}
return parts.length > 0 ? parts : "";
}
/**
* Recursively normalizes a frontend tool's JSON Schema so OpenAI accepts it as
* a function-tool schema.
*
* Frontend tools are often authored with permissive Zod (`z.any()`,
* `z.record(...)`, `.passthrough()`), which serialize to open objects —
* `additionalProperties: {}` (an empty sub-schema) or `additionalProperties:
* true`. OpenAI rejects both: strict mode requires `additionalProperties:
* false`, and an empty `{}` sub-schema fails base validation ("schema must
* have a 'type' key"). The classic (Vercel AI SDK) path sanitized these
* implicitly via a Zod round-trip; the TanStack path forwards the raw schema,
* so we close open objects here to match. (Models can't supply free-form extra
* keys either way — same as the classic path.)
*/
function sanitizeClientToolSchema(schema: unknown): unknown {
if (Array.isArray(schema)) {
return schema.map(sanitizeClientToolSchema);
}
if (!schema || typeof schema !== "object") {
return schema;
}
const node: Record<string, unknown> = {
...(schema as Record<string, unknown>),
};
// Any `additionalProperties` (empty `{}`, `true`, or a sub-schema) becomes
// `false` — the only form OpenAI accepts for strict function tools.
if ("additionalProperties" in node) {
node.additionalProperties = false;
}
if (node.properties && typeof node.properties === "object") {
const props: Record<string, unknown> = {};
for (const [key, value] of Object.entries(
node.properties as Record<string, unknown>,
)) {
props[key] = sanitizeClientToolSchema(value);
}
node.properties = props;
}
if ("items" in node) {
node.items = sanitizeClientToolSchema(node.items);
}
for (const combinator of ["anyOf", "allOf", "oneOf"] as const) {
if (Array.isArray(node[combinator])) {
node[combinator] = (node[combinator] as unknown[]).map(
sanitizeClientToolSchema,
);
}
}
return node;
}
/**
* Converts a RunAgentInput into the format expected by TanStack AI's `chat()`.
*
* - Keeps only user/assistant/tool messages (activity, reasoning, and other roles are also excluded)
* - Extracts system/developer messages into `systemPrompts`
* - Appends context entries and application state to `systemPrompts`
* - Preserves tool calls on assistant messages and toolCallId on tool messages
*/
export function convertInputToTanStackAI(
input: RunAgentInput,
): TanStackInputResult {
// Allowlist: only pass user/assistant/tool messages to TanStack.
// Other roles (system, developer, activity, reasoning) are either
// extracted into systemPrompts or not applicable.
const chatRoles = new Set(["user", "assistant", "tool"]);
const messages: TanStackChatMessage[] = input.messages
.filter((m: Message) => chatRoles.has(m.role))
.map((m: Message): TanStackChatMessage => {
const msg: TanStackChatMessage = {
role: m.role as "user" | "assistant" | "tool",
content:
m.role === "user"
? convertUserContent(m.content)
: typeof m.content === "string"
? m.content
: null,
};
if (m.role === "assistant" && "toolCalls" in m && m.toolCalls) {
msg.toolCalls = m.toolCalls.map((tc) => ({
id: tc.id,
type: "function" as const,
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
}));
}
if (m.role === "tool" && "toolCallId" in m) {
msg.toolCallId = (m as Record<string, unknown>).toolCallId as string;
}
return msg;
});
const systemPrompts: string[] = [];
for (const m of input.messages) {
if ((m.role === "system" || m.role === "developer") && m.content) {
systemPrompts.push(
typeof m.content === "string" ? m.content : JSON.stringify(m.content),
);
}
}
if (input.context?.length) {
for (const ctx of input.context) {
systemPrompts.push(`${ctx.description}:\n${ctx.value}`);
}
}
if (
input.state !== undefined &&
input.state !== null &&
typeof input.state === "object" &&
Object.keys(input.state).length > 0
) {
systemPrompts.push(
`Application State:\n\`\`\`json\n${JSON.stringify(input.state, null, 2)}\n\`\`\``,
);
}
// Frontend-provided tools become client-side TanStack tools (no executor):
// the model can call them, TanStack pauses the run, and the AG-UI client
// executes them and resumes — the CopilotKit client-tool round-trip.
const tools: TanStackClientTool[] = (input.tools ?? []).map((t) => ({
__toolSide: "client",
name: t.name,
description: t.description,
inputSchema: sanitizeClientToolSchema(t.parameters),
}));
return { messages, systemPrompts, tools };
}
/**
* Converts a TanStack AI stream into AG-UI `BaseEvent` objects.
*
* This is a pure converter — it does NOT emit lifecycle events
* (RUN_STARTED / RUN_FINISHED / RUN_ERROR). The caller (Agent class)
* is responsible for those.
*
* `pendingInterrupts`, when provided, is filled with one AG-UI Interrupt per
* CUSTOM "approval-requested" chunk (a tool declared `needsApproval: true`).
* The caller turns a non-empty array into a RUN_FINISHED `outcome:interrupt`.
*/
export async function* convertTanStackStream(
stream: AsyncIterable<unknown>,
abortSignal: AbortSignal,
pendingInterrupts?: Interrupt[],
): AsyncGenerator<BaseEvent> {
const messageId = randomUUID();
const toolNamesById = new Map<string, string>();
// Track the reasoning lifecycle at two granularities so closeReasoningIfOpen
// emits exactly the events still owed. A single boolean conflates the run
// (REASONING_START → REASONING_END) with the message
// (REASONING_MESSAGE_START → REASONING_MESSAGE_END) and produces a duplicate
// REASONING_MESSAGE_END when upstream emits MSG_END but not END before
// text/tools resume.
let reasoningRunOpen = false;
let reasoningMessageOpen = false;
let reasoningMessageId = randomUUID();
function* closeReasoningIfOpen(): Generator<BaseEvent> {
if (reasoningMessageOpen) {
reasoningMessageOpen = false;
const msgEnd: ReasoningMessageEndEvent = {
type: EventType.REASONING_MESSAGE_END,
messageId: reasoningMessageId,
};
yield msgEnd;
}
if (reasoningRunOpen) {
reasoningRunOpen = false;
const end: ReasoningEndEvent = {
type: EventType.REASONING_END,
messageId: reasoningMessageId,
};
yield end;
}
}
// TanStack's chat() engine runs a multi-turn agent loop and emits a
// RUN_STARTED / RUN_FINISHED pair PER model turn — not once for the whole
// run. When it executes a tool itself (an MCP server tool or a provider tool
// like web_search), it does so between turns and streams a TOOL_CALL_RESULT
// followed by the next turn's text. The overall run lifecycle is owned by the
// Agent wrapper (it emits exactly one outer RUN_STARTED / RUN_FINISHED), so
// we drop TanStack's per-turn lifecycle markers and convert every content
// event across all turns. (A previous version stopped converting at the first
// RUN_FINISHED — that truncated the run at the first tool turn and silently
// dropped both the tool result and the model's final answer.)
//
// chat() can re-announce a tool call when it re-prompts after executing it,
// so START / END are de-duplicated by toolCallId to avoid emitting a pair
// twice (which would violate the ag-ui verify middleware).
const startedToolCalls = new Set<string>();
const endedToolCalls = new Set<string>();
for await (const chunk of stream) {
if (abortSignal.aborted) break;
const raw = chunk as Record<string, unknown>;
const type = raw.type as string;
// TanStack native human-in-the-loop: a tool declared `needsApproval: true`
// emits a CUSTOM "approval-requested" chunk. These are built from the
// finish event and can arrive around lifecycle markers, so handle them
// before dropping TanStack's per-turn lifecycle events.
// The tool-call lifecycle was already streamed in the model pass.
if (type === "CUSTOM" && raw.name === "approval-requested") {
const value = (raw.value ?? {}) as {
toolCallId?: string;
toolName?: string;
};
const toolCallId = value.toolCallId;
if (toolCallId) {
pendingInterrupts?.push({
id: toolCallId,
toolCallId,
reason: "tool_approval",
message: value.toolName ? `Approve "${value.toolName}"?` : undefined,
...(value.toolName ? { metadata: { toolName: value.toolName } } : {}),
});
}
continue;
}
// Per-turn lifecycle markers are owned by the Agent wrapper, not forwarded.
if (type === "RUN_STARTED" || type === "RUN_FINISHED") {
continue;
}
// Surface engine errors instead of dropping them: throw so the Agent
// wrapper emits a terminal RUN_ERROR. Without this a failed run (e.g. a
// provider 4xx) would finish empty with no indication of what went wrong.
if (type === "RUN_ERROR") {
throw new Error(
typeof raw.message === "string" ? raw.message : "TanStack AI run error",
);
}
if (type === "TEXT_MESSAGE_CONTENT" && raw.delta != null) {
yield* closeReasoningIfOpen();
const textEvent: TextMessageChunkEvent = {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
messageId,
delta: raw.delta as string,
};
yield textEvent;
} else if (type === "TOOL_CALL_START") {
const toolCallId = raw.toolCallId as string;
if (startedToolCalls.has(toolCallId)) continue;
startedToolCalls.add(toolCallId);
yield* closeReasoningIfOpen();
toolNamesById.set(toolCallId, raw.toolCallName as string);
const startEvent: ToolCallStartEvent = {
type: EventType.TOOL_CALL_START,
parentMessageId: messageId,
toolCallId,
toolCallName: raw.toolCallName as string,
};
yield startEvent;
} else if (type === "TOOL_CALL_ARGS") {
// Drop args re-announced after the call has ended (the re-prompt pass);
// forwarding them would corrupt the already-closed call's accumulated args.
if (endedToolCalls.has(raw.toolCallId as string)) continue;
yield* closeReasoningIfOpen();
const argsEvent: ToolCallArgsEvent = {
type: EventType.TOOL_CALL_ARGS,
toolCallId: raw.toolCallId as string,
delta: raw.delta as string,
};
yield argsEvent;
} else if (type === "TOOL_CALL_END") {
const toolCallId = raw.toolCallId as string;
if (endedToolCalls.has(toolCallId)) continue;
endedToolCalls.add(toolCallId);
yield* closeReasoningIfOpen();
const endEvent: ToolCallEndEvent = {
type: EventType.TOOL_CALL_END,
toolCallId,
};
yield endEvent;
} else if (type === "TOOL_CALL_RESULT") {
yield* closeReasoningIfOpen();
const toolCallId = raw.toolCallId as string;
const toolName = toolNamesById.get(toolCallId);
// Accept the payload from either `content` (canonical TanStack shape)
// or `result` (alternate shape used by some adapters / tests). Both
// state-tool detection and the final TOOL_CALL_RESULT serialization
// must read the same field, otherwise STATE_SNAPSHOT/STATE_DELTA can
// be silently dropped when upstream uses `result`.
const rawPayload = raw.content ?? raw.result;
const parsedContent =
typeof rawPayload === "string" ? safeParse(rawPayload) : rawPayload;
if (
toolName === "AGUISendStateSnapshot" &&
parsedContent &&
typeof parsedContent === "object" &&
"snapshot" in parsedContent
) {
const stateSnapshotEvent: StateSnapshotEvent = {
type: EventType.STATE_SNAPSHOT,
snapshot: (parsedContent as Record<string, unknown>).snapshot,
};
yield stateSnapshotEvent;
}
if (
toolName === "AGUISendStateDelta" &&
parsedContent &&
typeof parsedContent === "object" &&
"delta" in parsedContent
) {
const stateDeltaEvent: StateDeltaEvent = {
type: EventType.STATE_DELTA,
delta: (parsedContent as Record<string, unknown>).delta as never,
};
yield stateDeltaEvent;
}
let serializedContent: string;
if (typeof rawPayload === "string") {
serializedContent = rawPayload;
} else {
try {
serializedContent = JSON.stringify(rawPayload ?? null);
} catch {
serializedContent = "[Unserializable tool result]";
}
}
const resultEvent: ToolCallResultEvent = {
type: EventType.TOOL_CALL_RESULT,
role: "tool",
messageId: randomUUID(),
toolCallId,
content: serializedContent,
};
yield resultEvent;
toolNamesById.delete(toolCallId);
} else if (type === "REASONING_START") {
// If a prior reasoning run is still open (no REASONING_END before this
// new START), close it cleanly first so MSG_END / END pair correctly.
yield* closeReasoningIfOpen();
reasoningRunOpen = true;
reasoningMessageId = (raw.messageId as string) ?? randomUUID();
const startEvt: ReasoningStartEvent = {
type: EventType.REASONING_START,
messageId: reasoningMessageId,
};
yield startEvt;
} else if (type === "REASONING_MESSAGE_START") {
reasoningMessageOpen = true;
const evt: ReasoningMessageStartEvent = {
type: EventType.REASONING_MESSAGE_START,
messageId: reasoningMessageId,
role: "reasoning",
};
yield evt;
} else if (type === "REASONING_MESSAGE_CONTENT") {
const evt: ReasoningMessageContentEvent = {
type: EventType.REASONING_MESSAGE_CONTENT,
messageId: reasoningMessageId,
delta: raw.delta as string,
};
yield evt;
} else if (type === "REASONING_MESSAGE_END") {
reasoningMessageOpen = false;
const evt: ReasoningMessageEndEvent = {
type: EventType.REASONING_MESSAGE_END,
messageId: reasoningMessageId,
};
yield evt;
} else if (type === "REASONING_END") {
// If upstream sends REASONING_END while a message is still open, emit
// the missing REASONING_MESSAGE_END FIRST so the closing pair stays in
// order (MSG_END before END). Otherwise the next non-reasoning chunk
// would trigger closeReasoningIfOpen and emit MSG_END after END.
if (reasoningMessageOpen) {
reasoningMessageOpen = false;
const msgEnd: ReasoningMessageEndEvent = {
type: EventType.REASONING_MESSAGE_END,
messageId: reasoningMessageId,
};
yield msgEnd;
}
reasoningRunOpen = false;
const evt: ReasoningEndEvent = {
type: EventType.REASONING_END,
messageId: reasoningMessageId,
};
yield evt;
}
}
yield* closeReasoningIfOpen();
}
function safeParse(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return value;
}
}
File diff suppressed because it is too large Load Diff