chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,683 @@
|
||||
/**
|
||||
* Shared test utilities for BuiltInAgent factory-mode tests.
|
||||
*
|
||||
* Re-exports everything from the existing test-helpers module and adds
|
||||
* BuiltInAgent-specific factories, mock stream builders, and assertion helpers.
|
||||
*/
|
||||
|
||||
import { EventType } from "@ag-ui/client";
|
||||
import type { BaseEvent, RunAgentInput } from "@ag-ui/client";
|
||||
import type { Observable } from "rxjs";
|
||||
import { MockLanguageModelV3, simulateReadableStream } from "ai/test";
|
||||
import { BuiltInAgent } from "../index";
|
||||
import type {
|
||||
AgentFactoryContext,
|
||||
BuiltInAgentFactoryConfig,
|
||||
ToolDefinition,
|
||||
} from "../index";
|
||||
import type { MockStreamEvent } from "./test-helpers";
|
||||
|
||||
// Re-export everything from existing test helpers
|
||||
export {
|
||||
type MockStreamEvent,
|
||||
mockStreamTextResponse,
|
||||
textStart,
|
||||
textDelta,
|
||||
toolCallStreamingStart,
|
||||
toolCallDelta,
|
||||
toolCall,
|
||||
toolResult,
|
||||
reasoningStart,
|
||||
reasoningDelta,
|
||||
reasoningEnd,
|
||||
finish,
|
||||
abort,
|
||||
error,
|
||||
collectEvents,
|
||||
} from "./test-helpers";
|
||||
|
||||
// Re-export for test files that need to construct agents directly
|
||||
export {
|
||||
BuiltInAgent,
|
||||
type AgentFactoryContext,
|
||||
type BuiltInAgentFactoryConfig,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default input factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns a valid `RunAgentInput` with sensible defaults.
|
||||
* Any field can be overridden via the `overrides` parameter.
|
||||
*/
|
||||
export function createDefaultInput(overrides?: Partial<RunAgentInput>) {
|
||||
return {
|
||||
threadId: "test-thread",
|
||||
runId: "test-run",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
forwardedProps: {},
|
||||
...overrides,
|
||||
} as RunAgentInput;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI SDK native human-in-the-loop stream part
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* AI SDK `fullStream` part emitted when a tool declared `needsApproval: true`
|
||||
* is called. In the real SDK the tool name + args arrive in a preceding
|
||||
* `tool-call` part; this part only carries the id to pause on.
|
||||
*/
|
||||
export function aisdkToolApprovalRequest(toolCallId: string): MockStreamEvent {
|
||||
return {
|
||||
type: "tool-approval-request",
|
||||
toolCallId,
|
||||
approvalId: `approval-${toolCallId}`,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TanStack mock stream chunk builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** TanStack text content chunk */
|
||||
export function tanstackTextChunk(delta: string) {
|
||||
return { type: "TEXT_MESSAGE_CONTENT", delta } as const;
|
||||
}
|
||||
|
||||
/** TanStack tool call start chunk */
|
||||
export function tanstackToolCallStart(
|
||||
toolCallId: string,
|
||||
toolCallName: string,
|
||||
) {
|
||||
return { type: "TOOL_CALL_START", toolCallId, toolCallName } as const;
|
||||
}
|
||||
|
||||
/** TanStack tool call args chunk */
|
||||
export function tanstackToolCallArgs(toolCallId: string, delta: string) {
|
||||
return { type: "TOOL_CALL_ARGS", toolCallId, delta } as const;
|
||||
}
|
||||
|
||||
/** TanStack tool call end chunk */
|
||||
export function tanstackToolCallEnd(toolCallId: string) {
|
||||
return { type: "TOOL_CALL_END", toolCallId } as const;
|
||||
}
|
||||
|
||||
/** TanStack tool-call result chunk. `content` is what the tool's `execute()` returned. */
|
||||
export function tanstackToolCallResult(toolCallId: string, content: unknown) {
|
||||
return { type: "TOOL_CALL_RESULT", toolCallId, content } as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* TanStack native human-in-the-loop chunk: the CUSTOM "approval-requested"
|
||||
* event emitted by `chat()` when a tool declared `needsApproval: true` is called.
|
||||
*/
|
||||
export function tanstackApprovalRequested(
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
input?: unknown,
|
||||
) {
|
||||
return {
|
||||
type: "CUSTOM",
|
||||
name: "approval-requested",
|
||||
value: {
|
||||
toolCallId,
|
||||
toolName,
|
||||
input,
|
||||
approval: { id: `approval-${toolCallId}`, needsApproval: true },
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
/** TanStack reasoning lifecycle chunk builders */
|
||||
export function tanstackReasoningStart(messageId: string) {
|
||||
return { type: "REASONING_START", messageId } as const;
|
||||
}
|
||||
export function tanstackReasoningMessageStart(messageId: string) {
|
||||
return {
|
||||
type: "REASONING_MESSAGE_START",
|
||||
messageId,
|
||||
role: "reasoning",
|
||||
} as const;
|
||||
}
|
||||
export function tanstackReasoningMessageContent(
|
||||
messageId: string,
|
||||
delta: string,
|
||||
) {
|
||||
return { type: "REASONING_MESSAGE_CONTENT", messageId, delta } as const;
|
||||
}
|
||||
export function tanstackReasoningMessageEnd(messageId: string) {
|
||||
return { type: "REASONING_MESSAGE_END", messageId } as const;
|
||||
}
|
||||
export function tanstackReasoningEnd(messageId: string) {
|
||||
return { type: "REASONING_END", messageId } as const;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock async iterable builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Creates an `AsyncIterable<unknown>` from an array of TanStack-style chunks.
|
||||
*/
|
||||
export function mockTanStackStream(chunks: Record<string, unknown>[]) {
|
||||
return {
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
for (const chunk of chunks) {
|
||||
yield chunk;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an `AsyncIterable<BaseEvent>` from an array of AG-UI events.
|
||||
*/
|
||||
export function mockCustomStream(
|
||||
events: BaseEvent[],
|
||||
): AsyncIterable<BaseEvent> {
|
||||
return {
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
for (const event of events) {
|
||||
yield event;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BuiltInAgent factories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AgentType = "aisdk" | "tanstack" | "custom";
|
||||
|
||||
/**
|
||||
* Creates a BuiltInAgent backed by a mock factory that yields the given stream data.
|
||||
*
|
||||
* Overloaded for each supported agent type:
|
||||
* - `"aisdk"` expects `MockStreamEvent[]` (AI SDK fullStream events)
|
||||
* - `"tanstack"` expects `Record<string, unknown>[]` (TanStack chunks)
|
||||
* - `"custom"` expects `BaseEvent[]` (AG-UI events directly)
|
||||
*/
|
||||
export function createAgent(
|
||||
type: "aisdk",
|
||||
streamData: MockStreamEvent[],
|
||||
): BuiltInAgent;
|
||||
export function createAgent(
|
||||
type: "tanstack",
|
||||
streamData: Record<string, unknown>[],
|
||||
): BuiltInAgent;
|
||||
export function createAgent(
|
||||
type: "custom",
|
||||
streamData: BaseEvent[],
|
||||
): BuiltInAgent;
|
||||
export function createAgent(
|
||||
type: AgentType,
|
||||
streamData: MockStreamEvent[] | Record<string, unknown>[] | BaseEvent[],
|
||||
): BuiltInAgent;
|
||||
export function createAgent(
|
||||
type: AgentType,
|
||||
streamData: MockStreamEvent[] | Record<string, unknown>[] | BaseEvent[],
|
||||
): BuiltInAgent {
|
||||
switch (type) {
|
||||
case "aisdk": {
|
||||
// Cast needed: TypeScript's control-flow narrowing doesn't propagate
|
||||
// through overload signatures to narrow the union parameter type.
|
||||
const events = streamData as MockStreamEvent[];
|
||||
return new BuiltInAgent({
|
||||
type: "aisdk",
|
||||
factory: () => ({
|
||||
fullStream: (async function* () {
|
||||
for (const event of events) {
|
||||
yield event;
|
||||
}
|
||||
})(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
case "tanstack": {
|
||||
// Cast needed: same overload-narrowing limitation as above.
|
||||
const chunks = streamData as Record<string, unknown>[];
|
||||
return new BuiltInAgent({
|
||||
type: "tanstack",
|
||||
factory: () => mockTanStackStream(chunks),
|
||||
});
|
||||
}
|
||||
case "custom": {
|
||||
// Cast needed: same overload-narrowing limitation as above.
|
||||
const events = streamData as BaseEvent[];
|
||||
return new BuiltInAgent({
|
||||
type: "custom",
|
||||
factory: () => mockCustomStream(events),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Classic (model-based) agent factory with a capturing mock model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The element type of the `ReadableStream` returned by a V3 model's `doStream`.
|
||||
* Derived from `MockLanguageModelV3` so we never need a direct (non-resolvable
|
||||
* from this package) `@ai-sdk/provider` import.
|
||||
*/
|
||||
type V3StreamResult = Awaited<ReturnType<MockLanguageModelV3["doStream"]>>;
|
||||
type V3StreamPart =
|
||||
V3StreamResult["stream"] extends ReadableStream<infer P> ? P : never;
|
||||
type V3FinishPart = Extract<V3StreamPart, { type: "finish" }>;
|
||||
/** The shape of the options object the model's `doStream` is invoked with. */
|
||||
type V3CallOptions = MockLanguageModelV3["doStreamCalls"][number];
|
||||
|
||||
function toV3FinishReason(value: unknown): V3FinishPart["finishReason"] {
|
||||
if (value && typeof value === "object" && "unified" in value) {
|
||||
return value as V3FinishPart["finishReason"];
|
||||
}
|
||||
|
||||
const raw = typeof value === "string" ? value : undefined;
|
||||
switch (raw) {
|
||||
case "length":
|
||||
case "content-filter":
|
||||
case "tool-calls":
|
||||
case "error":
|
||||
case "other":
|
||||
case "stop":
|
||||
return { unified: raw, raw };
|
||||
default:
|
||||
return { unified: "stop", raw };
|
||||
}
|
||||
}
|
||||
|
||||
/** A BuiltInAgent augmented with a test-only seam exposing the messages the
|
||||
* model received on its most recent `doStream` invocation. */
|
||||
export type ClassicAgentWithCapture = BuiltInAgent & {
|
||||
/** The `prompt` (provider-level message list) passed to the mock model on its
|
||||
* most recent `doStream` call, or `undefined` if the model was never run. */
|
||||
readonly __lastModelMessages: V3CallOptions["prompt"] | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Translates the existing fullStream-style `MockStreamEvent` chunks (built via
|
||||
* `textDelta`, `toolCall`, `finish`, …) into the provider-level
|
||||
* `LanguageModelV3` stream parts that the REAL `streamText` consumes from a
|
||||
* model's `doStream`.
|
||||
*
|
||||
* Only the chunk kinds needed by the classic-path interrupt tests are
|
||||
* translated; anything else is passed through unchanged so it can be extended
|
||||
* later without breaking existing callers.
|
||||
*/
|
||||
function toV3StreamParts(chunks: MockStreamEvent[]): V3StreamPart[] {
|
||||
const parts: V3StreamPart[] = [];
|
||||
let openTextId: string | undefined;
|
||||
|
||||
const closeTextIfOpen = () => {
|
||||
if (openTextId !== undefined) {
|
||||
parts.push({ type: "text-end", id: openTextId } as V3StreamPart);
|
||||
openTextId = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
let autoId = 0;
|
||||
for (const chunk of chunks) {
|
||||
switch (chunk.type) {
|
||||
case "text-start": {
|
||||
const id = (chunk.id as string | undefined) ?? `text-${autoId++}`;
|
||||
openTextId = id;
|
||||
parts.push({ type: "text-start", id } as V3StreamPart);
|
||||
break;
|
||||
}
|
||||
case "text-delta": {
|
||||
if (openTextId === undefined) {
|
||||
openTextId = `text-${autoId++}`;
|
||||
parts.push({ type: "text-start", id: openTextId } as V3StreamPart);
|
||||
}
|
||||
parts.push({
|
||||
type: "text-delta",
|
||||
id: openTextId,
|
||||
delta: String(chunk.text ?? ""),
|
||||
} as V3StreamPart);
|
||||
break;
|
||||
}
|
||||
case "tool-call": {
|
||||
closeTextIfOpen();
|
||||
// Provider-level tool calls carry a stringified-JSON `input`; the
|
||||
// fullStream builder accepts an object, so stringify it here.
|
||||
const input =
|
||||
typeof chunk.input === "string"
|
||||
? chunk.input
|
||||
: JSON.stringify(chunk.input ?? {});
|
||||
parts.push({
|
||||
type: "tool-call",
|
||||
toolCallId: String(chunk.toolCallId),
|
||||
toolName: String(chunk.toolName),
|
||||
input,
|
||||
} as V3StreamPart);
|
||||
break;
|
||||
}
|
||||
case "finish": {
|
||||
closeTextIfOpen();
|
||||
const finishPart: V3FinishPart = {
|
||||
type: "finish",
|
||||
finishReason: toV3FinishReason(chunk.finishReason),
|
||||
usage: {
|
||||
inputTokens: {
|
||||
total: 0,
|
||||
noCache: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
outputTokens: { total: 0, text: 0, reasoning: 0 },
|
||||
},
|
||||
};
|
||||
parts.push(finishPart);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Pass through any already-provider-shaped part unchanged.
|
||||
parts.push(chunk as unknown as V3StreamPart);
|
||||
}
|
||||
}
|
||||
}
|
||||
closeTextIfOpen();
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a CLASSIC (model-based) `BuiltInAgent` driven by a mock
|
||||
* `LanguageModelV3`, wired with the given `tools`.
|
||||
*
|
||||
* The mock model surfaces `streamChunks` (the same fullStream-style chunks used
|
||||
* by the AI-SDK factory tests — `toolCall`, `textDelta`, `finish`, …) through
|
||||
* the real `streamText` pipeline, and RECORDS the messages it was invoked with.
|
||||
* Read them via `agent.__lastModelMessages` after running the agent.
|
||||
*/
|
||||
export function createClassicAgentWithTools(
|
||||
streamChunks: MockStreamEvent[],
|
||||
tools: ToolDefinition[],
|
||||
): ClassicAgentWithCapture {
|
||||
const model = new MockLanguageModelV3({
|
||||
doStream: async () => ({
|
||||
stream: simulateReadableStream({
|
||||
chunks: toV3StreamParts(streamChunks),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const agent = new BuiltInAgent({ model, tools }) as ClassicAgentWithCapture;
|
||||
|
||||
Object.defineProperty(agent, "__lastModelMessages", {
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
get() {
|
||||
const calls = model.doStreamCalls;
|
||||
return calls.length > 0 ? calls[calls.length - 1].prompt : undefined;
|
||||
},
|
||||
});
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error agent factories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Creates a BuiltInAgent whose factory immediately throws.
|
||||
*/
|
||||
export function createThrowingAgent(
|
||||
type: AgentType,
|
||||
errorMessage: string,
|
||||
): BuiltInAgent {
|
||||
// All three factory signatures accept (ctx) and can throw before returning.
|
||||
// Since the factory throws, the return type is irrelevant — TypeScript's
|
||||
// `never` return satisfies all three config shapes.
|
||||
const thrower = (): never => {
|
||||
throw new Error(errorMessage);
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case "aisdk":
|
||||
return new BuiltInAgent({ type: "aisdk", factory: thrower });
|
||||
case "tanstack":
|
||||
return new BuiltInAgent({ type: "tanstack", factory: thrower });
|
||||
case "custom":
|
||||
return new BuiltInAgent({ type: "custom", factory: thrower });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a BuiltInAgent that yields one partial event and then throws.
|
||||
*
|
||||
* - `"aisdk"`: yields `{ type: "text-delta", text: "partial" }` then throws
|
||||
* - `"tanstack"`: yields `{ type: "TEXT_MESSAGE_CONTENT", delta: "partial" }` then throws
|
||||
* - `"custom"`: yields a `TEXT_MESSAGE_CHUNK` BaseEvent then throws
|
||||
*/
|
||||
export function createMidStreamErrorAgent(
|
||||
type: AgentType,
|
||||
errorMessage: string,
|
||||
): BuiltInAgent {
|
||||
switch (type) {
|
||||
case "aisdk": {
|
||||
return new BuiltInAgent({
|
||||
type: "aisdk",
|
||||
factory: () => ({
|
||||
fullStream: (async function* () {
|
||||
yield { type: "text-delta", text: "partial" };
|
||||
throw new Error(errorMessage);
|
||||
})(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
case "tanstack": {
|
||||
return new BuiltInAgent({
|
||||
type: "tanstack",
|
||||
factory: () => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
type: "TEXT_MESSAGE_CONTENT",
|
||||
delta: "partial",
|
||||
};
|
||||
throw new Error(errorMessage);
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
case "custom": {
|
||||
return new BuiltInAgent({
|
||||
type: "custom",
|
||||
factory: () => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
type: EventType.TEXT_MESSAGE_CHUNK,
|
||||
role: "assistant",
|
||||
delta: "partial",
|
||||
} as const as BaseEvent;
|
||||
throw new Error(errorMessage);
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event collection utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Result of collecting events from an observable that may error.
|
||||
*/
|
||||
export interface CollectedEventsResult {
|
||||
events: BaseEvent[];
|
||||
/** Whether the observable completed via error (true) or normal completion (false) */
|
||||
errored: boolean;
|
||||
/** Whether the safety timeout fired (indicates a hung observable) */
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `collectEvents` but resolves (instead of rejecting) when the
|
||||
* observable errors. Returns the events collected up to and including
|
||||
* the error point, along with whether it errored or completed normally.
|
||||
*/
|
||||
export async function collectEventsIncludingErrors(
|
||||
observable: Observable<BaseEvent>,
|
||||
): Promise<CollectedEventsResult> {
|
||||
return new Promise((resolve) => {
|
||||
const events: BaseEvent[] = [];
|
||||
const timeoutId = setTimeout(() => {
|
||||
subscription.unsubscribe();
|
||||
resolve({ events, errored: false, timedOut: true });
|
||||
}, 5000);
|
||||
const subscription = observable.subscribe({
|
||||
next: (event) => events.push(event),
|
||||
error: () => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve({ events, errored: true, timedOut: false });
|
||||
},
|
||||
complete: () => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve({ events, errored: false, timedOut: false });
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typed event field accessors (avoids `as any` casts in tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Reads a field from a BaseEvent. AG-UI's BaseEvent uses `.passthrough()` so
|
||||
* extra fields exist at runtime but aren't in the static type. This helper
|
||||
* provides typed access without casts.
|
||||
*/
|
||||
export function eventField<T = unknown>(event: BaseEvent, field: string): T {
|
||||
return (event as Record<string, unknown>)[field] as T;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assertion helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Asserts that the events are wrapped with RUN_STARTED as the first event
|
||||
* and RUN_FINISHED as the last event. Optionally checks threadId and runId.
|
||||
*/
|
||||
export function expectLifecycleWrapped(
|
||||
events: BaseEvent[],
|
||||
threadId?: string,
|
||||
runId?: string,
|
||||
): void {
|
||||
if (events.length < 2) {
|
||||
throw new Error(
|
||||
`Expected at least 2 events (RUN_STARTED + RUN_FINISHED), got ${events.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const first = events[0];
|
||||
const last = events[events.length - 1];
|
||||
|
||||
if (first.type !== EventType.RUN_STARTED) {
|
||||
throw new Error(
|
||||
`Expected first event to be RUN_STARTED, got ${first.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (last.type !== EventType.RUN_FINISHED) {
|
||||
throw new Error(`Expected last event to be RUN_FINISHED, got ${last.type}`);
|
||||
}
|
||||
|
||||
if (threadId !== undefined) {
|
||||
if (eventField<string>(first, "threadId") !== threadId) {
|
||||
throw new Error(
|
||||
`Expected RUN_STARTED threadId to be "${threadId}", got "${eventField(first, "threadId")}"`,
|
||||
);
|
||||
}
|
||||
if (eventField<string>(last, "threadId") !== threadId) {
|
||||
throw new Error(
|
||||
`Expected RUN_FINISHED threadId to be "${threadId}", got "${eventField(last, "threadId")}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (runId !== undefined) {
|
||||
if (eventField<string>(first, "runId") !== runId) {
|
||||
throw new Error(
|
||||
`Expected RUN_STARTED runId to be "${runId}", got "${eventField(first, "runId")}"`,
|
||||
);
|
||||
}
|
||||
if (eventField<string>(last, "runId") !== runId) {
|
||||
throw new Error(
|
||||
`Expected RUN_FINISHED runId to be "${runId}", got "${eventField(last, "runId")}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the event types match the expected sequence exactly.
|
||||
*/
|
||||
export function expectEventSequence(
|
||||
events: BaseEvent[],
|
||||
expectedTypes: EventType[],
|
||||
): void {
|
||||
const actualTypes = events.map((e) => e.type);
|
||||
|
||||
if (actualTypes.length !== expectedTypes.length) {
|
||||
throw new Error(
|
||||
`Event count mismatch: expected ${expectedTypes.length}, got ${actualTypes.length}.\n` +
|
||||
`Expected: [${expectedTypes.join(", ")}]\n` +
|
||||
`Actual: [${actualTypes.join(", ")}]`,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < expectedTypes.length; i++) {
|
||||
if (actualTypes[i] !== expectedTypes[i]) {
|
||||
throw new Error(
|
||||
`Event type mismatch at index ${i}: expected ${expectedTypes[i]}, got ${actualTypes[i]}.\n` +
|
||||
`Expected: [${expectedTypes.join(", ")}]\n` +
|
||||
`Actual: [${actualTypes.join(", ")}]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that no content events appear after the specified terminal event type.
|
||||
*
|
||||
* "Content events" are everything except RUN_STARTED, RUN_FINISHED, and RUN_ERROR.
|
||||
*/
|
||||
export function expectNoEventAfter(
|
||||
events: BaseEvent[],
|
||||
terminalType: EventType,
|
||||
): void {
|
||||
const terminalIndex = events.findIndex((e) => e.type === terminalType);
|
||||
if (terminalIndex === -1) {
|
||||
throw new Error(`Terminal event type ${terminalType} not found in events`);
|
||||
}
|
||||
|
||||
const lifecycleTypes = new Set([
|
||||
EventType.RUN_STARTED,
|
||||
EventType.RUN_FINISHED,
|
||||
EventType.RUN_ERROR,
|
||||
]);
|
||||
|
||||
const eventsAfter = events.slice(terminalIndex + 1);
|
||||
const contentEventsAfter = eventsAfter.filter(
|
||||
(e) => !lifecycleTypes.has(e.type),
|
||||
);
|
||||
|
||||
if (contentEventsAfter.length > 0) {
|
||||
throw new Error(
|
||||
`Found ${contentEventsAfter.length} content event(s) after ${terminalType}: ` +
|
||||
`[${contentEventsAfter.map((e) => e.type).join(", ")}]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,987 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { EventType } from "@ag-ui/client";
|
||||
import type {
|
||||
BaseEvent,
|
||||
Interrupt,
|
||||
ResumeEntry,
|
||||
RunAgentInput,
|
||||
} from "@ag-ui/client";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
BuiltInAgent,
|
||||
createDefaultInput,
|
||||
createAgent,
|
||||
createThrowingAgent,
|
||||
createMidStreamErrorAgent,
|
||||
createClassicAgentWithTools,
|
||||
collectEvents,
|
||||
collectEventsIncludingErrors,
|
||||
expectLifecycleWrapped,
|
||||
eventField,
|
||||
textDelta,
|
||||
finish,
|
||||
toolCall,
|
||||
tanstackTextChunk,
|
||||
tanstackToolCallStart,
|
||||
tanstackToolCallEnd,
|
||||
tanstackApprovalRequested,
|
||||
aisdkToolApprovalRequest,
|
||||
} from "./agent-test-helpers";
|
||||
import type {
|
||||
AgentFactoryContext,
|
||||
BuiltInAgentFactoryConfig,
|
||||
AgentType,
|
||||
MockStreamEvent,
|
||||
} from "./agent-test-helpers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local helpers for parameterized tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const allTypes: AgentType[] = ["aisdk", "tanstack", "custom"];
|
||||
|
||||
function minimalStreamData(
|
||||
type: AgentType,
|
||||
): MockStreamEvent[] | Record<string, unknown>[] | BaseEvent[] {
|
||||
switch (type) {
|
||||
case "aisdk":
|
||||
return [textDelta("hi"), finish()];
|
||||
case "tanstack":
|
||||
return [tanstackTextChunk("hi")];
|
||||
case "custom":
|
||||
return [
|
||||
{
|
||||
type: EventType.TEXT_MESSAGE_CHUNK,
|
||||
role: "assistant",
|
||||
delta: "hi",
|
||||
} as BaseEvent,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function emptyStreamData(
|
||||
type: AgentType,
|
||||
): MockStreamEvent[] | Record<string, unknown>[] | BaseEvent[] {
|
||||
switch (type) {
|
||||
case "aisdk":
|
||||
return [finish()];
|
||||
case "tanstack":
|
||||
return [];
|
||||
case "custom":
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parameterized test suites
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe.each(allTypes)("Agent [%s]", (type) => {
|
||||
// -------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// -------------------------------------------------------------------------
|
||||
describe("lifecycle", () => {
|
||||
it("emits RUN_STARTED as the first event with correct threadId/runId", async () => {
|
||||
const agent = createAgent(type, minimalStreamData(type));
|
||||
const input = createDefaultInput({ threadId: "t1", runId: "r1" });
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
expect(events.length).toBeGreaterThanOrEqual(2);
|
||||
const first = events[0];
|
||||
expect(first.type).toBe(EventType.RUN_STARTED);
|
||||
expect(eventField<string>(first, "threadId")).toBe("t1");
|
||||
expect(eventField<string>(first, "runId")).toBe("r1");
|
||||
});
|
||||
|
||||
it("emits RUN_FINISHED as the last event with correct threadId/runId", async () => {
|
||||
const agent = createAgent(type, minimalStreamData(type));
|
||||
const input = createDefaultInput({ threadId: "t2", runId: "r2" });
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
const last = events[events.length - 1];
|
||||
expect(last.type).toBe(EventType.RUN_FINISHED);
|
||||
expect(eventField<string>(last, "threadId")).toBe("t2");
|
||||
expect(eventField<string>(last, "runId")).toBe("r2");
|
||||
});
|
||||
|
||||
it("emits RUN_FINISHED for an empty stream", async () => {
|
||||
const agent = createAgent(type, emptyStreamData(type));
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
expect(events.length).toBeGreaterThanOrEqual(2);
|
||||
expect(events[0].type).toBe(EventType.RUN_STARTED);
|
||||
expect(events[events.length - 1].type).toBe(EventType.RUN_FINISHED);
|
||||
});
|
||||
|
||||
it("wraps content with lifecycle events", async () => {
|
||||
const agent = createAgent(type, minimalStreamData(type));
|
||||
const input = createDefaultInput({ threadId: "wrap-t", runId: "wrap-r" });
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
expectLifecycleWrapped(events, "wrap-t", "wrap-r");
|
||||
|
||||
// There should be content events between the lifecycle bookends
|
||||
const contentEvents = events.slice(1, -1);
|
||||
expect(contentEvents.length).toBeGreaterThan(0);
|
||||
for (const e of contentEvents) {
|
||||
expect(e.type).not.toBe(EventType.RUN_STARTED);
|
||||
expect(e.type).not.toBe(EventType.RUN_FINISHED);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// RUN_ERROR
|
||||
// -------------------------------------------------------------------------
|
||||
describe("RUN_ERROR", () => {
|
||||
it("emits RUN_ERROR when factory throws", async () => {
|
||||
const agent = createThrowingAgent(type, "factory-boom");
|
||||
const input = createDefaultInput();
|
||||
const { events, errored } = await collectEventsIncludingErrors(
|
||||
agent.run(input),
|
||||
);
|
||||
|
||||
expect(errored).toBe(true);
|
||||
const errorEvents = events.filter((e) => e.type === EventType.RUN_ERROR);
|
||||
expect(errorEvents.length).toBe(1);
|
||||
expect(eventField<string>(errorEvents[0], "message")).toBe(
|
||||
"factory-boom",
|
||||
);
|
||||
});
|
||||
|
||||
it("emits RUN_ERROR when stream throws mid-iteration", async () => {
|
||||
const agent = createMidStreamErrorAgent(type, "mid-stream-boom");
|
||||
const input = createDefaultInput();
|
||||
const { events, errored } = await collectEventsIncludingErrors(
|
||||
agent.run(input),
|
||||
);
|
||||
|
||||
expect(errored).toBe(true);
|
||||
const errorEvents = events.filter((e) => e.type === EventType.RUN_ERROR);
|
||||
expect(errorEvents.length).toBe(1);
|
||||
expect(eventField<string>(errorEvents[0], "message")).toBe(
|
||||
"mid-stream-boom",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not emit RUN_FINISHED after RUN_ERROR", async () => {
|
||||
const agent = createThrowingAgent(type, "no-finish");
|
||||
const input = createDefaultInput();
|
||||
const { events } = await collectEventsIncludingErrors(agent.run(input));
|
||||
|
||||
const errorIdx = events.findIndex((e) => e.type === EventType.RUN_ERROR);
|
||||
expect(errorIdx).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const eventsAfterError = events.slice(errorIdx + 1);
|
||||
const finishAfterError = eventsAfterError.filter(
|
||||
(e) => e.type === EventType.RUN_FINISHED,
|
||||
);
|
||||
expect(finishAfterError.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Abort
|
||||
// -------------------------------------------------------------------------
|
||||
describe("abort", () => {
|
||||
it("completes without error after abortRun()", async () => {
|
||||
// Use a signal to synchronize: abort after the first chunk is emitted
|
||||
let emittedFirstChunk: () => void;
|
||||
const firstChunkEmitted = new Promise<void>(
|
||||
(r) => (emittedFirstChunk = r),
|
||||
);
|
||||
|
||||
let config: BuiltInAgentFactoryConfig;
|
||||
switch (type) {
|
||||
case "aisdk":
|
||||
config = {
|
||||
type: "aisdk",
|
||||
factory: ({ abortSignal }: AgentFactoryContext) => ({
|
||||
fullStream: (async function* () {
|
||||
yield { type: "text-delta", text: "tick" };
|
||||
emittedFirstChunk();
|
||||
// Wait for abort — use a promise that resolves on abort
|
||||
await new Promise<void>((r) => {
|
||||
if (abortSignal.aborted) return r();
|
||||
abortSignal.addEventListener("abort", () => r(), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
})(),
|
||||
}),
|
||||
};
|
||||
break;
|
||||
case "tanstack":
|
||||
config = {
|
||||
type: "tanstack",
|
||||
factory: ({ abortSignal }: AgentFactoryContext) => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield { type: "TEXT_MESSAGE_CONTENT", delta: "tick" };
|
||||
emittedFirstChunk();
|
||||
await new Promise<void>((r) => {
|
||||
if (abortSignal.aborted) return r();
|
||||
abortSignal.addEventListener("abort", () => r(), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
break;
|
||||
case "custom":
|
||||
config = {
|
||||
type: "custom",
|
||||
factory: ({ abortSignal }: AgentFactoryContext) => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
type: EventType.TEXT_MESSAGE_CHUNK,
|
||||
role: "assistant",
|
||||
delta: "tick",
|
||||
} as BaseEvent;
|
||||
emittedFirstChunk();
|
||||
await new Promise<void>((r) => {
|
||||
if (abortSignal.aborted) return r();
|
||||
abortSignal.addEventListener("abort", () => r(), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
const agent = new BuiltInAgent(config);
|
||||
const input = createDefaultInput();
|
||||
|
||||
const completed = await new Promise<boolean>((resolve) => {
|
||||
agent.run(input).subscribe({
|
||||
next: () => {},
|
||||
error: () => resolve(false),
|
||||
complete: () => resolve(true),
|
||||
});
|
||||
|
||||
// Wait for the first chunk to be emitted, then abort
|
||||
firstChunkEmitted.then(() => agent.abortRun());
|
||||
});
|
||||
|
||||
expect(completed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Factory Context
|
||||
// -------------------------------------------------------------------------
|
||||
describe("factory context", () => {
|
||||
it("receives correct input with threadId, runId, and forwardedProps", async () => {
|
||||
let capturedCtx: AgentFactoryContext | null = null;
|
||||
|
||||
let config: BuiltInAgentFactoryConfig;
|
||||
switch (type) {
|
||||
case "aisdk":
|
||||
config = {
|
||||
type: "aisdk",
|
||||
factory: (ctx: AgentFactoryContext) => {
|
||||
capturedCtx = ctx;
|
||||
return {
|
||||
fullStream: (async function* () {
|
||||
yield { type: "finish", finishReason: "stop" };
|
||||
})(),
|
||||
};
|
||||
},
|
||||
};
|
||||
break;
|
||||
case "tanstack":
|
||||
config = {
|
||||
type: "tanstack",
|
||||
factory: (ctx: AgentFactoryContext) => {
|
||||
capturedCtx = ctx;
|
||||
return (async function* () {
|
||||
// empty stream
|
||||
})();
|
||||
},
|
||||
};
|
||||
break;
|
||||
case "custom":
|
||||
config = {
|
||||
type: "custom",
|
||||
factory: (ctx: AgentFactoryContext) => {
|
||||
capturedCtx = ctx;
|
||||
return (async function* () {
|
||||
// empty stream
|
||||
})();
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
const agent = new BuiltInAgent(config);
|
||||
const input = createDefaultInput({
|
||||
threadId: "ctx-thread",
|
||||
runId: "ctx-run",
|
||||
forwardedProps: { model: "gpt-4" },
|
||||
});
|
||||
|
||||
await collectEvents(agent.run(input));
|
||||
|
||||
expect(capturedCtx).not.toBeNull();
|
||||
expect(capturedCtx!.input.threadId).toBe("ctx-thread");
|
||||
expect(capturedCtx!.input.runId).toBe("ctx-run");
|
||||
expect(capturedCtx!.input.forwardedProps).toEqual({ model: "gpt-4" });
|
||||
});
|
||||
|
||||
it("receives abortController and abortSignal", async () => {
|
||||
let capturedCtx: AgentFactoryContext | null = null;
|
||||
|
||||
let config: BuiltInAgentFactoryConfig;
|
||||
switch (type) {
|
||||
case "aisdk":
|
||||
config = {
|
||||
type: "aisdk",
|
||||
factory: (ctx: AgentFactoryContext) => {
|
||||
capturedCtx = ctx;
|
||||
return {
|
||||
fullStream: (async function* () {
|
||||
yield { type: "finish", finishReason: "stop" };
|
||||
})(),
|
||||
};
|
||||
},
|
||||
};
|
||||
break;
|
||||
case "tanstack":
|
||||
config = {
|
||||
type: "tanstack",
|
||||
factory: (ctx: AgentFactoryContext) => {
|
||||
capturedCtx = ctx;
|
||||
return (async function* () {
|
||||
// empty
|
||||
})();
|
||||
},
|
||||
};
|
||||
break;
|
||||
case "custom":
|
||||
config = {
|
||||
type: "custom",
|
||||
factory: (ctx: AgentFactoryContext) => {
|
||||
capturedCtx = ctx;
|
||||
return (async function* () {
|
||||
// empty
|
||||
})();
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
const agent = new BuiltInAgent(config);
|
||||
const input = createDefaultInput();
|
||||
await collectEvents(agent.run(input));
|
||||
|
||||
expect(capturedCtx!.abortController).toBeInstanceOf(AbortController);
|
||||
expect(capturedCtx!.abortSignal).toBe(
|
||||
capturedCtx!.abortController.signal,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// clone()
|
||||
// -------------------------------------------------------------------------
|
||||
describe("clone()", () => {
|
||||
it("returns a new Agent instance (not the same reference)", () => {
|
||||
const agent = createAgent(type, minimalStreamData(type));
|
||||
const cloned = agent.clone();
|
||||
|
||||
expect(cloned).toBeInstanceOf(BuiltInAgent);
|
||||
expect(cloned).not.toBe(agent);
|
||||
});
|
||||
|
||||
it("produces correct lifecycle events from a cloned agent", async () => {
|
||||
const agent = createAgent(type, minimalStreamData(type));
|
||||
const cloned = agent.clone();
|
||||
const input = createDefaultInput({
|
||||
threadId: "clone-t",
|
||||
runId: "clone-r",
|
||||
});
|
||||
|
||||
const events = await collectEvents(cloned.run(input));
|
||||
|
||||
expectLifecycleWrapped(events, "clone-t", "clone-r");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type Discrimination (NOT parameterized)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Agent type discrimination", () => {
|
||||
it('"aisdk" routes to AI SDK converter and produces text content', async () => {
|
||||
const agent = createAgent("aisdk", [
|
||||
textDelta("hello from aisdk"),
|
||||
finish(),
|
||||
]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
const textEvents = events.filter(
|
||||
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
|
||||
);
|
||||
expect(textEvents.length).toBe(1);
|
||||
expect(eventField<string>(textEvents[0], "delta")).toBe("hello from aisdk");
|
||||
});
|
||||
|
||||
it('"tanstack" routes to TanStack converter and produces text content', async () => {
|
||||
const agent = createAgent("tanstack", [
|
||||
tanstackTextChunk("hello from tanstack"),
|
||||
]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
const textEvents = events.filter(
|
||||
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
|
||||
);
|
||||
expect(textEvents.length).toBe(1);
|
||||
expect(eventField<string>(textEvents[0], "delta")).toBe(
|
||||
"hello from tanstack",
|
||||
);
|
||||
});
|
||||
|
||||
it('"custom" forwards events directly without conversion', async () => {
|
||||
const customEvent: BaseEvent = {
|
||||
type: EventType.TEXT_MESSAGE_CHUNK,
|
||||
role: "assistant",
|
||||
delta: "hello from custom",
|
||||
} as BaseEvent;
|
||||
|
||||
const agent = createAgent("custom", [customEvent]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
const textEvents = events.filter(
|
||||
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
|
||||
);
|
||||
expect(textEvents.length).toBe(1);
|
||||
expect(eventField<string>(textEvents[0], "delta")).toBe(
|
||||
"hello from custom",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Async Factory (Promise-returning)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Async factory (Promise-returning)", () => {
|
||||
it("aisdk: async factory resolves and streams correctly", async () => {
|
||||
const agent = new BuiltInAgent({
|
||||
type: "aisdk",
|
||||
factory: async () => {
|
||||
// Simulate async setup (e.g., fetching API key)
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
return {
|
||||
fullStream: (async function* () {
|
||||
yield { type: "text-delta", text: "async-aisdk" };
|
||||
yield { type: "finish", finishReason: "stop" };
|
||||
})(),
|
||||
};
|
||||
},
|
||||
});
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
const textEvents = events.filter(
|
||||
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
|
||||
);
|
||||
expect(textEvents).toHaveLength(1);
|
||||
expect(eventField<string>(textEvents[0], "delta")).toBe("async-aisdk");
|
||||
});
|
||||
|
||||
it("tanstack: async factory resolves and streams correctly", async () => {
|
||||
const agent = new BuiltInAgent({
|
||||
type: "tanstack",
|
||||
factory: async () => {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
return (async function* () {
|
||||
yield { type: "TEXT_MESSAGE_CONTENT", delta: "async-tanstack" };
|
||||
})();
|
||||
},
|
||||
});
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
const textEvents = events.filter(
|
||||
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
|
||||
);
|
||||
expect(textEvents).toHaveLength(1);
|
||||
expect(eventField<string>(textEvents[0], "delta")).toBe("async-tanstack");
|
||||
});
|
||||
|
||||
it("custom: async factory resolves and streams correctly", async () => {
|
||||
const agent = new BuiltInAgent({
|
||||
type: "custom",
|
||||
factory: async () => {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
return (async function* () {
|
||||
yield {
|
||||
type: EventType.TEXT_MESSAGE_CHUNK,
|
||||
role: "assistant",
|
||||
delta: "async-custom",
|
||||
} as BaseEvent;
|
||||
})();
|
||||
},
|
||||
});
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
const textEvents = events.filter(
|
||||
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
|
||||
);
|
||||
expect(textEvents).toHaveLength(1);
|
||||
expect(eventField<string>(textEvents[0], "delta")).toBe("async-custom");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RUN_ERROR includes threadId and runId
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("RUN_ERROR correlation fields", () => {
|
||||
it("RUN_ERROR includes threadId and runId for run correlation", async () => {
|
||||
const agent = new BuiltInAgent({
|
||||
type: "aisdk",
|
||||
factory: () => {
|
||||
throw new Error("test-error");
|
||||
},
|
||||
});
|
||||
const input = createDefaultInput({
|
||||
threadId: "err-thread",
|
||||
runId: "err-run",
|
||||
});
|
||||
const { events, errored } = await collectEventsIncludingErrors(
|
||||
agent.run(input),
|
||||
);
|
||||
|
||||
expect(errored).toBe(true);
|
||||
const errorEvents = events.filter((e) => e.type === EventType.RUN_ERROR);
|
||||
expect(errorEvents).toHaveLength(1);
|
||||
expect(eventField<string>(errorEvents[0], "threadId")).toBe("err-thread");
|
||||
expect(eventField<string>(errorEvents[0], "runId")).toBe("err-run");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Concurrent run guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Concurrent run guard", () => {
|
||||
it("throws when run() is called while another run is in progress", async () => {
|
||||
let resolveFactory: () => void;
|
||||
const factoryBlocked = new Promise<void>((r) => (resolveFactory = r));
|
||||
|
||||
const agent = new BuiltInAgent({
|
||||
type: "custom",
|
||||
factory: async function* ({ abortSignal }) {
|
||||
// Block until resolved externally
|
||||
await new Promise<void>((r) => {
|
||||
if (abortSignal.aborted) return r();
|
||||
abortSignal.addEventListener("abort", () => r(), { once: true });
|
||||
factoryBlocked.then(() => r());
|
||||
});
|
||||
},
|
||||
});
|
||||
const input = createDefaultInput();
|
||||
|
||||
// Start first run — abortController is now set synchronously in run()
|
||||
const sub = agent.run(input).subscribe({ next: () => {} });
|
||||
|
||||
// Second run should throw immediately (no timing dependency)
|
||||
expect(() => agent.run(input)).toThrow("Agent is already running");
|
||||
|
||||
// Cleanup
|
||||
resolveFactory!();
|
||||
sub.unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BuiltInAgent factory interrupt() primitive
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("BuiltInAgent factory interrupt() primitive", () => {
|
||||
const INT: Interrupt = {
|
||||
id: "int-1",
|
||||
reason: "confirmation",
|
||||
message: "Approve?",
|
||||
};
|
||||
|
||||
function makeCustomInterruptAgent() {
|
||||
return new BuiltInAgent({
|
||||
type: "custom",
|
||||
factory: async function* (ctx) {
|
||||
const responses = await ctx.interrupt([INT]); // pauses on fresh run
|
||||
// resume run continues here:
|
||||
yield {
|
||||
type: EventType.TEXT_MESSAGE_START,
|
||||
messageId: "m1",
|
||||
role: "assistant",
|
||||
} as any;
|
||||
yield {
|
||||
type: EventType.TEXT_MESSAGE_CONTENT,
|
||||
messageId: "m1",
|
||||
delta: `resolved:${JSON.stringify(responses)}`,
|
||||
} as any;
|
||||
yield { type: EventType.TEXT_MESSAGE_END, messageId: "m1" } as any;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("emits RUN_FINISHED with outcome:interrupt on a fresh run", async () => {
|
||||
const agent = makeCustomInterruptAgent();
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
const finished = events.find(
|
||||
(e) => e.type === EventType.RUN_FINISHED,
|
||||
) as any;
|
||||
expect(finished).toBeDefined();
|
||||
expect(finished.outcome).toEqual({ type: "interrupt", interrupts: [INT] });
|
||||
// No text emitted on the paused run:
|
||||
expect(events.some((e) => e.type === EventType.TEXT_MESSAGE_CONTENT)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("continues past interrupt() on a resume run, returning resume payloads", async () => {
|
||||
const agent = makeCustomInterruptAgent();
|
||||
const resume: ResumeEntry[] = [
|
||||
{ interruptId: "int-1", status: "resolved", payload: { ok: true } },
|
||||
];
|
||||
const events = await collectEvents(
|
||||
agent.run(createDefaultInput({ resume })),
|
||||
);
|
||||
const content = events.find(
|
||||
(e) => e.type === EventType.TEXT_MESSAGE_CONTENT,
|
||||
) as any;
|
||||
expect(content.delta).toContain("resolved:");
|
||||
expect(content.delta).toContain('"ok":true');
|
||||
const finished = events.find(
|
||||
(e) => e.type === EventType.RUN_FINISHED,
|
||||
) as any;
|
||||
expect(finished.outcome).toBeUndefined(); // normal completion
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BuiltInAgent classic interrupt tools
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("BuiltInAgent classic interrupt tools", () => {
|
||||
const interruptTool = {
|
||||
name: "confirm_action",
|
||||
description: "Ask the human to confirm",
|
||||
parameters: z.object({ summary: z.string() }),
|
||||
interrupt: true as const,
|
||||
interruptReason: "confirmation",
|
||||
interruptMessage: "Please confirm",
|
||||
};
|
||||
|
||||
it("pauses + emits outcome:interrupt (keyed by toolCallId), withholding the tool result", async () => {
|
||||
// mock stream: assistant calls the interrupt tool, then 'finish'
|
||||
const agent = createClassicAgentWithTools(
|
||||
[toolCall("tc-1", "confirm_action", { summary: "delete X" }), finish()],
|
||||
[interruptTool],
|
||||
);
|
||||
const events = await collectEvents(
|
||||
agent.run(
|
||||
createDefaultInput({
|
||||
messages: [{ id: "u1", role: "user", content: "do it" }] as any,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(events.some((e) => e.type === EventType.TOOL_CALL_START)).toBe(true);
|
||||
expect(events.some((e) => e.type === EventType.TOOL_CALL_END)).toBe(true);
|
||||
// result withheld:
|
||||
expect(events.some((e) => e.type === EventType.TOOL_CALL_RESULT)).toBe(
|
||||
false,
|
||||
);
|
||||
const finished = events.find(
|
||||
(e) => e.type === EventType.RUN_FINISHED,
|
||||
) as any;
|
||||
expect(finished.outcome.type).toBe("interrupt");
|
||||
expect(finished.outcome.interrupts[0]).toMatchObject({
|
||||
id: "tc-1",
|
||||
toolCallId: "tc-1",
|
||||
reason: "confirmation",
|
||||
message: "Please confirm",
|
||||
});
|
||||
});
|
||||
|
||||
it("on resume, injects the resume payload as the interrupt tool's result and continues", async () => {
|
||||
// Resume run: the model just replies after the injected tool result.
|
||||
const agent = createClassicAgentWithTools(
|
||||
[textDelta("done"), finish()],
|
||||
[interruptTool],
|
||||
);
|
||||
const input = createDefaultInput({
|
||||
resume: [
|
||||
{
|
||||
interruptId: "tc-1",
|
||||
status: "resolved",
|
||||
payload: { approved: true },
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{ id: "u1", role: "user", content: "do it" },
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
toolCalls: [
|
||||
{
|
||||
id: "tc-1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "confirm_action",
|
||||
arguments: '{"summary":"delete X"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as any,
|
||||
});
|
||||
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
// The run completes normally (no interrupt outcome on the resume run):
|
||||
const finished = events.find(
|
||||
(e) => e.type === EventType.RUN_FINISHED,
|
||||
) as any;
|
||||
expect(finished.outcome).toBeUndefined();
|
||||
|
||||
// The synthesized tool result reached the model: a tool-role message
|
||||
// addressing tc-1 with the resume payload must be present.
|
||||
expect(agent.__lastModelMessages).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ role: "tool" })]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BuiltInAgent factory-mode NATIVE approval interrupts (aisdk + tanstack)
|
||||
//
|
||||
// A tool declared with the SDK's native `needsApproval: true` pauses the run:
|
||||
// AI SDK emits a `tool-approval-request` fullStream part; TanStack emits a
|
||||
// CUSTOM `approval-requested` chunk. Both surface as RUN_FINISHED
|
||||
// outcome:interrupt, and on resume the payload is injected as that tool call's
|
||||
// native tool-result so the factory continues.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("BuiltInAgent factory native approval interrupts", () => {
|
||||
it("aisdk: tool-approval-request → outcome:interrupt keyed by toolCallId", async () => {
|
||||
const agent = createAgent("aisdk", [
|
||||
toolCall("tc-1", "bookFlight", { dest: "NRT" }),
|
||||
aisdkToolApprovalRequest("tc-1"),
|
||||
finish(),
|
||||
]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
// The tool call is surfaced so the client knows what it's approving.
|
||||
expect(events.some((e) => e.type === EventType.TOOL_CALL_START)).toBe(true);
|
||||
expect(events.some((e) => e.type === EventType.TOOL_CALL_END)).toBe(true);
|
||||
|
||||
const finished = events.find(
|
||||
(e) => e.type === EventType.RUN_FINISHED,
|
||||
) as any;
|
||||
expect(finished.outcome.type).toBe("interrupt");
|
||||
expect(finished.outcome.interrupts[0]).toMatchObject({
|
||||
id: "tc-1",
|
||||
toolCallId: "tc-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("aisdk: multiple needsApproval tool calls → one interrupt each", async () => {
|
||||
const agent = createAgent("aisdk", [
|
||||
toolCall("tc-1", "bookFlight", { dest: "NRT" }),
|
||||
aisdkToolApprovalRequest("tc-1"),
|
||||
toolCall("tc-2", "bookFlight", { dest: "CDG" }),
|
||||
aisdkToolApprovalRequest("tc-2"),
|
||||
finish(),
|
||||
]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
const finished = events.find(
|
||||
(e) => e.type === EventType.RUN_FINISHED,
|
||||
) as any;
|
||||
expect(finished.outcome.type).toBe("interrupt");
|
||||
expect(finished.outcome.interrupts.map((i: any) => i.toolCallId)).toEqual([
|
||||
"tc-1",
|
||||
"tc-2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("multiple resume entries each inject their own tool-role message", async () => {
|
||||
let captured: RunAgentInput | undefined;
|
||||
const agent = new BuiltInAgent({
|
||||
type: "aisdk",
|
||||
factory: (ctx) => {
|
||||
captured = ctx.input;
|
||||
return {
|
||||
fullStream: (async function* () {
|
||||
yield textDelta("done");
|
||||
yield finish();
|
||||
})(),
|
||||
};
|
||||
},
|
||||
});
|
||||
const resume: ResumeEntry[] = [
|
||||
{ interruptId: "tc-1", status: "resolved", payload: { approved: true } },
|
||||
{ interruptId: "tc-2", status: "cancelled" },
|
||||
];
|
||||
await collectEvents(agent.run(createDefaultInput({ resume })));
|
||||
const toolMsgs = captured!.messages.filter(
|
||||
(m) => m.role === "tool",
|
||||
) as any[];
|
||||
expect(toolMsgs.map((m) => m.toolCallId)).toEqual(["tc-1", "tc-2"]);
|
||||
expect(toolMsgs[0].content).toContain("approved");
|
||||
expect(toolMsgs[1].content).toContain("cancelled");
|
||||
});
|
||||
|
||||
it("resume injection is idempotent — does not double-answer a tool call the client already recorded", async () => {
|
||||
// The client (useInterrupt) persists the resolution as a tool message so the
|
||||
// thread stays well-formed across turns. The runtime must NOT then inject a
|
||||
// second tool-result for the same toolCallId.
|
||||
let captured: RunAgentInput | undefined;
|
||||
const agent = new BuiltInAgent({
|
||||
type: "aisdk",
|
||||
factory: (ctx) => {
|
||||
captured = ctx.input;
|
||||
return {
|
||||
fullStream: (async function* () {
|
||||
yield textDelta("ok");
|
||||
yield finish();
|
||||
})(),
|
||||
};
|
||||
},
|
||||
});
|
||||
const resume: ResumeEntry[] = [
|
||||
{ interruptId: "tc-1", status: "resolved", payload: { approved: true } },
|
||||
];
|
||||
await collectEvents(
|
||||
agent.run(
|
||||
createDefaultInput({
|
||||
resume,
|
||||
messages: [
|
||||
{ id: "u1", role: "user", content: "go" },
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
toolCalls: [
|
||||
{
|
||||
id: "tc-1",
|
||||
type: "function",
|
||||
function: { name: "bookFlight", arguments: "{}" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
toolCallId: "tc-1",
|
||||
content: JSON.stringify({ approved: true }),
|
||||
},
|
||||
] as RunAgentInput["messages"],
|
||||
}),
|
||||
),
|
||||
);
|
||||
const toolMsgs = captured!.messages.filter(
|
||||
(m) => m.role === "tool" && (m as any).toolCallId === "tc-1",
|
||||
);
|
||||
expect(toolMsgs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("tanstack: CUSTOM approval-requested (even after RUN_FINISHED) → outcome:interrupt", async () => {
|
||||
// The approval chunk is built from the finish event and can arrive AFTER
|
||||
// RUN_FINISHED — assert it's still captured (ordering-robust).
|
||||
const agent = createAgent("tanstack", [
|
||||
tanstackToolCallStart("tc-9", "bookFlight"),
|
||||
tanstackToolCallEnd("tc-9"),
|
||||
{ type: "RUN_FINISHED" },
|
||||
tanstackApprovalRequested("tc-9", "bookFlight", { dest: "NRT" }),
|
||||
]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
const finished = events.find(
|
||||
(e) => e.type === EventType.RUN_FINISHED,
|
||||
) as any;
|
||||
expect(finished.outcome.type).toBe("interrupt");
|
||||
expect(finished.outcome.interrupts[0]).toMatchObject({
|
||||
id: "tc-9",
|
||||
toolCallId: "tc-9",
|
||||
message: 'Approve "bookFlight"?',
|
||||
});
|
||||
});
|
||||
|
||||
it("aisdk: resume injects the payload as a tool-role message the factory sees", async () => {
|
||||
let captured: RunAgentInput | undefined;
|
||||
const agent = new BuiltInAgent({
|
||||
type: "aisdk",
|
||||
factory: (ctx) => {
|
||||
captured = ctx.input;
|
||||
return {
|
||||
fullStream: (async function* () {
|
||||
yield textDelta("booked");
|
||||
yield finish();
|
||||
})(),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const resume: ResumeEntry[] = [
|
||||
{ interruptId: "tc-1", status: "resolved", payload: { approved: true } },
|
||||
];
|
||||
const events = await collectEvents(
|
||||
agent.run(createDefaultInput({ resume })),
|
||||
);
|
||||
|
||||
// Normal completion on the resume run (no re-interrupt).
|
||||
const finished = events.find(
|
||||
(e) => e.type === EventType.RUN_FINISHED,
|
||||
) as any;
|
||||
expect(finished.outcome).toBeUndefined();
|
||||
|
||||
const toolMsg = captured!.messages.find(
|
||||
(m) => m.role === "tool" && (m as any).toolCallId === "tc-1",
|
||||
) as any;
|
||||
expect(toolMsg).toBeDefined();
|
||||
expect(toolMsg.content).toContain("approved");
|
||||
});
|
||||
|
||||
it("tanstack: cancelled resume injects a cancellation tool-role message", async () => {
|
||||
let captured: RunAgentInput | undefined;
|
||||
const agent = new BuiltInAgent({
|
||||
type: "tanstack",
|
||||
factory: (ctx) => {
|
||||
captured = ctx.input;
|
||||
return {
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield tanstackTextChunk("ok");
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const resume: ResumeEntry[] = [
|
||||
{ interruptId: "tc-9", status: "cancelled" },
|
||||
];
|
||||
await collectEvents(agent.run(createDefaultInput({ resume })));
|
||||
|
||||
const toolMsg = captured!.messages.find(
|
||||
(m) => m.role === "tool" && (m as any).toolCallId === "tc-9",
|
||||
) as any;
|
||||
expect(toolMsg).toBeDefined();
|
||||
expect(toolMsg.content).toContain("cancelled");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { BuiltInAgent } from "../index";
|
||||
import { EventType, type RunAgentInput } from "@ag-ui/client";
|
||||
import { streamText } from "ai";
|
||||
import {
|
||||
mockStreamTextResponse,
|
||||
textDelta,
|
||||
finish,
|
||||
collectEvents,
|
||||
} from "./test-helpers";
|
||||
|
||||
// Mock the ai module
|
||||
vi.mock("ai", () => ({
|
||||
streamText: vi.fn(),
|
||||
tool: vi.fn((config) => config),
|
||||
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
|
||||
}));
|
||||
|
||||
// Mock SDK providers (not used directly, but resolveModel imports them)
|
||||
vi.mock("@ai-sdk/openai", () => ({
|
||||
createOpenAI: vi.fn(() => (modelId: string) => ({
|
||||
specificationVersion: "v3",
|
||||
modelId,
|
||||
provider: "openai",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@ai-sdk/anthropic", () => ({
|
||||
createAnthropic: vi.fn(() => (modelId: string) => ({
|
||||
specificationVersion: "v3",
|
||||
modelId,
|
||||
provider: "anthropic",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@ai-sdk/google", () => ({
|
||||
createGoogleGenerativeAI: vi.fn(() => (modelId: string) => ({
|
||||
specificationVersion: "v3",
|
||||
modelId,
|
||||
provider: "google",
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("AI SDK v6 Compatibility", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
process.env.OPENAI_API_KEY = "test-key";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it("should accept a LanguageModelV3 instance (specificationVersion 'v3')", async () => {
|
||||
// Simulate what @ai-sdk/openai@^3 (AI SDK v6) returns:
|
||||
// a model object with specificationVersion: "v3"
|
||||
const v3Model = {
|
||||
specificationVersion: "v3" as const,
|
||||
modelId: "gpt-4o-mini",
|
||||
provider: "openai",
|
||||
supportedUrls: {},
|
||||
doGenerate: vi.fn(),
|
||||
doStream: vi.fn(),
|
||||
};
|
||||
|
||||
// After upgrading to ai@^6, LanguageModel = string | LanguageModelV2 | LanguageModelV3.
|
||||
// No 'as any' cast needed — the type accepts V3 models natively.
|
||||
const agent = new BuiltInAgent({
|
||||
model: v3Model,
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([
|
||||
textDelta("Hello from V3 model"),
|
||||
finish(),
|
||||
]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread-v3",
|
||||
runId: "run-v3",
|
||||
messages: [{ id: "1", role: "user", content: "Hi" }],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
const events = await collectEvents(agent["run"](input));
|
||||
|
||||
// Verify the model was passed through to streamText
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.model).toBe(v3Model);
|
||||
|
||||
// Verify normal event emission still works
|
||||
expect(events[0]).toMatchObject({
|
||||
type: EventType.RUN_STARTED,
|
||||
threadId: "thread-v3",
|
||||
runId: "run-v3",
|
||||
});
|
||||
|
||||
const textEvents = events.filter(
|
||||
(e: any) => e.type === EventType.TEXT_MESSAGE_CHUNK,
|
||||
);
|
||||
expect(textEvents).toHaveLength(1);
|
||||
expect(textEvents[0]).toMatchObject({
|
||||
delta: "Hello from V3 model",
|
||||
});
|
||||
|
||||
expect(events[events.length - 1]).toMatchObject({
|
||||
type: EventType.RUN_FINISHED,
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { BuiltInAgent } from "../index";
|
||||
|
||||
describe("BuiltInAgent.getCapabilities", () => {
|
||||
it("should return default inferred capabilities", async () => {
|
||||
const agent = new BuiltInAgent({
|
||||
model: "openai/gpt-5.5",
|
||||
});
|
||||
|
||||
const capabilities = await agent.getCapabilities();
|
||||
|
||||
expect(capabilities).toEqual({
|
||||
tools: {
|
||||
supported: true,
|
||||
clientProvided: true,
|
||||
},
|
||||
transport: {
|
||||
streaming: true,
|
||||
},
|
||||
humanInTheLoop: {
|
||||
interrupts: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should merge explicit overrides with inferred defaults", async () => {
|
||||
const agent = new BuiltInAgent({
|
||||
model: "openai/gpt-5.5",
|
||||
capabilities: {
|
||||
reasoning: {
|
||||
supported: true,
|
||||
streaming: true,
|
||||
},
|
||||
identity: {
|
||||
name: "my-agent",
|
||||
type: "custom",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const capabilities = await agent.getCapabilities();
|
||||
|
||||
expect(capabilities).toEqual({
|
||||
tools: {
|
||||
supported: true,
|
||||
clientProvided: true,
|
||||
},
|
||||
transport: {
|
||||
streaming: true,
|
||||
},
|
||||
humanInTheLoop: {
|
||||
interrupts: true,
|
||||
},
|
||||
reasoning: {
|
||||
supported: true,
|
||||
streaming: true,
|
||||
},
|
||||
identity: {
|
||||
name: "my-agent",
|
||||
type: "custom",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow overrides to replace entire categories", async () => {
|
||||
const agent = new BuiltInAgent({
|
||||
model: "openai/gpt-5.5",
|
||||
capabilities: {
|
||||
tools: {
|
||||
supported: true,
|
||||
clientProvided: true,
|
||||
parallelCalls: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const capabilities = await agent.getCapabilities();
|
||||
|
||||
expect(capabilities.tools).toEqual({
|
||||
supported: true,
|
||||
clientProvided: true,
|
||||
parallelCalls: true,
|
||||
});
|
||||
// transport still inferred
|
||||
expect(capabilities.transport).toEqual({ streaming: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,519 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { BasicAgent, defineTool } from "../index";
|
||||
import { EventType } from "@ag-ui/client";
|
||||
import type { RunAgentInput } from "@ag-ui/client";
|
||||
import { streamText } from "ai";
|
||||
import {
|
||||
mockStreamTextResponse,
|
||||
toolCallStreamingStart,
|
||||
toolCall,
|
||||
toolResult,
|
||||
finish,
|
||||
collectEvents,
|
||||
} from "./test-helpers";
|
||||
|
||||
// Mock the ai module
|
||||
vi.mock("ai", () => ({
|
||||
streamText: vi.fn(),
|
||||
tool: vi.fn((config) => config),
|
||||
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
|
||||
}));
|
||||
|
||||
// Mock the SDK clients
|
||||
vi.mock("@ai-sdk/openai", () => ({
|
||||
createOpenAI: vi.fn(() => (modelId: string) => ({
|
||||
modelId,
|
||||
provider: "openai",
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("Config Tools Server-Side Execution", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
process.env.OPENAI_API_KEY = "test-key";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe("Tool Definition with Execute", () => {
|
||||
it("should pass execute function to streamText tools", async () => {
|
||||
const executeFn = vi.fn().mockResolvedValue({ result: "executed" });
|
||||
|
||||
const weatherTool = defineTool({
|
||||
name: "getWeather",
|
||||
description: "Get weather for a city",
|
||||
parameters: z.object({
|
||||
city: z.string().describe("The city name"),
|
||||
}),
|
||||
execute: executeFn,
|
||||
});
|
||||
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
tools: [weatherTool],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
// Verify streamText was called with tools that have execute functions
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.tools).toHaveProperty("getWeather");
|
||||
expect(callArgs.tools.getWeather).toHaveProperty("execute");
|
||||
expect(typeof callArgs.tools.getWeather.execute).toBe("function");
|
||||
});
|
||||
|
||||
it("should include all tool properties in the Vercel AI SDK tool", async () => {
|
||||
const executeFn = vi.fn().mockResolvedValue({ temperature: 72 });
|
||||
|
||||
const weatherTool = defineTool({
|
||||
name: "getWeather",
|
||||
description: "Get weather for a city",
|
||||
parameters: z.object({
|
||||
city: z.string(),
|
||||
units: z.enum(["celsius", "fahrenheit"]).optional(),
|
||||
}),
|
||||
execute: executeFn,
|
||||
});
|
||||
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
tools: [weatherTool],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
const tool = callArgs.tools.getWeather;
|
||||
|
||||
expect(tool.description).toBe("Get weather for a city");
|
||||
expect(tool.inputSchema).toBeDefined();
|
||||
expect(tool.execute).toBe(executeFn);
|
||||
});
|
||||
|
||||
it("should handle multiple config tools with execute functions", async () => {
|
||||
const weatherExecute = vi.fn().mockResolvedValue({ temp: 72 });
|
||||
const searchExecute = vi.fn().mockResolvedValue({ results: [] });
|
||||
|
||||
const weatherTool = defineTool({
|
||||
name: "getWeather",
|
||||
description: "Get weather",
|
||||
parameters: z.object({ city: z.string() }),
|
||||
execute: weatherExecute,
|
||||
});
|
||||
|
||||
const searchTool = defineTool({
|
||||
name: "search",
|
||||
description: "Search the web",
|
||||
parameters: z.object({ query: z.string() }),
|
||||
execute: searchExecute,
|
||||
});
|
||||
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
tools: [weatherTool, searchTool],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
|
||||
expect(callArgs.tools.getWeather.execute).toBe(weatherExecute);
|
||||
expect(callArgs.tools.search.execute).toBe(searchExecute);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Config Tools vs Input Tools", () => {
|
||||
it("config tools should have execute, input tools should not", async () => {
|
||||
const configExecute = vi.fn().mockResolvedValue({ result: "server" });
|
||||
|
||||
const configTool = defineTool({
|
||||
name: "serverTool",
|
||||
description: "Runs on server",
|
||||
parameters: z.object({ data: z.string() }),
|
||||
execute: configExecute,
|
||||
});
|
||||
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
tools: [configTool],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [
|
||||
{
|
||||
name: "clientTool",
|
||||
description: "Runs on client",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { input: { type: "string" } },
|
||||
},
|
||||
},
|
||||
],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
|
||||
// Config tool has execute
|
||||
expect(callArgs.tools.serverTool.execute).toBe(configExecute);
|
||||
|
||||
// Input tool does NOT have execute (client-side execution)
|
||||
expect(callArgs.tools.clientTool.execute).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Execute Function Invocation", () => {
|
||||
it("execute function can be called with correct arguments", async () => {
|
||||
const executeFn = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ weather: "sunny", temp: 72 });
|
||||
|
||||
const weatherTool = defineTool({
|
||||
name: "getWeather",
|
||||
description: "Get weather",
|
||||
parameters: z.object({
|
||||
city: z.string(),
|
||||
units: z.enum(["celsius", "fahrenheit"]),
|
||||
}),
|
||||
execute: executeFn,
|
||||
});
|
||||
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
tools: [weatherTool],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
// Get the execute function that was passed to streamText
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
const passedExecute = callArgs.tools.getWeather.execute;
|
||||
|
||||
// Manually invoke it to verify it works correctly
|
||||
const result = await passedExecute(
|
||||
{ city: "New York", units: "fahrenheit" },
|
||||
{ toolCallId: "tool-call-1", messages: [] },
|
||||
);
|
||||
|
||||
expect(executeFn).toHaveBeenCalledWith(
|
||||
{ city: "New York", units: "fahrenheit" },
|
||||
{ toolCallId: "tool-call-1", messages: [] },
|
||||
);
|
||||
expect(result).toEqual({ weather: "sunny", temp: 72 });
|
||||
});
|
||||
|
||||
it("execute function errors are propagated", async () => {
|
||||
const executeFn = vi.fn().mockRejectedValue(new Error("API unavailable"));
|
||||
|
||||
const failingTool = defineTool({
|
||||
name: "failingTool",
|
||||
description: "A tool that fails",
|
||||
parameters: z.object({}),
|
||||
execute: executeFn,
|
||||
});
|
||||
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
tools: [failingTool],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
const passedExecute = callArgs.tools.failingTool.execute;
|
||||
|
||||
await expect(
|
||||
passedExecute({}, { toolCallId: "tool-call-1", messages: [] }),
|
||||
).rejects.toThrow("API unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Built-in State Tools Still Work", () => {
|
||||
it("AGUISendStateSnapshot should have execute alongside config tools", async () => {
|
||||
const configExecute = vi.fn().mockResolvedValue({});
|
||||
|
||||
const configTool = defineTool({
|
||||
name: "myTool",
|
||||
description: "My tool",
|
||||
parameters: z.object({}),
|
||||
execute: configExecute,
|
||||
});
|
||||
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
tools: [configTool],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: { value: 1 },
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
|
||||
// Both config tool and state tools should have execute
|
||||
expect(callArgs.tools.myTool.execute).toBe(configExecute);
|
||||
expect(callArgs.tools.AGUISendStateSnapshot.execute).toBeDefined();
|
||||
expect(callArgs.tools.AGUISendStateDelta.execute).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Message ID Generation", () => {
|
||||
it("should use messageId from text-start event", async () => {
|
||||
const executeFn = vi.fn().mockResolvedValue({ result: "ok" });
|
||||
|
||||
const tool = defineTool({
|
||||
name: "myTool",
|
||||
description: "My tool",
|
||||
parameters: z.object({}),
|
||||
execute: executeFn,
|
||||
});
|
||||
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
tools: [tool],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([
|
||||
{ type: "text-start", id: "msg-1" },
|
||||
{ type: "text-delta", text: "Before " },
|
||||
{ type: "text-delta", text: "tool" },
|
||||
toolCallStreamingStart("call1", "myTool"),
|
||||
toolCall("call1", "myTool"),
|
||||
toolResult("call1", "myTool", { result: "ok" }),
|
||||
{ type: "text-start", id: "msg-2" },
|
||||
{ type: "text-delta", text: "After " },
|
||||
{ type: "text-delta", text: "tool" },
|
||||
finish(),
|
||||
]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
const events = await collectEvents(agent["run"](input));
|
||||
|
||||
const textEvents = events.filter(
|
||||
(e: any) => e.type === EventType.TEXT_MESSAGE_CHUNK,
|
||||
);
|
||||
|
||||
// First two text chunks should have messageId from first text-start
|
||||
expect(textEvents[0].messageId).toBe("msg-1");
|
||||
expect(textEvents[1].messageId).toBe("msg-1");
|
||||
|
||||
// After tool result, text chunks should have messageId from second text-start
|
||||
expect(textEvents[2].messageId).toBe("msg-2");
|
||||
expect(textEvents[3].messageId).toBe("msg-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Multi-Step Execution (maxSteps)", () => {
|
||||
it("should pass stopWhen with stepCountIs when maxSteps is configured", async () => {
|
||||
const executeFn = vi.fn().mockResolvedValue({ result: "ok" });
|
||||
|
||||
const tool = defineTool({
|
||||
name: "myTool",
|
||||
description: "My tool",
|
||||
parameters: z.object({}),
|
||||
execute: executeFn,
|
||||
});
|
||||
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
maxSteps: 5,
|
||||
tools: [tool],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
|
||||
// stopWhen should be set with stepCountIs(5)
|
||||
expect(callArgs.stopWhen).toEqual({ type: "stepCount", count: 5 });
|
||||
});
|
||||
|
||||
it("should not set stopWhen when maxSteps is not configured", async () => {
|
||||
const executeFn = vi.fn().mockResolvedValue({ result: "ok" });
|
||||
|
||||
const tool = defineTool({
|
||||
name: "myTool",
|
||||
description: "My tool",
|
||||
parameters: z.object({}),
|
||||
execute: executeFn,
|
||||
});
|
||||
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
// maxSteps not set
|
||||
tools: [tool],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
|
||||
// stopWhen should be undefined (defaults to stepCountIs(1) in SDK)
|
||||
expect(callArgs.stopWhen).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should allow high maxSteps for complex tool chains", async () => {
|
||||
const executeFn = vi.fn().mockResolvedValue({});
|
||||
|
||||
const tool = defineTool({
|
||||
name: "chainTool",
|
||||
description: "Tool for chaining",
|
||||
parameters: z.object({}),
|
||||
execute: executeFn,
|
||||
});
|
||||
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
maxSteps: 10,
|
||||
tools: [tool],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
|
||||
expect(callArgs.stopWhen).toEqual({ type: "stepCount", count: 10 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,692 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { EventType } from "@ag-ui/client";
|
||||
import {
|
||||
createAgent,
|
||||
createDefaultInput,
|
||||
collectEvents,
|
||||
collectEventsIncludingErrors,
|
||||
expectLifecycleWrapped,
|
||||
expectEventSequence,
|
||||
eventField,
|
||||
textStart,
|
||||
textDelta,
|
||||
toolCallStreamingStart,
|
||||
toolCallDelta,
|
||||
toolCall,
|
||||
toolResult,
|
||||
reasoningStart,
|
||||
reasoningDelta,
|
||||
reasoningEnd,
|
||||
finish,
|
||||
} from "./agent-test-helpers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Basic Event Emission
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("AI SDK Converter", () => {
|
||||
describe("Basic Event Emission", () => {
|
||||
it("text delta emits TEXT_MESSAGE_CHUNK with correct role, messageId, and delta", async () => {
|
||||
const agent = createAgent("aisdk", [textDelta("Hello"), finish()]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
|
||||
const textChunks = events.filter(
|
||||
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
|
||||
);
|
||||
expect(textChunks).toHaveLength(1);
|
||||
|
||||
expect(eventField<string>(textChunks[0], "role")).toBe("assistant");
|
||||
expect(eventField<string>(textChunks[0], "delta")).toBe("Hello");
|
||||
expect(eventField<string>(textChunks[0], "messageId")).toBeDefined();
|
||||
expect(typeof eventField<string>(textChunks[0], "messageId")).toBe(
|
||||
"string",
|
||||
);
|
||||
});
|
||||
|
||||
it("text-start with provider id uses that id as messageId", async () => {
|
||||
const agent = createAgent("aisdk", [
|
||||
textStart("custom-msg-id"),
|
||||
textDelta("Hi"),
|
||||
finish(),
|
||||
]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
const chunk = events.find(
|
||||
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
|
||||
)!;
|
||||
expect(eventField<string>(chunk, "messageId")).toBe("custom-msg-id");
|
||||
});
|
||||
|
||||
it('text-start with "0" generates a unique messageId (not "0")', async () => {
|
||||
const agent = createAgent("aisdk", [
|
||||
textStart("0"),
|
||||
textDelta("Hi"),
|
||||
finish(),
|
||||
]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
const chunk = events.find(
|
||||
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
|
||||
)!;
|
||||
expect(eventField<string>(chunk, "messageId")).not.toBe("0");
|
||||
expect(eventField<string>(chunk, "messageId")).toBeDefined();
|
||||
expect(eventField<string>(chunk, "messageId").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("multiple text deltas share the same messageId", async () => {
|
||||
const agent = createAgent("aisdk", [
|
||||
textDelta("Hello "),
|
||||
textDelta("world"),
|
||||
finish(),
|
||||
]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
const textChunks = events.filter(
|
||||
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
|
||||
);
|
||||
expect(textChunks).toHaveLength(2);
|
||||
expect(eventField<string>(textChunks[0], "messageId")).toBe(
|
||||
eventField<string>(textChunks[1], "messageId"),
|
||||
);
|
||||
});
|
||||
|
||||
it("empty stream (only finish) emits only RUN_STARTED + RUN_FINISHED", async () => {
|
||||
const agent = createAgent("aisdk", [finish()]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
expectEventSequence(events, [
|
||||
EventType.RUN_STARTED,
|
||||
EventType.RUN_FINISHED,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool Call Events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Tool Call Events", () => {
|
||||
it("streamed tool call emits correct START/ARGS/END/RESULT events", async () => {
|
||||
const agent = createAgent("aisdk", [
|
||||
toolCallStreamingStart("tc-1", "myTool"),
|
||||
toolCallDelta("tc-1", '{"key":'),
|
||||
toolCallDelta("tc-1", '"value"}'),
|
||||
toolCall("tc-1", "myTool"),
|
||||
toolResult("tc-1", "myTool", { result: "ok" }),
|
||||
finish(),
|
||||
]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
|
||||
// Check the sequence of tool events
|
||||
const toolEvents = events.filter(
|
||||
(e) =>
|
||||
e.type === EventType.TOOL_CALL_START ||
|
||||
e.type === EventType.TOOL_CALL_ARGS ||
|
||||
e.type === EventType.TOOL_CALL_END ||
|
||||
e.type === EventType.TOOL_CALL_RESULT,
|
||||
);
|
||||
|
||||
expectEventSequence(toolEvents, [
|
||||
EventType.TOOL_CALL_START,
|
||||
EventType.TOOL_CALL_ARGS,
|
||||
EventType.TOOL_CALL_ARGS,
|
||||
EventType.TOOL_CALL_END,
|
||||
EventType.TOOL_CALL_RESULT,
|
||||
]);
|
||||
|
||||
// Verify TOOL_CALL_START details
|
||||
expect(eventField<string>(toolEvents[0], "toolCallId")).toBe("tc-1");
|
||||
expect(eventField<string>(toolEvents[0], "toolCallName")).toBe("myTool");
|
||||
|
||||
// Verify TOOL_CALL_ARGS deltas
|
||||
const argsEvts = toolEvents.filter(
|
||||
(e) => e.type === EventType.TOOL_CALL_ARGS,
|
||||
);
|
||||
expect(eventField<string>(argsEvts[0], "delta")).toBe('{"key":');
|
||||
expect(eventField<string>(argsEvts[1], "delta")).toBe('"value"}');
|
||||
|
||||
// Verify TOOL_CALL_END
|
||||
expect(eventField<string>(toolEvents[2 + 1], "toolCallId")).toBe("tc-1");
|
||||
|
||||
// Verify TOOL_CALL_RESULT
|
||||
expect(eventField<string>(toolEvents[4], "toolCallId")).toBe("tc-1");
|
||||
expect(JSON.parse(eventField<string>(toolEvents[4], "content"))).toEqual({
|
||||
result: "ok",
|
||||
});
|
||||
});
|
||||
|
||||
it("non-streamed tool call (tool-call with input, no prior tool-input-start) emits START + ARGS + END", async () => {
|
||||
const agent = createAgent("aisdk", [
|
||||
toolCall("tc-2", "directTool", { foo: "bar" }),
|
||||
finish(),
|
||||
]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
|
||||
const toolEvents = events.filter(
|
||||
(e) =>
|
||||
e.type === EventType.TOOL_CALL_START ||
|
||||
e.type === EventType.TOOL_CALL_ARGS ||
|
||||
e.type === EventType.TOOL_CALL_END,
|
||||
);
|
||||
|
||||
expectEventSequence(toolEvents, [
|
||||
EventType.TOOL_CALL_START,
|
||||
EventType.TOOL_CALL_ARGS,
|
||||
EventType.TOOL_CALL_END,
|
||||
]);
|
||||
|
||||
expect(eventField<string>(toolEvents[0], "toolCallId")).toBe("tc-2");
|
||||
expect(eventField<string>(toolEvents[0], "toolCallName")).toBe(
|
||||
"directTool",
|
||||
);
|
||||
|
||||
expect(JSON.parse(eventField<string>(toolEvents[1], "delta"))).toEqual({
|
||||
foo: "bar",
|
||||
});
|
||||
});
|
||||
|
||||
it("no duplicate START after tool-input-start followed by tool-call", async () => {
|
||||
const agent = createAgent("aisdk", [
|
||||
toolCallStreamingStart("tc-3", "myTool"),
|
||||
toolCallDelta("tc-3", "{}"),
|
||||
toolCall("tc-3", "myTool"),
|
||||
finish(),
|
||||
]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
const startEvents = events.filter(
|
||||
(e) =>
|
||||
e.type === EventType.TOOL_CALL_START &&
|
||||
eventField<string>(e, "toolCallId") === "tc-3",
|
||||
);
|
||||
expect(startEvents).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("multiple concurrent tool calls have events correctly paired by toolCallId", async () => {
|
||||
const agent = createAgent("aisdk", [
|
||||
toolCallStreamingStart("tc-a", "toolA"),
|
||||
toolCallStreamingStart("tc-b", "toolB"),
|
||||
toolCallDelta("tc-a", '{"a":1}'),
|
||||
toolCallDelta("tc-b", '{"b":2}'),
|
||||
toolCall("tc-a", "toolA"),
|
||||
toolCall("tc-b", "toolB"),
|
||||
toolResult("tc-a", "toolA", "resultA"),
|
||||
toolResult("tc-b", "toolB", "resultB"),
|
||||
finish(),
|
||||
]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
|
||||
// Verify each tool call has its own START
|
||||
const startsA = events.filter(
|
||||
(e) =>
|
||||
e.type === EventType.TOOL_CALL_START &&
|
||||
eventField<string>(e, "toolCallId") === "tc-a",
|
||||
);
|
||||
const startsB = events.filter(
|
||||
(e) =>
|
||||
e.type === EventType.TOOL_CALL_START &&
|
||||
eventField<string>(e, "toolCallId") === "tc-b",
|
||||
);
|
||||
expect(startsA).toHaveLength(1);
|
||||
expect(startsB).toHaveLength(1);
|
||||
|
||||
// Verify args are correctly paired
|
||||
const argsA = events.filter(
|
||||
(e) =>
|
||||
e.type === EventType.TOOL_CALL_ARGS &&
|
||||
eventField<string>(e, "toolCallId") === "tc-a",
|
||||
);
|
||||
const argsB = events.filter(
|
||||
(e) =>
|
||||
e.type === EventType.TOOL_CALL_ARGS &&
|
||||
eventField<string>(e, "toolCallId") === "tc-b",
|
||||
);
|
||||
expect(eventField<string>(argsA[0], "delta")).toBe('{"a":1}');
|
||||
expect(eventField<string>(argsB[0], "delta")).toBe('{"b":2}');
|
||||
|
||||
// Verify results are correctly paired
|
||||
const resultsA = events.filter(
|
||||
(e) =>
|
||||
e.type === EventType.TOOL_CALL_RESULT &&
|
||||
eventField<string>(e, "toolCallId") === "tc-a",
|
||||
);
|
||||
const resultsB = events.filter(
|
||||
(e) =>
|
||||
e.type === EventType.TOOL_CALL_RESULT &&
|
||||
eventField<string>(e, "toolCallId") === "tc-b",
|
||||
);
|
||||
expect(JSON.parse(eventField<string>(resultsA[0], "content"))).toBe(
|
||||
"resultA",
|
||||
);
|
||||
expect(JSON.parse(eventField<string>(resultsB[0], "content"))).toBe(
|
||||
"resultB",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reasoning Events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Reasoning Events", () => {
|
||||
it("full reasoning lifecycle emits correct REASONING_START/MESSAGE_START/CONTENT/MESSAGE_END/END events", async () => {
|
||||
const agent = createAgent("aisdk", [
|
||||
reasoningStart("r-1"),
|
||||
reasoningDelta("thinking..."),
|
||||
reasoningEnd(),
|
||||
textDelta("Answer"),
|
||||
finish(),
|
||||
]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
|
||||
const reasoningEvents = events.filter(
|
||||
(e) =>
|
||||
e.type === EventType.REASONING_START ||
|
||||
e.type === EventType.REASONING_MESSAGE_START ||
|
||||
e.type === EventType.REASONING_MESSAGE_CONTENT ||
|
||||
e.type === EventType.REASONING_MESSAGE_END ||
|
||||
e.type === EventType.REASONING_END,
|
||||
);
|
||||
|
||||
expectEventSequence(reasoningEvents, [
|
||||
EventType.REASONING_START,
|
||||
EventType.REASONING_MESSAGE_START,
|
||||
EventType.REASONING_MESSAGE_CONTENT,
|
||||
EventType.REASONING_MESSAGE_END,
|
||||
EventType.REASONING_END,
|
||||
]);
|
||||
|
||||
// Verify messageId consistency
|
||||
expect(eventField<string>(reasoningEvents[0], "messageId")).toBe("r-1");
|
||||
expect(eventField<string>(reasoningEvents[1], "messageId")).toBe("r-1");
|
||||
expect(eventField<string>(reasoningEvents[1], "role")).toBe("reasoning");
|
||||
expect(eventField<string>(reasoningEvents[2], "messageId")).toBe("r-1");
|
||||
expect(eventField<string>(reasoningEvents[2], "delta")).toBe(
|
||||
"thinking...",
|
||||
);
|
||||
expect(eventField<string>(reasoningEvents[3], "messageId")).toBe("r-1");
|
||||
expect(eventField<string>(reasoningEvents[4], "messageId")).toBe("r-1");
|
||||
});
|
||||
|
||||
it("empty reasoning deltas are skipped", async () => {
|
||||
const agent = createAgent("aisdk", [
|
||||
reasoningStart(),
|
||||
reasoningDelta(""),
|
||||
reasoningDelta("actual content"),
|
||||
reasoningDelta(""),
|
||||
reasoningEnd(),
|
||||
finish(),
|
||||
]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
const contentEvents = events.filter(
|
||||
(e) => e.type === EventType.REASONING_MESSAGE_CONTENT,
|
||||
);
|
||||
expect(contentEvents).toHaveLength(1);
|
||||
expect(eventField<string>(contentEvents[0], "delta")).toBe(
|
||||
"actual content",
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-close reasoning before text-delta", async () => {
|
||||
// No explicit reasoning-end — the converter should auto-close
|
||||
const agent = createAgent("aisdk", [
|
||||
reasoningStart("r-auto"),
|
||||
reasoningDelta("thinking"),
|
||||
textDelta("Answer"),
|
||||
finish(),
|
||||
]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
|
||||
// Reasoning should be closed before the text event
|
||||
const types = events.map((e) => e.type);
|
||||
const msgEndIdx = types.indexOf(EventType.REASONING_MESSAGE_END);
|
||||
const reasoningEndIdx = types.indexOf(EventType.REASONING_END);
|
||||
const textIdx = types.indexOf(EventType.TEXT_MESSAGE_CHUNK);
|
||||
|
||||
expect(msgEndIdx).toBeGreaterThan(-1);
|
||||
expect(reasoningEndIdx).toBeGreaterThan(-1);
|
||||
expect(textIdx).toBeGreaterThan(reasoningEndIdx);
|
||||
});
|
||||
|
||||
it("auto-close reasoning before tool-input-start", async () => {
|
||||
const agent = createAgent("aisdk", [
|
||||
reasoningStart(),
|
||||
reasoningDelta("thinking about tools"),
|
||||
toolCallStreamingStart("tc-r", "someTool"),
|
||||
toolCallDelta("tc-r", "{}"),
|
||||
toolCall("tc-r", "someTool"),
|
||||
finish(),
|
||||
]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
|
||||
const types = events.map((e) => e.type);
|
||||
const reasoningEndIdx = types.indexOf(EventType.REASONING_END);
|
||||
const toolStartIdx = types.indexOf(EventType.TOOL_CALL_START);
|
||||
|
||||
expect(reasoningEndIdx).toBeGreaterThan(-1);
|
||||
expect(toolStartIdx).toBeGreaterThan(reasoningEndIdx);
|
||||
});
|
||||
|
||||
it("auto-close reasoning before finish", async () => {
|
||||
const agent = createAgent("aisdk", [
|
||||
reasoningStart(),
|
||||
reasoningDelta("deep thought"),
|
||||
finish(),
|
||||
]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
|
||||
// Should contain reasoning close events
|
||||
const types = events.map((e) => e.type);
|
||||
expect(types).toContain(EventType.REASONING_MESSAGE_END);
|
||||
expect(types).toContain(EventType.REASONING_END);
|
||||
|
||||
// They should appear before RUN_FINISHED
|
||||
const reasoningEndIdx = types.indexOf(EventType.REASONING_END);
|
||||
const runFinishedIdx = types.indexOf(EventType.RUN_FINISHED);
|
||||
expect(reasoningEndIdx).toBeLessThan(runFinishedIdx);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State Management Tool Results
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("State Management Tool Results", () => {
|
||||
it("AGUISendStateSnapshot tool result emits STATE_SNAPSHOT event", async () => {
|
||||
const agent = createAgent("aisdk", [
|
||||
toolCallStreamingStart("tc-state", "AGUISendStateSnapshot"),
|
||||
toolCallDelta("tc-state", '{"snapshot":{"count":1}}'),
|
||||
toolCall("tc-state", "AGUISendStateSnapshot"),
|
||||
toolResult("tc-state", "AGUISendStateSnapshot", {
|
||||
snapshot: { count: 1 },
|
||||
}),
|
||||
finish(),
|
||||
]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
const stateSnapshots = events.filter(
|
||||
(e) => e.type === EventType.STATE_SNAPSHOT,
|
||||
);
|
||||
expect(stateSnapshots).toHaveLength(1);
|
||||
expect(eventField(stateSnapshots[0], "snapshot")).toEqual({ count: 1 });
|
||||
});
|
||||
|
||||
it("AGUISendStateDelta tool result emits STATE_DELTA event", async () => {
|
||||
const delta = [{ op: "replace", path: "/count", value: 2 }];
|
||||
const agent = createAgent("aisdk", [
|
||||
toolCallStreamingStart("tc-delta", "AGUISendStateDelta"),
|
||||
toolCallDelta("tc-delta", JSON.stringify({ delta })),
|
||||
toolCall("tc-delta", "AGUISendStateDelta"),
|
||||
toolResult("tc-delta", "AGUISendStateDelta", { delta }),
|
||||
finish(),
|
||||
]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
const stateDeltas = events.filter(
|
||||
(e) => e.type === EventType.STATE_DELTA,
|
||||
);
|
||||
expect(stateDeltas).toHaveLength(1);
|
||||
expect(eventField(stateDeltas[0], "delta")).toEqual(delta);
|
||||
});
|
||||
|
||||
it("state tool result also emits TOOL_CALL_RESULT event", async () => {
|
||||
const agent = createAgent("aisdk", [
|
||||
toolCallStreamingStart("tc-state2", "AGUISendStateSnapshot"),
|
||||
toolCallDelta("tc-state2", "{}"),
|
||||
toolCall("tc-state2", "AGUISendStateSnapshot"),
|
||||
toolResult("tc-state2", "AGUISendStateSnapshot", {
|
||||
snapshot: { x: 1 },
|
||||
}),
|
||||
finish(),
|
||||
]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
const toolResults = events.filter(
|
||||
(e) => e.type === EventType.TOOL_CALL_RESULT,
|
||||
);
|
||||
expect(toolResults).toHaveLength(1);
|
||||
expect(eventField<string>(toolResults[0], "toolCallId")).toBe(
|
||||
"tc-state2",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not emit STATE_SNAPSHOT when snapshot field is undefined", async () => {
|
||||
const agent = createAgent("aisdk", [
|
||||
toolCallStreamingStart("tc-no-snap", "AGUISendStateSnapshot"),
|
||||
toolCallDelta("tc-no-snap", "{}"),
|
||||
toolCall("tc-no-snap", "AGUISendStateSnapshot"),
|
||||
toolResult("tc-no-snap", "AGUISendStateSnapshot", {
|
||||
success: true,
|
||||
}),
|
||||
finish(),
|
||||
]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
const stateSnapshots = events.filter(
|
||||
(e) => e.type === EventType.STATE_SNAPSHOT,
|
||||
);
|
||||
expect(stateSnapshots).toHaveLength(0);
|
||||
|
||||
// Should still emit the TOOL_CALL_RESULT
|
||||
const toolResults = events.filter(
|
||||
(e) => e.type === EventType.TOOL_CALL_RESULT,
|
||||
);
|
||||
expect(toolResults).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not emit STATE_DELTA when delta field is undefined", async () => {
|
||||
const agent = createAgent("aisdk", [
|
||||
toolCallStreamingStart("tc-no-delta", "AGUISendStateDelta"),
|
||||
toolCallDelta("tc-no-delta", "{}"),
|
||||
toolCall("tc-no-delta", "AGUISendStateDelta"),
|
||||
toolResult("tc-no-delta", "AGUISendStateDelta", {
|
||||
success: true,
|
||||
}),
|
||||
finish(),
|
||||
]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
const stateDeltas = events.filter(
|
||||
(e) => e.type === EventType.STATE_DELTA,
|
||||
);
|
||||
expect(stateDeltas).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool Result Property Compatibility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Tool Result Property Compatibility", () => {
|
||||
it("reads tool result from 'result' property when 'output' is absent", async () => {
|
||||
// Simulate older AI SDK that uses "result" instead of "output"
|
||||
const agent = createAgent("aisdk", [
|
||||
toolCall("tc-compat", "myTool"),
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "tc-compat",
|
||||
toolName: "myTool",
|
||||
result: { data: "from-result-prop" },
|
||||
},
|
||||
finish(),
|
||||
]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
const resultEvents = events.filter(
|
||||
(e) => e.type === EventType.TOOL_CALL_RESULT,
|
||||
);
|
||||
expect(resultEvents).toHaveLength(1);
|
||||
expect(
|
||||
JSON.parse(eventField<string>(resultEvents[0], "content")),
|
||||
).toEqual({ data: "from-result-prop" });
|
||||
});
|
||||
|
||||
it("prefers 'output' over 'result' when both are present", async () => {
|
||||
const agent = createAgent("aisdk", [
|
||||
toolCall("tc-both", "myTool"),
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "tc-both",
|
||||
toolName: "myTool",
|
||||
output: { source: "output" },
|
||||
result: { source: "result" },
|
||||
},
|
||||
finish(),
|
||||
]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
const resultEvents = events.filter(
|
||||
(e) => e.type === EventType.TOOL_CALL_RESULT,
|
||||
);
|
||||
expect(
|
||||
JSON.parse(eventField<string>(resultEvents[0], "content")),
|
||||
).toEqual({ source: "output" });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error Event Handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Error Event Handling", () => {
|
||||
it("wraps undefined error in a descriptive Error", async () => {
|
||||
const agent = createAgent("aisdk", [
|
||||
textDelta("before error"),
|
||||
{ type: "error" }, // no .error property
|
||||
]);
|
||||
const input = createDefaultInput();
|
||||
|
||||
const { events, errored } = await collectEventsIncludingErrors(
|
||||
agent.run(input),
|
||||
);
|
||||
|
||||
expect(errored).toBe(true);
|
||||
const errorEvents = events.filter((e) => e.type === EventType.RUN_ERROR);
|
||||
expect(errorEvents).toHaveLength(1);
|
||||
// Should contain a useful message, not just "undefined"
|
||||
expect(eventField<string>(errorEvents[0], "message")).not.toBe(
|
||||
"undefined",
|
||||
);
|
||||
expect(
|
||||
eventField<string>(errorEvents[0], "message").length,
|
||||
).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it("preserves Error instances from error event", async () => {
|
||||
const agent = createAgent("aisdk", [
|
||||
{ type: "error", error: new Error("rate limit exceeded") },
|
||||
]);
|
||||
const input = createDefaultInput();
|
||||
|
||||
const { events, errored } = await collectEventsIncludingErrors(
|
||||
agent.run(input),
|
||||
);
|
||||
|
||||
expect(errored).toBe(true);
|
||||
const errorEvents = events.filter((e) => e.type === EventType.RUN_ERROR);
|
||||
expect(eventField<string>(errorEvents[0], "message")).toBe(
|
||||
"rate limit exceeded",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles string error from error event", async () => {
|
||||
const agent = createAgent("aisdk", [
|
||||
{ type: "error", message: "auth failed" },
|
||||
]);
|
||||
const input = createDefaultInput();
|
||||
|
||||
const { events, errored } = await collectEventsIncludingErrors(
|
||||
agent.run(input),
|
||||
);
|
||||
|
||||
expect(errored).toBe(true);
|
||||
const errorEvents = events.filter((e) => e.type === EventType.RUN_ERROR);
|
||||
expect(eventField<string>(errorEvents[0], "message")).toBe("auth failed");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge Cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("unknown event types are silently ignored", async () => {
|
||||
const agent = createAgent("aisdk", [
|
||||
{ type: "some-unknown-event", data: "hello" },
|
||||
textDelta("text after unknown"),
|
||||
{ type: "another-mystery-event" },
|
||||
finish(),
|
||||
]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
|
||||
// Should still have the text chunk
|
||||
const textChunks = events.filter(
|
||||
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
|
||||
);
|
||||
expect(textChunks).toHaveLength(1);
|
||||
|
||||
// No events for unknown types — only RUN_STARTED, TEXT_MESSAGE_CHUNK, RUN_FINISHED
|
||||
expectEventSequence(events, [
|
||||
EventType.RUN_STARTED,
|
||||
EventType.TEXT_MESSAGE_CHUNK,
|
||||
EventType.RUN_FINISHED,
|
||||
]);
|
||||
});
|
||||
|
||||
it("large text deltas (100k chars) are passed through", async () => {
|
||||
const largeText = "x".repeat(100_000);
|
||||
const agent = createAgent("aisdk", [textDelta(largeText), finish()]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
|
||||
const chunk = events.find(
|
||||
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
|
||||
)!;
|
||||
expect(eventField<string>(chunk, "delta")).toBe(largeText);
|
||||
expect(eventField<string>(chunk, "delta").length).toBe(100_000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { EventType, type BaseEvent } from "@ag-ui/client";
|
||||
import { BuiltInAgent } from "../index";
|
||||
import {
|
||||
createAgent,
|
||||
createDefaultInput,
|
||||
collectEvents,
|
||||
expectLifecycleWrapped,
|
||||
expectEventSequence,
|
||||
eventField,
|
||||
mockCustomStream,
|
||||
} from "./agent-test-helpers";
|
||||
|
||||
describe("Custom Converter (passthrough)", () => {
|
||||
// -----------------------------------------------------------------------
|
||||
// Event Forwarding
|
||||
// -----------------------------------------------------------------------
|
||||
describe("Event Forwarding", () => {
|
||||
it("should forward a single TEXT_MESSAGE_CHUNK as-is between lifecycle events", async () => {
|
||||
const chunk: BaseEvent = {
|
||||
type: EventType.TEXT_MESSAGE_CHUNK,
|
||||
role: "assistant",
|
||||
delta: "Hello world",
|
||||
} as BaseEvent;
|
||||
|
||||
const agent = createAgent("custom", [chunk]);
|
||||
const input = createDefaultInput();
|
||||
const events = await collectEvents(agent.run(input));
|
||||
|
||||
expectLifecycleWrapped(events, "test-thread", "test-run");
|
||||
expectEventSequence(events, [
|
||||
EventType.RUN_STARTED,
|
||||
EventType.TEXT_MESSAGE_CHUNK,
|
||||
EventType.RUN_FINISHED,
|
||||
]);
|
||||
|
||||
expect(eventField<string>(events[1], "delta")).toBe("Hello world");
|
||||
expect(eventField<string>(events[1], "role")).toBe("assistant");
|
||||
});
|
||||
|
||||
it("should forward multiple event types in order", async () => {
|
||||
const userEvents: BaseEvent[] = [
|
||||
{ type: EventType.TEXT_MESSAGE_START, role: "assistant" } as BaseEvent,
|
||||
{
|
||||
type: EventType.TEXT_MESSAGE_CONTENT,
|
||||
role: "assistant",
|
||||
content: "Hi",
|
||||
} as BaseEvent,
|
||||
{ type: EventType.TEXT_MESSAGE_END } as BaseEvent,
|
||||
];
|
||||
|
||||
const agent = createAgent("custom", userEvents);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
expectEventSequence(events, [
|
||||
EventType.RUN_STARTED,
|
||||
EventType.TEXT_MESSAGE_START,
|
||||
EventType.TEXT_MESSAGE_CONTENT,
|
||||
EventType.TEXT_MESSAGE_END,
|
||||
EventType.RUN_FINISHED,
|
||||
]);
|
||||
});
|
||||
|
||||
it("should forward TOOL_CALL_START + TOOL_CALL_ARGS + TOOL_CALL_END", async () => {
|
||||
const toolEvents: BaseEvent[] = [
|
||||
{
|
||||
type: EventType.TOOL_CALL_START,
|
||||
toolCallId: "tc-1",
|
||||
toolCallName: "myTool",
|
||||
} as BaseEvent,
|
||||
{
|
||||
type: EventType.TOOL_CALL_ARGS,
|
||||
toolCallId: "tc-1",
|
||||
delta: '{"key":"value"}',
|
||||
} as BaseEvent,
|
||||
{
|
||||
type: EventType.TOOL_CALL_END,
|
||||
toolCallId: "tc-1",
|
||||
} as BaseEvent,
|
||||
];
|
||||
|
||||
const agent = createAgent("custom", toolEvents);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
expectEventSequence(events, [
|
||||
EventType.RUN_STARTED,
|
||||
EventType.TOOL_CALL_START,
|
||||
EventType.TOOL_CALL_ARGS,
|
||||
EventType.TOOL_CALL_END,
|
||||
EventType.RUN_FINISHED,
|
||||
]);
|
||||
|
||||
expect(eventField<string>(events[1], "toolCallId")).toBe("tc-1");
|
||||
expect(eventField<string>(events[1], "toolCallName")).toBe("myTool");
|
||||
|
||||
expect(eventField<string>(events[2], "toolCallId")).toBe("tc-1");
|
||||
expect(eventField<string>(events[2], "delta")).toBe('{"key":"value"}');
|
||||
|
||||
expect(eventField<string>(events[3], "toolCallId")).toBe("tc-1");
|
||||
});
|
||||
|
||||
it("should forward a STATE_SNAPSHOT event", async () => {
|
||||
const snapshot: BaseEvent = {
|
||||
type: EventType.STATE_SNAPSHOT,
|
||||
snapshot: { counter: 42, items: ["a", "b"] },
|
||||
} as BaseEvent;
|
||||
|
||||
const agent = createAgent("custom", [snapshot]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
expectEventSequence(events, [
|
||||
EventType.RUN_STARTED,
|
||||
EventType.STATE_SNAPSHOT,
|
||||
EventType.RUN_FINISHED,
|
||||
]);
|
||||
|
||||
expect(
|
||||
eventField<Record<string, unknown>>(events[1], "snapshot"),
|
||||
).toEqual({ counter: 42, items: ["a", "b"] });
|
||||
});
|
||||
|
||||
it("should forward a STATE_DELTA event", async () => {
|
||||
const delta: BaseEvent = {
|
||||
type: EventType.STATE_DELTA,
|
||||
delta: [{ op: "replace", path: "/counter", value: 43 }],
|
||||
} as BaseEvent;
|
||||
|
||||
const agent = createAgent("custom", [delta]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
expectEventSequence(events, [
|
||||
EventType.RUN_STARTED,
|
||||
EventType.STATE_DELTA,
|
||||
EventType.RUN_FINISHED,
|
||||
]);
|
||||
|
||||
expect(eventField<unknown[]>(events[1], "delta")).toEqual([
|
||||
{ op: "replace", path: "/counter", value: 43 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should forward reasoning events in order", async () => {
|
||||
const reasoningEvents: BaseEvent[] = [
|
||||
{ type: EventType.REASONING_START } as BaseEvent,
|
||||
{ type: EventType.REASONING_MESSAGE_START } as BaseEvent,
|
||||
{
|
||||
type: EventType.REASONING_MESSAGE_CONTENT,
|
||||
content: "Thinking step 1",
|
||||
} as BaseEvent,
|
||||
{
|
||||
type: EventType.REASONING_MESSAGE_CONTENT,
|
||||
content: "Thinking step 2",
|
||||
} as BaseEvent,
|
||||
{ type: EventType.REASONING_MESSAGE_END } as BaseEvent,
|
||||
{ type: EventType.REASONING_END } as BaseEvent,
|
||||
];
|
||||
|
||||
const agent = createAgent("custom", reasoningEvents);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
expectEventSequence(events, [
|
||||
EventType.RUN_STARTED,
|
||||
EventType.REASONING_START,
|
||||
EventType.REASONING_MESSAGE_START,
|
||||
EventType.REASONING_MESSAGE_CONTENT,
|
||||
EventType.REASONING_MESSAGE_CONTENT,
|
||||
EventType.REASONING_MESSAGE_END,
|
||||
EventType.REASONING_END,
|
||||
EventType.RUN_FINISHED,
|
||||
]);
|
||||
|
||||
expect(eventField<string>(events[3], "content")).toBe("Thinking step 1");
|
||||
expect(eventField<string>(events[4], "content")).toBe("Thinking step 2");
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Lifecycle Boundary
|
||||
// -----------------------------------------------------------------------
|
||||
describe("Lifecycle Boundary", () => {
|
||||
it("should result in duplicate RUN_STARTED when user emits one in custom stream", async () => {
|
||||
const userEvents: BaseEvent[] = [
|
||||
{
|
||||
type: EventType.RUN_STARTED,
|
||||
threadId: "user-thread",
|
||||
runId: "user-run",
|
||||
} as BaseEvent,
|
||||
{
|
||||
type: EventType.TEXT_MESSAGE_CHUNK,
|
||||
role: "assistant",
|
||||
delta: "Hello",
|
||||
} as BaseEvent,
|
||||
];
|
||||
|
||||
const agent = createAgent("custom", userEvents);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
// Agent emits its own RUN_STARTED, then the user's RUN_STARTED is forwarded
|
||||
const runStartedEvents = events.filter(
|
||||
(e) => e.type === EventType.RUN_STARTED,
|
||||
);
|
||||
expect(runStartedEvents).toHaveLength(2);
|
||||
|
||||
// First is from the Agent lifecycle
|
||||
expect(runStartedEvents[0]).toMatchObject({
|
||||
type: EventType.RUN_STARTED,
|
||||
threadId: "test-thread",
|
||||
runId: "test-run",
|
||||
});
|
||||
|
||||
// Second is the user-emitted one, forwarded as-is
|
||||
expect(eventField<string>(runStartedEvents[1], "threadId")).toBe(
|
||||
"user-thread",
|
||||
);
|
||||
expect(eventField<string>(runStartedEvents[1], "runId")).toBe("user-run");
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Edge Cases
|
||||
// -----------------------------------------------------------------------
|
||||
describe("Edge Cases", () => {
|
||||
it("should emit only lifecycle events for an empty async iterable", async () => {
|
||||
const agent = createAgent("custom", []);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
expectEventSequence(events, [
|
||||
EventType.RUN_STARTED,
|
||||
EventType.RUN_FINISHED,
|
||||
]);
|
||||
expectLifecycleWrapped(events, "test-thread", "test-run");
|
||||
});
|
||||
|
||||
it("should work correctly with an async generator factory", async () => {
|
||||
const agent = new BuiltInAgent({
|
||||
type: "custom",
|
||||
factory: async function* () {
|
||||
yield {
|
||||
type: EventType.TEXT_MESSAGE_CHUNK,
|
||||
role: "assistant",
|
||||
delta: "from generator",
|
||||
} as BaseEvent;
|
||||
yield {
|
||||
type: EventType.TEXT_MESSAGE_CHUNK,
|
||||
role: "assistant",
|
||||
delta: " factory",
|
||||
} as BaseEvent;
|
||||
},
|
||||
});
|
||||
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
expectEventSequence(events, [
|
||||
EventType.RUN_STARTED,
|
||||
EventType.TEXT_MESSAGE_CHUNK,
|
||||
EventType.TEXT_MESSAGE_CHUNK,
|
||||
EventType.RUN_FINISHED,
|
||||
]);
|
||||
|
||||
expect(eventField<string>(events[1], "delta")).toBe("from generator");
|
||||
expect(eventField<string>(events[2], "delta")).toBe(" factory");
|
||||
});
|
||||
|
||||
it("should pass through events with extra/unknown fields", async () => {
|
||||
const eventWithExtras: BaseEvent = {
|
||||
type: EventType.CUSTOM,
|
||||
customField: "custom-value",
|
||||
nestedData: { deep: { value: 123 } },
|
||||
arrayField: [1, 2, 3],
|
||||
} as BaseEvent;
|
||||
|
||||
const agent = createAgent("custom", [eventWithExtras]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
expectEventSequence(events, [
|
||||
EventType.RUN_STARTED,
|
||||
EventType.CUSTOM,
|
||||
EventType.RUN_FINISHED,
|
||||
]);
|
||||
|
||||
expect(eventField<string>(events[1], "customField")).toBe("custom-value");
|
||||
expect(
|
||||
eventField<{ deep: { value: number } }>(events[1], "nestedData"),
|
||||
).toEqual({ deep: { value: 123 } });
|
||||
expect(eventField<number[]>(events[1], "arrayField")).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("should forward 1000+ events without loss", async () => {
|
||||
const count = 1500;
|
||||
const manyEvents: BaseEvent[] = Array.from({ length: count }, (_, i) => ({
|
||||
type: EventType.TEXT_MESSAGE_CHUNK,
|
||||
role: "assistant",
|
||||
delta: `chunk-${i}`,
|
||||
})) as BaseEvent[];
|
||||
|
||||
const agent = createAgent("custom", manyEvents);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
// Total = RUN_STARTED + 1500 chunks + RUN_FINISHED
|
||||
expect(events).toHaveLength(count + 2);
|
||||
|
||||
// First and last are lifecycle
|
||||
expect(events[0].type).toBe(EventType.RUN_STARTED);
|
||||
expect(events[events.length - 1].type).toBe(EventType.RUN_FINISHED);
|
||||
|
||||
// All content events are TEXT_MESSAGE_CHUNK
|
||||
const contentEvents = events.slice(1, -1);
|
||||
expect(contentEvents).toHaveLength(count);
|
||||
|
||||
// Verify order preservation
|
||||
for (let i = 0; i < count; i++) {
|
||||
expect(contentEvents[i].type).toBe(EventType.TEXT_MESSAGE_CHUNK);
|
||||
expect(eventField<string>(contentEvents[i], "delta")).toBe(
|
||||
`chunk-${i}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { convertInputToTanStackAI } from "../converters/tanstack";
|
||||
import { createDefaultInput } from "./agent-test-helpers";
|
||||
|
||||
describe("convertInputToTanStackAI", () => {
|
||||
// -------------------------------------------------------------------------
|
||||
// Message filtering
|
||||
// -------------------------------------------------------------------------
|
||||
describe("message filtering", () => {
|
||||
it("filters out system messages from the messages array", () => {
|
||||
const input = createDefaultInput({
|
||||
messages: [
|
||||
{ id: "msg-1", role: "system", content: "You are helpful" },
|
||||
{ id: "msg-2", role: "user", content: "Hello" },
|
||||
],
|
||||
});
|
||||
|
||||
const { messages } = convertInputToTanStackAI(input);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].role).toBe("user");
|
||||
expect(messages[0].content).toBe("Hello");
|
||||
});
|
||||
|
||||
it("filters out developer messages from the messages array", () => {
|
||||
const input = createDefaultInput({
|
||||
messages: [
|
||||
{ id: "msg-3", role: "developer", content: "Internal instruction" },
|
||||
{ id: "msg-4", role: "user", content: "Hi" },
|
||||
],
|
||||
});
|
||||
|
||||
const { messages } = convertInputToTanStackAI(input);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].role).toBe("user");
|
||||
});
|
||||
|
||||
it("extracts system and developer messages into systemPrompts", () => {
|
||||
const input = createDefaultInput({
|
||||
messages: [
|
||||
{ id: "msg-5", role: "system", content: "System prompt" },
|
||||
{ id: "msg-6", role: "developer", content: "Dev instruction" },
|
||||
{ id: "msg-7", role: "user", content: "Hello" },
|
||||
],
|
||||
});
|
||||
|
||||
const { systemPrompts } = convertInputToTanStackAI(input);
|
||||
|
||||
expect(systemPrompts).toContain("System prompt");
|
||||
expect(systemPrompts).toContain("Dev instruction");
|
||||
});
|
||||
|
||||
it("preserves user and assistant messages in order", () => {
|
||||
const input = createDefaultInput({
|
||||
messages: [
|
||||
{ id: "msg-8", role: "user", content: "Question 1" },
|
||||
{ id: "msg-9", role: "assistant", content: "Answer 1" },
|
||||
{ id: "msg-10", role: "user", content: "Question 2" },
|
||||
],
|
||||
});
|
||||
|
||||
const { messages } = convertInputToTanStackAI(input);
|
||||
|
||||
expect(messages).toHaveLength(3);
|
||||
expect(messages[0]).toMatchObject({
|
||||
role: "user",
|
||||
content: "Question 1",
|
||||
});
|
||||
expect(messages[1]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "Answer 1",
|
||||
});
|
||||
expect(messages[2]).toMatchObject({
|
||||
role: "user",
|
||||
content: "Question 2",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tool call mapping
|
||||
// -------------------------------------------------------------------------
|
||||
describe("tool call mapping", () => {
|
||||
it("maps assistant message toolCalls to TanStack format", () => {
|
||||
const input = createDefaultInput({
|
||||
messages: [
|
||||
{
|
||||
id: "msg-14",
|
||||
role: "assistant",
|
||||
content: null,
|
||||
toolCalls: [
|
||||
{
|
||||
id: "tc-1",
|
||||
type: "function",
|
||||
function: { name: "getWeather", arguments: '{"city":"NYC"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { messages } = convertInputToTanStackAI(input);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].toolCalls).toHaveLength(1);
|
||||
expect(messages[0].toolCalls![0]).toEqual({
|
||||
id: "tc-1",
|
||||
type: "function",
|
||||
function: { name: "getWeather", arguments: '{"city":"NYC"}' },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps tool messages with toolCallId", () => {
|
||||
const input = createDefaultInput({
|
||||
messages: [
|
||||
{
|
||||
id: "msg-15",
|
||||
role: "tool",
|
||||
content: '{"temp": 72}',
|
||||
toolCallId: "tc-1",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { messages } = convertInputToTanStackAI(input);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].role).toBe("tool");
|
||||
expect(messages[0].toolCallId).toBe("tc-1");
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Client tools (frontend-provided tools → TanStack client tools)
|
||||
// -------------------------------------------------------------------------
|
||||
describe("client tools", () => {
|
||||
it("converts input.tools to TanStack client tools (no executor)", () => {
|
||||
const params = {
|
||||
type: "object" as const,
|
||||
properties: { issues: { type: "array" as const } },
|
||||
required: ["issues"],
|
||||
};
|
||||
const input = createDefaultInput({
|
||||
tools: [
|
||||
{
|
||||
name: "issue_list",
|
||||
description: "Render a list of issues",
|
||||
parameters: params,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { tools } = convertInputToTanStackAI(input);
|
||||
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0]).toEqual({
|
||||
__toolSide: "client",
|
||||
name: "issue_list",
|
||||
description: "Render a list of issues",
|
||||
inputSchema: params,
|
||||
});
|
||||
// Client tools must NOT carry an executor — TanStack pauses and hands
|
||||
// the call back to the AG-UI client instead of running it.
|
||||
expect("execute" in tools[0]).toBe(false);
|
||||
});
|
||||
|
||||
it("returns an empty tools array when input has no tools", () => {
|
||||
const { tools } = convertInputToTanStackAI(createDefaultInput());
|
||||
expect(tools).toEqual([]);
|
||||
});
|
||||
|
||||
it("closes open objects (additionalProperties → false) for OpenAI compatibility", () => {
|
||||
const input = createDefaultInput({
|
||||
tools: [
|
||||
{
|
||||
name: "render_chart",
|
||||
description: "Render a chart",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
// z.record(z.string(), z.any()) → additionalProperties: {}
|
||||
options: { type: "object", additionalProperties: {} },
|
||||
data: {
|
||||
type: "array",
|
||||
// .passthrough() → additionalProperties: true on items
|
||||
items: {
|
||||
type: "object",
|
||||
properties: { v: { type: "number" } },
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { tools } = convertInputToTanStackAI(input);
|
||||
const schema = tools[0].inputSchema as Record<string, any>;
|
||||
|
||||
expect(schema.properties.options.additionalProperties).toBe(false);
|
||||
expect(schema.properties.data.items.additionalProperties).toBe(false);
|
||||
// Nested defined properties are preserved.
|
||||
expect(schema.properties.data.items.properties.v).toEqual({
|
||||
type: "number",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Context injection
|
||||
// -------------------------------------------------------------------------
|
||||
describe("context injection", () => {
|
||||
it("appends context entries to systemPrompts", () => {
|
||||
const input = createDefaultInput({
|
||||
context: [
|
||||
{ description: "User preferences", value: "Dark mode enabled" },
|
||||
{ description: "Current page", value: "/dashboard" },
|
||||
],
|
||||
});
|
||||
|
||||
const { systemPrompts } = convertInputToTanStackAI(input);
|
||||
|
||||
expect(systemPrompts).toContain("User preferences:\nDark mode enabled");
|
||||
expect(systemPrompts).toContain("Current page:\n/dashboard");
|
||||
});
|
||||
|
||||
it("does not add context when context array is empty", () => {
|
||||
const input = createDefaultInput({ context: [] });
|
||||
|
||||
const { systemPrompts } = convertInputToTanStackAI(input);
|
||||
|
||||
expect(systemPrompts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// State serialization
|
||||
// -------------------------------------------------------------------------
|
||||
describe("state serialization", () => {
|
||||
it("serializes non-empty state into systemPrompts", () => {
|
||||
const input = createDefaultInput({
|
||||
state: { count: 42, items: ["a", "b"] },
|
||||
});
|
||||
|
||||
const { systemPrompts } = convertInputToTanStackAI(input);
|
||||
|
||||
const statePrompt = systemPrompts.find((p) =>
|
||||
p.startsWith("Application State:"),
|
||||
);
|
||||
expect(statePrompt).toBeDefined();
|
||||
expect(statePrompt).toContain('"count": 42');
|
||||
});
|
||||
|
||||
it("does not add state when state is empty object", () => {
|
||||
const input = createDefaultInput({ state: {} });
|
||||
|
||||
const { systemPrompts } = convertInputToTanStackAI(input);
|
||||
|
||||
expect(
|
||||
systemPrompts.find((p) => p.startsWith("Application State:")),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not add state when state is null", () => {
|
||||
const input = createDefaultInput({ state: null });
|
||||
|
||||
const { systemPrompts } = convertInputToTanStackAI(input);
|
||||
|
||||
expect(
|
||||
systemPrompts.find((p) => p.startsWith("Application State:")),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Empty input
|
||||
// -------------------------------------------------------------------------
|
||||
describe("empty input", () => {
|
||||
it("returns empty messages and systemPrompts for default input", () => {
|
||||
const input = createDefaultInput();
|
||||
|
||||
const { messages, systemPrompts } = convertInputToTanStackAI(input);
|
||||
|
||||
expect(messages).toHaveLength(0);
|
||||
expect(systemPrompts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,688 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { EventType } from "@ag-ui/client";
|
||||
import {
|
||||
createAgent,
|
||||
createDefaultInput,
|
||||
collectEvents,
|
||||
collectEventsIncludingErrors,
|
||||
expectLifecycleWrapped,
|
||||
expectEventSequence,
|
||||
eventField,
|
||||
tanstackTextChunk,
|
||||
tanstackToolCallStart,
|
||||
tanstackToolCallArgs,
|
||||
tanstackToolCallEnd,
|
||||
tanstackToolCallResult,
|
||||
tanstackReasoningStart,
|
||||
tanstackReasoningMessageStart,
|
||||
tanstackReasoningMessageContent,
|
||||
tanstackReasoningMessageEnd,
|
||||
tanstackReasoningEnd,
|
||||
} from "./agent-test-helpers";
|
||||
|
||||
describe("TanStack AI converter (via Agent)", () => {
|
||||
// -------------------------------------------------------------------------
|
||||
// Text Events
|
||||
// -------------------------------------------------------------------------
|
||||
describe("Text Events", () => {
|
||||
it("TEXT_MESSAGE_CONTENT chunk produces TEXT_MESSAGE_CHUNK with role assistant and correct delta", async () => {
|
||||
const agent = createAgent("tanstack", [tanstackTextChunk("Hello world")]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
|
||||
const textEvents = events.filter(
|
||||
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
|
||||
);
|
||||
expect(textEvents).toHaveLength(1);
|
||||
|
||||
expect(eventField<string>(textEvents[0], "role")).toBe("assistant");
|
||||
expect(eventField<string>(textEvents[0], "delta")).toBe("Hello world");
|
||||
expect(eventField<string>(textEvents[0], "messageId")).toBeDefined();
|
||||
expect(typeof eventField<string>(textEvents[0], "messageId")).toBe(
|
||||
"string",
|
||||
);
|
||||
expect(
|
||||
eventField<string>(textEvents[0], "messageId").length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("multiple text chunks share the same messageId", async () => {
|
||||
const agent = createAgent("tanstack", [
|
||||
tanstackTextChunk("Hello "),
|
||||
tanstackTextChunk("world"),
|
||||
tanstackTextChunk("!"),
|
||||
]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
|
||||
const textEvents = events.filter(
|
||||
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
|
||||
);
|
||||
expect(textEvents).toHaveLength(3);
|
||||
|
||||
const messageIds = new Set(
|
||||
textEvents.map((e) => eventField<string>(e, "messageId")),
|
||||
);
|
||||
expect(messageIds.size).toBe(1);
|
||||
});
|
||||
|
||||
it("empty stream produces only RUN_STARTED + RUN_FINISHED", async () => {
|
||||
const agent = createAgent("tanstack", []);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
expectEventSequence(events, [
|
||||
EventType.RUN_STARTED,
|
||||
EventType.RUN_FINISHED,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tool Call Events
|
||||
// -------------------------------------------------------------------------
|
||||
describe("Tool Call Events", () => {
|
||||
it("full tool call lifecycle produces START, ARGS, END events in order", async () => {
|
||||
const agent = createAgent("tanstack", [
|
||||
tanstackToolCallStart("tc-1", "myTool"),
|
||||
tanstackToolCallArgs("tc-1", '{"key":'),
|
||||
tanstackToolCallArgs("tc-1", '"value"}'),
|
||||
tanstackToolCallEnd("tc-1"),
|
||||
]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
expectEventSequence(events, [
|
||||
EventType.RUN_STARTED,
|
||||
EventType.TOOL_CALL_START,
|
||||
EventType.TOOL_CALL_ARGS,
|
||||
EventType.TOOL_CALL_ARGS,
|
||||
EventType.TOOL_CALL_END,
|
||||
EventType.RUN_FINISHED,
|
||||
]);
|
||||
|
||||
expect(eventField<string>(events[1], "toolCallId")).toBe("tc-1");
|
||||
expect(eventField<string>(events[1], "toolCallName")).toBe("myTool");
|
||||
|
||||
expect(eventField<string>(events[2], "toolCallId")).toBe("tc-1");
|
||||
expect(eventField<string>(events[2], "delta")).toBe('{"key":');
|
||||
|
||||
expect(eventField<string>(events[3], "toolCallId")).toBe("tc-1");
|
||||
expect(eventField<string>(events[3], "delta")).toBe('"value"}');
|
||||
|
||||
expect(eventField<string>(events[4], "toolCallId")).toBe("tc-1");
|
||||
});
|
||||
|
||||
it("TOOL_CALL_START sets parentMessageId", async () => {
|
||||
const agent = createAgent("tanstack", [
|
||||
tanstackTextChunk("before"),
|
||||
tanstackToolCallStart("tc-1", "myTool"),
|
||||
tanstackToolCallEnd("tc-1"),
|
||||
]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
const textEvent = events.find(
|
||||
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
|
||||
)!;
|
||||
const toolStartEvent = events.find(
|
||||
(e) => e.type === EventType.TOOL_CALL_START,
|
||||
)!;
|
||||
|
||||
expect(
|
||||
eventField<string>(toolStartEvent, "parentMessageId"),
|
||||
).toBeDefined();
|
||||
expect(eventField<string>(toolStartEvent, "parentMessageId")).toBe(
|
||||
eventField<string>(textEvent, "messageId"),
|
||||
);
|
||||
});
|
||||
|
||||
it("multiple tool calls in sequence each get correct events", async () => {
|
||||
const agent = createAgent("tanstack", [
|
||||
tanstackToolCallStart("tc-1", "toolA"),
|
||||
tanstackToolCallArgs("tc-1", '{"a":1}'),
|
||||
tanstackToolCallEnd("tc-1"),
|
||||
tanstackToolCallStart("tc-2", "toolB"),
|
||||
tanstackToolCallArgs("tc-2", '{"b":2}'),
|
||||
tanstackToolCallEnd("tc-2"),
|
||||
]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
expectEventSequence(events, [
|
||||
EventType.RUN_STARTED,
|
||||
EventType.TOOL_CALL_START,
|
||||
EventType.TOOL_CALL_ARGS,
|
||||
EventType.TOOL_CALL_END,
|
||||
EventType.TOOL_CALL_START,
|
||||
EventType.TOOL_CALL_ARGS,
|
||||
EventType.TOOL_CALL_END,
|
||||
EventType.RUN_FINISHED,
|
||||
]);
|
||||
|
||||
// Verify first tool call
|
||||
expect(eventField<string>(events[1], "toolCallId")).toBe("tc-1");
|
||||
expect(eventField<string>(events[1], "toolCallName")).toBe("toolA");
|
||||
|
||||
expect(eventField<string>(events[2], "toolCallId")).toBe("tc-1");
|
||||
|
||||
expect(eventField<string>(events[3], "toolCallId")).toBe("tc-1");
|
||||
|
||||
// Verify second tool call
|
||||
expect(eventField<string>(events[4], "toolCallId")).toBe("tc-2");
|
||||
expect(eventField<string>(events[4], "toolCallName")).toBe("toolB");
|
||||
|
||||
expect(eventField<string>(events[5], "toolCallId")).toBe("tc-2");
|
||||
|
||||
expect(eventField<string>(events[6], "toolCallId")).toBe("tc-2");
|
||||
});
|
||||
|
||||
it("tool call with no ARGS chunks produces only START + END", async () => {
|
||||
const agent = createAgent("tanstack", [
|
||||
tanstackToolCallStart("tc-1", "noArgsTool"),
|
||||
tanstackToolCallEnd("tc-1"),
|
||||
]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
expectEventSequence(events, [
|
||||
EventType.RUN_STARTED,
|
||||
EventType.TOOL_CALL_START,
|
||||
EventType.TOOL_CALL_END,
|
||||
EventType.RUN_FINISHED,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tool Call Result Events
|
||||
// -------------------------------------------------------------------------
|
||||
describe("Tool Call Result Events", () => {
|
||||
it("TOOL_CALL_RESULT chunk produces TOOL_CALL_RESULT event with correct content", async () => {
|
||||
const agent = createAgent("tanstack", [
|
||||
tanstackToolCallStart("tc-1", "myTool"),
|
||||
tanstackToolCallArgs("tc-1", '{"key":"value"}'),
|
||||
tanstackToolCallEnd("tc-1"),
|
||||
{
|
||||
type: "TOOL_CALL_RESULT",
|
||||
toolCallId: "tc-1",
|
||||
content: JSON.stringify({ result: "ok" }),
|
||||
},
|
||||
]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
|
||||
const resultEvents = events.filter(
|
||||
(e) => e.type === EventType.TOOL_CALL_RESULT,
|
||||
);
|
||||
expect(resultEvents).toHaveLength(1);
|
||||
expect(eventField<string>(resultEvents[0], "toolCallId")).toBe("tc-1");
|
||||
expect(eventField<string>(resultEvents[0], "role")).toBe("tool");
|
||||
expect(
|
||||
JSON.parse(eventField<string>(resultEvents[0], "content")),
|
||||
).toEqual({ result: "ok" });
|
||||
});
|
||||
|
||||
it("TOOL_CALL_RESULT with object content serializes to JSON", async () => {
|
||||
const agent = createAgent("tanstack", [
|
||||
tanstackToolCallStart("tc-2", "myTool"),
|
||||
tanstackToolCallEnd("tc-2"),
|
||||
{
|
||||
type: "TOOL_CALL_RESULT",
|
||||
toolCallId: "tc-2",
|
||||
result: { data: 42 },
|
||||
},
|
||||
]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
const resultEvents = events.filter(
|
||||
(e) => e.type === EventType.TOOL_CALL_RESULT,
|
||||
);
|
||||
expect(resultEvents).toHaveLength(1);
|
||||
expect(
|
||||
JSON.parse(eventField<string>(resultEvents[0], "content")),
|
||||
).toEqual({ data: 42 });
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Mixed Content
|
||||
// -------------------------------------------------------------------------
|
||||
describe("Mixed Content", () => {
|
||||
it("text interleaved with tool calls produces correct event types and order", async () => {
|
||||
const agent = createAgent("tanstack", [
|
||||
tanstackTextChunk("Let me help. "),
|
||||
tanstackToolCallStart("tc-1", "search"),
|
||||
tanstackToolCallArgs("tc-1", '{"q":"test"}'),
|
||||
tanstackToolCallEnd("tc-1"),
|
||||
tanstackTextChunk("Here are the results."),
|
||||
]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
expectEventSequence(events, [
|
||||
EventType.RUN_STARTED,
|
||||
EventType.TEXT_MESSAGE_CHUNK,
|
||||
EventType.TOOL_CALL_START,
|
||||
EventType.TOOL_CALL_ARGS,
|
||||
EventType.TOOL_CALL_END,
|
||||
EventType.TEXT_MESSAGE_CHUNK,
|
||||
EventType.RUN_FINISHED,
|
||||
]);
|
||||
|
||||
// Verify content of text events
|
||||
const textEvents = events.filter(
|
||||
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
|
||||
);
|
||||
expect(eventField<string>(textEvents[0], "delta")).toBe("Let me help. ");
|
||||
expect(eventField<string>(textEvents[1], "delta")).toBe(
|
||||
"Here are the results.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Edge Cases
|
||||
// -------------------------------------------------------------------------
|
||||
describe("Edge Cases", () => {
|
||||
it("unknown chunk types are silently ignored", async () => {
|
||||
const agent = createAgent("tanstack", [
|
||||
tanstackTextChunk("hello"),
|
||||
{ type: "SOME_UNKNOWN_TYPE", data: "foo" },
|
||||
{ type: "ANOTHER_MYSTERY", value: 42 },
|
||||
tanstackTextChunk(" world"),
|
||||
]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
expectEventSequence(events, [
|
||||
EventType.RUN_STARTED,
|
||||
EventType.TEXT_MESSAGE_CHUNK,
|
||||
EventType.TEXT_MESSAGE_CHUNK,
|
||||
EventType.RUN_FINISHED,
|
||||
]);
|
||||
});
|
||||
|
||||
it("large deltas (100k chars) are passed through", async () => {
|
||||
const largeDelta = "x".repeat(100_000);
|
||||
const agent = createAgent("tanstack", [tanstackTextChunk(largeDelta)]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
|
||||
const textEvent = events.find(
|
||||
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
|
||||
)!;
|
||||
expect(eventField<string>(textEvent, "delta")).toBe(largeDelta);
|
||||
expect(eventField<string>(textEvent, "delta").length).toBe(100_000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("TanStack AI converter — multi-turn agent loop", () => {
|
||||
// chat() emits a RUN_STARTED / RUN_FINISHED pair PER model turn and executes
|
||||
// server-side tools (MCP / provider tools) itself between turns. The run
|
||||
// lifecycle is owned by the Agent wrapper, so per-turn markers must be
|
||||
// dropped and every turn's content converted — a regression here truncated
|
||||
// the run at the first tool turn, dropping the tool result and final answer.
|
||||
|
||||
it("does NOT truncate at a per-turn RUN_FINISHED — tool result + final text still convert", async () => {
|
||||
const agent = createAgent("tanstack", [
|
||||
// Turn 1: model emits a tool call, then the turn finishes.
|
||||
tanstackToolCallStart("tc-1", "list_issues"),
|
||||
tanstackToolCallArgs("tc-1", '{"team":"CPK"}'),
|
||||
tanstackToolCallEnd("tc-1"),
|
||||
{ type: "RUN_FINISHED" },
|
||||
// Between turns: chat() executes the MCP tool itself.
|
||||
tanstackToolCallResult("tc-1", { issues: [] }),
|
||||
// Turn 2: model produces the final answer.
|
||||
{ type: "RUN_STARTED" },
|
||||
tanstackTextChunk("No open issues."),
|
||||
{ type: "RUN_FINISHED" },
|
||||
]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
expectEventSequence(events, [
|
||||
EventType.RUN_STARTED,
|
||||
EventType.TOOL_CALL_START,
|
||||
EventType.TOOL_CALL_ARGS,
|
||||
EventType.TOOL_CALL_END,
|
||||
EventType.TOOL_CALL_RESULT,
|
||||
EventType.TEXT_MESSAGE_CHUNK,
|
||||
EventType.RUN_FINISHED,
|
||||
]);
|
||||
|
||||
// Exactly one RUN_FINISHED — the outer wrapper's, not TanStack's per-turn ones.
|
||||
expect(
|
||||
events.filter((e) => e.type === EventType.RUN_FINISHED),
|
||||
).toHaveLength(1);
|
||||
// The final answer survived the per-turn RUN_FINISHED.
|
||||
const textEvent = events.find(
|
||||
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
|
||||
)!;
|
||||
expect(eventField<string>(textEvent, "delta")).toBe("No open issues.");
|
||||
});
|
||||
|
||||
it("de-duplicates a tool call re-announced after execution", async () => {
|
||||
const agent = createAgent("tanstack", [
|
||||
tanstackToolCallStart("tc-1", "search"),
|
||||
tanstackToolCallEnd("tc-1"),
|
||||
{ type: "RUN_FINISHED" },
|
||||
tanstackToolCallResult("tc-1", { ok: true }),
|
||||
// chat() re-announces the same call when it re-prompts — must be dropped.
|
||||
tanstackToolCallStart("tc-1", "search"),
|
||||
tanstackToolCallArgs("tc-1", '{"q":"x"}'),
|
||||
tanstackToolCallEnd("tc-1"),
|
||||
{ type: "RUN_STARTED" },
|
||||
tanstackTextChunk("done"),
|
||||
]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
expectEventSequence(events, [
|
||||
EventType.RUN_STARTED,
|
||||
EventType.TOOL_CALL_START,
|
||||
EventType.TOOL_CALL_END,
|
||||
EventType.TOOL_CALL_RESULT,
|
||||
EventType.TEXT_MESSAGE_CHUNK,
|
||||
EventType.RUN_FINISHED,
|
||||
]);
|
||||
});
|
||||
|
||||
it("surfaces a RUN_ERROR chunk as a terminal RUN_ERROR event", async () => {
|
||||
const agent = createAgent("tanstack", [
|
||||
tanstackTextChunk("partial"),
|
||||
{ type: "RUN_ERROR", message: "provider 400" },
|
||||
// Anything after the error must never be converted.
|
||||
tanstackTextChunk("should not appear"),
|
||||
]);
|
||||
const { events, errored } = await collectEventsIncludingErrors(
|
||||
agent.run(createDefaultInput()),
|
||||
);
|
||||
|
||||
expect(errored).toBe(true);
|
||||
const errorEvent = events.find((e) => e.type === EventType.RUN_ERROR)!;
|
||||
expect(errorEvent).toBeDefined();
|
||||
expect(eventField<string>(errorEvent, "message")).toBe("provider 400");
|
||||
// The post-error text chunk was not emitted.
|
||||
const texts = events.filter((e) => e.type === EventType.TEXT_MESSAGE_CHUNK);
|
||||
expect(texts).toHaveLength(1);
|
||||
expect(eventField<string>(texts[0], "delta")).toBe("partial");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TanStack AI converter — state tools", () => {
|
||||
it("emits STATE_SNAPSHOT before TOOL_CALL_RESULT for AGUISendStateSnapshot", async () => {
|
||||
const snapshot = { counter: 5, items: ["x", "y"] };
|
||||
const agent = createAgent("tanstack", [
|
||||
tanstackToolCallStart("call1", "AGUISendStateSnapshot"),
|
||||
tanstackToolCallEnd("call1"),
|
||||
tanstackToolCallResult("call1", { success: true, snapshot }),
|
||||
]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
|
||||
const snapshotIdx = events.findIndex(
|
||||
(e) => e.type === EventType.STATE_SNAPSHOT,
|
||||
);
|
||||
const resultIdx = events.findIndex(
|
||||
(e) => e.type === EventType.TOOL_CALL_RESULT,
|
||||
);
|
||||
|
||||
expect(snapshotIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(resultIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(snapshotIdx).toBeLessThan(resultIdx);
|
||||
expect(eventField<unknown>(events[snapshotIdx], "snapshot")).toEqual(
|
||||
snapshot,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits STATE_DELTA before TOOL_CALL_RESULT for AGUISendStateDelta", async () => {
|
||||
const delta = [{ op: "replace", path: "/counter", value: 7 }];
|
||||
const agent = createAgent("tanstack", [
|
||||
tanstackToolCallStart("call1", "AGUISendStateDelta"),
|
||||
tanstackToolCallEnd("call1"),
|
||||
tanstackToolCallResult("call1", { success: true, delta }),
|
||||
]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
|
||||
const deltaIdx = events.findIndex((e) => e.type === EventType.STATE_DELTA);
|
||||
const resultIdx = events.findIndex(
|
||||
(e) => e.type === EventType.TOOL_CALL_RESULT,
|
||||
);
|
||||
|
||||
expect(deltaIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(deltaIdx).toBeLessThan(resultIdx);
|
||||
expect(eventField<unknown>(events[deltaIdx], "delta")).toEqual(delta);
|
||||
});
|
||||
|
||||
it("emits STATE_SNAPSHOT when payload arrives in raw.result instead of raw.content", async () => {
|
||||
// Regression: serialization fell back to raw.result (`?? raw.result ?? null`)
|
||||
// but state-tool detection only inspected raw.content, so STATE_* events
|
||||
// were silently dropped if upstream used `result` for the state-tool body.
|
||||
// See the "TOOL_CALL_RESULT with object content serializes to JSON" test
|
||||
// above which already exercises the `result` field on a non-state tool.
|
||||
const snapshot = { counter: 99 };
|
||||
const agent = createAgent("tanstack", [
|
||||
tanstackToolCallStart("call1", "AGUISendStateSnapshot"),
|
||||
tanstackToolCallEnd("call1"),
|
||||
{
|
||||
type: "TOOL_CALL_RESULT",
|
||||
toolCallId: "call1",
|
||||
result: { success: true, snapshot },
|
||||
},
|
||||
]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
|
||||
const snapshotIdx = events.findIndex(
|
||||
(e) => e.type === EventType.STATE_SNAPSHOT,
|
||||
);
|
||||
expect(snapshotIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(eventField<unknown>(events[snapshotIdx], "snapshot")).toEqual(
|
||||
snapshot,
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT emit STATE_* for non-state tool results", async () => {
|
||||
const agent = createAgent("tanstack", [
|
||||
tanstackToolCallStart("call1", "getWeather"),
|
||||
tanstackToolCallEnd("call1"),
|
||||
tanstackToolCallResult("call1", {
|
||||
snapshot: { spoofed: true },
|
||||
delta: [{ op: "x" }],
|
||||
}),
|
||||
]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
|
||||
expect(
|
||||
events.find(
|
||||
(e) =>
|
||||
e.type === EventType.STATE_SNAPSHOT ||
|
||||
e.type === EventType.STATE_DELTA,
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
events.find((e) => e.type === EventType.TOOL_CALL_RESULT),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TanStack AI converter — reasoning", () => {
|
||||
it("emits the full REASONING lifecycle for reasoning chunks", async () => {
|
||||
const agent = createAgent("tanstack", [
|
||||
tanstackReasoningStart("r1"),
|
||||
tanstackReasoningMessageStart("r1"),
|
||||
tanstackReasoningMessageContent("r1", "thinking"),
|
||||
tanstackReasoningMessageEnd("r1"),
|
||||
tanstackReasoningEnd("r1"),
|
||||
]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
|
||||
expectLifecycleWrapped(events);
|
||||
|
||||
// Strip the lifecycle wrap and inspect the inner sequence.
|
||||
const inner = events.slice(1, -1).map((e) => e.type);
|
||||
expect(inner).toEqual([
|
||||
EventType.REASONING_START,
|
||||
EventType.REASONING_MESSAGE_START,
|
||||
EventType.REASONING_MESSAGE_CONTENT,
|
||||
EventType.REASONING_MESSAGE_END,
|
||||
EventType.REASONING_END,
|
||||
]);
|
||||
});
|
||||
|
||||
it("auto-closes an open reasoning lifecycle when text starts", async () => {
|
||||
const agent = createAgent("tanstack", [
|
||||
tanstackReasoningStart("r1"),
|
||||
tanstackReasoningMessageStart("r1"),
|
||||
tanstackReasoningMessageContent("r1", "thinking"),
|
||||
// No REASONING_MESSAGE_END / REASONING_END before text
|
||||
tanstackTextChunk("Hi"),
|
||||
]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
const types = events.map((e) => e.type);
|
||||
|
||||
const reasonEndIdx = types.indexOf(EventType.REASONING_END);
|
||||
const reasonMsgEndIdx = types.indexOf(EventType.REASONING_MESSAGE_END);
|
||||
const textIdx = types.indexOf(EventType.TEXT_MESSAGE_CHUNK);
|
||||
|
||||
expect(reasonMsgEndIdx).toBeGreaterThan(-1);
|
||||
expect(reasonEndIdx).toBeGreaterThan(-1);
|
||||
expect(reasonEndIdx).toBeLessThan(textIdx);
|
||||
});
|
||||
|
||||
it("auto-closes an open reasoning lifecycle when a tool call starts", async () => {
|
||||
const agent = createAgent("tanstack", [
|
||||
tanstackReasoningStart("r1"),
|
||||
tanstackReasoningMessageStart("r1"),
|
||||
tanstackReasoningMessageContent("r1", "..."),
|
||||
tanstackToolCallStart("t1", "x"),
|
||||
]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
const types = events.map((e) => e.type);
|
||||
|
||||
expect(types).toContain(EventType.REASONING_MESSAGE_END);
|
||||
expect(types).toContain(EventType.REASONING_END);
|
||||
expect(types.indexOf(EventType.REASONING_END)).toBeLessThan(
|
||||
types.indexOf(EventType.TOOL_CALL_START),
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-closes when the stream ends without explicit reasoning end", async () => {
|
||||
const agent = createAgent("tanstack", [
|
||||
tanstackReasoningStart("r1"),
|
||||
tanstackReasoningMessageStart("r1"),
|
||||
tanstackReasoningMessageContent("r1", "x"),
|
||||
]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
const types = events.map((e) => e.type);
|
||||
|
||||
expect(types).toContain(EventType.REASONING_MESSAGE_END);
|
||||
expect(types).toContain(EventType.REASONING_END);
|
||||
});
|
||||
|
||||
it("emits REASONING_MESSAGE_END before REASONING_END when upstream sends END with message still open", async () => {
|
||||
// Regression: if the converter received REASONING_END while a message
|
||||
// was still open, it cleared run-open and emitted END only — leaving
|
||||
// message-open true. The next non-reasoning chunk then triggered
|
||||
// closeReasoningIfOpen, which emitted MSG_END AFTER END (wrong order).
|
||||
// Fix: REASONING_END handler closes any open message first.
|
||||
const agent = createAgent("tanstack", [
|
||||
tanstackReasoningStart("r1"),
|
||||
tanstackReasoningMessageStart("r1"),
|
||||
tanstackReasoningMessageContent("r1", "thinking"),
|
||||
// Upstream skips MSG_END and goes straight to END
|
||||
tanstackReasoningEnd("r1"),
|
||||
tanstackTextChunk("Hi"),
|
||||
]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
const types = events.map((e) => e.type);
|
||||
|
||||
const msgEndIdx = types.indexOf(EventType.REASONING_MESSAGE_END);
|
||||
const endIdx = types.indexOf(EventType.REASONING_END);
|
||||
|
||||
expect(msgEndIdx).toBeGreaterThan(-1);
|
||||
expect(endIdx).toBeGreaterThan(-1);
|
||||
expect(msgEndIdx).toBeLessThan(endIdx);
|
||||
// No duplicate MSG_END or END from auto-close on the text chunk
|
||||
expect(
|
||||
types.filter((t) => t === EventType.REASONING_MESSAGE_END),
|
||||
).toHaveLength(1);
|
||||
expect(types.filter((t) => t === EventType.REASONING_END)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("auto-closes prior reasoning run when a new REASONING_START arrives without END", async () => {
|
||||
// Regression: REASONING_START used to overwrite reasoningMessageId
|
||||
// unconditionally, orphaning the prior run's MSG_END / END.
|
||||
// Fix: REASONING_START handler calls closeReasoningIfOpen() first.
|
||||
const agent = createAgent("tanstack", [
|
||||
tanstackReasoningStart("r1"),
|
||||
tanstackReasoningMessageStart("r1"),
|
||||
tanstackReasoningMessageContent("r1", "first"),
|
||||
// Second START with no intervening END
|
||||
tanstackReasoningStart("r2"),
|
||||
tanstackReasoningMessageStart("r2"),
|
||||
tanstackReasoningMessageContent("r2", "second"),
|
||||
tanstackReasoningMessageEnd("r2"),
|
||||
tanstackReasoningEnd("r2"),
|
||||
]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
const types = events.map((e) => e.type);
|
||||
|
||||
// Two complete START → MSG_START → ... → MSG_END → END sequences
|
||||
expect(types.filter((t) => t === EventType.REASONING_START)).toHaveLength(
|
||||
2,
|
||||
);
|
||||
expect(types.filter((t) => t === EventType.REASONING_END)).toHaveLength(2);
|
||||
expect(
|
||||
types.filter((t) => t === EventType.REASONING_MESSAGE_END),
|
||||
).toHaveLength(2);
|
||||
|
||||
// First START's prior message gets closed BEFORE the second START
|
||||
const firstEndIdx = types.indexOf(EventType.REASONING_END);
|
||||
const secondStartIdx = types.indexOf(
|
||||
EventType.REASONING_START,
|
||||
firstEndIdx + 1,
|
||||
);
|
||||
expect(firstEndIdx).toBeLessThan(secondStartIdx);
|
||||
});
|
||||
|
||||
it("does NOT duplicate REASONING_MESSAGE_END when upstream emits it explicitly before text", async () => {
|
||||
// Regression: a single isInReasoning flag conflated message-open with
|
||||
// run-open, so closeReasoningIfOpen on TEXT_MESSAGE_CONTENT emitted a
|
||||
// second REASONING_MESSAGE_END after upstream's own. Track message-open
|
||||
// and run-open separately so closeReasoningIfOpen owes only what's still
|
||||
// open.
|
||||
const agent = createAgent("tanstack", [
|
||||
tanstackReasoningStart("r1"),
|
||||
tanstackReasoningMessageStart("r1"),
|
||||
tanstackReasoningMessageContent("r1", "thinking"),
|
||||
tanstackReasoningMessageEnd("r1"),
|
||||
// No explicit REASONING_END before text — closeReasoningIfOpen should
|
||||
// emit REASONING_END but NOT a second REASONING_MESSAGE_END.
|
||||
tanstackTextChunk("Hi"),
|
||||
]);
|
||||
const events = await collectEvents(agent.run(createDefaultInput()));
|
||||
const types = events.map((e) => e.type);
|
||||
|
||||
const msgEndCount = types.filter(
|
||||
(t) => t === EventType.REASONING_MESSAGE_END,
|
||||
).length;
|
||||
const endCount = types.filter((t) => t === EventType.REASONING_END).length;
|
||||
|
||||
expect(msgEndCount).toBe(1);
|
||||
expect(endCount).toBe(1);
|
||||
expect(types.indexOf(EventType.REASONING_END)).toBeLessThan(
|
||||
types.indexOf(EventType.TEXT_MESSAGE_CHUNK),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Integration tests for factory-mode native interrupts driven by the REAL
|
||||
* AI SDK `streamText` (via a mock LanguageModelV3 — no network). These verify
|
||||
* the two things synthetic-stream tests can't:
|
||||
* 1. a tool declared `needsApproval: true` actually makes `streamText` emit a
|
||||
* `tool-approval-request` part → our converter turns it into outcome:interrupt;
|
||||
* 2. on resume, the tool-result the runtime injects from the resume payload is
|
||||
* accepted by `streamText` so the run continues (no re-approval loop).
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { EventType } from "@ag-ui/client";
|
||||
import type { RunAgentInput } from "@ag-ui/client";
|
||||
import { streamText, tool } from "ai";
|
||||
import type { FlexibleSchema } from "ai";
|
||||
import { MockLanguageModelV3, simulateReadableStream } from "ai/test";
|
||||
import { z } from "zod";
|
||||
import { BuiltInAgent, convertMessagesToVercelAISDKMessages } from "../index";
|
||||
import { collectEvents, createDefaultInput } from "./agent-test-helpers";
|
||||
|
||||
const USAGE = {
|
||||
inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
outputTokens: { total: 0, text: 0, reasoning: 0 },
|
||||
} as const;
|
||||
|
||||
type BookFlightInput = { destination: string };
|
||||
|
||||
const bookFlightInputSchema = z.object({
|
||||
destination: z.string(),
|
||||
}) as unknown as FlexibleSchema<BookFlightInput>;
|
||||
|
||||
function finishReason(unified: "stop" | "tool-calls") {
|
||||
return { unified, raw: unified };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function modelEmitting(chunks: any[]): MockLanguageModelV3 {
|
||||
return new MockLanguageModelV3({
|
||||
doStream: async () => ({
|
||||
stream: simulateReadableStream({ chunks }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function aisdkApprovalAgent(model: MockLanguageModelV3): BuiltInAgent {
|
||||
return new BuiltInAgent({
|
||||
type: "aisdk",
|
||||
factory: ({ input, abortSignal }) =>
|
||||
streamText({
|
||||
model,
|
||||
messages: convertMessagesToVercelAISDKMessages(input.messages),
|
||||
tools: {
|
||||
bookFlight: tool<BookFlightInput, never>({
|
||||
description: "Book a flight. Requires approval.",
|
||||
inputSchema: bookFlightInputSchema,
|
||||
needsApproval: true,
|
||||
}),
|
||||
},
|
||||
abortSignal,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("factory aisdk native interrupt (real streamText)", () => {
|
||||
it("a needsApproval tool call → RUN_FINISHED outcome:interrupt", async () => {
|
||||
const model = modelEmitting([
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "tc-1",
|
||||
toolName: "bookFlight",
|
||||
input: JSON.stringify({ destination: "Tokyo" }),
|
||||
},
|
||||
{
|
||||
type: "finish",
|
||||
finishReason: finishReason("tool-calls"),
|
||||
usage: USAGE,
|
||||
},
|
||||
]);
|
||||
const agent = aisdkApprovalAgent(model);
|
||||
|
||||
const events = await collectEvents(
|
||||
agent.run(
|
||||
createDefaultInput({
|
||||
messages: [
|
||||
{ id: "u1", role: "user", content: "Book a flight to Tokyo" },
|
||||
] as RunAgentInput["messages"],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const finished = events.find(
|
||||
(e) => e.type === EventType.RUN_FINISHED,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as any;
|
||||
expect(finished.outcome?.type).toBe("interrupt");
|
||||
expect(finished.outcome.interrupts[0]).toMatchObject({
|
||||
id: "tc-1",
|
||||
toolCallId: "tc-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("on resume, the injected tool-result is accepted and the run completes", async () => {
|
||||
// Resume run: model now just replies (the tool call is already answered by
|
||||
// the injected result, so streamText must NOT re-request approval).
|
||||
const model = modelEmitting([
|
||||
{ type: "text-start", id: "t1" },
|
||||
{ type: "text-delta", id: "t1", delta: "Booked your flight to Tokyo!" },
|
||||
{ type: "text-end", id: "t1" },
|
||||
{ type: "finish", finishReason: finishReason("stop"), usage: USAGE },
|
||||
]);
|
||||
const agent = aisdkApprovalAgent(model);
|
||||
|
||||
const events = await collectEvents(
|
||||
agent.run(
|
||||
createDefaultInput({
|
||||
resume: [
|
||||
{
|
||||
interruptId: "tc-1",
|
||||
status: "resolved",
|
||||
payload: { approved: true },
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{ id: "u1", role: "user", content: "Book a flight to Tokyo" },
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
toolCalls: [
|
||||
{
|
||||
id: "tc-1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "bookFlight",
|
||||
arguments: JSON.stringify({ destination: "Tokyo" }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as RunAgentInput["messages"],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const finished = events.find(
|
||||
(e) => e.type === EventType.RUN_FINISHED,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as any;
|
||||
// Normal completion — no re-interrupt.
|
||||
expect(finished.outcome).toBeUndefined();
|
||||
|
||||
const text = events
|
||||
.filter((e) => e.type === EventType.TEXT_MESSAGE_CHUNK)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map((e) => (e as any).delta)
|
||||
.join("");
|
||||
expect(text).toContain("Booked your flight");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { BasicAgent } from "../index";
|
||||
import type { MCPClientProvider } from "../index";
|
||||
import { EventType } from "@ag-ui/client";
|
||||
import type { RunAgentInput } from "@ag-ui/client";
|
||||
import { streamText } from "ai";
|
||||
import type { ToolSet } from "ai";
|
||||
import {
|
||||
mockStreamTextResponse,
|
||||
textDelta,
|
||||
finish,
|
||||
collectEvents,
|
||||
} from "./test-helpers";
|
||||
|
||||
// Mock the ai module
|
||||
vi.mock("ai", () => ({
|
||||
streamText: vi.fn(),
|
||||
tool: vi.fn((config) => config),
|
||||
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
|
||||
}));
|
||||
|
||||
// Mock the SDK clients
|
||||
vi.mock("@ai-sdk/openai", () => ({
|
||||
createOpenAI: vi.fn(() => (modelId: string) => ({
|
||||
modelId,
|
||||
provider: "openai",
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock MCP imports so mcpServers code path doesn't fail when tested alongside mcpClients
|
||||
vi.mock("@ai-sdk/mcp", () => ({
|
||||
createMCPClient: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("mcpClients — user-managed MCP clients", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
process.env.OPENAI_API_KEY = "test-key";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
const baseInput: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
function makeMockProvider(
|
||||
tools: Record<string, any>,
|
||||
): MCPClientProvider & { close: ReturnType<typeof vi.fn> } {
|
||||
return {
|
||||
tools: vi.fn().mockResolvedValue(tools),
|
||||
close: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
it("tools from mcpClients are passed to streamText", async () => {
|
||||
const mockTools = {
|
||||
listEnvelopes: { description: "List envelopes", execute: vi.fn() },
|
||||
};
|
||||
const provider = makeMockProvider(mockTools);
|
||||
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
mcpClients: [provider],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([textDelta("Hello"), finish()]) as any,
|
||||
);
|
||||
|
||||
await collectEvents(agent["run"](baseInput));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.tools).toHaveProperty("listEnvelopes");
|
||||
expect(callArgs.tools.listEnvelopes.description).toBe("List envelopes");
|
||||
expect(provider.tools).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("mcpClients are NOT closed after run completes", async () => {
|
||||
const provider = makeMockProvider({
|
||||
myTool: { description: "A tool", execute: vi.fn() },
|
||||
});
|
||||
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
mcpClients: [provider],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
await collectEvents(agent["run"](baseInput));
|
||||
|
||||
expect(provider.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mcpServers tools override mcpClients tools on name collision", async () => {
|
||||
const clientExecute = vi.fn();
|
||||
const serverExecute = vi.fn();
|
||||
|
||||
const provider = makeMockProvider({
|
||||
sharedTool: { description: "from client", execute: clientExecute },
|
||||
});
|
||||
|
||||
// Mock mcpServers flow: createMCPClient returns a client with tools()
|
||||
const { createMCPClient } = await import("@ai-sdk/mcp");
|
||||
vi.mocked(createMCPClient).mockResolvedValue({
|
||||
tools: vi.fn().mockResolvedValue({
|
||||
sharedTool: { description: "from server", execute: serverExecute },
|
||||
}),
|
||||
close: vi.fn(),
|
||||
} as any);
|
||||
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
mcpClients: [provider],
|
||||
mcpServers: [{ type: "http", url: "http://localhost:9999" }],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
await collectEvents(agent["run"](baseInput));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
// mcpServers runs after mcpClients, so "from server" should win
|
||||
expect(callArgs.tools.sharedTool.description).toBe("from server");
|
||||
});
|
||||
|
||||
it("multiple mcpClients merge in order (later overrides earlier)", async () => {
|
||||
const provider1 = makeMockProvider({
|
||||
toolA: { description: "from provider 1", execute: vi.fn() },
|
||||
shared: { description: "from provider 1", execute: vi.fn() },
|
||||
});
|
||||
const provider2 = makeMockProvider({
|
||||
toolB: { description: "from provider 2", execute: vi.fn() },
|
||||
shared: { description: "from provider 2", execute: vi.fn() },
|
||||
});
|
||||
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
mcpClients: [provider1, provider2],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
await collectEvents(agent["run"](baseInput));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.tools).toHaveProperty("toolA");
|
||||
expect(callArgs.tools).toHaveProperty("toolB");
|
||||
expect(callArgs.tools.shared.description).toBe("from provider 2");
|
||||
});
|
||||
|
||||
it("empty mcpClients array is a no-op", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
mcpClients: [],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([textDelta("Hi"), finish()]) as any,
|
||||
);
|
||||
|
||||
const events = await collectEvents(agent["run"](baseInput));
|
||||
|
||||
// Should still work normally
|
||||
const textEvents = events.filter(
|
||||
(e: any) => e.type === EventType.TEXT_MESSAGE_CHUNK,
|
||||
);
|
||||
expect(textEvents.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("mcpClients .tools() rejection emits RUN_ERROR", async () => {
|
||||
const failingProvider: MCPClientProvider = {
|
||||
tools: vi.fn().mockRejectedValue(new Error("MCP connection lost")),
|
||||
};
|
||||
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
mcpClients: [failingProvider],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
// Collect events manually so we can capture RUN_ERROR before the rejection
|
||||
const events: any[] = [];
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
agent["run"](baseInput).subscribe({
|
||||
next: (event: any) => events.push(event),
|
||||
error: (err: any) => reject(err),
|
||||
complete: () => resolve(events),
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// Expected — Observable errors after emitting RUN_ERROR
|
||||
}
|
||||
|
||||
// streamText should NOT have been called (error before reaching it)
|
||||
expect(streamText).not.toHaveBeenCalled();
|
||||
// A RUN_ERROR event should have been emitted
|
||||
expect(events.some((e) => e.type === EventType.RUN_ERROR)).toBe(true);
|
||||
});
|
||||
|
||||
it("clone() shares the same mcpClients references", () => {
|
||||
const provider = makeMockProvider({});
|
||||
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
mcpClients: [provider],
|
||||
});
|
||||
|
||||
const cloned = agent.clone();
|
||||
|
||||
// Access the config to verify same reference
|
||||
// Both agents share the same config object (by reference)
|
||||
expect((cloned as any).config.mcpClients[0]).toBe(provider);
|
||||
});
|
||||
|
||||
it("type compatibility: @ai-sdk/mcp MCPClient satisfies MCPClientProvider", async () => {
|
||||
// Compile-time check that `MCPClientProvider` is structurally compatible
|
||||
// with `@ai-sdk/mcp`'s `MCPClient`. After the refactor `MCPClientProvider`
|
||||
// is an alias for `Pick<MCPClient, "tools">`, so this is trivially true —
|
||||
// but keeping the test guards against future divergence.
|
||||
type MCPClient = Awaited<
|
||||
ReturnType<typeof import("@ai-sdk/mcp").createMCPClient>
|
||||
>;
|
||||
const client = {} as MCPClient;
|
||||
const _assignable: MCPClientProvider = {
|
||||
tools: () => client.tools() as unknown as Promise<ToolSet>,
|
||||
};
|
||||
void _assignable;
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { BasicAgent } from "../index";
|
||||
import { EventType } from "@ag-ui/client";
|
||||
import { streamText } from "ai";
|
||||
import { LLMock, MCPMock } from "@copilotkit/aimock";
|
||||
import {
|
||||
mockStreamTextResponse,
|
||||
textDelta,
|
||||
finish,
|
||||
collectEvents,
|
||||
toolCall,
|
||||
toolResult,
|
||||
} from "./test-helpers";
|
||||
|
||||
// Mock the ai module — we don't want real LLM calls
|
||||
vi.mock("ai", () => ({
|
||||
streamText: vi.fn(),
|
||||
tool: vi.fn((config) => config),
|
||||
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
|
||||
}));
|
||||
|
||||
vi.mock("@ai-sdk/openai", () => ({
|
||||
createOpenAI: vi.fn(() => (modelId: string) => ({
|
||||
modelId,
|
||||
provider: "openai",
|
||||
})),
|
||||
}));
|
||||
|
||||
// Do NOT mock @ai-sdk/mcp or @modelcontextprotocol/sdk transports —
|
||||
// we want real HTTP connections to the MCPMock server.
|
||||
|
||||
describe("mcpServers — real MCP server integration", () => {
|
||||
const originalEnv = process.env;
|
||||
let llm: LLMock;
|
||||
let mcpMock: MCPMock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
process.env.OPENAI_API_KEY = "test-key";
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.env = originalEnv;
|
||||
if (llm) {
|
||||
await llm.stop().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
const baseInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Start an LLMock with an MCPMock mounted at /mcp.
|
||||
* Returns the full MCP endpoint URL.
|
||||
*/
|
||||
async function startMcpServer(
|
||||
tools: Array<{ name: string; description?: string }>,
|
||||
): Promise<{ mcpUrl: string; llm: LLMock; mcpMock: MCPMock }> {
|
||||
const mock = new MCPMock();
|
||||
for (const t of tools) {
|
||||
mock.addTool({
|
||||
name: t.name,
|
||||
description: t.description ?? `${t.name} tool`,
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { query: { type: "string" } },
|
||||
},
|
||||
});
|
||||
mock.onToolCall(t.name, () => `result from ${t.name}`);
|
||||
}
|
||||
|
||||
const server = new LLMock({ port: 0 });
|
||||
server.mount("/mcp", mock);
|
||||
await server.start();
|
||||
|
||||
return {
|
||||
mcpUrl: `${server.url}/mcp`,
|
||||
llm: server,
|
||||
mcpMock: mock,
|
||||
};
|
||||
}
|
||||
|
||||
it("HTTP transport fetches tools from MCPMock", async () => {
|
||||
const result = await startMcpServer([
|
||||
{ name: "get_weather", description: "Get the weather" },
|
||||
]);
|
||||
llm = result.llm;
|
||||
mcpMock = result.mcpMock;
|
||||
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
mcpServers: [{ type: "http", url: result.mcpUrl }],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([textDelta("Hello"), finish()]) as any,
|
||||
);
|
||||
|
||||
await collectEvents(agent["run"](baseInput));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.tools).toHaveProperty("get_weather");
|
||||
});
|
||||
|
||||
it("an unreachable MCP server (SSE vs HTTP-only mock) is skipped, not fatal", async () => {
|
||||
// MCPMock only supports Streamable HTTP, not SSE — so the SSE client
|
||||
// fails to connect. The run must degrade gracefully: no RUN_ERROR, and
|
||||
// streamText still runs (just without that server's tools).
|
||||
const result = await startMcpServer([
|
||||
{ name: "get_weather", description: "Get the weather" },
|
||||
]);
|
||||
llm = result.llm;
|
||||
mcpMock = result.mcpMock;
|
||||
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
mcpServers: [{ type: "sse", url: result.mcpUrl }],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([textDelta("ok"), finish()]) as any,
|
||||
);
|
||||
|
||||
const events = await collectEvents(agent["run"](baseInput));
|
||||
|
||||
expect(events.some((e: any) => e.type === EventType.RUN_ERROR)).toBe(false);
|
||||
expect(vi.mocked(streamText).mock.calls.length).toBeGreaterThan(0);
|
||||
// The failed server contributed no tools.
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.tools ?? {}).not.toHaveProperty("get_weather");
|
||||
});
|
||||
|
||||
it("a down MCP server is skipped while a healthy one still loads its tools", async () => {
|
||||
const result = await startMcpServer([
|
||||
{ name: "get_weather", description: "Get the weather" },
|
||||
]);
|
||||
llm = result.llm;
|
||||
mcpMock = result.mcpMock;
|
||||
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
mcpServers: [
|
||||
{ type: "http", url: "http://127.0.0.1:1/mcp" }, // unreachable
|
||||
{ type: "http", url: result.mcpUrl }, // healthy
|
||||
],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([textDelta("ok"), finish()]) as any,
|
||||
);
|
||||
|
||||
const events = await collectEvents(agent["run"](baseInput));
|
||||
|
||||
// The dead server is skipped; the run completes and the healthy server's
|
||||
// tools are present.
|
||||
expect(events.some((e: any) => e.type === EventType.RUN_ERROR)).toBe(false);
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.tools).toHaveProperty("get_weather");
|
||||
});
|
||||
|
||||
it("tool call round-trip emits TOOL_CALL_START, TOOL_CALL_RESULT, and TEXT_MESSAGE_CHUNK", async () => {
|
||||
const result = await startMcpServer([
|
||||
{ name: "get_weather", description: "Get the weather" },
|
||||
]);
|
||||
llm = result.llm;
|
||||
mcpMock = result.mcpMock;
|
||||
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
mcpServers: [{ type: "http", url: result.mcpUrl }],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([
|
||||
toolCall("tc1", "get_weather", { query: "NYC" }),
|
||||
toolResult("tc1", "get_weather", "Sunny 72F"),
|
||||
textDelta("The weather is sunny."),
|
||||
finish(),
|
||||
]) as any,
|
||||
);
|
||||
|
||||
const events = await collectEvents(agent["run"](baseInput));
|
||||
|
||||
const types = events.map((e: any) => e.type);
|
||||
expect(types).toContain(EventType.TOOL_CALL_START);
|
||||
expect(types).toContain(EventType.TOOL_CALL_RESULT);
|
||||
expect(types).toContain(EventType.TEXT_MESSAGE_CHUNK);
|
||||
|
||||
// Verify the tool call result content
|
||||
const resultEvent = events.find(
|
||||
(e: any) => e.type === EventType.TOOL_CALL_RESULT,
|
||||
) as any;
|
||||
expect(resultEvent.toolCallId).toBe("tc1");
|
||||
expect(resultEvent.content).toContain("Sunny 72F");
|
||||
});
|
||||
|
||||
it("MCP clients are cleaned up after completion — second run creates fresh connections", async () => {
|
||||
const result = await startMcpServer([
|
||||
{ name: "get_weather", description: "Get the weather" },
|
||||
]);
|
||||
llm = result.llm;
|
||||
mcpMock = result.mcpMock;
|
||||
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
mcpServers: [{ type: "http", url: result.mcpUrl }],
|
||||
});
|
||||
|
||||
// First run
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([textDelta("Run 1"), finish()]) as any,
|
||||
);
|
||||
const events1 = await collectEvents(agent["run"](baseInput));
|
||||
expect(events1.some((e: any) => e.type === EventType.RUN_FINISHED)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// Second run — should succeed with fresh MCP client connections
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([textDelta("Run 2"), finish()]) as any,
|
||||
);
|
||||
const events2 = await collectEvents(agent["run"](baseInput));
|
||||
expect(events2.some((e: any) => e.type === EventType.RUN_FINISHED)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// streamText was called twice (once per run), each time with MCP tools
|
||||
expect(vi.mocked(streamText).mock.calls).toHaveLength(2);
|
||||
expect(vi.mocked(streamText).mock.calls[0][0].tools).toHaveProperty(
|
||||
"get_weather",
|
||||
);
|
||||
expect(vi.mocked(streamText).mock.calls[1][0].tools).toHaveProperty(
|
||||
"get_weather",
|
||||
);
|
||||
});
|
||||
|
||||
it("the only MCP server being unreachable does NOT fail the run", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
mcpServers: [{ type: "http", url: "http://localhost:59999" }],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([textDelta("ok"), finish()]) as any,
|
||||
);
|
||||
|
||||
const events = await collectEvents(agent["run"](baseInput));
|
||||
|
||||
// Graceful degradation: the unreachable server is skipped, no RUN_ERROR,
|
||||
// and the run still proceeds (just with no MCP tools).
|
||||
expect(events.some((e: any) => e.type === EventType.RUN_ERROR)).toBe(false);
|
||||
expect(vi.mocked(streamText).mock.calls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("MCP clients are cleaned up after streamText error — subsequent run still works", async () => {
|
||||
const result = await startMcpServer([
|
||||
{ name: "get_weather", description: "Get the weather" },
|
||||
]);
|
||||
llm = result.llm;
|
||||
mcpMock = result.mcpMock;
|
||||
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
mcpServers: [{ type: "http", url: result.mcpUrl }],
|
||||
});
|
||||
|
||||
// First run — streamText throws an error
|
||||
vi.mocked(streamText).mockImplementation(() => {
|
||||
throw new Error("LLM connection failed");
|
||||
});
|
||||
|
||||
const events1: any[] = [];
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
agent["run"](baseInput).subscribe({
|
||||
next: (event: any) => events1.push(event),
|
||||
error: (err: any) => reject(err),
|
||||
complete: () => resolve(events1),
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// Expected — streamText threw
|
||||
}
|
||||
|
||||
// Should have emitted RUN_ERROR
|
||||
expect(events1.some((e) => e.type === EventType.RUN_ERROR)).toBe(true);
|
||||
|
||||
// Second run — streamText works normally, proving MCP cleanup happened
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([textDelta("Recovery"), finish()]) as any,
|
||||
);
|
||||
const events2 = await collectEvents(agent["run"](baseInput));
|
||||
expect(events2.some((e: any) => e.type === EventType.RUN_FINISHED)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// The second run should have MCP tools available
|
||||
const secondCallArgs = vi.mocked(streamText).mock.calls[1][0];
|
||||
expect(secondCallArgs.tools).toHaveProperty("get_weather");
|
||||
});
|
||||
|
||||
it("MCP tool descriptions are passed to streamText tools config", async () => {
|
||||
const result = await startMcpServer([
|
||||
{ name: "get_weather", description: "Get the weather" },
|
||||
]);
|
||||
llm = result.llm;
|
||||
mcpMock = result.mcpMock;
|
||||
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
mcpServers: [{ type: "http", url: result.mcpUrl }],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([textDelta("Hello"), finish()]) as any,
|
||||
);
|
||||
|
||||
await collectEvents(agent["run"](baseInput));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.tools).toHaveProperty("get_weather");
|
||||
// The MCP tool should include the description from the MCPMock server
|
||||
expect(callArgs.tools.get_weather.description).toBe("Get the weather");
|
||||
});
|
||||
|
||||
it("multiple MCP servers merge tools from both", async () => {
|
||||
// First server with get_weather
|
||||
const result1 = await startMcpServer([
|
||||
{ name: "get_weather", description: "Get the weather" },
|
||||
]);
|
||||
llm = result1.llm;
|
||||
|
||||
// Second server with search_docs
|
||||
const mock2 = new MCPMock();
|
||||
mock2.addTool({
|
||||
name: "search_docs",
|
||||
description: "Search documentation",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { query: { type: "string" } },
|
||||
},
|
||||
});
|
||||
mock2.onToolCall("search_docs", () => "doc results");
|
||||
|
||||
const llm2 = new LLMock({ port: 0 });
|
||||
llm2.mount("/mcp", mock2);
|
||||
await llm2.start();
|
||||
|
||||
try {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
mcpServers: [
|
||||
{ type: "http", url: result1.mcpUrl },
|
||||
{ type: "http", url: `${llm2.url}/mcp` },
|
||||
],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([
|
||||
textDelta("Both tools available"),
|
||||
finish(),
|
||||
]) as any,
|
||||
);
|
||||
|
||||
await collectEvents(agent["run"](baseInput));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.tools).toHaveProperty("get_weather");
|
||||
expect(callArgs.tools).toHaveProperty("search_docs");
|
||||
} finally {
|
||||
await llm2.stop().catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { convertInputToTanStackAI } from "../converters/tanstack";
|
||||
import { createDefaultInput } from "./agent-test-helpers";
|
||||
import type { Message, InputContent } from "@ag-ui/client";
|
||||
|
||||
/**
|
||||
* Helper: build a user message with the given content parts, run through
|
||||
* convertInputToTanStackAI, and return the first converted message for assertion.
|
||||
*/
|
||||
function convertUserContent(content: string | InputContent[]) {
|
||||
const input = createDefaultInput({
|
||||
messages: [{ role: "user", content } as Message],
|
||||
});
|
||||
const { messages } = convertInputToTanStackAI(input);
|
||||
return messages[0];
|
||||
}
|
||||
|
||||
/** Shorthand factories for AG-UI input parts */
|
||||
function dataSource(value: string, mimeType: string) {
|
||||
return { type: "data" as const, value, mimeType };
|
||||
}
|
||||
|
||||
function urlSource(value: string, mimeType?: string) {
|
||||
return { type: "url" as const, value, ...(mimeType ? { mimeType } : {}) };
|
||||
}
|
||||
|
||||
describe("convertInputToTanStackAI — multimodal", () => {
|
||||
it("passes through plain string user content", () => {
|
||||
const result = convertUserContent("Hello");
|
||||
expect(result.content).toBe("Hello");
|
||||
});
|
||||
|
||||
it("converts text-only InputContent[] to TanStack TextPart array", () => {
|
||||
const result = convertUserContent([{ type: "text", text: "Hello world" }]);
|
||||
expect(result.content).toEqual([{ type: "text", content: "Hello world" }]);
|
||||
});
|
||||
|
||||
it("converts ImageInputPart with data source", () => {
|
||||
const result = convertUserContent([
|
||||
{ type: "text", text: "What is this?" },
|
||||
{ type: "image", source: dataSource("iVBORw0KGgo=", "image/png") },
|
||||
]);
|
||||
expect(result.content).toEqual([
|
||||
{ type: "text", content: "What is this?" },
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "data", value: "iVBORw0KGgo=", mimeType: "image/png" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("converts ImageInputPart with url source", () => {
|
||||
const result = convertUserContent([
|
||||
{
|
||||
type: "image",
|
||||
source: urlSource("https://example.com/photo.jpg", "image/jpeg"),
|
||||
},
|
||||
]);
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: "image",
|
||||
source: {
|
||||
type: "url",
|
||||
value: "https://example.com/photo.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("converts AudioInputPart", () => {
|
||||
const result = convertUserContent([
|
||||
{ type: "audio", source: dataSource("base64audiodata", "audio/mp3") },
|
||||
]);
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: "audio",
|
||||
source: {
|
||||
type: "data",
|
||||
value: "base64audiodata",
|
||||
mimeType: "audio/mp3",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("converts VideoInputPart with url source", () => {
|
||||
const result = convertUserContent([
|
||||
{
|
||||
type: "video",
|
||||
source: urlSource("https://example.com/video.mp4", "video/mp4"),
|
||||
},
|
||||
]);
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: "video",
|
||||
source: {
|
||||
type: "url",
|
||||
value: "https://example.com/video.mp4",
|
||||
mimeType: "video/mp4",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("converts DocumentInputPart", () => {
|
||||
const result = convertUserContent([
|
||||
{
|
||||
type: "document",
|
||||
source: dataSource("base64pdfdata", "application/pdf"),
|
||||
},
|
||||
]);
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: "document",
|
||||
source: {
|
||||
type: "data",
|
||||
value: "base64pdfdata",
|
||||
mimeType: "application/pdf",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles mixed text and multimodal parts", () => {
|
||||
const result = convertUserContent([
|
||||
{ type: "text", text: "Analyze these:" },
|
||||
{ type: "image", source: dataSource("imgdata", "image/png") },
|
||||
{
|
||||
type: "document",
|
||||
source: dataSource("docdata", "application/pdf"),
|
||||
},
|
||||
]);
|
||||
expect(result.content).toEqual([
|
||||
{ type: "text", content: "Analyze these:" },
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "data", value: "imgdata", mimeType: "image/png" },
|
||||
},
|
||||
{
|
||||
type: "document",
|
||||
source: {
|
||||
type: "data",
|
||||
value: "docdata",
|
||||
mimeType: "application/pdf",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty string for empty content array", () => {
|
||||
const result = convertUserContent([]);
|
||||
expect(result.content).toBe("");
|
||||
});
|
||||
|
||||
it("preserves empty string text parts", () => {
|
||||
const result = convertUserContent([{ type: "text", text: "" }]);
|
||||
expect(result.content).toEqual([{ type: "text", content: "" }]);
|
||||
});
|
||||
|
||||
it("skips parts with missing source without crashing", () => {
|
||||
const result = convertUserContent([
|
||||
{ type: "text", text: "check this" },
|
||||
{ type: "image" } as any,
|
||||
]);
|
||||
expect(result.content).toEqual([{ type: "text", content: "check this" }]);
|
||||
});
|
||||
|
||||
it("silently skips unknown part types", () => {
|
||||
const result = convertUserContent([
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "spreadsheet", source: dataSource("data", "text/csv") } as any,
|
||||
]);
|
||||
expect(result.content).toEqual([{ type: "text", content: "hello" }]);
|
||||
});
|
||||
|
||||
it("returns null for null or undefined content", () => {
|
||||
const nullInput = createDefaultInput({
|
||||
messages: [{ role: "user", content: null } as unknown as Message],
|
||||
});
|
||||
const { messages: nullMessages } = convertInputToTanStackAI(nullInput);
|
||||
expect(nullMessages[0].content).toBeNull();
|
||||
|
||||
const undefinedInput = createDefaultInput({
|
||||
messages: [{ role: "user", content: undefined } as unknown as Message],
|
||||
});
|
||||
const { messages: undefinedMessages } =
|
||||
convertInputToTanStackAI(undefinedInput);
|
||||
expect(undefinedMessages[0].content).toBeNull();
|
||||
});
|
||||
|
||||
describe("legacy BinaryInputContent backward compat", () => {
|
||||
it("converts binary with image mimeType and data", () => {
|
||||
const legacyPart = {
|
||||
type: "binary",
|
||||
mimeType: "image/jpeg",
|
||||
data: "legacybase64",
|
||||
};
|
||||
const input = createDefaultInput({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [legacyPart] as unknown as InputContent[],
|
||||
} as Message,
|
||||
],
|
||||
});
|
||||
const { messages } = convertInputToTanStackAI(input);
|
||||
expect(messages[0].content).toEqual([
|
||||
{
|
||||
type: "image",
|
||||
source: {
|
||||
type: "data",
|
||||
value: "legacybase64",
|
||||
mimeType: "image/jpeg",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips binary with neither data nor url", () => {
|
||||
const legacyPart = {
|
||||
type: "binary",
|
||||
mimeType: "image/png",
|
||||
};
|
||||
const input = createDefaultInput({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [legacyPart] as unknown as InputContent[],
|
||||
} as Message,
|
||||
],
|
||||
});
|
||||
const { messages } = convertInputToTanStackAI(input);
|
||||
expect(messages[0].content).toBe("");
|
||||
});
|
||||
|
||||
it("converts binary with non-image mimeType and url", () => {
|
||||
const legacyPart = {
|
||||
type: "binary",
|
||||
mimeType: "application/pdf",
|
||||
url: "https://example.com/doc.pdf",
|
||||
};
|
||||
const input = createDefaultInput({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [legacyPart] as unknown as InputContent[],
|
||||
} as Message,
|
||||
],
|
||||
});
|
||||
const { messages } = convertInputToTanStackAI(input);
|
||||
expect(messages[0].content).toEqual([
|
||||
{
|
||||
type: "document",
|
||||
source: {
|
||||
type: "url",
|
||||
value: "https://example.com/doc.pdf",
|
||||
mimeType: "application/pdf",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("only converts user message content, not assistant messages", () => {
|
||||
const input = createDefaultInput({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Look at this" },
|
||||
{ type: "image", source: dataSource("imgdata", "image/png") },
|
||||
],
|
||||
} as Message,
|
||||
{ role: "assistant", content: "I see an image" } as Message,
|
||||
],
|
||||
});
|
||||
const { messages } = convertInputToTanStackAI(input);
|
||||
// User message should have array content
|
||||
expect(Array.isArray(messages[0].content)).toBe(true);
|
||||
// Assistant message should keep string content
|
||||
expect(messages[1].content).toBe("I see an image");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { convertMessagesToVercelAISDKMessages } from "../index";
|
||||
import type { Message, InputContent } from "@ag-ui/client";
|
||||
import type { UserModelMessage } from "ai";
|
||||
|
||||
/**
|
||||
* Helper: build a user message with the given content parts and convert it.
|
||||
* Returns the converted UserModelMessage for assertion.
|
||||
*/
|
||||
function convertUserContent(content: string | InputContent[]) {
|
||||
const messages: Message[] = [{ id: "1", role: "user", content }];
|
||||
const result = convertMessagesToVercelAISDKMessages(messages);
|
||||
return result[0] as UserModelMessage;
|
||||
}
|
||||
|
||||
/** Shorthand factories for AG-UI input parts */
|
||||
function dataSource(value: string, mimeType: string) {
|
||||
return { type: "data" as const, value, mimeType };
|
||||
}
|
||||
|
||||
function urlSource(value: string, mimeType?: string) {
|
||||
return { type: "url" as const, value, ...(mimeType ? { mimeType } : {}) };
|
||||
}
|
||||
|
||||
describe("convertMessagesToVercelAISDKMessages — multimodal", () => {
|
||||
it("passes through plain string user content", () => {
|
||||
const result = convertUserContent("Hello");
|
||||
expect(result).toEqual({ role: "user", content: "Hello" });
|
||||
});
|
||||
|
||||
it("converts text-only InputContent[] to parts array", () => {
|
||||
const result = convertUserContent([{ type: "text", text: "Hello world" }]);
|
||||
expect(result.role).toBe("user");
|
||||
expect(result.content).toEqual([{ type: "text", text: "Hello world" }]);
|
||||
});
|
||||
|
||||
it("converts ImageInputPart with data source to ImagePart", () => {
|
||||
const result = convertUserContent([
|
||||
{ type: "text", text: "What is this?" },
|
||||
{ type: "image", source: dataSource("iVBORw0KGgo=", "image/png") },
|
||||
]);
|
||||
expect(result.content).toEqual([
|
||||
{ type: "text", text: "What is this?" },
|
||||
{ type: "image", image: "iVBORw0KGgo=", mediaType: "image/png" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("converts ImageInputPart with url source to ImagePart with URL", () => {
|
||||
const result = convertUserContent([
|
||||
{
|
||||
type: "image",
|
||||
source: urlSource("https://example.com/photo.jpg", "image/jpeg"),
|
||||
},
|
||||
]);
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: "image",
|
||||
image: new URL("https://example.com/photo.jpg"),
|
||||
mediaType: "image/jpeg",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("converts AudioInputPart to FilePart", () => {
|
||||
const result = convertUserContent([
|
||||
{ type: "audio", source: dataSource("base64audiodata", "audio/mp3") },
|
||||
]);
|
||||
expect(result.content).toEqual([
|
||||
{ type: "file", data: "base64audiodata", mediaType: "audio/mp3" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("converts VideoInputPart with url source to FilePart", () => {
|
||||
const result = convertUserContent([
|
||||
{
|
||||
type: "video",
|
||||
source: urlSource("https://example.com/video.mp4", "video/mp4"),
|
||||
},
|
||||
]);
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: "file",
|
||||
data: new URL("https://example.com/video.mp4"),
|
||||
mediaType: "video/mp4",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("converts DocumentInputPart to FilePart", () => {
|
||||
const result = convertUserContent([
|
||||
{
|
||||
type: "document",
|
||||
source: dataSource("base64pdfdata", "application/pdf"),
|
||||
},
|
||||
]);
|
||||
expect(result.content).toEqual([
|
||||
{ type: "file", data: "base64pdfdata", mediaType: "application/pdf" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles mixed text and multimodal parts", () => {
|
||||
const result = convertUserContent([
|
||||
{ type: "text", text: "Analyze these:" },
|
||||
{ type: "image", source: dataSource("imgdata", "image/png") },
|
||||
{ type: "document", source: dataSource("docdata", "application/pdf") },
|
||||
]);
|
||||
expect(result.content).toEqual([
|
||||
{ type: "text", text: "Analyze these:" },
|
||||
{ type: "image", image: "imgdata", mediaType: "image/png" },
|
||||
{ type: "file", data: "docdata", mediaType: "application/pdf" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty string for empty content array", () => {
|
||||
const result = convertUserContent([]);
|
||||
expect(result.content).toBe("");
|
||||
});
|
||||
|
||||
it("skips image parts with malformed URLs without crashing", () => {
|
||||
const result = convertUserContent([
|
||||
{ type: "text", text: "check this" },
|
||||
{ type: "image", source: { type: "url", value: "not-a-url" } },
|
||||
]);
|
||||
// Malformed URL part is skipped, text part preserved
|
||||
expect(result.content).toEqual([{ type: "text", text: "check this" }]);
|
||||
});
|
||||
|
||||
// Legacy backward compat — BinaryInputContent is not in the current schema
|
||||
// but older clients may still send it. We intentionally construct untyped
|
||||
// objects here to simulate that scenario.
|
||||
describe("legacy BinaryInputContent backward compat", () => {
|
||||
it("converts binary with image mimeType and data to ImagePart", () => {
|
||||
const legacyPart = {
|
||||
type: "binary",
|
||||
mimeType: "image/jpeg",
|
||||
data: "legacybase64",
|
||||
};
|
||||
const messages: Message[] = [
|
||||
{
|
||||
id: "1",
|
||||
role: "user",
|
||||
content: [legacyPart] as unknown as InputContent[],
|
||||
},
|
||||
];
|
||||
const result = convertMessagesToVercelAISDKMessages(messages);
|
||||
const userMsg = result[0] as UserModelMessage;
|
||||
expect(userMsg.content).toEqual([
|
||||
{ type: "image", image: "legacybase64", mediaType: "image/jpeg" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("converts binary with non-image mimeType and url to FilePart", () => {
|
||||
const legacyPart = {
|
||||
type: "binary",
|
||||
mimeType: "application/pdf",
|
||||
url: "https://example.com/doc.pdf",
|
||||
};
|
||||
const messages: Message[] = [
|
||||
{
|
||||
id: "1",
|
||||
role: "user",
|
||||
content: [legacyPart] as unknown as InputContent[],
|
||||
},
|
||||
];
|
||||
const result = convertMessagesToVercelAISDKMessages(messages);
|
||||
const userMsg = result[0] as UserModelMessage;
|
||||
expect(userMsg.content).toEqual([
|
||||
{
|
||||
type: "file",
|
||||
data: new URL("https://example.com/doc.pdf"),
|
||||
mediaType: "application/pdf",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,599 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { BasicAgent } from "../index";
|
||||
import type { RunAgentInput } from "@ag-ui/client";
|
||||
import { streamText } from "ai";
|
||||
import { mockStreamTextResponse, finish, collectEvents } from "./test-helpers";
|
||||
|
||||
// Mock the ai module
|
||||
vi.mock("ai", () => ({
|
||||
streamText: vi.fn(),
|
||||
tool: vi.fn((config) => config),
|
||||
}));
|
||||
|
||||
// Mock the SDK clients
|
||||
vi.mock("@ai-sdk/openai", () => ({
|
||||
createOpenAI: vi.fn(() => (modelId: string) => ({
|
||||
modelId,
|
||||
provider: "openai",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@ai-sdk/anthropic", () => ({
|
||||
createAnthropic: vi.fn(() => (modelId: string) => ({
|
||||
modelId,
|
||||
provider: "anthropic",
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("Property Overrides - Edge Cases", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
process.env.OPENAI_API_KEY = "test-key";
|
||||
process.env.ANTHROPIC_API_KEY = "test-key";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe("Model Override", () => {
|
||||
it("should override model when allowed", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
overridableProperties: ["model"],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
forwardedProps: { model: "anthropic/claude-sonnet-4.5" },
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
const model = callArgs.model as { provider: string; modelId: string };
|
||||
expect(model.provider).toBe("anthropic");
|
||||
expect(model.modelId).toBe("claude-sonnet-4.5");
|
||||
});
|
||||
|
||||
it("should accept LanguageModel instance for override", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
overridableProperties: ["model"],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const customModel = {
|
||||
modelId: "custom-model",
|
||||
provider: "custom",
|
||||
};
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
forwardedProps: { model: customModel },
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.model).toBe(customModel);
|
||||
});
|
||||
|
||||
it("should ignore invalid model override types", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
overridableProperties: ["model"],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
forwardedProps: { model: 123 }, // Invalid type
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect((callArgs.model as { modelId: string }).modelId).toBe("gpt-4o"); // Original value
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolChoice Override", () => {
|
||||
it("should override with 'auto'", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
toolChoice: "required",
|
||||
overridableProperties: ["toolChoice"],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
forwardedProps: { toolChoice: "auto" },
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.toolChoice).toBe("auto");
|
||||
});
|
||||
|
||||
it("should override with 'required'", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
toolChoice: "auto",
|
||||
overridableProperties: ["toolChoice"],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
forwardedProps: { toolChoice: "required" },
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.toolChoice).toBe("required");
|
||||
});
|
||||
|
||||
it("should override with 'none'", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
toolChoice: "auto",
|
||||
overridableProperties: ["toolChoice"],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
forwardedProps: { toolChoice: "none" },
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.toolChoice).toBe("none");
|
||||
});
|
||||
|
||||
it("should override with specific tool selection", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
toolChoice: "auto",
|
||||
overridableProperties: ["toolChoice"],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
forwardedProps: {
|
||||
toolChoice: { type: "tool", toolName: "specificTool" },
|
||||
},
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.toolChoice).toEqual({
|
||||
type: "tool",
|
||||
toolName: "specificTool",
|
||||
});
|
||||
});
|
||||
|
||||
it("should ignore invalid toolChoice values", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
toolChoice: "auto",
|
||||
overridableProperties: ["toolChoice"],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
forwardedProps: { toolChoice: "invalid" },
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.toolChoice).toBe("auto"); // Original value
|
||||
});
|
||||
});
|
||||
|
||||
describe("StopSequences Override", () => {
|
||||
it("should override stopSequences with valid array", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
stopSequences: ["STOP"],
|
||||
overridableProperties: ["stopSequences"],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
forwardedProps: { stopSequences: ["END", "FINISH"] },
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.stopSequences).toEqual(["END", "FINISH"]);
|
||||
});
|
||||
|
||||
it("should ignore stopSequences with non-string elements", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
stopSequences: ["STOP"],
|
||||
overridableProperties: ["stopSequences"],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
forwardedProps: { stopSequences: ["STOP", 123, "END"] as any },
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.stopSequences).toEqual(["STOP"]); // Original value
|
||||
});
|
||||
|
||||
it("should ignore non-array stopSequences", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
stopSequences: ["STOP"],
|
||||
overridableProperties: ["stopSequences"],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
forwardedProps: { stopSequences: "STOP" as any },
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.stopSequences).toEqual(["STOP"]); // Original value
|
||||
});
|
||||
});
|
||||
|
||||
describe("Numeric Property Overrides", () => {
|
||||
it("should override all numeric properties when allowed", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
maxOutputTokens: 100,
|
||||
temperature: 0.5,
|
||||
topP: 0.9,
|
||||
topK: 50,
|
||||
presencePenalty: 0.0,
|
||||
frequencyPenalty: 0.0,
|
||||
seed: 123,
|
||||
maxRetries: 3,
|
||||
overridableProperties: [
|
||||
"maxOutputTokens",
|
||||
"temperature",
|
||||
"topP",
|
||||
"topK",
|
||||
"presencePenalty",
|
||||
"frequencyPenalty",
|
||||
"seed",
|
||||
"maxRetries",
|
||||
],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
forwardedProps: {
|
||||
maxOutputTokens: 500,
|
||||
temperature: 0.8,
|
||||
topP: 0.95,
|
||||
topK: 100,
|
||||
presencePenalty: 0.5,
|
||||
frequencyPenalty: 0.5,
|
||||
seed: 456,
|
||||
maxRetries: 5,
|
||||
},
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.maxOutputTokens).toBe(500);
|
||||
expect(callArgs.temperature).toBe(0.8);
|
||||
expect(callArgs.topP).toBe(0.95);
|
||||
expect(callArgs.topK).toBe(100);
|
||||
expect(callArgs.presencePenalty).toBe(0.5);
|
||||
expect(callArgs.frequencyPenalty).toBe(0.5);
|
||||
expect(callArgs.seed).toBe(456);
|
||||
expect(callArgs.maxRetries).toBe(5);
|
||||
});
|
||||
|
||||
it("should ignore non-numeric values for numeric properties", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
temperature: 0.5,
|
||||
overridableProperties: ["temperature"],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
forwardedProps: { temperature: "high" as any },
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.temperature).toBe(0.5); // Original value
|
||||
});
|
||||
});
|
||||
|
||||
describe("Multiple Property Overrides", () => {
|
||||
it("should only override allowed properties", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
temperature: 0.5,
|
||||
topP: 0.9,
|
||||
maxOutputTokens: 100,
|
||||
overridableProperties: ["temperature"], // Only temperature is overridable
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
forwardedProps: {
|
||||
temperature: 0.9,
|
||||
topP: 0.5,
|
||||
maxOutputTokens: 500,
|
||||
},
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.temperature).toBe(0.9); // Overridden
|
||||
expect(callArgs.topP).toBe(0.9); // Original
|
||||
expect(callArgs.maxOutputTokens).toBe(100); // Original
|
||||
});
|
||||
|
||||
it("should handle undefined forwardedProps", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
temperature: 0.5,
|
||||
overridableProperties: ["temperature"],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
// No forwardedProps
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.temperature).toBe(0.5); // Original value
|
||||
});
|
||||
|
||||
it("should handle non-object forwardedProps", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
temperature: 0.5,
|
||||
overridableProperties: ["temperature"],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
forwardedProps: "not an object" as any,
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.temperature).toBe(0.5); // Original value
|
||||
});
|
||||
|
||||
it("should handle undefined property values in forwardedProps", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
temperature: 0.5,
|
||||
overridableProperties: ["temperature"],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
forwardedProps: { temperature: undefined },
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.temperature).toBe(0.5); // Original value (undefined is ignored)
|
||||
});
|
||||
});
|
||||
|
||||
describe("canOverride method", () => {
|
||||
it("should return true for overridable properties", () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
overridableProperties: ["temperature", "topP"],
|
||||
});
|
||||
|
||||
expect(agent.canOverride("temperature")).toBe(true);
|
||||
expect(agent.canOverride("topP")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for non-overridable properties", () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
overridableProperties: ["temperature"],
|
||||
});
|
||||
|
||||
expect(agent.canOverride("topP")).toBe(false);
|
||||
expect(agent.canOverride("seed")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when overridableProperties is undefined", () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
});
|
||||
|
||||
expect(agent.canOverride("temperature")).toBe(false);
|
||||
expect(agent.canOverride("topP")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when overridableProperties is empty array", () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
overridableProperties: [],
|
||||
});
|
||||
|
||||
expect(agent.canOverride("temperature")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { BuiltInAgent } from "../index";
|
||||
import { EventType, type RunAgentInput } from "@ag-ui/client";
|
||||
import { streamText } from "ai";
|
||||
import {
|
||||
mockStreamTextResponse,
|
||||
textDelta,
|
||||
finish,
|
||||
collectEvents,
|
||||
} from "./test-helpers";
|
||||
|
||||
// Mock the ai module
|
||||
vi.mock("ai", () => ({
|
||||
streamText: vi.fn(),
|
||||
tool: vi.fn((config) => config),
|
||||
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
|
||||
}));
|
||||
|
||||
vi.mock("@ai-sdk/openai", () => ({
|
||||
createOpenAI: vi.fn(() => (modelId: string) => ({
|
||||
specificationVersion: "v3",
|
||||
modelId,
|
||||
provider: "openai",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@ai-sdk/anthropic", () => ({
|
||||
createAnthropic: vi.fn(() => (modelId: string) => ({
|
||||
specificationVersion: "v3",
|
||||
modelId,
|
||||
provider: "anthropic",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@ai-sdk/google", () => ({
|
||||
createGoogleGenerativeAI: vi.fn(() => (modelId: string) => ({
|
||||
specificationVersion: "v3",
|
||||
modelId,
|
||||
provider: "google",
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("Provider ID collision (#3410, #3623)", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
process.env.OPENAI_API_KEY = "test-key";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should replace text-start providedId "txt-0" with a UUID', async () => {
|
||||
const agent = new BuiltInAgent({ model: "openai:gpt-4o-mini" });
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([
|
||||
{ type: "text-start", id: "txt-0" },
|
||||
textDelta("Hello"),
|
||||
finish(),
|
||||
]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread-1",
|
||||
runId: "run-1",
|
||||
messages: [{ id: "1", role: "user", content: "Hi" }],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
const events = await collectEvents(agent["run"](input));
|
||||
|
||||
// Find the TEXT_MESSAGE_CHUNK event and check its messageId
|
||||
const textChunks = events.filter(
|
||||
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
|
||||
);
|
||||
expect(textChunks.length).toBeGreaterThan(0);
|
||||
const messageId = (textChunks[0] as any).messageId;
|
||||
|
||||
// The messageId should NOT be "txt-0" — it should be a UUID
|
||||
expect(messageId).not.toBe("txt-0");
|
||||
// UUID v4 pattern
|
||||
expect(messageId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should replace reasoning-start providedId "reasoning-0" with a UUID', async () => {
|
||||
const agent = new BuiltInAgent({ model: "openai:gpt-4o-mini" });
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([
|
||||
{ type: "reasoning-start", id: "reasoning-0" },
|
||||
{ type: "reasoning-delta", text: "Thinking..." },
|
||||
{ type: "reasoning-end" },
|
||||
{ type: "text-start", id: "txt-0" },
|
||||
textDelta("Answer"),
|
||||
finish(),
|
||||
]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread-2",
|
||||
runId: "run-2",
|
||||
messages: [{ id: "1", role: "user", content: "Hi" }],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
const events = await collectEvents(agent["run"](input));
|
||||
|
||||
// Find REASONING_START event
|
||||
const reasoningStarts = events.filter(
|
||||
(e) => e.type === EventType.REASONING_START,
|
||||
);
|
||||
expect(reasoningStarts.length).toBeGreaterThan(0);
|
||||
const reasoningId = (reasoningStarts[0] as any).messageId;
|
||||
|
||||
// Should NOT be "reasoning-0"
|
||||
expect(reasoningId).not.toBe("reasoning-0");
|
||||
expect(reasoningId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should replace providedId "msg-0" with a UUID', async () => {
|
||||
const agent = new BuiltInAgent({ model: "openai:gpt-4o-mini" });
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([
|
||||
{ type: "text-start", id: "msg-0" },
|
||||
textDelta("Hello"),
|
||||
finish(),
|
||||
]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread-3",
|
||||
runId: "run-3",
|
||||
messages: [{ id: "1", role: "user", content: "Hi" }],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
const events = await collectEvents(agent["run"](input));
|
||||
|
||||
const textChunks = events.filter(
|
||||
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
|
||||
);
|
||||
expect(textChunks.length).toBeGreaterThan(0);
|
||||
const messageId = (textChunks[0] as any).messageId;
|
||||
|
||||
expect(messageId).not.toBe("msg-0");
|
||||
expect(messageId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should preserve legitimate provider IDs", async () => {
|
||||
const agent = new BuiltInAgent({ model: "openai:gpt-4o-mini" });
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([
|
||||
{ type: "text-start", id: "custom-msg-id-123" },
|
||||
textDelta("Hello"),
|
||||
finish(),
|
||||
]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread-4",
|
||||
runId: "run-4",
|
||||
messages: [{ id: "1", role: "user", content: "Hi" }],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
const events = await collectEvents(agent["run"](input));
|
||||
|
||||
const textChunks = events.filter(
|
||||
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
|
||||
);
|
||||
expect(textChunks.length).toBeGreaterThan(0);
|
||||
// Legitimate IDs should be preserved
|
||||
expect((textChunks[0] as any).messageId).toBe("custom-msg-id-123");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { toStandardJsonSchema } from "@valibot/to-json-schema";
|
||||
import { type } from "arktype";
|
||||
import type { StandardSchemaV1 } from "@copilotkit/shared";
|
||||
import {
|
||||
defineTool,
|
||||
convertToolDefinitionsToVercelAITools,
|
||||
BuiltInAgent,
|
||||
} from "../index";
|
||||
import type { RunAgentInput } from "@ag-ui/client";
|
||||
import { streamText } from "ai";
|
||||
import { mockStreamTextResponse, finish, collectEvents } from "./test-helpers";
|
||||
|
||||
const arktype = <Out>(def: Record<string, string>) =>
|
||||
(type as unknown as (d: unknown) => StandardSchemaV1<unknown, Out>)(def);
|
||||
|
||||
// Mock the ai module — keep jsonSchema real for non-Zod path
|
||||
vi.mock("ai", async () => {
|
||||
const actual = await vi.importActual("ai");
|
||||
return {
|
||||
...actual,
|
||||
streamText: vi.fn(),
|
||||
tool: vi.fn((config) => config),
|
||||
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the SDK clients
|
||||
vi.mock("@ai-sdk/openai", () => ({
|
||||
createOpenAI: vi.fn(() => (modelId: string) => ({
|
||||
modelId,
|
||||
provider: "openai",
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("Standard Schema support in agent tools", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
process.env.OPENAI_API_KEY = "test-key";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe("defineTool with different schema libraries", () => {
|
||||
it("accepts a Zod schema (existing behavior)", () => {
|
||||
const tool = defineTool({
|
||||
name: "zodTool",
|
||||
description: "A tool with Zod schema",
|
||||
parameters: z.object({
|
||||
city: z.string(),
|
||||
units: z.enum(["celsius", "fahrenheit"]).optional(),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
return { city: args.city };
|
||||
},
|
||||
});
|
||||
|
||||
expect(tool.name).toBe("zodTool");
|
||||
expect(tool.parameters["~standard"].vendor).toBe("zod");
|
||||
});
|
||||
|
||||
it("accepts a Valibot schema (via toStandardJsonSchema)", () => {
|
||||
const tool = defineTool({
|
||||
name: "valibotTool",
|
||||
description: "A tool with Valibot schema",
|
||||
parameters: toStandardJsonSchema(
|
||||
v.object({
|
||||
query: v.string(),
|
||||
limit: v.optional(v.number()),
|
||||
}),
|
||||
),
|
||||
execute: async (args) => {
|
||||
return { query: args.query };
|
||||
},
|
||||
});
|
||||
|
||||
expect(tool.name).toBe("valibotTool");
|
||||
expect(tool.parameters["~standard"].vendor).toBe("valibot");
|
||||
});
|
||||
|
||||
it("accepts an ArkType schema", () => {
|
||||
const tool = defineTool({
|
||||
name: "arktypeTool",
|
||||
description: "A tool with ArkType schema",
|
||||
parameters: arktype<{ query: string; limit: number }>({
|
||||
query: "string",
|
||||
limit: "number",
|
||||
}),
|
||||
execute: async (args) => {
|
||||
return { query: args.query };
|
||||
},
|
||||
});
|
||||
|
||||
expect(tool.name).toBe("arktypeTool");
|
||||
expect(tool.parameters["~standard"].vendor).toBe("arktype");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertToolDefinitionsToVercelAITools", () => {
|
||||
it("converts Zod tool definitions to AI SDK tools (passes Zod directly)", () => {
|
||||
const tools = [
|
||||
defineTool({
|
||||
name: "zodTool",
|
||||
description: "Zod test",
|
||||
parameters: z.object({ name: z.string() }),
|
||||
execute: async () => ({}),
|
||||
}),
|
||||
];
|
||||
|
||||
const aiTools = convertToolDefinitionsToVercelAITools(tools);
|
||||
|
||||
expect(aiTools).toHaveProperty("zodTool");
|
||||
expect(aiTools.zodTool).toHaveProperty("description", "Zod test");
|
||||
expect(aiTools.zodTool).toHaveProperty("execute");
|
||||
expect(aiTools.zodTool.inputSchema).toBeDefined();
|
||||
});
|
||||
|
||||
it("converts Valibot tool definitions to AI SDK tools", () => {
|
||||
const tools = [
|
||||
defineTool({
|
||||
name: "valibotTool",
|
||||
description: "Valibot test",
|
||||
parameters: toStandardJsonSchema(v.object({ name: v.string() })),
|
||||
execute: async () => ({}),
|
||||
}),
|
||||
];
|
||||
|
||||
const aiTools = convertToolDefinitionsToVercelAITools(tools);
|
||||
|
||||
expect(aiTools).toHaveProperty("valibotTool");
|
||||
expect(aiTools.valibotTool).toHaveProperty("description", "Valibot test");
|
||||
expect(aiTools.valibotTool).toHaveProperty("execute");
|
||||
expect(aiTools.valibotTool.inputSchema).toBeDefined();
|
||||
});
|
||||
|
||||
it("converts ArkType tool definitions to AI SDK tools", () => {
|
||||
const tools = [
|
||||
defineTool({
|
||||
name: "arktypeTool",
|
||||
description: "ArkType test",
|
||||
parameters: arktype<{ name: string }>({ name: "string" }),
|
||||
execute: async () => ({}),
|
||||
}),
|
||||
];
|
||||
|
||||
const aiTools = convertToolDefinitionsToVercelAITools(tools);
|
||||
|
||||
expect(aiTools).toHaveProperty("arktypeTool");
|
||||
expect(aiTools.arktypeTool).toHaveProperty("description", "ArkType test");
|
||||
expect(aiTools.arktypeTool).toHaveProperty("execute");
|
||||
expect(aiTools.arktypeTool.inputSchema).toBeDefined();
|
||||
});
|
||||
|
||||
it("converts mixed schema types in the same tool set", () => {
|
||||
const tools = [
|
||||
defineTool({
|
||||
name: "zodTool",
|
||||
description: "Zod",
|
||||
parameters: z.object({ a: z.string() }),
|
||||
execute: async () => ({}),
|
||||
}),
|
||||
defineTool({
|
||||
name: "valibotTool",
|
||||
description: "Valibot",
|
||||
parameters: toStandardJsonSchema(v.object({ b: v.string() })),
|
||||
execute: async () => ({}),
|
||||
}),
|
||||
defineTool({
|
||||
name: "arktypeTool",
|
||||
description: "ArkType",
|
||||
parameters: arktype<{ c: string }>({ c: "string" }),
|
||||
execute: async () => ({}),
|
||||
}),
|
||||
];
|
||||
|
||||
const aiTools = convertToolDefinitionsToVercelAITools(tools);
|
||||
|
||||
expect(Object.keys(aiTools)).toEqual([
|
||||
"zodTool",
|
||||
"valibotTool",
|
||||
"arktypeTool",
|
||||
]);
|
||||
for (const key of Object.keys(aiTools)) {
|
||||
expect(aiTools[key]).toHaveProperty("execute");
|
||||
expect(aiTools[key]).toHaveProperty("inputSchema");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("BuiltInAgent with non-Zod schemas", () => {
|
||||
it("includes Valibot-based config tools alongside input tools", async () => {
|
||||
const executeFn = vi.fn().mockResolvedValue({ result: "ok" });
|
||||
|
||||
const valibotTool = defineTool({
|
||||
name: "searchValibot",
|
||||
description: "Search with Valibot schema",
|
||||
parameters: toStandardJsonSchema(v.object({ query: v.string() })),
|
||||
execute: executeFn,
|
||||
});
|
||||
|
||||
const agent = new BuiltInAgent({
|
||||
model: "openai/gpt-4o",
|
||||
tools: [valibotTool],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.tools).toHaveProperty("searchValibot");
|
||||
expect(typeof callArgs.tools?.searchValibot.execute).toBe("function");
|
||||
});
|
||||
|
||||
it("includes ArkType-based config tools alongside input tools", async () => {
|
||||
const executeFn = vi.fn().mockResolvedValue({ result: "ok" });
|
||||
|
||||
const arktypeTool = defineTool({
|
||||
name: "searchArktype",
|
||||
description: "Search with ArkType schema",
|
||||
parameters: arktype<{ query: string }>({ query: "string" }),
|
||||
execute: executeFn,
|
||||
});
|
||||
|
||||
const agent = new BuiltInAgent({
|
||||
model: "openai/gpt-4o",
|
||||
tools: [arktypeTool],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.tools).toHaveProperty("searchArktype");
|
||||
expect(typeof callArgs.tools?.searchArktype.execute).toBe("function");
|
||||
});
|
||||
|
||||
it("mixes Zod, Valibot, and ArkType tools in one agent", async () => {
|
||||
const agent = new BuiltInAgent({
|
||||
model: "openai/gpt-4o",
|
||||
tools: [
|
||||
defineTool({
|
||||
name: "zodTool",
|
||||
description: "Zod",
|
||||
parameters: z.object({ a: z.string() }),
|
||||
execute: async () => ({}),
|
||||
}),
|
||||
defineTool({
|
||||
name: "valibotTool",
|
||||
description: "Valibot",
|
||||
parameters: toStandardJsonSchema(v.object({ b: v.string() })),
|
||||
execute: async () => ({}),
|
||||
}),
|
||||
defineTool({
|
||||
name: "arktypeTool",
|
||||
description: "ArkType",
|
||||
parameters: arktype<{ c: string }>({ c: "string" }),
|
||||
execute: async () => ({}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.tools).toHaveProperty("zodTool");
|
||||
expect(callArgs.tools).toHaveProperty("valibotTool");
|
||||
expect(callArgs.tools).toHaveProperty("arktypeTool");
|
||||
// State tools should also be present
|
||||
expect(callArgs.tools).toHaveProperty("AGUISendStateSnapshot");
|
||||
expect(callArgs.tools).toHaveProperty("AGUISendStateDelta");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, it, expectTypeOf } from "vitest";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { type } from "arktype";
|
||||
import type { StandardSchemaV1 } from "@copilotkit/shared";
|
||||
import { defineTool } from "../index";
|
||||
import type { ToolDefinition } from "../index";
|
||||
|
||||
const arktype = <Out>(def: Record<string, string>) =>
|
||||
(type as unknown as (d: unknown) => StandardSchemaV1<unknown, Out>)(def);
|
||||
|
||||
describe("ToolDefinition type inference", () => {
|
||||
describe("defineTool with Zod", () => {
|
||||
it("infers execute args from Zod schema", () => {
|
||||
const tool = defineTool({
|
||||
name: "weather",
|
||||
description: "Get weather",
|
||||
parameters: z.object({
|
||||
city: z.string(),
|
||||
units: z.enum(["celsius", "fahrenheit"]),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
// args should be fully typed
|
||||
expectTypeOf(args).toEqualTypeOf<{
|
||||
city?: string;
|
||||
units?: "celsius" | "fahrenheit";
|
||||
}>();
|
||||
return {};
|
||||
},
|
||||
});
|
||||
|
||||
expectTypeOf(tool.name).toBeString();
|
||||
expectTypeOf(tool.description).toBeString();
|
||||
expectTypeOf(tool.parameters).toMatchTypeOf<StandardSchemaV1>();
|
||||
});
|
||||
|
||||
it("infers execute args with optional Zod fields", () => {
|
||||
defineTool({
|
||||
name: "search",
|
||||
description: "Search",
|
||||
parameters: z.object({
|
||||
query: z.string(),
|
||||
limit: z.number().optional(),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
expectTypeOf(args).toEqualTypeOf<{
|
||||
query?: string;
|
||||
limit?: number;
|
||||
}>();
|
||||
return {};
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("defineTool with Valibot", () => {
|
||||
it("infers execute args from Valibot schema", () => {
|
||||
defineTool({
|
||||
name: "search",
|
||||
description: "Search",
|
||||
parameters: v.object({
|
||||
query: v.string(),
|
||||
limit: v.number(),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
expectTypeOf(args).toEqualTypeOf<{
|
||||
query: string;
|
||||
limit: number;
|
||||
}>();
|
||||
return {};
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("infers execute args with optional Valibot fields", () => {
|
||||
defineTool({
|
||||
name: "search",
|
||||
description: "Search",
|
||||
parameters: v.object({
|
||||
query: v.string(),
|
||||
limit: v.optional(v.number()),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
expectTypeOf(args).toEqualTypeOf<{
|
||||
query: string;
|
||||
limit?: number | undefined;
|
||||
}>();
|
||||
return {};
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("defineTool with ArkType", () => {
|
||||
it("infers execute args from ArkType schema", () => {
|
||||
defineTool({
|
||||
name: "search",
|
||||
description: "Search",
|
||||
parameters: arktype<{ query: string; limit: number }>({
|
||||
query: "string",
|
||||
limit: "number",
|
||||
}),
|
||||
execute: async (args) => {
|
||||
expectTypeOf(args).toEqualTypeOf<{
|
||||
query: string;
|
||||
limit: number;
|
||||
}>();
|
||||
return {};
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("infers execute args with optional ArkType fields", () => {
|
||||
defineTool({
|
||||
name: "profile",
|
||||
description: "Profile",
|
||||
parameters: arktype<{ name: string; age?: number }>({
|
||||
name: "string",
|
||||
"age?": "number",
|
||||
}),
|
||||
execute: async (args) => {
|
||||
expectTypeOf(args).toEqualTypeOf<{
|
||||
name: string;
|
||||
age?: number;
|
||||
}>();
|
||||
return {};
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolDefinition interface", () => {
|
||||
it("accepts Zod schema as generic parameter", () => {
|
||||
type ZodTool = ToolDefinition<z.ZodObject<{ city: z.ZodString }>>;
|
||||
|
||||
expectTypeOf<ZodTool["execute"]>().toBeFunction();
|
||||
expectTypeOf<Parameters<ZodTool["execute"]>[0]>().toEqualTypeOf<{
|
||||
city?: string;
|
||||
}>();
|
||||
});
|
||||
|
||||
it("default generic parameter is StandardSchemaV1", () => {
|
||||
type DefaultTool = ToolDefinition;
|
||||
|
||||
expectTypeOf<
|
||||
DefaultTool["parameters"]
|
||||
>().toMatchTypeOf<StandardSchemaV1>();
|
||||
});
|
||||
|
||||
it("preserves schema type through defineTool return", () => {
|
||||
const schema = z.object({ x: z.number() });
|
||||
const tool = defineTool({
|
||||
name: "t",
|
||||
description: "d",
|
||||
parameters: schema,
|
||||
execute: async () => ({}),
|
||||
});
|
||||
|
||||
expectTypeOf(tool.parameters).toEqualTypeOf<typeof schema>();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,436 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { BasicAgent } from "../index";
|
||||
import { EventType, type RunAgentInput } from "@ag-ui/client";
|
||||
import { streamText } from "ai";
|
||||
import {
|
||||
mockStreamTextResponse,
|
||||
toolCallStreamingStart,
|
||||
toolCall,
|
||||
toolResult,
|
||||
finish,
|
||||
collectEvents,
|
||||
} from "./test-helpers";
|
||||
|
||||
// Mock the ai module
|
||||
vi.mock("ai", () => ({
|
||||
streamText: vi.fn(),
|
||||
tool: vi.fn((config) => config),
|
||||
}));
|
||||
|
||||
// Mock the SDK clients
|
||||
vi.mock("@ai-sdk/openai", () => ({
|
||||
createOpenAI: vi.fn(() => (modelId: string) => ({
|
||||
modelId,
|
||||
provider: "openai",
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("State Update Tools", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
process.env.OPENAI_API_KEY = "test-key";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe("AGUISendStateSnapshot", () => {
|
||||
it("should emit STATE_SNAPSHOT event when tool is called", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
});
|
||||
|
||||
const newState = { counter: 5, items: ["x", "y"] };
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([
|
||||
toolCallStreamingStart("call1", "AGUISendStateSnapshot"),
|
||||
toolCall("call1", "AGUISendStateSnapshot"),
|
||||
toolResult("call1", "AGUISendStateSnapshot", {
|
||||
success: true,
|
||||
snapshot: newState,
|
||||
}),
|
||||
finish(),
|
||||
]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: { counter: 0 },
|
||||
};
|
||||
|
||||
const events = await collectEvents(agent["run"](input));
|
||||
|
||||
// Find STATE_SNAPSHOT event
|
||||
const snapshotEvent = events.find(
|
||||
(e: any) => e.type === EventType.STATE_SNAPSHOT,
|
||||
);
|
||||
expect(snapshotEvent).toBeDefined();
|
||||
expect(snapshotEvent).toMatchObject({
|
||||
type: EventType.STATE_SNAPSHOT,
|
||||
snapshot: newState,
|
||||
});
|
||||
});
|
||||
|
||||
it("should still emit TOOL_CALL_RESULT for the LLM", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([
|
||||
toolCallStreamingStart("call1", "AGUISendStateSnapshot"),
|
||||
toolCall("call1", "AGUISendStateSnapshot"),
|
||||
toolResult("call1", "AGUISendStateSnapshot", {
|
||||
success: true,
|
||||
snapshot: { value: 1 },
|
||||
}),
|
||||
finish(),
|
||||
]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
const events = await collectEvents(agent["run"](input));
|
||||
|
||||
// Should have both STATE_SNAPSHOT and TOOL_CALL_RESULT
|
||||
const snapshotEvent = events.find(
|
||||
(e: any) => e.type === EventType.STATE_SNAPSHOT,
|
||||
);
|
||||
const toolResultEvent = events.find(
|
||||
(e: any) => e.type === EventType.TOOL_CALL_RESULT,
|
||||
);
|
||||
|
||||
expect(snapshotEvent).toBeDefined();
|
||||
expect(toolResultEvent).toBeDefined();
|
||||
expect(toolResultEvent).toMatchObject({
|
||||
type: EventType.TOOL_CALL_RESULT,
|
||||
toolCallId: "call1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AGUISendStateDelta", () => {
|
||||
it("should emit STATE_DELTA event when tool is called", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
});
|
||||
|
||||
const delta = [
|
||||
{ op: "replace", path: "/counter", value: 10 },
|
||||
{ op: "add", path: "/newField", value: "test" },
|
||||
];
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([
|
||||
toolCallStreamingStart("call1", "AGUISendStateDelta"),
|
||||
toolCall("call1", "AGUISendStateDelta"),
|
||||
toolResult("call1", "AGUISendStateDelta", { success: true, delta }),
|
||||
finish(),
|
||||
]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: { counter: 0 },
|
||||
};
|
||||
|
||||
const events = await collectEvents(agent["run"](input));
|
||||
|
||||
// Find STATE_DELTA event
|
||||
const deltaEvent = events.find(
|
||||
(e: any) => e.type === EventType.STATE_DELTA,
|
||||
);
|
||||
expect(deltaEvent).toBeDefined();
|
||||
expect(deltaEvent).toMatchObject({
|
||||
type: EventType.STATE_DELTA,
|
||||
delta,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle add operations", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
});
|
||||
|
||||
const delta = [{ op: "add", path: "/items/0", value: "new item" }];
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([
|
||||
toolCallStreamingStart("call1", "AGUISendStateDelta"),
|
||||
toolCall("call1", "AGUISendStateDelta"),
|
||||
toolResult("call1", "AGUISendStateDelta", { success: true, delta }),
|
||||
finish(),
|
||||
]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: { items: [] },
|
||||
};
|
||||
|
||||
const events = await collectEvents(agent["run"](input));
|
||||
|
||||
const deltaEvent = events.find(
|
||||
(e: any) => e.type === EventType.STATE_DELTA,
|
||||
);
|
||||
expect(deltaEvent?.delta).toEqual(delta);
|
||||
});
|
||||
|
||||
it("should handle replace operations", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
});
|
||||
|
||||
const delta = [{ op: "replace", path: "/status", value: "active" }];
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([
|
||||
toolCallStreamingStart("call1", "AGUISendStateDelta"),
|
||||
toolCall("call1", "AGUISendStateDelta"),
|
||||
toolResult("call1", "AGUISendStateDelta", { success: true, delta }),
|
||||
finish(),
|
||||
]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: { status: "inactive" },
|
||||
};
|
||||
|
||||
const events = await collectEvents(agent["run"](input));
|
||||
|
||||
const deltaEvent = events.find(
|
||||
(e: any) => e.type === EventType.STATE_DELTA,
|
||||
);
|
||||
expect(deltaEvent?.delta).toEqual(delta);
|
||||
});
|
||||
|
||||
it("should handle remove operations", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
});
|
||||
|
||||
const delta = [{ op: "remove", path: "/oldField" }];
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([
|
||||
toolCallStreamingStart("call1", "AGUISendStateDelta"),
|
||||
toolCall("call1", "AGUISendStateDelta"),
|
||||
toolResult("call1", "AGUISendStateDelta", { success: true, delta }),
|
||||
finish(),
|
||||
]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: { oldField: "value", keepField: "keep" },
|
||||
};
|
||||
|
||||
const events = await collectEvents(agent["run"](input));
|
||||
|
||||
const deltaEvent = events.find(
|
||||
(e: any) => e.type === EventType.STATE_DELTA,
|
||||
);
|
||||
expect(deltaEvent?.delta).toEqual(delta);
|
||||
});
|
||||
|
||||
it("should handle multiple operations in a single delta", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
});
|
||||
|
||||
const delta = [
|
||||
{ op: "replace", path: "/counter", value: 5 },
|
||||
{ op: "add", path: "/items/-", value: "new" },
|
||||
{ op: "remove", path: "/temp" },
|
||||
];
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([
|
||||
toolCallStreamingStart("call1", "AGUISendStateDelta"),
|
||||
toolCall("call1", "AGUISendStateDelta"),
|
||||
toolResult("call1", "AGUISendStateDelta", { success: true, delta }),
|
||||
finish(),
|
||||
]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: { counter: 0, items: [], temp: "remove me" },
|
||||
};
|
||||
|
||||
const events = await collectEvents(agent["run"](input));
|
||||
|
||||
const deltaEvent = events.find(
|
||||
(e: any) => e.type === EventType.STATE_DELTA,
|
||||
);
|
||||
expect(deltaEvent?.delta).toEqual(delta);
|
||||
});
|
||||
|
||||
it("should still emit TOOL_CALL_RESULT for the LLM", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
});
|
||||
|
||||
const delta = [{ op: "replace", path: "/value", value: 1 }];
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([
|
||||
toolCallStreamingStart("call1", "AGUISendStateDelta"),
|
||||
toolCall("call1", "AGUISendStateDelta"),
|
||||
toolResult("call1", "AGUISendStateDelta", { success: true, delta }),
|
||||
finish(),
|
||||
]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
const events = await collectEvents(agent["run"](input));
|
||||
|
||||
// Should have both STATE_DELTA and TOOL_CALL_RESULT
|
||||
const deltaEvent = events.find(
|
||||
(e: any) => e.type === EventType.STATE_DELTA,
|
||||
);
|
||||
const toolResultEvent = events.find(
|
||||
(e: any) => e.type === EventType.TOOL_CALL_RESULT,
|
||||
);
|
||||
|
||||
expect(deltaEvent).toBeDefined();
|
||||
expect(toolResultEvent).toBeDefined();
|
||||
expect(toolResultEvent).toMatchObject({
|
||||
type: EventType.TOOL_CALL_RESULT,
|
||||
toolCallId: "call1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("State Tools Integration", () => {
|
||||
it("should handle both snapshot and delta in same run", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([
|
||||
toolCallStreamingStart("call1", "AGUISendStateSnapshot"),
|
||||
toolCall("call1", "AGUISendStateSnapshot"),
|
||||
toolResult("call1", "AGUISendStateSnapshot", {
|
||||
success: true,
|
||||
snapshot: { value: 1 },
|
||||
}),
|
||||
toolCallStreamingStart("call2", "AGUISendStateDelta"),
|
||||
toolCall("call2", "AGUISendStateDelta"),
|
||||
toolResult("call2", "AGUISendStateDelta", {
|
||||
success: true,
|
||||
delta: [{ op: "replace", path: "/value", value: 2 }],
|
||||
}),
|
||||
finish(),
|
||||
]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
const events = await collectEvents(agent["run"](input));
|
||||
|
||||
const snapshotEvents = events.filter(
|
||||
(e: any) => e.type === EventType.STATE_SNAPSHOT,
|
||||
);
|
||||
const deltaEvents = events.filter(
|
||||
(e: any) => e.type === EventType.STATE_DELTA,
|
||||
);
|
||||
|
||||
expect(snapshotEvents).toHaveLength(1);
|
||||
expect(deltaEvents).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should not emit state events for non-state tools", async () => {
|
||||
const agent = new BasicAgent({
|
||||
model: "openai/gpt-4o",
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([
|
||||
toolCallStreamingStart("call1", "otherTool"),
|
||||
toolCall("call1", "otherTool"),
|
||||
toolResult("call1", "otherTool", { result: "data" }),
|
||||
finish(),
|
||||
]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [
|
||||
{
|
||||
name: "otherTool",
|
||||
description: "Other tool",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
const events = await collectEvents(agent["run"](input));
|
||||
|
||||
const stateEvents = events.filter(
|
||||
(e: any) =>
|
||||
e.type === EventType.STATE_SNAPSHOT ||
|
||||
e.type === EventType.STATE_DELTA,
|
||||
);
|
||||
|
||||
expect(stateEvents).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Test helpers for mocking streamText responses
|
||||
*/
|
||||
|
||||
import type { streamText } from "ai";
|
||||
import type { Observable } from "rxjs";
|
||||
import type { BaseEvent } from "@ag-ui/client";
|
||||
|
||||
export interface MockStreamEvent {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock streamText response with controlled events.
|
||||
*/
|
||||
export function mockStreamTextResponse(
|
||||
events: MockStreamEvent[],
|
||||
): ReturnType<typeof streamText> {
|
||||
return {
|
||||
fullStream: (async function* () {
|
||||
for (const event of events) {
|
||||
yield event;
|
||||
}
|
||||
})(),
|
||||
} as unknown as ReturnType<typeof streamText>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a text-start event
|
||||
*/
|
||||
export function textStart(id?: string): MockStreamEvent {
|
||||
const event: MockStreamEvent = {
|
||||
type: "text-start",
|
||||
};
|
||||
if (id !== undefined) {
|
||||
event.id = id;
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a text delta event
|
||||
*/
|
||||
export function textDelta(text: string): MockStreamEvent {
|
||||
return {
|
||||
type: "text-delta",
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a tool call streaming start event
|
||||
*/
|
||||
export function toolCallStreamingStart(
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
): MockStreamEvent {
|
||||
return {
|
||||
type: "tool-input-start",
|
||||
id: toolCallId,
|
||||
toolName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a tool call delta event
|
||||
*/
|
||||
export function toolCallDelta(
|
||||
toolCallId: string,
|
||||
argsTextDelta: string,
|
||||
): MockStreamEvent {
|
||||
return {
|
||||
type: "tool-input-delta",
|
||||
id: toolCallId,
|
||||
delta: argsTextDelta,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a tool call event
|
||||
*/
|
||||
export function toolCall(
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
input: unknown = {},
|
||||
): MockStreamEvent {
|
||||
return {
|
||||
type: "tool-call",
|
||||
toolCallId,
|
||||
toolName,
|
||||
input,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a tool result event
|
||||
*/
|
||||
export function toolResult(
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
output: unknown,
|
||||
): MockStreamEvent {
|
||||
return {
|
||||
type: "tool-result",
|
||||
toolCallId,
|
||||
toolName,
|
||||
output,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a reasoning-start event
|
||||
*/
|
||||
export function reasoningStart(id?: string): MockStreamEvent {
|
||||
const event: MockStreamEvent = {
|
||||
type: "reasoning-start",
|
||||
};
|
||||
if (id !== undefined) {
|
||||
event.id = id;
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a reasoning-delta event
|
||||
*/
|
||||
export function reasoningDelta(text: string): MockStreamEvent {
|
||||
return {
|
||||
type: "reasoning-delta",
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a reasoning-end event
|
||||
*/
|
||||
export function reasoningEnd(): MockStreamEvent {
|
||||
return {
|
||||
type: "reasoning-end",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a finish event
|
||||
*/
|
||||
export function finish(): MockStreamEvent {
|
||||
return {
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create an abort event
|
||||
*/
|
||||
export function abort(): MockStreamEvent {
|
||||
return {
|
||||
type: "abort",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create an error event
|
||||
*/
|
||||
export function error(errorMessage: string): MockStreamEvent {
|
||||
return {
|
||||
type: "error",
|
||||
error: new Error(errorMessage),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects all events from an Observable<BaseEvent> into an array.
|
||||
*/
|
||||
export async function collectEvents(
|
||||
observable: Observable<BaseEvent>,
|
||||
): Promise<BaseEvent[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const events: BaseEvent[] = [];
|
||||
const timeoutId = setTimeout(() => {
|
||||
subscription.unsubscribe();
|
||||
reject(new Error("Observable did not complete within timeout"));
|
||||
}, 5000);
|
||||
const subscription = observable.subscribe({
|
||||
next: (event) => events.push(event),
|
||||
error: (err: unknown) => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(err);
|
||||
},
|
||||
complete: () => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(events);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
resolveModel,
|
||||
convertMessagesToVercelAISDKMessages,
|
||||
convertJsonSchemaToZodSchema,
|
||||
convertToolsToVercelAITools,
|
||||
convertToolDefinitionsToVercelAITools,
|
||||
defineTool,
|
||||
} from "../index";
|
||||
import type { ToolDefinition } from "../index";
|
||||
import type { Message } from "@ag-ui/client";
|
||||
|
||||
describe("resolveModel", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
process.env.OPENAI_API_KEY = "test-openai-key";
|
||||
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";
|
||||
process.env.GOOGLE_API_KEY = "test-google-key";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it("should resolve OpenAI models with / separator", () => {
|
||||
const model = resolveModel("openai/gpt-4o");
|
||||
expect(model).toBeDefined();
|
||||
expect((model as { modelId: string }).modelId).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("should resolve OpenAI models with : separator", () => {
|
||||
const model = resolveModel("openai:gpt-4o-mini");
|
||||
expect(model).toBeDefined();
|
||||
expect((model as { modelId: string }).modelId).toBe("gpt-4o-mini");
|
||||
});
|
||||
|
||||
it("should resolve Anthropic models", () => {
|
||||
const model = resolveModel("anthropic/claude-sonnet-4.5");
|
||||
expect(model).toBeDefined();
|
||||
expect((model as { modelId: string }).modelId).toBe("claude-sonnet-4.5");
|
||||
});
|
||||
|
||||
it("should resolve Google models", () => {
|
||||
const model = resolveModel("google/gemini-2.5-pro");
|
||||
expect(model).toBeDefined();
|
||||
expect((model as { modelId: string }).modelId).toBe("gemini-2.5-pro");
|
||||
});
|
||||
|
||||
it("should handle gemini provider alias", () => {
|
||||
const model = resolveModel("gemini/gemini-2.5-flash");
|
||||
expect(model).toBeDefined();
|
||||
expect((model as { modelId: string }).modelId).toBe("gemini-2.5-flash");
|
||||
});
|
||||
|
||||
it("should throw error for invalid format", () => {
|
||||
expect(() => resolveModel("invalid")).toThrow("Invalid model string");
|
||||
});
|
||||
|
||||
it("should throw error for unknown provider", () => {
|
||||
expect(() => resolveModel("unknown/model")).toThrow("Unknown provider");
|
||||
});
|
||||
|
||||
it("should pass through LanguageModel instances", () => {
|
||||
const mockModel = resolveModel("openai/gpt-4o");
|
||||
const result = resolveModel(mockModel as any);
|
||||
expect(result).toBe(mockModel);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertMessagesToVercelAISDKMessages", () => {
|
||||
it("should convert user messages", () => {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
id: "1",
|
||||
role: "user",
|
||||
content: "Hello",
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertMessagesToVercelAISDKMessages(messages);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: "Hello",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should convert assistant messages with text content", () => {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
id: "1",
|
||||
role: "assistant",
|
||||
content: "Hello there",
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertMessagesToVercelAISDKMessages(messages);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello there" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should convert assistant messages with tool calls", () => {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
id: "1",
|
||||
role: "assistant",
|
||||
content: "Let me help",
|
||||
toolCalls: [
|
||||
{
|
||||
id: "call1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "getTool",
|
||||
arguments: '{"arg":"value"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertMessagesToVercelAISDKMessages(messages);
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Let me help" },
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call1",
|
||||
toolName: "getTool",
|
||||
input: { arg: "value" },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should convert tool messages", () => {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
id: "1",
|
||||
role: "assistant",
|
||||
toolCalls: [
|
||||
{
|
||||
id: "call1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "getTool",
|
||||
arguments: "{}",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
role: "tool",
|
||||
content: "result",
|
||||
toolCallId: "call1",
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertMessagesToVercelAISDKMessages(messages);
|
||||
|
||||
expect(result[1]).toEqual({
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call1",
|
||||
toolName: "getTool",
|
||||
output: {
|
||||
type: "text",
|
||||
value: "result",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle multiple messages", () => {
|
||||
const messages: Message[] = [
|
||||
{ id: "1", role: "user", content: "Hi" },
|
||||
{ id: "2", role: "assistant", content: "Hello" },
|
||||
{ id: "3", role: "user", content: "How are you?" },
|
||||
];
|
||||
|
||||
const result = convertMessagesToVercelAISDKMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].role).toBe("user");
|
||||
expect(result[1].role).toBe("assistant");
|
||||
expect(result[2].role).toBe("user");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertJsonSchemaToZodSchema", () => {
|
||||
it("should convert string schema", () => {
|
||||
const jsonSchema = {
|
||||
type: "string" as const,
|
||||
description: "A string field",
|
||||
};
|
||||
|
||||
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, true);
|
||||
expect(zodSchema.parse("test")).toBe("test");
|
||||
expect(() => zodSchema.parse(123)).toThrow();
|
||||
});
|
||||
|
||||
it("should convert number schema", () => {
|
||||
const jsonSchema = {
|
||||
type: "number" as const,
|
||||
description: "A number field",
|
||||
};
|
||||
|
||||
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, true);
|
||||
expect(zodSchema.parse(123)).toBe(123);
|
||||
expect(() => zodSchema.parse("test")).toThrow();
|
||||
});
|
||||
|
||||
it("should convert boolean schema", () => {
|
||||
const jsonSchema = {
|
||||
type: "boolean" as const,
|
||||
description: "A boolean field",
|
||||
};
|
||||
|
||||
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, true);
|
||||
expect(zodSchema.parse(true)).toBe(true);
|
||||
expect(() => zodSchema.parse("true")).toThrow();
|
||||
});
|
||||
|
||||
it("should convert array schema", () => {
|
||||
const jsonSchema = {
|
||||
type: "array" as const,
|
||||
description: "An array field",
|
||||
items: {
|
||||
type: "string" as const,
|
||||
},
|
||||
};
|
||||
|
||||
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, true);
|
||||
expect(zodSchema.parse(["a", "b"])).toEqual(["a", "b"]);
|
||||
expect(() => zodSchema.parse([1, 2])).toThrow();
|
||||
});
|
||||
|
||||
it("should convert object schema with properties", () => {
|
||||
const jsonSchema = {
|
||||
type: "object" as const,
|
||||
description: "An object",
|
||||
properties: {
|
||||
name: { type: "string" as const },
|
||||
age: { type: "number" as const },
|
||||
},
|
||||
required: ["name"],
|
||||
};
|
||||
|
||||
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, true);
|
||||
const valid = zodSchema.parse({ name: "John", age: 30 });
|
||||
expect(valid).toEqual({ name: "John", age: 30 });
|
||||
|
||||
expect(() => zodSchema.parse({ age: 30 })).toThrow();
|
||||
});
|
||||
|
||||
it("should make schema optional when required is false", () => {
|
||||
const jsonSchema = {
|
||||
type: "string" as const,
|
||||
description: "Optional string",
|
||||
};
|
||||
|
||||
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, false);
|
||||
expect(zodSchema.parse(undefined)).toBeUndefined();
|
||||
expect(zodSchema.parse("test")).toBe("test");
|
||||
});
|
||||
|
||||
it("should preserve string enum constraints", () => {
|
||||
const jsonSchema = {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
status: {
|
||||
type: "string" as const,
|
||||
description: "The status",
|
||||
enum: ["todo", "done"],
|
||||
},
|
||||
},
|
||||
required: ["status"],
|
||||
};
|
||||
const result = convertJsonSchemaToZodSchema(jsonSchema, true);
|
||||
const statusSchema = (result as z.ZodObject<any>).shape.status;
|
||||
expect(statusSchema._def.typeName).toBe("ZodEnum");
|
||||
expect(statusSchema._def.values).toEqual(["todo", "done"]);
|
||||
});
|
||||
|
||||
it("should handle nested object schemas", () => {
|
||||
const jsonSchema = {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
user: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
name: { type: "string" as const },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
required: ["user"],
|
||||
};
|
||||
|
||||
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, true);
|
||||
const valid = zodSchema.parse({ user: { name: "John" } });
|
||||
expect(valid).toEqual({ user: { name: "John" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertToolsToVercelAITools", () => {
|
||||
it("should convert AG-UI tools to Vercel AI tools", () => {
|
||||
const tools = [
|
||||
{
|
||||
name: "testTool",
|
||||
description: "A test tool",
|
||||
parameters: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
input: { type: "string" as const },
|
||||
},
|
||||
required: ["input"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertToolsToVercelAITools(tools);
|
||||
|
||||
expect(result).toHaveProperty("testTool");
|
||||
expect(result.testTool).toBeDefined();
|
||||
});
|
||||
|
||||
it("should throw error for invalid JSON schema", () => {
|
||||
const tools = [
|
||||
{
|
||||
name: "testTool",
|
||||
description: "A test tool",
|
||||
parameters: { invalid: "schema" } as any,
|
||||
},
|
||||
];
|
||||
|
||||
expect(() => convertToolsToVercelAITools(tools)).toThrow(
|
||||
"Invalid JSON schema",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle multiple tools", () => {
|
||||
const tools = [
|
||||
{
|
||||
name: "tool1",
|
||||
description: "First tool",
|
||||
parameters: {
|
||||
type: "object" as const,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tool2",
|
||||
description: "Second tool",
|
||||
parameters: {
|
||||
type: "object" as const,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertToolsToVercelAITools(tools);
|
||||
|
||||
expect(result).toHaveProperty("tool1");
|
||||
expect(result).toHaveProperty("tool2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertToolDefinitionsToVercelAITools", () => {
|
||||
it("should convert ToolDefinitions to Vercel AI tools", () => {
|
||||
const tools: ToolDefinition[] = [
|
||||
{
|
||||
name: "testTool",
|
||||
description: "A test tool",
|
||||
parameters: z.object({
|
||||
input: z.string(),
|
||||
}),
|
||||
execute: async () => undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertToolDefinitionsToVercelAITools(tools);
|
||||
|
||||
expect(result).toHaveProperty("testTool");
|
||||
expect(result.testTool).toBeDefined();
|
||||
});
|
||||
|
||||
it("should handle multiple tool definitions", () => {
|
||||
const tools: ToolDefinition[] = [
|
||||
{
|
||||
name: "tool1",
|
||||
description: "First tool",
|
||||
parameters: z.object({}),
|
||||
execute: async () => undefined,
|
||||
},
|
||||
{
|
||||
name: "tool2",
|
||||
description: "Second tool",
|
||||
parameters: z.object({ value: z.number() }),
|
||||
execute: async () => undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertToolDefinitionsToVercelAITools(tools);
|
||||
|
||||
expect(result).toHaveProperty("tool1");
|
||||
expect(result).toHaveProperty("tool2");
|
||||
expect(Object.keys(result)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureObjectArgs via convertMessagesToVercelAISDKMessages", () => {
|
||||
function makeAssistantWithToolArgs(argsJson: string): Message[] {
|
||||
return [
|
||||
{
|
||||
id: "1",
|
||||
role: "assistant",
|
||||
content: "calling tool",
|
||||
toolCalls: [
|
||||
{
|
||||
id: "call1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "myTool",
|
||||
arguments: argsJson,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
it("should pass through valid object arguments", () => {
|
||||
const result = convertMessagesToVercelAISDKMessages(
|
||||
makeAssistantWithToolArgs('{"key":"value"}'),
|
||||
);
|
||||
const toolCall = (result[0] as any).content[1];
|
||||
expect(toolCall.input).toEqual({ key: "value" });
|
||||
});
|
||||
|
||||
it("should replace a string argument with an empty object", () => {
|
||||
const result = convertMessagesToVercelAISDKMessages(
|
||||
makeAssistantWithToolArgs('""'),
|
||||
);
|
||||
const toolCall = (result[0] as any).content[1];
|
||||
expect(toolCall.input).toEqual({});
|
||||
});
|
||||
|
||||
it("should replace an array argument with an empty object", () => {
|
||||
const result = convertMessagesToVercelAISDKMessages(
|
||||
makeAssistantWithToolArgs("[1,2,3]"),
|
||||
);
|
||||
const toolCall = (result[0] as any).content[1];
|
||||
expect(toolCall.input).toEqual({});
|
||||
});
|
||||
|
||||
it("should replace a null argument with an empty object", () => {
|
||||
const result = convertMessagesToVercelAISDKMessages(
|
||||
makeAssistantWithToolArgs("null"),
|
||||
);
|
||||
const toolCall = (result[0] as any).content[1];
|
||||
expect(toolCall.input).toEqual({});
|
||||
});
|
||||
|
||||
it("should replace a numeric argument with an empty object", () => {
|
||||
const result = convertMessagesToVercelAISDKMessages(
|
||||
makeAssistantWithToolArgs("42"),
|
||||
);
|
||||
const toolCall = (result[0] as any).content[1];
|
||||
expect(toolCall.input).toEqual({});
|
||||
});
|
||||
|
||||
it("should replace a boolean argument with an empty object", () => {
|
||||
const result = convertMessagesToVercelAISDKMessages(
|
||||
makeAssistantWithToolArgs("true"),
|
||||
);
|
||||
const toolCall = (result[0] as any).content[1];
|
||||
expect(toolCall.input).toEqual({});
|
||||
});
|
||||
|
||||
it("should replace unparseable JSON with an empty object", () => {
|
||||
const result = convertMessagesToVercelAISDKMessages(
|
||||
makeAssistantWithToolArgs("{broken"),
|
||||
);
|
||||
const toolCall = (result[0] as any).content[1];
|
||||
expect(toolCall.input).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("defineTool", () => {
|
||||
it("should create a ToolDefinition", () => {
|
||||
const tool = defineTool({
|
||||
name: "myTool",
|
||||
description: "My test tool",
|
||||
parameters: z.object({
|
||||
input: z.string(),
|
||||
}),
|
||||
execute: async () => undefined,
|
||||
});
|
||||
|
||||
expect(tool).toEqual({
|
||||
name: "myTool",
|
||||
description: "My test tool",
|
||||
parameters: expect.any(Object),
|
||||
execute: expect.any(Function),
|
||||
});
|
||||
|
||||
expect(tool.name).toBe("myTool");
|
||||
expect(tool.description).toBe("My test tool");
|
||||
});
|
||||
|
||||
it("should preserve type information", () => {
|
||||
const tool = defineTool({
|
||||
name: "calc",
|
||||
description: "Calculate",
|
||||
parameters: z.object({
|
||||
a: z.number(),
|
||||
b: z.number(),
|
||||
}),
|
||||
execute: async () => undefined,
|
||||
});
|
||||
|
||||
// Should be able to parse valid input
|
||||
const result = tool.parameters.parse({ a: 1, b: 2 });
|
||||
expect(result).toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* Regression tests proving that the agent package's defineTool and
|
||||
* convertToolDefinitionsToVercelAITools still work identically with
|
||||
* Zod schemas after the Standard Schema migration.
|
||||
*
|
||||
* Covers:
|
||||
* 1. defineTool with complex Zod schemas
|
||||
* 2. convertToolDefinitionsToVercelAITools passes Zod schemas directly
|
||||
* (not through JSON Schema conversion — critical behavioral regression)
|
||||
* 3. BuiltInAgent with Zod tools still works end-to-end
|
||||
* 4. execute callback receives correctly shaped args
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
defineTool,
|
||||
convertToolDefinitionsToVercelAITools,
|
||||
BuiltInAgent,
|
||||
} from "../index";
|
||||
import type { RunAgentInput } from "@ag-ui/client";
|
||||
import { streamText } from "ai";
|
||||
import { mockStreamTextResponse, finish, collectEvents } from "./test-helpers";
|
||||
|
||||
vi.mock("ai", async () => {
|
||||
const actual = await vi.importActual("ai");
|
||||
return {
|
||||
...actual,
|
||||
streamText: vi.fn(),
|
||||
tool: vi.fn((config) => config),
|
||||
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@ai-sdk/openai", () => ({
|
||||
createOpenAI: vi.fn(() => (modelId: string) => ({
|
||||
modelId,
|
||||
provider: "openai",
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("defineTool Zod regression", () => {
|
||||
it("complex Zod schema with nested objects, arrays, unions", () => {
|
||||
const tool = defineTool({
|
||||
name: "complexTool",
|
||||
description: "A complex tool",
|
||||
parameters: z.object({
|
||||
query: z.string().describe("Search query"),
|
||||
filters: z
|
||||
.object({
|
||||
category: z.enum(["books", "movies", "music"]).optional(),
|
||||
minRating: z.number().min(0).max(5).optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
pagination: z
|
||||
.object({
|
||||
page: z.number().int().positive().default(1),
|
||||
perPage: z.number().int().positive().default(20),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
return { query: args.query };
|
||||
},
|
||||
});
|
||||
|
||||
expect(tool.name).toBe("complexTool");
|
||||
expect(tool.parameters["~standard"].vendor).toBe("zod");
|
||||
expect(tool.parameters["~standard"].version).toBe(1);
|
||||
});
|
||||
|
||||
it("Zod schema with discriminated union", () => {
|
||||
const tool = defineTool({
|
||||
name: "actionTool",
|
||||
description: "An action tool",
|
||||
parameters: z.object({
|
||||
action: z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("search"), query: z.string() }),
|
||||
z.object({ type: z.literal("navigate"), url: z.string().url() }),
|
||||
z.object({
|
||||
type: z.literal("execute"),
|
||||
code: z.string(),
|
||||
language: z.enum(["javascript", "python"]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
return { action: args.action.type };
|
||||
},
|
||||
});
|
||||
|
||||
expect(tool.name).toBe("actionTool");
|
||||
expect(tool.parameters["~standard"].vendor).toBe("zod");
|
||||
});
|
||||
|
||||
it("Zod schema with nullable and record fields", () => {
|
||||
const tool = defineTool({
|
||||
name: "flexTool",
|
||||
description: "Flexible tool",
|
||||
parameters: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
return { title: args.title };
|
||||
},
|
||||
});
|
||||
|
||||
expect(tool.name).toBe("flexTool");
|
||||
expect(tool.parameters["~standard"].vendor).toBe("zod");
|
||||
});
|
||||
|
||||
it("execute callback receives correctly typed args", async () => {
|
||||
const receivedArgs: unknown[] = [];
|
||||
|
||||
const tool = defineTool({
|
||||
name: "argCapture",
|
||||
description: "Captures args",
|
||||
parameters: z.object({
|
||||
city: z.string(),
|
||||
units: z.enum(["celsius", "fahrenheit"]),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
receivedArgs.push(args);
|
||||
return { temp: 22 };
|
||||
},
|
||||
});
|
||||
|
||||
await tool.execute({ city: "Berlin", units: "celsius" });
|
||||
|
||||
expect(receivedArgs).toHaveLength(1);
|
||||
expect(receivedArgs[0]).toEqual({ city: "Berlin", units: "celsius" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertToolDefinitionsToVercelAITools Zod regression", () => {
|
||||
it("Zod schemas are passed directly to AI SDK (not converted via JSON Schema)", () => {
|
||||
const zodSchema = z.object({
|
||||
city: z.string(),
|
||||
units: z.enum(["celsius", "fahrenheit"]).optional(),
|
||||
});
|
||||
|
||||
const tools = [
|
||||
defineTool({
|
||||
name: "weather",
|
||||
description: "Get weather",
|
||||
parameters: zodSchema,
|
||||
execute: async () => ({ temp: 22 }),
|
||||
}),
|
||||
];
|
||||
|
||||
const aiTools = convertToolDefinitionsToVercelAITools(tools);
|
||||
|
||||
expect(aiTools).toHaveProperty("weather");
|
||||
// The inputSchema should be the original Zod schema (passed directly),
|
||||
// NOT a jsonSchema() wrapper. We verify by checking the Zod-specific
|
||||
// ~standard.vendor property is preserved on the schema.
|
||||
const inputSchema = aiTools.weather.inputSchema;
|
||||
expect(inputSchema).toBeDefined();
|
||||
// For Zod schemas, the AI SDK receives the raw Zod object (which has ~standard.vendor === "zod")
|
||||
// If it went through jsonSchema(), it would lose the ~standard property
|
||||
expect(inputSchema["~standard"]?.vendor).toBe("zod");
|
||||
});
|
||||
|
||||
it("multiple Zod tools all get direct pass-through", () => {
|
||||
const tools = [
|
||||
defineTool({
|
||||
name: "tool1",
|
||||
description: "Tool 1",
|
||||
parameters: z.object({ a: z.string() }),
|
||||
execute: async () => ({}),
|
||||
}),
|
||||
defineTool({
|
||||
name: "tool2",
|
||||
description: "Tool 2",
|
||||
parameters: z.object({ b: z.number(), c: z.boolean().optional() }),
|
||||
execute: async () => ({}),
|
||||
}),
|
||||
defineTool({
|
||||
name: "tool3",
|
||||
description: "Tool 3",
|
||||
parameters: z.object({
|
||||
nested: z.object({ x: z.number(), y: z.number() }),
|
||||
}),
|
||||
execute: async () => ({}),
|
||||
}),
|
||||
];
|
||||
|
||||
const aiTools = convertToolDefinitionsToVercelAITools(tools);
|
||||
|
||||
for (const name of ["tool1", "tool2", "tool3"]) {
|
||||
expect(aiTools).toHaveProperty(name);
|
||||
expect(aiTools[name].inputSchema["~standard"]?.vendor).toBe("zod");
|
||||
expect(typeof aiTools[name].execute).toBe("function");
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves tool description and execute function", () => {
|
||||
const executeFn = vi.fn().mockResolvedValue({ result: "ok" });
|
||||
|
||||
const tools = [
|
||||
defineTool({
|
||||
name: "myTool",
|
||||
description: "My custom tool description",
|
||||
parameters: z.object({ input: z.string() }),
|
||||
execute: executeFn,
|
||||
}),
|
||||
];
|
||||
|
||||
const aiTools = convertToolDefinitionsToVercelAITools(tools);
|
||||
|
||||
expect(aiTools.myTool.description).toBe("My custom tool description");
|
||||
expect(aiTools.myTool.execute).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BuiltInAgent Zod regression", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
process.env.OPENAI_API_KEY = "test-key";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it("Zod tools are included in streamText call", async () => {
|
||||
const agent = new BuiltInAgent({
|
||||
model: "openai/gpt-4o",
|
||||
tools: [
|
||||
defineTool({
|
||||
name: "weather",
|
||||
description: "Get weather",
|
||||
parameters: z.object({
|
||||
city: z.string(),
|
||||
units: z.enum(["celsius", "fahrenheit"]).optional(),
|
||||
}),
|
||||
execute: async () => ({ temp: 22 }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
expect(callArgs.tools).toHaveProperty("weather");
|
||||
expect(typeof callArgs.tools?.weather.execute).toBe("function");
|
||||
});
|
||||
|
||||
it("multiple Zod tools coexist with built-in state tools", async () => {
|
||||
const agent = new BuiltInAgent({
|
||||
model: "openai/gpt-4o",
|
||||
tools: [
|
||||
defineTool({
|
||||
name: "search",
|
||||
description: "Search",
|
||||
parameters: z.object({ query: z.string() }),
|
||||
execute: async () => ({}),
|
||||
}),
|
||||
defineTool({
|
||||
name: "calculate",
|
||||
description: "Calculate",
|
||||
parameters: z.object({
|
||||
expression: z.string(),
|
||||
precision: z.number().int().optional(),
|
||||
}),
|
||||
execute: async () => ({}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
// User-defined Zod tools
|
||||
expect(callArgs.tools).toHaveProperty("search");
|
||||
expect(callArgs.tools).toHaveProperty("calculate");
|
||||
// Built-in state tools
|
||||
expect(callArgs.tools).toHaveProperty("AGUISendStateSnapshot");
|
||||
expect(callArgs.tools).toHaveProperty("AGUISendStateDelta");
|
||||
});
|
||||
|
||||
it("Zod tool execute is invocable after conversion", async () => {
|
||||
const executeFn = vi.fn().mockResolvedValue({ temp: 22 });
|
||||
|
||||
const agent = new BuiltInAgent({
|
||||
model: "openai/gpt-4o",
|
||||
tools: [
|
||||
defineTool({
|
||||
name: "weather",
|
||||
description: "Get weather",
|
||||
parameters: z.object({ city: z.string() }),
|
||||
execute: executeFn,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
vi.mocked(streamText).mockReturnValue(
|
||||
mockStreamTextResponse([finish()]) as any,
|
||||
);
|
||||
|
||||
const input: RunAgentInput = {
|
||||
threadId: "thread1",
|
||||
runId: "run1",
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
state: {},
|
||||
};
|
||||
|
||||
await collectEvents(agent["run"](input));
|
||||
|
||||
const callArgs = vi.mocked(streamText).mock.calls[0][0];
|
||||
const weatherTool = callArgs.tools?.weather;
|
||||
|
||||
// Invoke the execute function to verify it's wired correctly
|
||||
await weatherTool.execute(
|
||||
{ city: "Berlin" },
|
||||
{ toolCallId: "tool-call-1", messages: [] },
|
||||
);
|
||||
expect(executeFn).toHaveBeenCalledWith(
|
||||
{ city: "Berlin" },
|
||||
{ toolCallId: "tool-call-1", messages: [] },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,405 @@
|
||||
import type {
|
||||
BaseEvent,
|
||||
Interrupt,
|
||||
ReasoningEndEvent,
|
||||
ReasoningMessageContentEvent,
|
||||
ReasoningMessageEndEvent,
|
||||
ReasoningMessageStartEvent,
|
||||
ReasoningStartEvent,
|
||||
TextMessageChunkEvent,
|
||||
ToolCallArgsEvent,
|
||||
ToolCallEndEvent,
|
||||
ToolCallStartEvent,
|
||||
ToolCallResultEvent,
|
||||
StateSnapshotEvent,
|
||||
StateDeltaEvent,
|
||||
} from "@ag-ui/client";
|
||||
import { EventType } from "@ag-ui/client";
|
||||
import { randomUUID } from "@copilotkit/shared";
|
||||
|
||||
/**
|
||||
* Converts an AI SDK `fullStream` into AG-UI `BaseEvent` objects.
|
||||
*
|
||||
* This is a pure converter — it does NOT emit lifecycle events
|
||||
* (RUN_STARTED / RUN_FINISHED / RUN_ERROR). The caller (Agent class)
|
||||
* is responsible for those.
|
||||
*
|
||||
* Terminal stream events (finish, error, abort) cause the generator to
|
||||
* return so the caller can handle lifecycle appropriately.
|
||||
*
|
||||
* `pendingInterrupts`, when provided, is filled with one AG-UI Interrupt per
|
||||
* AI SDK `tool-approval-request` part (a tool declared `needsApproval: true`).
|
||||
* The caller turns a non-empty array into a RUN_FINISHED `outcome:interrupt`.
|
||||
*/
|
||||
export async function* convertAISDKStream(
|
||||
fullStream: AsyncIterable<unknown>,
|
||||
abortSignal: AbortSignal,
|
||||
pendingInterrupts?: Interrupt[],
|
||||
): AsyncGenerator<BaseEvent> {
|
||||
let messageId = randomUUID();
|
||||
let reasoningMessageId = randomUUID();
|
||||
let isInReasoning = false;
|
||||
|
||||
const toolCallStates = new Map<
|
||||
string,
|
||||
{
|
||||
started: boolean;
|
||||
hasArgsDelta: boolean;
|
||||
ended: boolean;
|
||||
toolName?: string;
|
||||
}
|
||||
>();
|
||||
|
||||
const ensureToolCallState = (toolCallId: string) => {
|
||||
let state = toolCallStates.get(toolCallId);
|
||||
if (!state) {
|
||||
state = { started: false, hasArgsDelta: false, ended: false };
|
||||
toolCallStates.set(toolCallId, state);
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
/**
|
||||
* Auto-close an open reasoning lifecycle.
|
||||
* Some AI SDK providers (notably @ai-sdk/anthropic) never emit "reasoning-end",
|
||||
* which leaves downstream state machines stuck. This helper emits the
|
||||
* missing REASONING_MESSAGE_END + REASONING_END events so the stream
|
||||
* can transition to text, tool-call, or finish phases.
|
||||
*/
|
||||
function* closeReasoningIfOpen(): Generator<BaseEvent> {
|
||||
if (!isInReasoning) return;
|
||||
isInReasoning = false;
|
||||
const reasoningMsgEnd: ReasoningMessageEndEvent = {
|
||||
type: EventType.REASONING_MESSAGE_END,
|
||||
messageId: reasoningMessageId,
|
||||
};
|
||||
yield reasoningMsgEnd;
|
||||
const reasoningEnd: ReasoningEndEvent = {
|
||||
type: EventType.REASONING_END,
|
||||
messageId: reasoningMessageId,
|
||||
};
|
||||
yield reasoningEnd;
|
||||
}
|
||||
|
||||
try {
|
||||
for await (const part of fullStream) {
|
||||
const p = part as Record<string, unknown>;
|
||||
|
||||
// Close any open reasoning lifecycle on every event except
|
||||
// reasoning-delta, which arrives mid-block and must not interrupt it.
|
||||
if (p.type !== "reasoning-delta") {
|
||||
yield* closeReasoningIfOpen();
|
||||
}
|
||||
|
||||
switch (p.type) {
|
||||
case "abort": {
|
||||
// Terminal — let the caller handle lifecycle
|
||||
return;
|
||||
}
|
||||
|
||||
case "reasoning-start": {
|
||||
// Use SDK-provided id, or generate a fresh UUID if id is falsy/"0"
|
||||
// to prevent consecutive reasoning blocks from sharing a messageId
|
||||
const providedId = "id" in p ? p.id : undefined;
|
||||
reasoningMessageId =
|
||||
providedId && providedId !== "0"
|
||||
? (providedId as string)
|
||||
: randomUUID();
|
||||
const reasoningStartEvent: ReasoningStartEvent = {
|
||||
type: EventType.REASONING_START,
|
||||
messageId: reasoningMessageId,
|
||||
};
|
||||
yield reasoningStartEvent;
|
||||
const reasoningMessageStart: ReasoningMessageStartEvent = {
|
||||
type: EventType.REASONING_MESSAGE_START,
|
||||
messageId: reasoningMessageId,
|
||||
role: "reasoning",
|
||||
};
|
||||
yield reasoningMessageStart;
|
||||
isInReasoning = true;
|
||||
break;
|
||||
}
|
||||
|
||||
case "reasoning-delta": {
|
||||
const delta = (p.text as string) ?? "";
|
||||
if (!delta) break; // skip — @ag-ui/core schema requires delta to be non-empty
|
||||
const reasoningDeltaEvent: ReasoningMessageContentEvent = {
|
||||
type: EventType.REASONING_MESSAGE_CONTENT,
|
||||
messageId: reasoningMessageId,
|
||||
delta,
|
||||
};
|
||||
yield reasoningDeltaEvent;
|
||||
break;
|
||||
}
|
||||
|
||||
case "reasoning-end": {
|
||||
// closeReasoningIfOpen() already called before the switch — no-op here
|
||||
// if the SDK never emits this event (e.g. @ai-sdk/anthropic).
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool-input-start": {
|
||||
const toolCallId = p.id as string;
|
||||
const state = ensureToolCallState(toolCallId);
|
||||
state.toolName = p.toolName as string;
|
||||
if (!state.started) {
|
||||
state.started = true;
|
||||
const startEvent: ToolCallStartEvent = {
|
||||
type: EventType.TOOL_CALL_START,
|
||||
parentMessageId: messageId,
|
||||
toolCallId,
|
||||
toolCallName: p.toolName as string,
|
||||
};
|
||||
yield startEvent;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool-input-delta": {
|
||||
const toolCallId = p.id as string;
|
||||
const state = ensureToolCallState(toolCallId);
|
||||
state.hasArgsDelta = true;
|
||||
const argsEvent: ToolCallArgsEvent = {
|
||||
type: EventType.TOOL_CALL_ARGS,
|
||||
toolCallId,
|
||||
delta: p.delta as string,
|
||||
};
|
||||
yield argsEvent;
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool-input-end": {
|
||||
// No direct event – the subsequent "tool-call" part marks completion.
|
||||
break;
|
||||
}
|
||||
|
||||
case "text-start": {
|
||||
// New text message starting - use the SDK-provided id
|
||||
// Use randomUUID() if part.id is falsy or "0" to prevent message merging issues
|
||||
const providedId = "id" in p ? p.id : undefined;
|
||||
messageId =
|
||||
providedId && providedId !== "0"
|
||||
? (providedId as string)
|
||||
: randomUUID();
|
||||
break;
|
||||
}
|
||||
|
||||
case "text-delta": {
|
||||
// AI SDK text-delta events use 'text' (not 'delta')
|
||||
const textDelta = "text" in p ? (p.text as string) : "";
|
||||
const textEvent: TextMessageChunkEvent = {
|
||||
type: EventType.TEXT_MESSAGE_CHUNK,
|
||||
role: "assistant",
|
||||
messageId,
|
||||
delta: textDelta,
|
||||
};
|
||||
yield textEvent;
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool-call": {
|
||||
const toolCallId = p.toolCallId as string;
|
||||
const state = ensureToolCallState(toolCallId);
|
||||
state.toolName = (p.toolName as string) ?? state.toolName;
|
||||
|
||||
if (!state.started) {
|
||||
state.started = true;
|
||||
const startEvent: ToolCallStartEvent = {
|
||||
type: EventType.TOOL_CALL_START,
|
||||
parentMessageId: messageId,
|
||||
toolCallId,
|
||||
toolCallName: p.toolName as string,
|
||||
};
|
||||
yield startEvent;
|
||||
}
|
||||
|
||||
if (!state.hasArgsDelta && "input" in p && p.input !== undefined) {
|
||||
let serializedInput = "";
|
||||
if (typeof p.input === "string") {
|
||||
serializedInput = p.input;
|
||||
} else {
|
||||
try {
|
||||
serializedInput = JSON.stringify(p.input);
|
||||
} catch {
|
||||
serializedInput = String(p.input);
|
||||
}
|
||||
}
|
||||
|
||||
if (serializedInput.length > 0) {
|
||||
const argsEvent: ToolCallArgsEvent = {
|
||||
type: EventType.TOOL_CALL_ARGS,
|
||||
toolCallId,
|
||||
delta: serializedInput,
|
||||
};
|
||||
yield argsEvent;
|
||||
state.hasArgsDelta = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.ended) {
|
||||
state.ended = true;
|
||||
const endEvent: ToolCallEndEvent = {
|
||||
type: EventType.TOOL_CALL_END,
|
||||
toolCallId,
|
||||
};
|
||||
yield endEvent;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool-approval-request": {
|
||||
// AI SDK native human-in-the-loop: a tool declared `needsApproval`
|
||||
// was called. The preceding "tool-call" part already streamed the
|
||||
// tool name + args; this part carries the toolCallId to pause on.
|
||||
// (Some shapes nest the call under `toolCall` — read both.)
|
||||
const nested = (p.toolCall ?? {}) as {
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
input?: unknown;
|
||||
};
|
||||
const toolCallId =
|
||||
(p.toolCallId as string | undefined) ?? nested.toolCallId;
|
||||
if (!toolCallId) {
|
||||
throw new Error(
|
||||
"AI SDK tool-approval-request is missing toolCallId",
|
||||
);
|
||||
}
|
||||
const state = ensureToolCallState(toolCallId);
|
||||
const toolName = state.toolName ?? nested.toolName;
|
||||
|
||||
// Defensive: emit the tool-call lifecycle if the "tool-call" part
|
||||
// didn't precede this one, so the assistant tool-call is in history
|
||||
// for the resume run (which keys the injected result by toolCallId).
|
||||
if (!state.started) {
|
||||
state.started = true;
|
||||
const startEvent: ToolCallStartEvent = {
|
||||
type: EventType.TOOL_CALL_START,
|
||||
parentMessageId: messageId,
|
||||
toolCallId,
|
||||
toolCallName: toolName ?? "",
|
||||
};
|
||||
yield startEvent;
|
||||
}
|
||||
if (!state.hasArgsDelta && nested.input !== undefined) {
|
||||
let serialized = "";
|
||||
try {
|
||||
serialized =
|
||||
typeof nested.input === "string"
|
||||
? nested.input
|
||||
: JSON.stringify(nested.input);
|
||||
} catch {
|
||||
serialized = String(nested.input);
|
||||
}
|
||||
if (serialized.length > 0) {
|
||||
const argsEvent: ToolCallArgsEvent = {
|
||||
type: EventType.TOOL_CALL_ARGS,
|
||||
toolCallId,
|
||||
delta: serialized,
|
||||
};
|
||||
yield argsEvent;
|
||||
state.hasArgsDelta = true;
|
||||
}
|
||||
}
|
||||
if (!state.ended) {
|
||||
state.ended = true;
|
||||
const endEvent: ToolCallEndEvent = {
|
||||
type: EventType.TOOL_CALL_END,
|
||||
toolCallId,
|
||||
};
|
||||
yield endEvent;
|
||||
}
|
||||
|
||||
pendingInterrupts?.push({
|
||||
id: toolCallId,
|
||||
toolCallId,
|
||||
reason: "tool_approval",
|
||||
message: toolName ? `Approve "${toolName}"?` : undefined,
|
||||
...(toolName ? { metadata: { toolName } } : {}),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool-result": {
|
||||
// AI SDK tool-result uses "output"; older versions used "result" — check both
|
||||
const toolResult =
|
||||
"output" in p ? p.output : "result" in p ? p.result : null;
|
||||
const toolName = "toolName" in p ? (p.toolName as string) : "";
|
||||
toolCallStates.delete(p.toolCallId as string);
|
||||
|
||||
// Check if this is a state update tool
|
||||
if (
|
||||
toolName === "AGUISendStateSnapshot" &&
|
||||
toolResult &&
|
||||
typeof toolResult === "object"
|
||||
) {
|
||||
const snapshot = (toolResult as Record<string, unknown>).snapshot;
|
||||
if (snapshot !== undefined) {
|
||||
const stateSnapshotEvent: StateSnapshotEvent = {
|
||||
type: EventType.STATE_SNAPSHOT,
|
||||
snapshot,
|
||||
};
|
||||
yield stateSnapshotEvent;
|
||||
}
|
||||
} else if (
|
||||
toolName === "AGUISendStateDelta" &&
|
||||
toolResult &&
|
||||
typeof toolResult === "object"
|
||||
) {
|
||||
const delta = (toolResult as { delta?: StateDeltaEvent["delta"] })
|
||||
.delta;
|
||||
if (delta !== undefined) {
|
||||
const stateDeltaEvent: StateDeltaEvent = {
|
||||
type: EventType.STATE_DELTA,
|
||||
delta,
|
||||
};
|
||||
yield stateDeltaEvent;
|
||||
}
|
||||
}
|
||||
|
||||
// Always emit the tool result event for the LLM
|
||||
let serializedResult: string;
|
||||
try {
|
||||
serializedResult = JSON.stringify(toolResult);
|
||||
} catch {
|
||||
serializedResult = `[Unserializable tool result from ${toolName || "unknown tool"}]`;
|
||||
}
|
||||
const resultEvent: ToolCallResultEvent = {
|
||||
type: EventType.TOOL_CALL_RESULT,
|
||||
role: "tool",
|
||||
messageId: randomUUID(),
|
||||
toolCallId: p.toolCallId as string,
|
||||
content: serializedResult,
|
||||
};
|
||||
yield resultEvent;
|
||||
break;
|
||||
}
|
||||
|
||||
case "finish": {
|
||||
// Terminal — let the caller handle lifecycle
|
||||
return;
|
||||
}
|
||||
|
||||
case "error": {
|
||||
if (abortSignal.aborted) {
|
||||
return;
|
||||
}
|
||||
// Re-throw so the caller can emit RUN_ERROR
|
||||
const err = p.error ?? p.message ?? p.cause;
|
||||
if (err instanceof Error) throw err;
|
||||
throw new Error(
|
||||
typeof err === "string"
|
||||
? err
|
||||
: `AI SDK stream error: ${JSON.stringify(p)}`,
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
// Unknown event types are silently ignored
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Always close reasoning on exit (normal or exceptional)
|
||||
yield* closeReasoningIfOpen();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { convertAISDKStream } from "./aisdk";
|
||||
export {
|
||||
convertTanStackStream,
|
||||
convertInputToTanStackAI,
|
||||
type TanStackChatMessage,
|
||||
type TanStackInputResult,
|
||||
} from "./tanstack";
|
||||
@@ -0,0 +1,594 @@
|
||||
import type {
|
||||
BaseEvent,
|
||||
Interrupt,
|
||||
RunAgentInput,
|
||||
Message,
|
||||
TextMessageChunkEvent,
|
||||
ToolCallArgsEvent,
|
||||
ToolCallEndEvent,
|
||||
ToolCallStartEvent,
|
||||
ToolCallResultEvent,
|
||||
StateSnapshotEvent,
|
||||
StateDeltaEvent,
|
||||
ReasoningStartEvent,
|
||||
ReasoningMessageStartEvent,
|
||||
ReasoningMessageContentEvent,
|
||||
ReasoningMessageEndEvent,
|
||||
ReasoningEndEvent,
|
||||
} from "@ag-ui/client";
|
||||
import { EventType } from "@ag-ui/client";
|
||||
import { randomUUID } from "@copilotkit/shared";
|
||||
|
||||
type ContentPartSource =
|
||||
| { type: "data"; value: string; mimeType: string }
|
||||
| { type: "url"; value: string; mimeType?: string };
|
||||
|
||||
/**
|
||||
* A TanStack AI content part (text, image, audio, video, or document).
|
||||
*/
|
||||
export type TanStackContentPart =
|
||||
| { type: "text"; content: string }
|
||||
| { type: "image"; source: ContentPartSource }
|
||||
| { type: "audio"; source: ContentPartSource }
|
||||
| { type: "video"; source: ContentPartSource }
|
||||
| { type: "document"; source: ContentPartSource };
|
||||
|
||||
/**
|
||||
* Message format expected by TanStack AI's `chat()`.
|
||||
*
|
||||
* Content is typed as `any[]` for the multimodal case so messages are directly
|
||||
* passable to any adapter without casts — different adapters constrain which
|
||||
* modalities they accept (e.g. OpenAI only allows text + image).
|
||||
* Use `TanStackContentPart` to inspect individual parts if needed.
|
||||
*/
|
||||
export interface TanStackChatMessage {
|
||||
role: "user" | "assistant" | "tool";
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
content: string | null | any[];
|
||||
name?: string;
|
||||
toolCalls?: Array<{
|
||||
id: string;
|
||||
type: "function";
|
||||
function: { name: string; arguments: string };
|
||||
}>;
|
||||
toolCallId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A TanStack AI client-side tool, derived from a frontend-provided AG-UI tool.
|
||||
*
|
||||
* Shaped to match `@tanstack/ai`'s `ClientTool` (`__toolSide: "client"`, no
|
||||
* `execute`): the model may CALL it, but TanStack does not run it — it pauses
|
||||
* the run and hands the call back to the AG-UI client (the CopilotKit frontend
|
||||
* / bot) to execute, mirroring CopilotKit's client-tool round-trip. `chat()`
|
||||
* accepts a JSON Schema directly as `inputSchema`, so the AG-UI tool's
|
||||
* `parameters` pass through unchanged.
|
||||
*/
|
||||
export interface TanStackClientTool {
|
||||
__toolSide: "client";
|
||||
name: string;
|
||||
description: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
inputSchema: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of converting RunAgentInput to TanStack AI format.
|
||||
*/
|
||||
export interface TanStackInputResult {
|
||||
/** Chat messages (only user/assistant/tool roles; all others excluded) */
|
||||
messages: TanStackChatMessage[];
|
||||
/** System prompts extracted from system/developer messages, context, and state */
|
||||
systemPrompts: string[];
|
||||
/**
|
||||
* Client-side tools derived from `input.tools` (the frontend-provided tools
|
||||
* the CopilotKit client forwards on every run). Pass these into `chat()`
|
||||
* alongside any server/provider tools so the model can call the frontend's
|
||||
* generative-UI and human-in-the-loop tools; TanStack pauses the run on a
|
||||
* client-tool call and the client executes it.
|
||||
*/
|
||||
tools: TanStackClientTool[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts AG-UI user message content to TanStack AI format.
|
||||
* Handles plain strings, multimodal parts (image/audio/video/document),
|
||||
* and legacy BinaryInputContent for backward compatibility.
|
||||
*/
|
||||
function convertUserContent(
|
||||
content: unknown,
|
||||
): string | null | TanStackContentPart[] {
|
||||
if (!content) return null;
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return null;
|
||||
if (content.length === 0) return "";
|
||||
|
||||
const parts: TanStackContentPart[] = [];
|
||||
|
||||
for (const part of content) {
|
||||
if (!part || typeof part !== "object" || !("type" in part)) continue;
|
||||
|
||||
switch ((part as { type: string }).type) {
|
||||
case "text": {
|
||||
const text = (part as { text?: string }).text;
|
||||
if (text != null) parts.push({ type: "text", content: text });
|
||||
break;
|
||||
}
|
||||
|
||||
case "image":
|
||||
case "audio":
|
||||
case "video":
|
||||
case "document": {
|
||||
const source = (part as { source?: any }).source;
|
||||
if (!source) break;
|
||||
const partType = (part as { type: string }).type as
|
||||
| "image"
|
||||
| "audio"
|
||||
| "video"
|
||||
| "document";
|
||||
if (source.type === "data") {
|
||||
parts.push({
|
||||
type: partType,
|
||||
source: {
|
||||
type: "data",
|
||||
value: source.value,
|
||||
mimeType: source.mimeType,
|
||||
},
|
||||
});
|
||||
} else if (source.type === "url") {
|
||||
parts.push({
|
||||
type: partType,
|
||||
source: {
|
||||
type: "url",
|
||||
value: source.value,
|
||||
...(source.mimeType ? { mimeType: source.mimeType } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Legacy BinaryInputContent backward compatibility
|
||||
case "binary": {
|
||||
const legacy = part as {
|
||||
mimeType?: string;
|
||||
data?: string;
|
||||
url?: string;
|
||||
};
|
||||
const mimeType = legacy.mimeType ?? "application/octet-stream";
|
||||
const isImage = mimeType.startsWith("image/");
|
||||
|
||||
if (legacy.data) {
|
||||
const partType = isImage ? "image" : "document";
|
||||
parts.push({
|
||||
type: partType,
|
||||
source: { type: "data", value: legacy.data, mimeType },
|
||||
});
|
||||
} else if (legacy.url) {
|
||||
const partType = isImage ? "image" : "document";
|
||||
parts.push({
|
||||
type: partType,
|
||||
source: { type: "url", value: legacy.url, mimeType },
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively normalizes a frontend tool's JSON Schema so OpenAI accepts it as
|
||||
* a function-tool schema.
|
||||
*
|
||||
* Frontend tools are often authored with permissive Zod (`z.any()`,
|
||||
* `z.record(...)`, `.passthrough()`), which serialize to open objects —
|
||||
* `additionalProperties: {}` (an empty sub-schema) or `additionalProperties:
|
||||
* true`. OpenAI rejects both: strict mode requires `additionalProperties:
|
||||
* false`, and an empty `{}` sub-schema fails base validation ("schema must
|
||||
* have a 'type' key"). The classic (Vercel AI SDK) path sanitized these
|
||||
* implicitly via a Zod round-trip; the TanStack path forwards the raw schema,
|
||||
* so we close open objects here to match. (Models can't supply free-form extra
|
||||
* keys either way — same as the classic path.)
|
||||
*/
|
||||
function sanitizeClientToolSchema(schema: unknown): unknown {
|
||||
if (Array.isArray(schema)) {
|
||||
return schema.map(sanitizeClientToolSchema);
|
||||
}
|
||||
if (!schema || typeof schema !== "object") {
|
||||
return schema;
|
||||
}
|
||||
const node: Record<string, unknown> = {
|
||||
...(schema as Record<string, unknown>),
|
||||
};
|
||||
|
||||
// Any `additionalProperties` (empty `{}`, `true`, or a sub-schema) becomes
|
||||
// `false` — the only form OpenAI accepts for strict function tools.
|
||||
if ("additionalProperties" in node) {
|
||||
node.additionalProperties = false;
|
||||
}
|
||||
|
||||
if (node.properties && typeof node.properties === "object") {
|
||||
const props: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(
|
||||
node.properties as Record<string, unknown>,
|
||||
)) {
|
||||
props[key] = sanitizeClientToolSchema(value);
|
||||
}
|
||||
node.properties = props;
|
||||
}
|
||||
|
||||
if ("items" in node) {
|
||||
node.items = sanitizeClientToolSchema(node.items);
|
||||
}
|
||||
|
||||
for (const combinator of ["anyOf", "allOf", "oneOf"] as const) {
|
||||
if (Array.isArray(node[combinator])) {
|
||||
node[combinator] = (node[combinator] as unknown[]).map(
|
||||
sanitizeClientToolSchema,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a RunAgentInput into the format expected by TanStack AI's `chat()`.
|
||||
*
|
||||
* - Keeps only user/assistant/tool messages (activity, reasoning, and other roles are also excluded)
|
||||
* - Extracts system/developer messages into `systemPrompts`
|
||||
* - Appends context entries and application state to `systemPrompts`
|
||||
* - Preserves tool calls on assistant messages and toolCallId on tool messages
|
||||
*/
|
||||
export function convertInputToTanStackAI(
|
||||
input: RunAgentInput,
|
||||
): TanStackInputResult {
|
||||
// Allowlist: only pass user/assistant/tool messages to TanStack.
|
||||
// Other roles (system, developer, activity, reasoning) are either
|
||||
// extracted into systemPrompts or not applicable.
|
||||
const chatRoles = new Set(["user", "assistant", "tool"]);
|
||||
const messages: TanStackChatMessage[] = input.messages
|
||||
.filter((m: Message) => chatRoles.has(m.role))
|
||||
.map((m: Message): TanStackChatMessage => {
|
||||
const msg: TanStackChatMessage = {
|
||||
role: m.role as "user" | "assistant" | "tool",
|
||||
content:
|
||||
m.role === "user"
|
||||
? convertUserContent(m.content)
|
||||
: typeof m.content === "string"
|
||||
? m.content
|
||||
: null,
|
||||
};
|
||||
if (m.role === "assistant" && "toolCalls" in m && m.toolCalls) {
|
||||
msg.toolCalls = m.toolCalls.map((tc) => ({
|
||||
id: tc.id,
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments,
|
||||
},
|
||||
}));
|
||||
}
|
||||
if (m.role === "tool" && "toolCallId" in m) {
|
||||
msg.toolCallId = (m as Record<string, unknown>).toolCallId as string;
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
|
||||
const systemPrompts: string[] = [];
|
||||
for (const m of input.messages) {
|
||||
if ((m.role === "system" || m.role === "developer") && m.content) {
|
||||
systemPrompts.push(
|
||||
typeof m.content === "string" ? m.content : JSON.stringify(m.content),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.context?.length) {
|
||||
for (const ctx of input.context) {
|
||||
systemPrompts.push(`${ctx.description}:\n${ctx.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
input.state !== undefined &&
|
||||
input.state !== null &&
|
||||
typeof input.state === "object" &&
|
||||
Object.keys(input.state).length > 0
|
||||
) {
|
||||
systemPrompts.push(
|
||||
`Application State:\n\`\`\`json\n${JSON.stringify(input.state, null, 2)}\n\`\`\``,
|
||||
);
|
||||
}
|
||||
|
||||
// Frontend-provided tools become client-side TanStack tools (no executor):
|
||||
// the model can call them, TanStack pauses the run, and the AG-UI client
|
||||
// executes them and resumes — the CopilotKit client-tool round-trip.
|
||||
const tools: TanStackClientTool[] = (input.tools ?? []).map((t) => ({
|
||||
__toolSide: "client",
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: sanitizeClientToolSchema(t.parameters),
|
||||
}));
|
||||
|
||||
return { messages, systemPrompts, tools };
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a TanStack AI stream into AG-UI `BaseEvent` objects.
|
||||
*
|
||||
* This is a pure converter — it does NOT emit lifecycle events
|
||||
* (RUN_STARTED / RUN_FINISHED / RUN_ERROR). The caller (Agent class)
|
||||
* is responsible for those.
|
||||
*
|
||||
* `pendingInterrupts`, when provided, is filled with one AG-UI Interrupt per
|
||||
* CUSTOM "approval-requested" chunk (a tool declared `needsApproval: true`).
|
||||
* The caller turns a non-empty array into a RUN_FINISHED `outcome:interrupt`.
|
||||
*/
|
||||
export async function* convertTanStackStream(
|
||||
stream: AsyncIterable<unknown>,
|
||||
abortSignal: AbortSignal,
|
||||
pendingInterrupts?: Interrupt[],
|
||||
): AsyncGenerator<BaseEvent> {
|
||||
const messageId = randomUUID();
|
||||
const toolNamesById = new Map<string, string>();
|
||||
// Track the reasoning lifecycle at two granularities so closeReasoningIfOpen
|
||||
// emits exactly the events still owed. A single boolean conflates the run
|
||||
// (REASONING_START → REASONING_END) with the message
|
||||
// (REASONING_MESSAGE_START → REASONING_MESSAGE_END) and produces a duplicate
|
||||
// REASONING_MESSAGE_END when upstream emits MSG_END but not END before
|
||||
// text/tools resume.
|
||||
let reasoningRunOpen = false;
|
||||
let reasoningMessageOpen = false;
|
||||
let reasoningMessageId = randomUUID();
|
||||
|
||||
function* closeReasoningIfOpen(): Generator<BaseEvent> {
|
||||
if (reasoningMessageOpen) {
|
||||
reasoningMessageOpen = false;
|
||||
const msgEnd: ReasoningMessageEndEvent = {
|
||||
type: EventType.REASONING_MESSAGE_END,
|
||||
messageId: reasoningMessageId,
|
||||
};
|
||||
yield msgEnd;
|
||||
}
|
||||
if (reasoningRunOpen) {
|
||||
reasoningRunOpen = false;
|
||||
const end: ReasoningEndEvent = {
|
||||
type: EventType.REASONING_END,
|
||||
messageId: reasoningMessageId,
|
||||
};
|
||||
yield end;
|
||||
}
|
||||
}
|
||||
|
||||
// TanStack's chat() engine runs a multi-turn agent loop and emits a
|
||||
// RUN_STARTED / RUN_FINISHED pair PER model turn — not once for the whole
|
||||
// run. When it executes a tool itself (an MCP server tool or a provider tool
|
||||
// like web_search), it does so between turns and streams a TOOL_CALL_RESULT
|
||||
// followed by the next turn's text. The overall run lifecycle is owned by the
|
||||
// Agent wrapper (it emits exactly one outer RUN_STARTED / RUN_FINISHED), so
|
||||
// we drop TanStack's per-turn lifecycle markers and convert every content
|
||||
// event across all turns. (A previous version stopped converting at the first
|
||||
// RUN_FINISHED — that truncated the run at the first tool turn and silently
|
||||
// dropped both the tool result and the model's final answer.)
|
||||
//
|
||||
// chat() can re-announce a tool call when it re-prompts after executing it,
|
||||
// so START / END are de-duplicated by toolCallId to avoid emitting a pair
|
||||
// twice (which would violate the ag-ui verify middleware).
|
||||
const startedToolCalls = new Set<string>();
|
||||
const endedToolCalls = new Set<string>();
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (abortSignal.aborted) break;
|
||||
|
||||
const raw = chunk as Record<string, unknown>;
|
||||
const type = raw.type as string;
|
||||
|
||||
// TanStack native human-in-the-loop: a tool declared `needsApproval: true`
|
||||
// emits a CUSTOM "approval-requested" chunk. These are built from the
|
||||
// finish event and can arrive around lifecycle markers, so handle them
|
||||
// before dropping TanStack's per-turn lifecycle events.
|
||||
// The tool-call lifecycle was already streamed in the model pass.
|
||||
if (type === "CUSTOM" && raw.name === "approval-requested") {
|
||||
const value = (raw.value ?? {}) as {
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
};
|
||||
const toolCallId = value.toolCallId;
|
||||
if (toolCallId) {
|
||||
pendingInterrupts?.push({
|
||||
id: toolCallId,
|
||||
toolCallId,
|
||||
reason: "tool_approval",
|
||||
message: value.toolName ? `Approve "${value.toolName}"?` : undefined,
|
||||
...(value.toolName ? { metadata: { toolName: value.toolName } } : {}),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Per-turn lifecycle markers are owned by the Agent wrapper, not forwarded.
|
||||
if (type === "RUN_STARTED" || type === "RUN_FINISHED") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Surface engine errors instead of dropping them: throw so the Agent
|
||||
// wrapper emits a terminal RUN_ERROR. Without this a failed run (e.g. a
|
||||
// provider 4xx) would finish empty with no indication of what went wrong.
|
||||
if (type === "RUN_ERROR") {
|
||||
throw new Error(
|
||||
typeof raw.message === "string" ? raw.message : "TanStack AI run error",
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "TEXT_MESSAGE_CONTENT" && raw.delta != null) {
|
||||
yield* closeReasoningIfOpen();
|
||||
const textEvent: TextMessageChunkEvent = {
|
||||
type: EventType.TEXT_MESSAGE_CHUNK,
|
||||
role: "assistant",
|
||||
messageId,
|
||||
delta: raw.delta as string,
|
||||
};
|
||||
yield textEvent;
|
||||
} else if (type === "TOOL_CALL_START") {
|
||||
const toolCallId = raw.toolCallId as string;
|
||||
if (startedToolCalls.has(toolCallId)) continue;
|
||||
startedToolCalls.add(toolCallId);
|
||||
yield* closeReasoningIfOpen();
|
||||
toolNamesById.set(toolCallId, raw.toolCallName as string);
|
||||
const startEvent: ToolCallStartEvent = {
|
||||
type: EventType.TOOL_CALL_START,
|
||||
parentMessageId: messageId,
|
||||
toolCallId,
|
||||
toolCallName: raw.toolCallName as string,
|
||||
};
|
||||
yield startEvent;
|
||||
} else if (type === "TOOL_CALL_ARGS") {
|
||||
// Drop args re-announced after the call has ended (the re-prompt pass);
|
||||
// forwarding them would corrupt the already-closed call's accumulated args.
|
||||
if (endedToolCalls.has(raw.toolCallId as string)) continue;
|
||||
yield* closeReasoningIfOpen();
|
||||
const argsEvent: ToolCallArgsEvent = {
|
||||
type: EventType.TOOL_CALL_ARGS,
|
||||
toolCallId: raw.toolCallId as string,
|
||||
delta: raw.delta as string,
|
||||
};
|
||||
yield argsEvent;
|
||||
} else if (type === "TOOL_CALL_END") {
|
||||
const toolCallId = raw.toolCallId as string;
|
||||
if (endedToolCalls.has(toolCallId)) continue;
|
||||
endedToolCalls.add(toolCallId);
|
||||
yield* closeReasoningIfOpen();
|
||||
const endEvent: ToolCallEndEvent = {
|
||||
type: EventType.TOOL_CALL_END,
|
||||
toolCallId,
|
||||
};
|
||||
yield endEvent;
|
||||
} else if (type === "TOOL_CALL_RESULT") {
|
||||
yield* closeReasoningIfOpen();
|
||||
const toolCallId = raw.toolCallId as string;
|
||||
const toolName = toolNamesById.get(toolCallId);
|
||||
// Accept the payload from either `content` (canonical TanStack shape)
|
||||
// or `result` (alternate shape used by some adapters / tests). Both
|
||||
// state-tool detection and the final TOOL_CALL_RESULT serialization
|
||||
// must read the same field, otherwise STATE_SNAPSHOT/STATE_DELTA can
|
||||
// be silently dropped when upstream uses `result`.
|
||||
const rawPayload = raw.content ?? raw.result;
|
||||
|
||||
const parsedContent =
|
||||
typeof rawPayload === "string" ? safeParse(rawPayload) : rawPayload;
|
||||
|
||||
if (
|
||||
toolName === "AGUISendStateSnapshot" &&
|
||||
parsedContent &&
|
||||
typeof parsedContent === "object" &&
|
||||
"snapshot" in parsedContent
|
||||
) {
|
||||
const stateSnapshotEvent: StateSnapshotEvent = {
|
||||
type: EventType.STATE_SNAPSHOT,
|
||||
snapshot: (parsedContent as Record<string, unknown>).snapshot,
|
||||
};
|
||||
yield stateSnapshotEvent;
|
||||
}
|
||||
|
||||
if (
|
||||
toolName === "AGUISendStateDelta" &&
|
||||
parsedContent &&
|
||||
typeof parsedContent === "object" &&
|
||||
"delta" in parsedContent
|
||||
) {
|
||||
const stateDeltaEvent: StateDeltaEvent = {
|
||||
type: EventType.STATE_DELTA,
|
||||
delta: (parsedContent as Record<string, unknown>).delta as never,
|
||||
};
|
||||
yield stateDeltaEvent;
|
||||
}
|
||||
|
||||
let serializedContent: string;
|
||||
if (typeof rawPayload === "string") {
|
||||
serializedContent = rawPayload;
|
||||
} else {
|
||||
try {
|
||||
serializedContent = JSON.stringify(rawPayload ?? null);
|
||||
} catch {
|
||||
serializedContent = "[Unserializable tool result]";
|
||||
}
|
||||
}
|
||||
|
||||
const resultEvent: ToolCallResultEvent = {
|
||||
type: EventType.TOOL_CALL_RESULT,
|
||||
role: "tool",
|
||||
messageId: randomUUID(),
|
||||
toolCallId,
|
||||
content: serializedContent,
|
||||
};
|
||||
yield resultEvent;
|
||||
toolNamesById.delete(toolCallId);
|
||||
} else if (type === "REASONING_START") {
|
||||
// If a prior reasoning run is still open (no REASONING_END before this
|
||||
// new START), close it cleanly first so MSG_END / END pair correctly.
|
||||
yield* closeReasoningIfOpen();
|
||||
reasoningRunOpen = true;
|
||||
reasoningMessageId = (raw.messageId as string) ?? randomUUID();
|
||||
const startEvt: ReasoningStartEvent = {
|
||||
type: EventType.REASONING_START,
|
||||
messageId: reasoningMessageId,
|
||||
};
|
||||
yield startEvt;
|
||||
} else if (type === "REASONING_MESSAGE_START") {
|
||||
reasoningMessageOpen = true;
|
||||
const evt: ReasoningMessageStartEvent = {
|
||||
type: EventType.REASONING_MESSAGE_START,
|
||||
messageId: reasoningMessageId,
|
||||
role: "reasoning",
|
||||
};
|
||||
yield evt;
|
||||
} else if (type === "REASONING_MESSAGE_CONTENT") {
|
||||
const evt: ReasoningMessageContentEvent = {
|
||||
type: EventType.REASONING_MESSAGE_CONTENT,
|
||||
messageId: reasoningMessageId,
|
||||
delta: raw.delta as string,
|
||||
};
|
||||
yield evt;
|
||||
} else if (type === "REASONING_MESSAGE_END") {
|
||||
reasoningMessageOpen = false;
|
||||
const evt: ReasoningMessageEndEvent = {
|
||||
type: EventType.REASONING_MESSAGE_END,
|
||||
messageId: reasoningMessageId,
|
||||
};
|
||||
yield evt;
|
||||
} else if (type === "REASONING_END") {
|
||||
// If upstream sends REASONING_END while a message is still open, emit
|
||||
// the missing REASONING_MESSAGE_END FIRST so the closing pair stays in
|
||||
// order (MSG_END before END). Otherwise the next non-reasoning chunk
|
||||
// would trigger closeReasoningIfOpen and emit MSG_END after END.
|
||||
if (reasoningMessageOpen) {
|
||||
reasoningMessageOpen = false;
|
||||
const msgEnd: ReasoningMessageEndEvent = {
|
||||
type: EventType.REASONING_MESSAGE_END,
|
||||
messageId: reasoningMessageId,
|
||||
};
|
||||
yield msgEnd;
|
||||
}
|
||||
reasoningRunOpen = false;
|
||||
const evt: ReasoningEndEvent = {
|
||||
type: EventType.REASONING_END,
|
||||
messageId: reasoningMessageId,
|
||||
};
|
||||
yield evt;
|
||||
}
|
||||
}
|
||||
|
||||
yield* closeReasoningIfOpen();
|
||||
}
|
||||
|
||||
function safeParse(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user