chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
# CopilotKit - Runtime
<img src="https://github.com/user-attachments/assets/0a6b64d9-e193-4940-a3f6-60334ac34084" alt="banner" style="border-radius: 12px; border: 2px solid #d6d4fa;" />
<br>
<div align="center" style="display:flex;justify-content:center;gap:16px;height:20px;margin: 0;">
<a href="https://www.npmjs.com/package/@copilotkit/react-core" target="_blank">
<img src="https://img.shields.io/npm/v/%40copilotkit%2Fruntime?logo=npm&logoColor=%23FFFFFF&label=Version&color=%236963ff" alt="NPM">
</a>
<a href="https://github.com/copilotkit/copilotkit/blob/main/LICENSE" target="_blank">
<img src="https://img.shields.io/github/license/copilotkit/copilotkit?color=%236963ff&label=License" alt="MIT">
</a>
<a href="https://discord.gg/6dffbvGU3D" target="_blank">
<img src="https://img.shields.io/discord/1122926057641742418?logo=discord&logoColor=%23FFFFFF&label=Discord&color=%236963ff" alt="Discord">
</a>
</div>
<br/>
<div align="center">
<a href="https://www.producthunt.com/posts/copilotkit" target="_blank">
<img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=428778&theme=light&period=daily">
</a>
</div>
## ✨ Why CopilotKit?
- Minutes to integrate - Get started quickly with our CLI
- Framework agnostic - Works with React, Next.js, AGUI and more
- Production-ready UI - Use customizable components or build with headless UI
- Built-in security - Prompt injection protection
- Open source - Full transparency and community-driven
<img src="https://github.com/user-attachments/assets/6cb425f8-ffcb-49d2-9bbb-87cab5995b78" alt="class-support-ecosystem" style="border-radius: 12px; border: 2px solid #d6d4fa;">
## 🧑‍💻 Real life use cases
<span>Deploy deeply-integrated AI assistants & agents that work alongside your users inside your applications.</span>
<img src="https://github.com/user-attachments/assets/3b810240-e9f8-43ae-acec-31a58095e223" alt="headless-ui" style="border-radius: 12px; border: 2px solid #d6d4fa;">
## 🏆 Featured Examples
<p align="center">
<a href="https://www.copilotkit.ai/examples/form-filling-copilot">
<img src="https://github.com/user-attachments/assets/874da84a-67ff-47fa-a6b4-cbc3c65eb704" width="300" style="border-radius: 16px;" />
</a>
<a href="https://www.copilotkit.ai/examples/state-machine-copilot">
<img src="https://github.com/user-attachments/assets/0b5e45b3-2704-4678-82dc-2f3e1c58e2dd" width="300" style="border-radius: 16px;" />
</a>
<a href="https://www.copilotkit.ai/examples/chat-with-your-data">
<img src="https://github.com/user-attachments/assets/0fed66be-a4c2-4093-8eab-75c0b27a62f6" width="300" style="border-radius: 16px;" />
</a>
</p>
## Documentation
To get started with CopilotKit, please check out the [documentation](https://docs.copilotkit.ai).
## Analytics & Privacy
CopilotKit uses [Scarf](https://scarf.sh) for anonymous usage analytics to help improve the product. Scarf handles all privacy compliance and does not store raw IP addresses. This helps us understand how CopilotKit is being used and prioritize improvements.
### Opting Out
To disable analytics, set the environment variable:
```bash
export COPILOTKIT_TELEMETRY_DISABLED=true
```
Or use the `DO_NOT_TRACK` standard:
```bash
export DO_NOT_TRACK=1
```
+197
View File
@@ -0,0 +1,197 @@
{
"name": "@copilotkit/runtime",
"version": "1.62.3",
"private": false,
"keywords": [
"ai",
"assistant",
"automation",
"copilot",
"copilotkit",
"javascript",
"nextjs",
"nodejs",
"react",
"tanstack-intent",
"textarea"
],
"homepage": "https://github.com/CopilotKit/CopilotKit",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/CopilotKit/CopilotKit.git"
},
"files": [
"dist",
"skills"
],
"sideEffects": [
"./dist/index.mjs",
"./dist/index.cjs",
"./dist/v2/index.mjs",
"./dist/v2/index.cjs"
],
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.cts",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./langgraph": {
"import": "./dist/langgraph.mjs",
"require": "./dist/langgraph.cjs"
},
"./v2": {
"import": "./dist/v2/index.mjs",
"require": "./dist/v2/index.cjs"
},
"./v2/express": {
"import": "./dist/v2/express.mjs",
"require": "./dist/v2/express.cjs"
},
"./v2/hono": {
"import": "./dist/v2/hono.mjs",
"require": "./dist/v2/hono.cjs"
},
"./v2/node": {
"import": "./dist/v2/node.mjs",
"require": "./dist/v2/node.cjs"
},
"./package.json": "./package.json"
},
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch",
"test": "vitest run",
"test:watch": "vitest",
"check-types": "tsc --noEmit",
"generate-graphql-schema": "node -e \"const fs=require('fs');fs.rmSync('__snapshots__',{recursive:true,force:true})\" && ts-node --transpile-only ./scripts/generate-gql-schema.ts",
"link:global": "pnpm link --global",
"unlink:global": "pnpm unlink --global",
"publint": "publint .",
"attw": "attw --pack . --profile node16"
},
"dependencies": {
"@ag-ui/a2ui-middleware": "0.0.10",
"@ag-ui/client": "0.0.57",
"@ag-ui/core": "0.0.57",
"@ag-ui/encoder": "0.0.57",
"@ag-ui/langgraph": "0.0.42",
"@ag-ui/mcp-apps-middleware": "0.0.3",
"@ag-ui/mcp-middleware": "0.0.1",
"@ai-sdk/anthropic": "^3.0.49",
"@ai-sdk/google": "^3.0.33",
"@ai-sdk/google-vertex": "^3.0.97",
"@ai-sdk/mcp": "^1.0.21",
"@ai-sdk/openai": "^3.0.36",
"@copilotkit/channels": "workspace:*",
"@copilotkit/license-verifier": "~0.5.0",
"@copilotkit/shared": "workspace:*",
"@graphql-yoga/plugin-defer-stream": "^3.3.1",
"@hono/node-server": "^1.13.5",
"@modelcontextprotocol/sdk": "^1.18.2",
"@remix-run/node-fetch-server": "^0.13.0",
"@scarf/scarf": "^1.3.0",
"@segment/analytics-node": "^2.1.2",
"ai": "^6.0.104",
"clarinet": "^0.12.4",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cors": "^2.8.5",
"express": "^4.21.2",
"graphql": "^16.8.1",
"graphql-scalars": "^1.23.0",
"graphql-yoga": "^5.3.1",
"hono": "^4.11.4",
"openai": "^4.85.1 || >=5.0.0",
"partial-json": "^0.1.7",
"phoenix": "^1.8.4",
"pino": "^9.2.0",
"pino-pretty": "^11.2.1",
"reflect-metadata": "^0.2.2",
"rxjs": "7.8.1",
"type-graphql": "2.0.0-rc.1",
"uuid": "^10.0.0",
"ws": "^8.18.0",
"zod": "^3.23.3"
},
"devDependencies": {
"@copilotkit/aimock": "latest",
"@swc/core": "1.5.28",
"@tanstack/intent": "^0.0.29",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^18.11.17",
"@types/phoenix": "^1.6.6",
"@valibot/to-json-schema": "^1.5.0",
"@vitest/coverage-v8": "^3.2.4",
"@whatwg-node/server": "^0.9.34",
"arktype": "^2.1.29",
"elysia": "^1.2.25",
"eslint": "^8.56.0",
"nodemon": "^3.1.3",
"supertest": "^7.1.1",
"ts-node": "^10.9.2",
"tsconfig": "workspace:*",
"tsdown": "^0.20.3",
"typescript": "^5.2.3",
"valibot": "^1.2.0",
"vitest": "^3.2.4"
},
"peerDependencies": {
"@anthropic-ai/sdk": "^0.57.0",
"@langchain/aws": ">=0.1.9",
"@langchain/community": ">=0.3.58",
"@langchain/core": ">=0.3.66",
"@langchain/google-gauth": ">=0.1.0",
"@langchain/langgraph-sdk": ">=0.1.2",
"@langchain/openai": ">=0.4.2",
"groq-sdk": ">=0.3.0 <1.0.0",
"langchain": ">=0.3.3",
"openai": "^4.85.1 || >=5.0.0"
},
"peerDependenciesMeta": {
"@anthropic-ai/sdk": {
"optional": true
},
"@langchain/aws": {
"optional": true
},
"@langchain/community": {
"optional": true
},
"@langchain/google-gauth": {
"optional": true
},
"@langchain/langgraph-sdk": {
"optional": true
},
"@langchain/openai": {
"optional": true
},
"groq-sdk": {
"optional": true
},
"langchain": {
"optional": true
},
"openai": {
"optional": true
}
},
"nx": {
"targets": {
"build": {
"dependsOn": [
"generate-graphql-schema",
"^build"
]
}
}
}
}
@@ -0,0 +1,16 @@
import "reflect-metadata";
import { buildSchema } from "../src/lib/integrations/shared";
import path from "node:path";
console.log("Generating schema...");
const outputPath = path.resolve(
__dirname,
"../__snapshots__/schema/schema.graphql",
);
buildSchema({
emitSchemaFile: path.resolve(__dirname, outputPath),
});
console.log(`Schema generated to ${outputPath}`);
+98
View File
@@ -0,0 +1,98 @@
---
name: runtime
description: >
@copilotkit/runtime — mount a fetch-native CopilotRuntime on any JS server, wire
middleware, pick an AgentRunner, instantiate BuiltInAgent (Factory Mode with TanStack AI
is the preferred default) or plug in any of 12 external agent frameworks (Mastra,
LangGraph, CrewAI Crews/Flows, PydanticAI, ADK, LlamaIndex, Agno, AWS Strands, MS Agent
Framework, AG2, A2A), enable Intelligence mode for durable threads + websocket,
register server-side tools via defineTool, and wire voice transcription. Uses the
fetch-based createCopilotRuntimeHandler primitive — the Express/Hono adapters are
discouraged. Load the reference under references/ that matches your task.
type: core
library: copilotkit
library_version: "1.56.2"
requires: []
sources:
- "CopilotKit/CopilotKit:packages/runtime/src/v2/runtime/core/fetch-handler.ts"
- "CopilotKit/CopilotKit:packages/runtime/src/v2/runtime/core/runtime.ts"
- "CopilotKit/CopilotKit:packages/runtime/src/v2/runtime/core/hooks.ts"
- "CopilotKit/CopilotKit:packages/runtime/src/v2/runtime/core/middleware.ts"
- "CopilotKit/CopilotKit:packages/runtime/src/v2/runtime/runner/agent-runner.ts"
- "CopilotKit/CopilotKit:packages/runtime/src/v2/runtime/runner/in-memory.ts"
- "CopilotKit/CopilotKit:packages/runtime/src/v2/runtime/runner/intelligence.ts"
- "CopilotKit/CopilotKit:packages/runtime/src/v2/runtime/intelligence-platform/client.ts"
- "CopilotKit/CopilotKit:packages/runtime/src/v2/runtime/transcription-service/transcription-service.ts"
- "CopilotKit/CopilotKit:packages/runtime/src/v2/runtime/handlers/handle-transcribe.ts"
- "CopilotKit/CopilotKit:packages/runtime/src/agent/index.ts"
- "CopilotKit/CopilotKit:packages/runtime/src/agent/converters/tanstack.ts"
- "CopilotKit/CopilotKit:packages/sqlite-runner/src/sqlite-runner.ts"
- "CopilotKit/CopilotKit:packages/shared/src/transcription-errors.ts"
---
# CopilotKit Runtime
`@copilotkit/runtime` is the server half of CopilotKit: it accepts AG-UI protocol
requests, dispatches them to an `AbstractAgent` (built-in or external), runs the
stream through an `AgentRunner`, and responds as Server-Sent Events.
This SKILL.md is the **index**. Read the reference under `references/` that matches
your task — do not try to absorb the whole package from this file.
## Mental Model — the three dictionaries you hand to `CopilotRuntime`
```ts
new CopilotRuntime({
agents, // Record<string, AbstractAgent> — see wiring-external-agents or built-in-agent
runner, // AgentRunner (optional) — see agent-runners
intelligence, // CopilotKitIntelligence (optional) — see intelligence-mode (auto-wires runner)
mcpApps, // McpAppsConfig (optional) — see wiring-mcp-apps-middleware
a2ui, // A2UIConfig (optional) — see packages/a2ui-renderer skill
hooks, // { onRequest, onBeforeHandler } — see middleware
beforeRequestMiddleware,
afterRequestMiddleware, // legacy — see middleware
transcription, // TranscriptionService (optional) — see transcription
});
```
You then mount it:
```ts
import { createCopilotRuntimeHandler } from "@copilotkit/runtime/v2";
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export default { fetch: handler };
```
## When to load which reference
| Task | Reference |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Mounting on any fetch-native server (Cloudflare Workers, Bun, Deno, Vercel Edge, Next.js App Router, React Router v7, TanStack Start) or delegating from Express/Node | `references/setup-endpoint.md` |
| Auth / logging / rate-limit / request-scoped guards via `hooks.onRequest` / `hooks.onBeforeHandler` (preferred) or legacy `beforeRequestMiddleware` / `afterRequestMiddleware` | `references/middleware.md` |
| Choosing between `InMemoryAgentRunner`, `SqliteAgentRunner`, or a custom subclass — including thread-locking semantics and the runner/Intelligence mutual exclusion | `references/agent-runners.md` (+ `-in-memory.md`, `-sqlite.md`, `-custom.md` for backend-specific detail) |
| Enabling durable threads + realtime websocket via CopilotKit Intelligence (a **managed service**, not self-hostable) | `references/intelligence-mode.md` |
| Voice transcription — implementing a `TranscriptionService` subclass for the `/transcribe` endpoint | `references/transcription.md` |
| Instantiating `BuiltInAgent` — Simple Mode (classic) or Factory Mode with TanStack AI (preferred AG-UI-compliant default), AI SDK, or custom factory | `references/built-in-agent.md` (+ `-factory-modes.md`, `-helper-utilities.md`, `-model-identifiers.md`) |
| Defining server-side tools via `defineTool` for `BuiltInAgent.config.tools` (Simple Mode only) | `references/server-side-tools.md` |
| Wiring an external agent framework into `CopilotRuntime({ agents })` | `references/wiring-external-agents.md` (index) + per-framework refs (`wiring-mastra.md`, `wiring-langgraph.md`, `wiring-crewai-crews.md`, `wiring-crewai-flows.md`, `wiring-pydantic-ai.md`, `wiring-adk.md`, `wiring-llamaindex.md`, `wiring-agno.md`, `wiring-aws-strands.md`, `wiring-ms-agent-framework.md`, `wiring-ag2.md`, `wiring-a2a.md`) |
| Wiring MCP Apps (runtime-level middleware, not an agent) | `references/wiring-mcp-apps-middleware.md` |
## Invariants and gotchas (load-once, before any reference)
- `createCopilotRuntimeHandler` is the canonical primitive. `createCopilotExpressHandler` / `createCopilotHonoHandler` exist but are **avoid at all costs** — delegate from Express/Hono routes to the fetch primitive instead.
- `publicLicenseKey` is the canonical provider-side field. `publicApiKey` is a **deprecated alias** — expect to see it in legacy code, emit the canonical name in new code.
- Intelligence mode auto-wires `IntelligenceAgentRunner`. Passing both `runner` and `intelligence` to `CopilotRuntime` is rejected at construction.
- Intelligence mode targets the managed CopilotKit Intelligence service (`api.cloud.copilotkit.ai`) and is **not self-hostable**.
- `hooks.onRequest` runs **before** `beforeRequestMiddleware` (hook-based middleware wins for Response short-circuits). `beforeRequestMiddleware` runs **after** `hooks.onRequest` (see `fetch-handler.ts:136-147`).
- `identifyUser` (Intelligence) does **not** forward thrown `Response` objects — convert to 500. Gate auth rejection in `hooks.onRequest`, which does forward Responses.
- `agents__unsafe_dev_only` and `selfManagedAgents` are dev-only aliases of each other; do not reach for them in production. Either signals that the SPA is in dev mode.
## Reading order for a first-time reader
1. `setup-endpoint` — the primitive.
2. `built-in-agent` **or** pick one from `wiring-external-agents` — the agent.
3. `agent-runners` — production persistence choice.
4. Optional: `middleware`, `intelligence-mode`, `server-side-tools`, `transcription`.
@@ -0,0 +1,161 @@
Custom AgentRunner — subclass the abstract `AgentRunner` to back thread state with Redis,
Postgres, Durable Objects, or anything else you own.
## The abstract contract
```typescript
// packages/runtime/src/v2/runtime/runner/agent-runner.ts
import {
AbstractAgent,
BaseEvent,
Message,
RunAgentInput,
} from "@ag-ui/client";
import { Observable } from "rxjs";
export interface AgentRunnerRunRequest {
threadId: string;
agent: AbstractAgent;
input: RunAgentInput;
joinCode?: string;
persistedInputMessages?: Message[];
}
export interface AgentRunnerConnectRequest {
threadId: string;
headers?: Record<string, string>;
joinCode?: string;
}
export interface AgentRunnerIsRunningRequest {
threadId: string;
}
export interface AgentRunnerStopRequest {
threadId: string;
}
export abstract class AgentRunner {
abstract run(request: AgentRunnerRunRequest): Observable<BaseEvent>;
abstract connect(request: AgentRunnerConnectRequest): Observable<BaseEvent>;
abstract isRunning(request: AgentRunnerIsRunningRequest): Promise<boolean>;
abstract stop(request: AgentRunnerStopRequest): Promise<boolean | undefined>;
}
```
## Redis-backed skeleton (for reference)
```typescript
import { AgentRunner } from "@copilotkit/runtime/v2";
import type {
AgentRunnerRunRequest,
AgentRunnerConnectRequest,
AgentRunnerIsRunningRequest,
AgentRunnerStopRequest,
} from "@copilotkit/runtime/v2";
import { Observable, ReplaySubject } from "rxjs";
import type { BaseEvent } from "@ag-ui/client";
import { Redis } from "ioredis";
const RUNNING_KEY = (t: string) => `copilotkit:running:${t}`;
const STREAM_KEY = (t: string) => `copilotkit:stream:${t}`;
export class RedisAgentRunner extends AgentRunner {
constructor(private redis: Redis) {
super();
}
run(request: AgentRunnerRunRequest): Observable<BaseEvent> {
const { threadId, agent, input } = request;
const subject = new ReplaySubject<BaseEvent>();
(async () => {
// NX guard — return 409-equivalent if another instance is running this thread
const acquired = await this.redis.set(
RUNNING_KEY(threadId),
"1",
"EX",
600,
"NX",
);
if (!acquired) {
subject.error(new Error("Thread already running"));
return;
}
const sub = agent.run(input).subscribe({
next: async (event) => {
subject.next(event);
await this.redis.xadd(
STREAM_KEY(threadId),
"*",
"event",
JSON.stringify(event),
);
},
error: async (err) => {
subject.error(err);
await this.redis.del(RUNNING_KEY(threadId));
},
complete: async () => {
subject.complete();
await this.redis.del(RUNNING_KEY(threadId));
},
});
// stop hook
this.stopHandlers.set(threadId, () => sub.unsubscribe());
})();
return subject.asObservable();
}
connect(request: AgentRunnerConnectRequest): Observable<BaseEvent> {
const subject = new ReplaySubject<BaseEvent>();
(async () => {
const entries = await this.redis.xrange(
STREAM_KEY(request.threadId),
"-",
"+",
);
for (const [, fields] of entries) {
const eventStr = fields[1];
if (eventStr) subject.next(JSON.parse(eventStr));
}
subject.complete();
})();
return subject.asObservable();
}
async isRunning(request: AgentRunnerIsRunningRequest): Promise<boolean> {
return (await this.redis.exists(RUNNING_KEY(request.threadId))) === 1;
}
async stop(request: AgentRunnerStopRequest): Promise<boolean | undefined> {
const stop = this.stopHandlers.get(request.threadId);
if (stop) {
stop();
this.stopHandlers.delete(request.threadId);
}
await this.redis.del(RUNNING_KEY(request.threadId));
return true;
}
private stopHandlers = new Map<string, () => void>();
}
```
## Contract gotchas
- `run()` must throw `Error("Thread already running")` (or let a distributed lock return a
non-acquired state) when a run is already active. Intelligence mode surfaces the 409 to
the client as the typed `agent_thread_locked` error code; SSE mode (direct runner use)
only emits a generic 500 response with the error message — so clients cannot depend on
the typed code there, and should additionally guard with a busy flag on submit.
- `connect()` must replay historic events so late clients can catch up on an active run.
- `stop()` is optional to implement in the sense that returning `undefined` is allowed, but
surface cancellations through `abortController.abort()` to the underlying agent if you can.
- The runner does not persist user messages on its own — that is the Intelligence platform's
job. A custom runner that persists only its own event stream is still a drop-in replacement
for `InMemoryAgentRunner` / `SqliteAgentRunner`.
Source: `packages/runtime/src/v2/runtime/runner/agent-runner.ts`,
`packages/runtime/src/v2/runtime/runner/in-memory.ts`,
`packages/sqlite-runner/src/sqlite-runner.ts`.
@@ -0,0 +1,64 @@
InMemoryAgentRunner — default ephemeral runner. Keyed on a `globalThis` Symbol so thread state survives hot-module reloads during development.
## Store layout
```typescript
// packages/runtime/src/v2/runtime/runner/in-memory.ts
const GLOBAL_STORE_KEY = Symbol.for("@copilotkit/runtime/in-memory-store");
interface GlobalStoreData {
stores: Map<string, InMemoryEventStore>; // per-threadId
historicRunsBackup: Map<string, HistoricRun[]>; // restored after HMR
}
```
One `InMemoryEventStore` per `threadId`. Each store tracks:
- `subject: ReplaySubject<BaseEvent> | null` — current consumers
- `isRunning: boolean` — gate for the `"Thread already running"` throw
- `currentRunId: string | null`
- `historicRuns: HistoricRun[]` — completed runs (backed up across HMR)
- `agent: AbstractAgent | null` — the instance that owns the active run
- `runSubject`, `currentEvents`, `stopRequested`
## Lifecycle
1. `run({ threadId, agent, input })` — if `store.isRunning` throw `Error("Thread already running")`. Otherwise create a `ReplaySubject`, subscribe to `agent.run(input)`, push events into the subject, track them in `currentEvents`, mark the store `isRunning`.
2. On `RunFinishedEvent` / `RunErrorEvent`: finalize the run, push its events into `historicRuns`, clear `isRunning`, `currentRunId`, `agent`.
3. `connect({ threadId })` — returns a `ReplaySubject` that replays the active run events or (if no active run) the most recent historic run.
4. `stop({ threadId })` — sets `stopRequested = true`; the active subscription checks the flag on each event and tears down.
## Hot reload (development)
In dev, bundlers replace modules. `GLOBAL_STORE_KEY` uses `Symbol.for(...)` so the same well-known symbol is reused across module instances — `globalThis[KEY]` survives. On module re-evaluation, if the `stores` map is empty but `historicRunsBackup` still has entries, the runner rehydrates historic-only stores from the backup (active runs are lost, historic runs come back).
## When NOT to use
- Multi-instance production deploys — each process has its own store.
- Long-lived servers — restart wipes active threads (historic runs are only preserved in the HMR-dev backup, not across process exit).
- Load-balanced serverless with cold starts — new workers see empty stores.
## When it is OK
- Local development.
- Single-instance preview environments.
- Tests (each `new InMemoryAgentRunner()` still shares the globalThis store — pass a fresh threadId per test, or clear the captured store in place between tests). Do NOT `delete globalThis[Symbol.for("@copilotkit/runtime/in-memory-store")]`: `in-memory.ts:98` captures `GLOBAL_STORE = getGlobalStore()` as a module-level const referencing the inner `stores` Map, so replacing `globalThis[KEY]` creates a new object that the module no longer consults. Mutate the existing maps in place:
```ts
// test setup
const storeKey = Symbol.for("@copilotkit/runtime/in-memory-store");
const data = (globalThis as any)[storeKey] as
| {
stores: Map<string, unknown>;
historicRunsBackup: Map<string, unknown>;
}
| undefined;
if (data) {
data.stores.clear();
data.historicRunsBackup.clear();
}
```
The runtime does not yet expose an official reset helper — a `__TEST_ONLY_clearGlobalStore` export would be a reasonable follow-up.
Source: `packages/runtime/src/v2/runtime/runner/in-memory.ts`.
@@ -0,0 +1,90 @@
SqliteAgentRunner — file-backed agent runner in `@copilotkit/sqlite-runner`. Uses `better-sqlite3` as a required peer dep.
## Install
```bash
pnpm add @copilotkit/sqlite-runner better-sqlite3
```
If `better-sqlite3` is missing, the `import` of `@copilotkit/sqlite-runner` itself fails
at module load (`Cannot find module 'better-sqlite3'`). The runner's constructor has a
friendlier multi-line install hint as a fallback, but you will see the bare resolution
error first — install the peer before the runner import resolves.
## Configure
```typescript
import { CopilotRuntime } from "@copilotkit/runtime/v2";
import { SqliteAgentRunner } from "@copilotkit/sqlite-runner";
const runtime = new CopilotRuntime({
agents: {
/* ... */
} as any,
runner: new SqliteAgentRunner({
dbPath: "./data/threads.db", // REQUIRED — default is ":memory:"
}),
});
```
`dbPath: ":memory:"` is the default if omitted — that reverts to an in-memory store and
loses data at restart. Always set a file path in production.
## Schema
Three tables are created on first use (`packages/sqlite-runner/src/sqlite-runner.ts:75-109`):
```sql
CREATE TABLE IF NOT EXISTS agent_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id TEXT NOT NULL,
run_id TEXT NOT NULL UNIQUE,
parent_run_id TEXT,
events TEXT NOT NULL, -- JSON-encoded BaseEvent[]
input TEXT NOT NULL, -- JSON-encoded RunAgentInput
created_at INTEGER NOT NULL,
version INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS run_state (
thread_id TEXT PRIMARY KEY,
is_running INTEGER DEFAULT 0,
current_run_id TEXT,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_thread_id ON agent_runs(thread_id);
CREATE INDEX IF NOT EXISTS idx_parent_run_id ON agent_runs(parent_run_id);
```
`agent_runs` is append-only — one row per completed run, full event log in the `events`
column. `run_state` gates concurrent runs (the `"Thread already running"` check).
`schema_version` tracks applied migrations so future releases can upgrade existing
databases in place.
## Retention
There is no automatic retention. If you need bounded history, add a periodic purge:
```typescript
import Database from "better-sqlite3";
const db = new Database("./data/threads.db");
setInterval(
() => {
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000; // 30 days
db.prepare("DELETE FROM agent_runs WHERE created_at < ?").run(cutoff);
},
60 * 60 * 1000,
);
```
## When NOT to use
- Multi-instance deploys without shared storage — each instance would have its own DB file.
Either put the DB on a shared volume (EFS, persistent disk) with a single writer, or
choose Intelligence mode or a custom Redis/Postgres runner.
Source: `packages/sqlite-runner/src/sqlite-runner.ts`.
@@ -0,0 +1,304 @@
# CopilotKit Agent Runners
`AgentRunner` is the abstraction that owns thread run state — active runs, the event stream
replay, and stop semantics. Pick one per `CopilotRuntime` instance.
- `InMemoryAgentRunner` — default; globalThis-keyed Map; lost on restart.
- `SqliteAgentRunner` — file-backed; requires `better-sqlite3` peer.
- `IntelligenceAgentRunner` — auto-wired by `CopilotIntelligenceRuntime`. You do NOT
construct this directly and you cannot pass `runner` alongside `intelligence`.
- Custom — subclass `AgentRunner` for Redis / Postgres / any backend.
## Setup
Default (in-memory, dev only):
```typescript
import { CopilotRuntime } from "@copilotkit/runtime/v2";
// Equivalent to passing `runner: new InMemoryAgentRunner()`
const runtime = new CopilotRuntime({
agents: {
/* ... */
} as any,
});
```
Production (file-backed SQLite):
```typescript
import { CopilotRuntime } from "@copilotkit/runtime/v2";
import { SqliteAgentRunner } from "@copilotkit/sqlite-runner";
const runtime = new CopilotRuntime({
agents: {
/* ... */
} as any,
runner: new SqliteAgentRunner({ dbPath: "./data/threads.db" }),
});
```
Installation for the SQLite runner (the `better-sqlite3` peer is required):
```bash
pnpm add @copilotkit/sqlite-runner better-sqlite3
```
## Core Patterns
### The AgentRunner contract
```typescript
import { AgentRunner } from "@copilotkit/runtime/v2";
import type {
AgentRunnerRunRequest,
AgentRunnerConnectRequest,
AgentRunnerIsRunningRequest,
AgentRunnerStopRequest,
} from "@copilotkit/runtime/v2";
import { Observable } from "rxjs";
import type { BaseEvent } from "@ag-ui/client";
class MyRunner extends AgentRunner {
run(request: AgentRunnerRunRequest): Observable<BaseEvent> {
// Start a new run for request.threadId. Throw `new Error("Thread already running")`
// if a run is in flight. Stream events from agent.run(request.input).
return new Observable<BaseEvent>();
}
connect(request: AgentRunnerConnectRequest): Observable<BaseEvent> {
// Replay events for an active run, or historic runs for request.threadId.
return new Observable<BaseEvent>();
}
async isRunning(request: AgentRunnerIsRunningRequest): Promise<boolean> {
return false;
}
async stop(request: AgentRunnerStopRequest): Promise<boolean | undefined> {
return true;
}
}
```
### Handle double-submit on the client
Both `InMemoryAgentRunner` and `SqliteAgentRunner` throw
`"Thread already running"` on concurrent `run()` calls for the same `threadId`. How
that surfaces to the client depends on the runtime mode:
- **Intelligence mode** — the Intelligence platform returns HTTP `409` when a lock is
held. The client core maps this to `CopilotKitCoreErrorCode.AGENT_THREAD_LOCKED`
and fires `onError({ code: "agent_thread_locked", ... })`. Handle this in
`<CopilotKit onError>` (the `CopilotKit` provider from `@copilotkit/react-core/v2`).
- **SSE mode** (default, in-memory / SQLite runners) — the runner throws
synchronously and the handler returns a plain `500` JSON body like
`{ "error": "Failed to run agent", "message": "Thread already running" }`.
There is no typed `agent_thread_locked` code — match on the message text or
just guard on the client with a busy flag.
```tsx
// client — Intelligence mode (typed code)
import { CopilotKit } from "@copilotkit/react-core/v2";
<CopilotKit
onError={({ code }) => {
if (code === "agent_thread_locked") {
alert("Agent is busy — wait for the current response to finish.");
}
}}
/>;
```
```tsx
// client — any mode: guard with a busy flag so double-submit is impossible
import { useAgent } from "@copilotkit/react-core/v2";
import { useState } from "react";
function Composer() {
const agent = useAgent({ agentId: "default" });
const [busy, setBusy] = useState(false);
async function send(text: string) {
if (busy) return;
setBusy(true);
try {
await agent?.addMessage({ role: "user", content: text });
} finally {
setBusy(false);
}
}
return null;
}
```
## Common Mistakes
### HIGH Shipping InMemoryAgentRunner to production
Wrong:
```typescript
// production:
new CopilotRuntime({ agents: { default: agent } });
```
Correct:
```typescript
import { SqliteAgentRunner } from "@copilotkit/sqlite-runner";
new CopilotRuntime({
agents: { default: agent },
runner: new SqliteAgentRunner({ dbPath: "./data/threads.db" }),
});
// Or upgrade to Intelligence mode for managed durability.
```
The default runner is `new InMemoryAgentRunner()`. It keeps state in a `globalThis`-keyed
Map — threads are lost on restart, and horizontally-scaled instances see divergent state.
Source: `packages/runtime/src/v2/runtime/runner/in-memory.ts:63-96`.
### HIGH Setting runner alongside intelligence option
Wrong:
```typescript
new CopilotRuntime({
agents,
intelligence,
runner: new SqliteAgentRunner({ dbPath: "./data/threads.db" }),
});
```
Correct:
```typescript
new CopilotRuntime({
agents,
intelligence,
identifyUser: (req) => ({ id: req.headers.get("x-user-id")! }),
});
```
`CopilotIntelligenceRuntimeOptions` does not declare a `runner` field — Intelligence mode
auto-wires `IntelligenceAgentRunner` pointed at the Intelligence service socket. Excess-property checks will
flag a `runner:` key on an Intelligence-shaped options object as a type error, and at runtime
the auto-wired Intelligence runner wins regardless of what you pass.
Source: `packages/runtime/src/v2/runtime/core/runtime.ts:149-173,285-294`.
### HIGH Forgetting the better-sqlite3 peer
Wrong:
```bash
pnpm add @copilotkit/sqlite-runner
```
Correct:
```bash
pnpm add @copilotkit/sqlite-runner better-sqlite3
```
`@copilotkit/sqlite-runner` imports `better-sqlite3` at the top of its module, so if the peer
is missing, `import { SqliteAgentRunner } from "@copilotkit/sqlite-runner"` itself fails at
module load with `Cannot find module 'better-sqlite3'` — long before the constructor runs.
(The constructor has a friendlier multi-line install hint as a belt-and-suspenders fallback,
but in practice you will see the bare module-resolution error first.) It is a peer dependency,
not a direct dep.
Source: `packages/sqlite-runner/src/sqlite-runner.ts:18`, `:55-66`.
### HIGH Default SqliteAgentRunner with :memory: dbPath
Wrong:
```typescript
new SqliteAgentRunner();
```
Correct:
```typescript
new SqliteAgentRunner({ dbPath: "./data/threads.db" });
```
The default `dbPath` is `":memory:"` — SQLite's in-memory mode. Data is lost at restart,
defeating the reason to use the file-backed runner.
Source: `packages/sqlite-runner/src/sqlite-runner.ts:48-54`.
### MEDIUM Concurrent run() on the same threadId
Wrong:
```tsx
// Double-click send button → two POST /agent/:id/run to the same thread
<button onClick={() => agent.addMessage({ role: "user", content })}>
Send
</button>
```
Correct:
```tsx
const [busy, setBusy] = useState(false);
<button
disabled={busy}
onClick={async () => {
setBusy(true);
try {
await agent.addMessage({ role: "user", content });
} finally {
setBusy(false);
}
}}
>
Send
</button>;
```
Both runners throw `"Thread already running"` on concurrent runs. Debounce on the client.
In Intelligence mode you can additionally handle `code === "agent_thread_locked"` in
`<CopilotKit onError>`; SSE mode surfaces only a generic 500 with that message.
Source: `packages/runtime/src/v2/runtime/runner/in-memory.ts:110`;
`packages/core/src/intelligence-agent.ts:368-369`.
### HIGH In-memory runner + horizontal scaling
Wrong:
```typescript
// 3 Fly.io / Cloud Run instances, each with its own InMemoryAgentRunner
new CopilotRuntime({ agents });
```
Correct:
```typescript
// Either sticky-session a single instance per thread, or use shared state:
new CopilotRuntime({
agents,
runner: new SqliteAgentRunner({ dbPath: process.env.THREADS_DB! }),
});
// Best: Intelligence mode for managed multi-instance durability.
```
`InMemoryAgentRunner`'s `globalThis` store is per-process — multi-instance deploys see
totally different thread state per worker, making reconnects and `GET /connect` non-deterministic.
Source: `packages/runtime/src/v2/runtime/runner/in-memory.ts:63-96`.
## References
- [InMemoryAgentRunner — internals and hot-reload note](agent-runners-in-memory.md)
- [SqliteAgentRunner — schema, retention, ops](agent-runners-sqlite.md)
- [Custom runner — Redis/Postgres skeleton](agent-runners-custom.md)
## See also
- `copilotkit/intelligence-mode` — managed durability alternative (CopilotKit Intelligence managed service, not self-hostable)
- `copilotkit/setup-endpoint` — runner is passed via the CopilotRuntime constructor
- `copilotkit/scale-to-multi-agent` — horizontal scaling considerations
@@ -0,0 +1,232 @@
BuiltInAgent Factory Modes — cookbook for TanStack AI, AI SDK, and custom AG-UI factories.
## The AgentFactoryContext
```typescript
// packages/runtime/src/agent/index.ts
export interface AgentFactoryContext {
input: RunAgentInput; // messages, tools, forwardedProps, context
abortController: AbortController; // prefer abortSignal
abortSignal: AbortSignal; // pass to AI SDK / fetch / custom
}
```
Rule of thumb:
- Prefer `abortSignal` for AI SDK, fetch, custom backends.
- Use `abortController` for TanStack AI (its `chat()` takes the controller, not the signal).
- NEVER call `ctx.abortController.abort()` inside the factory — use
`agent.abortRun()` from outside.
## TanStack AI factory (preferred)
```typescript
import { BuiltInAgent, convertInputToTanStackAI } from "@copilotkit/runtime/v2";
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
new BuiltInAgent({
type: "tanstack",
factory: ({ input, abortController }) => {
const { messages, systemPrompts } = convertInputToTanStackAI(input);
systemPrompts.unshift("You are a helpful assistant.");
return chat({
adapter: openaiText("gpt-4o"),
messages,
systemPrompts,
tools: [
/* TanStack AI toolDefinition()s */
],
abortController,
});
},
});
```
### TanStack AI + forwardedProps
```typescript
new BuiltInAgent({
type: "tanstack",
factory: ({ input, abortController }) => {
const { messages, systemPrompts } = convertInputToTanStackAI(input);
const fwd = input.forwardedProps as
| { model?: string; temperature?: number }
| undefined;
return chat({
adapter: openaiText(fwd?.model ?? "gpt-4o"),
messages,
systemPrompts,
modelOptions: { temperature: fwd?.temperature ?? 0.2 },
abortController,
});
},
});
```
## AI SDK factory (use when reasoning events are required)
```typescript
import {
BuiltInAgent,
convertMessagesToVercelAISDKMessages,
convertToolsToVercelAITools,
} from "@copilotkit/runtime/v2";
import { streamText, stepCountIs } from "ai";
import { openai } from "@ai-sdk/openai";
new BuiltInAgent({
type: "aisdk",
factory: ({ input, abortSignal }) => {
const messages = convertMessagesToVercelAISDKMessages(input.messages, {
forwardSystemMessages: true,
});
const tools = convertToolsToVercelAITools(input.tools);
return streamText({
model: openai("gpt-4o"),
messages,
tools,
abortSignal,
stopWhen: stepCountIs(5),
});
},
});
```
The `BuiltInAgentAISDKFactoryConfig` contract requires an object with a `fullStream`
async iterable — this is exactly what `streamText()` returns.
## AI SDK + reasoning (Anthropic thinking)
```typescript
import { anthropic } from "@ai-sdk/anthropic";
import { streamText } from "ai";
import {
BuiltInAgent,
convertMessagesToVercelAISDKMessages,
} from "@copilotkit/runtime/v2";
new BuiltInAgent({
type: "aisdk",
factory: ({ input, abortSignal }) =>
streamText({
model: anthropic("claude-sonnet-4"),
messages: convertMessagesToVercelAISDKMessages(input.messages),
providerOptions: {
anthropic: { thinking: { type: "enabled", budgetTokens: 10000 } },
},
abortSignal,
}),
});
```
TanStack AI silently drops reasoning events — only AI SDK surfaces them.
## Custom factory (raw AG-UI events)
```typescript
import { BuiltInAgent } from "@copilotkit/runtime/v2";
import type { BaseEvent } from "@ag-ui/client";
import { EventType } from "@ag-ui/client";
new BuiltInAgent({
type: "custom",
factory: async function* ({ input, abortSignal }): AsyncIterable<BaseEvent> {
// Check abortSignal.aborted on every iteration — agent.abortRun() signals
// cancellation via this flag, but the generator must consult it to stop yielding.
if (abortSignal.aborted) return;
const messageId = crypto.randomUUID();
yield {
type: EventType.TEXT_MESSAGE_START,
messageId,
role: "assistant",
} as any;
for (const delta of ["Hello", ", ", "world."]) {
if (abortSignal.aborted) return; // honor cancellation between yields
yield {
type: EventType.TEXT_MESSAGE_CONTENT,
messageId,
delta,
} as any;
}
yield { type: EventType.TEXT_MESSAGE_END, messageId } as any;
},
});
```
A custom factory that never checks `abortSignal.aborted` (or registers an
`addEventListener("abort", …)` handler to break its loop) is non-cancellable —
`agent.abortRun()` will flip the flag but the generator will keep yielding until it
exhausts its own source. Pass `abortSignal` through to any underlying `fetch` /
streaming API as well so the upstream request is torn down.
## Manual state-tool wiring (Factory Mode only)
Simple Mode auto-injects `AGUISendStateSnapshot` / `AGUISendStateDelta`. In Factory Mode
you must register them by hand for shared-state updates to reach the LLM. The AI SDK
factory works out of the box because `defineTool` output adapts through
`convertToolDefinitionsToVercelAITools`:
```typescript
import {
BuiltInAgent,
convertMessagesToVercelAISDKMessages,
convertToolDefinitionsToVercelAITools,
defineTool,
} from "@copilotkit/runtime/v2";
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
const sendStateSnapshot = defineTool({
name: "AGUISendStateSnapshot",
description: "Replace the entire application state with a new snapshot",
parameters: z.object({
snapshot: z.any().describe("The complete new state object"),
}),
execute: async ({ snapshot }) => ({ success: true, snapshot }),
});
const sendStateDelta = defineTool({
name: "AGUISendStateDelta",
description:
"Apply incremental updates to application state using JSON Patch operations",
// MUST mirror the Simple-Mode auto-injected schema (src/agent/index.ts:1140-1176)
// or the frontend's state handler won't recognize the payload.
parameters: z.object({
delta: z
.array(
z.object({
op: z.enum(["add", "replace", "remove"]),
path: z.string(),
value: z.any().optional(),
}),
)
.describe("Array of JSON Patch operations"),
}),
execute: async ({ delta }) => ({ success: true, delta }),
});
new BuiltInAgent({
type: "aisdk",
factory: ({ input, abortSignal }) =>
streamText({
model: openai("gpt-4o"),
messages: convertMessagesToVercelAISDKMessages(input.messages),
tools: convertToolDefinitionsToVercelAITools([
sendStateSnapshot,
sendStateDelta,
]),
abortSignal,
}),
});
```
For TanStack AI factories, `defineTool` output is NOT a TanStack tool — passing it to
`chat({ tools })` does not work. Either switch to the AI SDK factory above, or redefine
the tools with `toolDefinition()` from `@tanstack/ai`.
Source: `packages/runtime/src/agent/index.ts`,
`docs/content/docs/integrations/built-in-agent/custom-agent.mdx`.
@@ -0,0 +1,123 @@
BuiltInAgent helper utilities — exported from `@copilotkit/runtime/v2`.
## convertInputToTanStackAI
```typescript
import { convertInputToTanStackAI } from "@copilotkit/runtime/v2";
// signature (simplified):
// convertInputToTanStackAI(input: RunAgentInput): {
// messages: TanStackAIMessage[];
// systemPrompts: string[];
// }
```
Converts the AG-UI `RunAgentInput` into TanStack AI's `chat()` inputs. System messages in
the input are collected into the `systemPrompts` array (not the `messages` array). Unshift
your own system prompt onto `systemPrompts` before calling `chat()`:
```typescript
const { messages, systemPrompts } = convertInputToTanStackAI(input);
systemPrompts.unshift("You are a helpful assistant.");
return chat({ adapter, messages, systemPrompts, abortController });
```
Source: `packages/runtime/src/agent/converters/tanstack.ts:156`.
## convertMessagesToVercelAISDKMessages
```typescript
import { convertMessagesToVercelAISDKMessages } from "@copilotkit/runtime/v2";
// signature:
// convertMessagesToVercelAISDKMessages(
// messages: Message[],
// options?: { forwardSystemMessages?: boolean; forwardDeveloperMessages?: boolean }
// ): ModelMessage[]
```
Converts AG-UI `Message[]` to the Vercel AI SDK's `ModelMessage[]`. Handles multimodal
content (text, image, audio/video/document, and legacy `binary`). By default drops
`role: "system"` and `role: "developer"` messages — set the options to opt in.
```typescript
const messages = convertMessagesToVercelAISDKMessages(input.messages, {
forwardSystemMessages: true,
});
```
Source: `packages/runtime/src/agent/index.ts:435`.
## convertToolsToVercelAITools
```typescript
import { convertToolsToVercelAITools } from "@copilotkit/runtime/v2";
// signature:
// convertToolsToVercelAITools(tools: RunAgentInput["tools"]): ToolSet
```
Converts AG-UI `input.tools` (tools registered on the frontend — their parameters are plain
JSON Schema) into the AI SDK's `ToolSet`. Throws `Invalid JSON schema for tool ${name}`
when a tool's parameters aren't a JSON schema object. The resulting tools have no
`execute` — the AI SDK emits tool-call events and the frontend handles them.
```typescript
const tools = convertToolsToVercelAITools(input.tools);
return streamText({ model, messages, tools, abortSignal });
```
Source: `packages/runtime/src/agent/index.ts:599`.
## convertToolDefinitionsToVercelAITools
```typescript
import { convertToolDefinitionsToVercelAITools } from "@copilotkit/runtime/v2";
// signature:
// convertToolDefinitionsToVercelAITools(tools: ToolDefinition[]): ToolSet
```
Converts server-side `ToolDefinition[]` (Standard Schema V1 parameters + `execute`
function) into an AI SDK `ToolSet`. Zod schemas pass through directly; non-Zod Standard
Schema V1 parameters (Valibot, ArkType, ...) are converted to JSON Schema via
`schemaToJsonSchema` and wrapped with `jsonSchema()` from `ai`.
```typescript
import { defineTool } from "@copilotkit/runtime/v2";
import { z } from "zod";
const searchTool = defineTool({
name: "search",
description: "Search the web.",
parameters: z.object({ query: z.string() }),
execute: async ({ query }) => ({ results: [] }),
});
const tools = convertToolDefinitionsToVercelAITools([searchTool]);
return streamText({ model, messages, tools, abortSignal });
```
Source: `packages/runtime/src/agent/index.ts:633`.
## resolveModel
```typescript
import { resolveModel } from "@copilotkit/runtime/v2";
// signature:
// resolveModel(spec: ModelSpecifier, apiKey?: string): LanguageModel
```
Resolves a `"provider/model"` (or `"provider:model"`) string to a `LanguageModel`. If
`spec` is already a `LanguageModel`, it's returned as-is. Throws
`Invalid model string "..."` when the provider separator is missing.
Supported providers: `openai`, `anthropic`, `google`/`gemini`/`google-gemini`, `vertex`.
Unknown providers throw `Unknown provider "..." in "...". Supported: openai, anthropic, google (gemini).`
```typescript
const model = resolveModel("openai/gpt-4o", process.env.OPENAI_API_KEY);
```
Source: `packages/runtime/src/agent/index.ts:176-249`.
@@ -0,0 +1,59 @@
BuiltInAgent model identifiers — the full set of `"provider/model"` strings that
`resolveModel` accepts out of the box.
## Shape
`"provider/model"` or `"provider:model"` — both separators are normalized. Case-insensitive on the provider segment.
```typescript
new BuiltInAgent({ model: "openai/gpt-4o" });
new BuiltInAgent({ model: "anthropic:claude-sonnet-4.5" });
```
## Supported providers
| Provider | Env var | Notes |
| ------------------------------------- | ------------------- | ---------------------------------- |
| `openai` | `OPENAI_API_KEY` | Lazily creates `@ai-sdk/openai` |
| `anthropic` | `ANTHROPIC_API_KEY` | Lazily creates `@ai-sdk/anthropic` |
| `google` / `gemini` / `google-gemini` | `GOOGLE_API_KEY` | `@ai-sdk/google` under the hood |
| `vertex` | (GCP auth) | `@ai-sdk/google-vertex` |
Pass `apiKey` on the constructor to override env vars.
## Pinned identifiers in the union type
These are the concrete strings typed in `BuiltInAgentModel`:
```
openai/gpt-5 openai/gpt-5-mini
openai/gpt-4.1 openai/gpt-4.1-mini openai/gpt-4.1-nano
openai/gpt-4o openai/gpt-4o-mini
openai/o3 openai/o3-mini openai/o4-mini
anthropic/claude-sonnet-4.5 anthropic/claude-sonnet-4
anthropic/claude-3.7-sonnet anthropic/claude-opus-4.1
anthropic/claude-opus-4 anthropic/claude-3.5-haiku
google/gemini-2.5-pro google/gemini-2.5-flash google/gemini-2.5-flash-lite
```
Any other valid model id is still accepted — the type is
`BuiltInAgentModel = "openai/gpt-5" | ... | (string & {})`, and the AI SDK provider will
accept any id it knows about. The pinned union is for autocomplete, not an exhaustive allowlist.
## Passing a LanguageModel instance directly
Instead of a string, pass a pre-configured `LanguageModel`:
```typescript
import { BuiltInAgent } from "@copilotkit/runtime/v2";
import { createOpenAI } from "@ai-sdk/openai";
const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY });
new BuiltInAgent({ model: openai("gpt-4o") });
```
This bypasses `resolveModel` entirely.
Source: `packages/runtime/src/agent/index.ts:82-109, 176-249`.
@@ -0,0 +1,523 @@
# CopilotKit BuiltInAgent
`BuiltInAgent` has two modes:
- **Factory Mode** (preferred default) — you own the LLM call, BuiltInAgent owns the AG-UI
lifecycle. TanStack AI factory is AG-UI-native and the canonical preferred choice. AI SDK
and custom (raw AG-UI event) factories are also supported.
- **Simple Mode** (classic config) — `{ model, apiKey, prompt, tools, mcpServers, maxSteps, ... }`.
Convenient for quickstarts. Simple Mode auto-injects the `AGUISendStateSnapshot` /
`AGUISendStateDelta` state tools; Factory Mode does not.
Use Factory Mode with TanStack AI for new code.
## Setup
Factory Mode with TanStack AI (preferred default):
```typescript
import {
CopilotRuntime,
createCopilotRuntimeHandler,
BuiltInAgent,
convertInputToTanStackAI,
} from "@copilotkit/runtime/v2";
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const agent = new BuiltInAgent({
type: "tanstack",
factory: ({ input, abortController }) => {
const { messages, systemPrompts } = convertInputToTanStackAI(input);
systemPrompts.unshift("You are a helpful assistant.");
return chat({
adapter: openaiText("gpt-4o"),
messages,
systemPrompts,
abortController,
});
},
});
const runtime = new CopilotRuntime({ agents: { default: agent } });
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export default { fetch: handler };
```
Simple Mode (quickstart only):
```typescript
import {
BuiltInAgent,
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
const agent = new BuiltInAgent({
model: "openai/gpt-4o",
apiKey: process.env.OPENAI_API_KEY,
prompt: "You are a helpful assistant.",
maxSteps: 5, // enable the tool-call loop
});
const runtime = new CopilotRuntime({ agents: { default: agent } });
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export default { fetch: handler };
```
## Core Patterns
### Factory Mode with AI SDK (needed for reasoning events)
```typescript
import {
BuiltInAgent,
convertMessagesToVercelAISDKMessages,
convertToolsToVercelAITools,
} from "@copilotkit/runtime/v2";
import { streamText, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
const agent = new BuiltInAgent({
type: "aisdk",
factory: ({ input, abortSignal }) => {
const messages = convertMessagesToVercelAISDKMessages(input.messages);
const tools = convertToolsToVercelAITools(input.tools);
return streamText({
model: anthropic("claude-sonnet-4-5-20250929"),
messages,
tools,
abortSignal,
stopWhen: stepCountIs(5),
});
},
});
```
### Per-request agent via a factory function on CopilotRuntime
```typescript
import {
CopilotRuntime,
BuiltInAgent,
convertInputToTanStackAI,
} from "@copilotkit/runtime/v2";
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const runtime = new CopilotRuntime({
agents: ({ request }) => {
const tenantId = request.headers.get("x-tenant-id") ?? "default";
return {
default: new BuiltInAgent({
type: "tanstack",
factory: ({ input, abortController }) => {
const { messages, systemPrompts } = convertInputToTanStackAI(input);
systemPrompts.unshift(`You are the ${tenantId} assistant.`);
return chat({
adapter: openaiText("gpt-4o"),
messages,
systemPrompts,
abortController,
});
},
}),
};
},
});
```
### Simple Mode — MCP servers
```typescript
new BuiltInAgent({
model: "openai/gpt-4o",
maxSteps: 5,
mcpServers: [
{ type: "http", url: "https://mcp.example.com/mcp" },
{
type: "sse",
url: "https://mcp.example.com/sse",
headers: { Authorization: `Bearer ${process.env.MCP_TOKEN}` },
},
],
});
```
### Model specifier format
`"provider/model"` or `"provider:model"`. Supported providers: `openai`, `anthropic`,
`google` (aliases `gemini`, `google-gemini`), `vertex`. The bare model id (`"gpt-4o"`) is
rejected.
```typescript
new BuiltInAgent({ model: "openai/gpt-4o" });
new BuiltInAgent({ model: "anthropic/claude-sonnet-4.5" });
new BuiltInAgent({ model: "google/gemini-2.5-pro" });
```
## Common Mistakes
### HIGH Defaulting to Simple Mode when Factory Mode (TanStack AI) is preferred
Wrong:
```typescript
const agent = new BuiltInAgent({
model: "openai/gpt-4o",
prompt: "You are a helpful assistant.",
});
```
Correct:
```typescript
import { BuiltInAgent, convertInputToTanStackAI } from "@copilotkit/runtime/v2";
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const agent = new BuiltInAgent({
type: "tanstack",
factory: ({ input, abortController }) => {
const { messages, systemPrompts } = convertInputToTanStackAI(input);
systemPrompts.unshift("You are a helpful assistant.");
return chat({
adapter: openaiText("gpt-4o"),
messages,
systemPrompts,
abortController,
});
},
});
```
Factory Mode with TanStack AI is the canonical in-tree default (see
`examples/v2/react-router/app/routes/api.copilotkit.$.tsx`) and is AG-UI-native. Simple
Mode is fine for quickstarts but reaches its ceiling on anything non-standard.
Source: `examples/v2/react-router/app/routes/api.copilotkit.$.tsx`; maintainer Phase 4c.
### HIGH Expecting tool-call loop without raising maxSteps
Wrong:
```typescript
new BuiltInAgent({
model: "openai/gpt-4o",
tools: [searchTool],
// maxSteps defaults to undefined → AI SDK stops after one generation; tool results
// are never fed back. Set maxSteps: N to enable the tool-call loop.
});
```
Correct:
```typescript
new BuiltInAgent({
model: "openai/gpt-4o",
tools: [searchTool],
maxSteps: 5,
});
```
`maxSteps` defaults to `undefined`, so `stopWhen` is `undefined` and the AI SDK's own
default applies — `streamText` stops after a single generation, the tool call happens,
but results are never fed back for a second turn. Set `maxSteps: N` to install
`stepCountIs(N)` and enable the tool-call loop up to N steps.
Source: `packages/runtime/src/agent/index.ts:988-990`.
### HIGH Wrong model specifier format
Wrong:
```typescript
new BuiltInAgent({ model: "gpt-4o" });
```
Correct:
```typescript
new BuiltInAgent({ model: "openai/gpt-4o" });
// Also valid: "openai:gpt-4o"
```
`resolveModel` throws `Invalid model string "gpt-4o". Use "openai/gpt-5",
"anthropic/claude-sonnet-4.5", or "google/gemini-2.5-pro".` when the provider separator
is missing.
Source: `packages/runtime/src/agent/index.ts:186-204`.
### HIGH Concurrent run() on the same BuiltInAgent instance
Wrong:
```typescript
// One shared instance across tenants
const agent = new BuiltInAgent({ model: "openai/gpt-4o" });
new CopilotRuntime({ agents: { default: agent } });
```
Correct:
```typescript
// Use the agents-as-factory form for per-request instances
new CopilotRuntime({
agents: ({ request }) => ({
default: new BuiltInAgent({ model: "openai/gpt-4o" }),
}),
});
```
A single `BuiltInAgent` instance guards against concurrent `run()` with
`"Agent is already running. Call abortRun() first or create a new instance."` Multi-tenant
servers that share one instance see errors on the second concurrent user.
Source: `packages/runtime/src/agent/index.ts:895-898`.
### HIGH Expecting state tools to auto-inject in Factory Mode
Wrong:
```typescript
new BuiltInAgent({
type: "tanstack",
factory: ({ input, abortController }) => {
const { messages, systemPrompts } = convertInputToTanStackAI(input);
return chat({
adapter: openaiText("gpt-4o"),
messages,
systemPrompts,
abortController,
});
},
});
// Frontend uses useAgent + shared state — but no state-tool calls come back
```
Correct (AI SDK factory — `defineTool` output converts via
`convertToolDefinitionsToVercelAITools`):
```typescript
import {
BuiltInAgent,
convertMessagesToVercelAISDKMessages,
convertToolDefinitionsToVercelAITools,
defineTool,
} from "@copilotkit/runtime/v2";
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
const sendStateSnapshot = defineTool({
name: "AGUISendStateSnapshot",
description: "Replace the entire application state with a new snapshot",
parameters: z.object({
snapshot: z.any().describe("The complete new state object"),
}),
execute: async ({ snapshot }) => ({ success: true, snapshot }),
});
const sendStateDelta = defineTool({
name: "AGUISendStateDelta",
description:
"Apply incremental updates to application state using JSON Patch operations",
// MUST mirror the Simple-Mode auto-injected schema (src/agent/index.ts:1140-1176)
// or the frontend's state handler won't recognize the payload.
parameters: z.object({
delta: z
.array(
z.object({
op: z.enum(["add", "replace", "remove"]),
path: z.string(), // JSON Pointer, e.g. "/foo/bar"
value: z.any().optional(), // required for add/replace, ignored for remove
}),
)
.describe("Array of JSON Patch operations"),
}),
execute: async ({ delta }) => ({ success: true, delta }),
});
// If you don't want to hand-wire this, use Simple Mode — it auto-injects both
// AGUISendStateSnapshot and AGUISendStateDelta with the correct JSON Patch schema.
// Source: packages/runtime/src/agent/index.ts:1140-1176
new BuiltInAgent({
type: "aisdk",
factory: ({ input, abortSignal }) =>
streamText({
model: openai("gpt-4o"),
messages: convertMessagesToVercelAISDKMessages(input.messages),
tools: convertToolDefinitionsToVercelAITools([
sendStateSnapshot,
sendStateDelta,
]),
abortSignal,
}),
});
```
Only Simple Mode auto-injects the AG-UI state tools. In Factory Mode you must register
them by hand or shared-state updates never reach the LLM. `defineTool` produces a Standard
Schema V1 + `execute` shape — use `convertToolDefinitionsToVercelAITools([...])` to adapt
it to the AI SDK's `streamText({ tools })`. TanStack AI factories cannot consume
`defineTool` output directly; either redefine the tools with `toolDefinition()` from
`@tanstack/ai`, or switch to the AI SDK factory above.
Source: `docs/snippets/shared/backend/custom-agent.mdx:495-588`.
### MEDIUM Mixing Simple Mode tools with Factory Mode
Wrong:
```typescript
new BuiltInAgent({
type: "tanstack",
factory: myFactory,
tools: [t1, t2], // ignored in Factory Mode
});
```
Correct:
```typescript
new BuiltInAgent({
type: "tanstack",
factory: ({ input, abortController }) => {
const { messages, systemPrompts } = convertInputToTanStackAI(input);
return chat({
adapter: openaiText("gpt-4o"),
messages,
systemPrompts,
tools: [t1, t2],
abortController,
});
},
});
```
Factory Mode ignores `config.tools`, `config.mcpServers`, `config.prompt` entirely — the
factory owns the call. Wire tools inside `chat({ tools })` for TanStack AI, or via
`convertToolsToVercelAITools(input.tools)` / `convertToolDefinitionsToVercelAITools([...])`
for AI SDK.
Source: `packages/runtime/src/agent/index.ts:1581-1671`.
### HIGH Expecting reasoning events from TanStack AI
Wrong:
```typescript
new BuiltInAgent({
type: "tanstack",
factory: ({ input, abortController }) => {
const { messages, systemPrompts } = convertInputToTanStackAI(input);
return chat({
adapter: anthropicText("claude-sonnet-4-5-20250929"),
messages,
systemPrompts,
modelOptions: { thinking: { type: "enabled", budgetTokens: 10000 } },
abortController,
});
},
});
// expecting REASONING_START / REASONING_MESSAGE_CONTENT / REASONING_END — nothing arrives
```
Correct:
```typescript
import {
BuiltInAgent,
convertMessagesToVercelAISDKMessages,
} from "@copilotkit/runtime/v2";
import { streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
new BuiltInAgent({
type: "aisdk",
factory: ({ input, abortSignal }) =>
streamText({
model: anthropic("claude-sonnet-4-5-20250929"),
messages: convertMessagesToVercelAISDKMessages(input.messages),
providerOptions: {
anthropic: { thinking: { type: "enabled", budgetTokens: 10000 } },
},
abortSignal,
}),
});
```
The TanStack AI converter does NOT surface `REASONING_START` /
`REASONING_MESSAGE_CONTENT` / `REASONING_END` events — even with a thinking-capable model.
Use AI SDK when the frontend needs a reasoning UI.
Source: `docs/snippets/shared/backend/custom-agent.mdx:315-317` (warn callout).
### MEDIUM Expecting forwarded system messages
Wrong:
```typescript
// Client sends { role: "system", content: "You are..." } and expects it prefixed
new BuiltInAgent({ model: "openai/gpt-4o" });
```
Correct:
```typescript
// Either set the server-side prompt
new BuiltInAgent({ model: "openai/gpt-4o", prompt: "You are..." });
// or opt in explicitly
new BuiltInAgent({ model: "openai/gpt-4o", forwardSystemMessages: true });
```
`forwardSystemMessages` and `forwardDeveloperMessages` default to `false`. System/developer
messages from the AG-UI input are dropped unless opted in.
Source: `packages/runtime/src/agent/index.ts:440-456,809-815`.
### MEDIUM Aborting factory's abortController directly
Wrong:
```typescript
factory: (ctx) => {
ctx.abortController.abort(); // JSDoc says don't
return streamText({
/* ... */
});
};
```
Correct:
```typescript
factory: (ctx) => streamText({ /* ... */, abortSignal: ctx.abortSignal });
// Externally, from outside the factory:
agent.abortRun();
```
The JSDoc on `AgentFactoryContext.abortController` explicitly warns against calling
`.abort()` on it inside the factory — use `agent.abortRun()` or pass `abortSignal` to the
downstream fetch/LLM call.
Source: `packages/runtime/src/agent/index.ts:670-672`.
## References
- [Model identifiers — supported strings](built-in-agent-model-identifiers.md)
- [Factory modes — TanStack AI / AI SDK / custom cookbook](built-in-agent-factory-modes.md)
- [Helper utilities — converter function signatures](built-in-agent-helper-utilities.md)
## See also
- `copilotkit/server-side-tools``defineTool` powers `config.tools` in Simple Mode
- `copilotkit/setup-endpoint` — mount the runtime that hosts this agent
- `copilotkit/wiring-external-agents` — alternative when you want an external framework
@@ -0,0 +1,332 @@
# CopilotKit Intelligence Mode
Intelligence currently ships as a managed cloud service. The only supported `apiUrl` /
`wsUrl` today is the CopilotKit-managed cloud Intelligence instance — the `ɵ`-prefixed
runtime internals and REST/WebSocket contract that back Intelligence are still
stabilizing and `organizationId` is reserved for future self-hosted deployments. If you
need on-prem durable threads today, use SSE mode with a persistent runner
(`SqliteAgentRunner` or a custom one) instead.
Obtain `apiKey` and `organizationId` from the CopilotKit Intelligence dashboard.
### URL format
The client prepends `/api/...` and the Intelligence websocket layer derives `/runner`
or `/client` suffixes internally. Pass the bare base URLs — do NOT append `/api`,
`/socket`, `/runner`, or `/client` yourself:
```typescript
// Correct — bare base URLs
apiUrl: "https://api.copilotkit.ai",
wsUrl: "wss://api.copilotkit.ai",
// Wrong — adding /api produces /api/api/... on every REST call; /socket/runner is not a real path
apiUrl: "https://api.copilotkit.ai/api",
wsUrl: "wss://api.copilotkit.ai/socket",
```
Source: `packages/runtime/src/v2/runtime/intelligence-platform/client.ts:41-46, 259,
356-357, 437, 468, 682-708`.
## Setup
```typescript
import {
CopilotRuntime,
CopilotKitIntelligence,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
const intelligence = new CopilotKitIntelligence({
apiUrl: "https://api.copilotkit.ai",
wsUrl: "wss://api.copilotkit.ai",
apiKey: process.env.COPILOTKIT_INTELLIGENCE_API_KEY!,
organizationId: process.env.COPILOTKIT_INTELLIGENCE_ORG_ID!,
});
const runtime = new CopilotRuntime({
agents: {
/* ... */
} as any,
intelligence,
identifyUser: (request) => ({
id: request.headers.get("x-user-id") ?? "anonymous",
}),
// Optional tuning:
generateThreadNames: true, // default true — 1 LLM call per new thread
lockTtlSeconds: 20, // clamped to ≤ 3600
lockHeartbeatIntervalSeconds: 15, // clamped to ≤ 3000
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export default { fetch: handler };
```
When `intelligence` is set, the runtime auto-wires `IntelligenceAgentRunner` internally.
Do NOT pass `runner` — see the failure-modes section.
## Core Patterns
### Identify the user from an auth cookie
`identifyUser` is for user identification only — it does NOT forward thrown `Response`s.
`resolveIntelligenceUser` (`handlers/shared/resolve-intelligence-user.ts:14-24`) wraps the
call in try/catch and converts any thrown value (including `Response`) into a generic
`errorResponse("Failed to identify user", 500)`. Gate auth in `hooks.onRequest`
(see the `middleware` skill) and keep `identifyUser` focused on returning an id:
```typescript
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { parse } from "cookie";
const runtime = new CopilotRuntime({
agents,
intelligence,
// identifyUser returns the id; auth rejection is hooked elsewhere.
identifyUser: async (request) => {
const cookies = parse(request.headers.get("cookie") ?? "");
const user = await resolveSession(cookies["session"]); // your auth lib
return { id: user?.id ?? "anonymous" };
},
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
hooks: {
onRequest: async ({ request }) => {
const cookies = parse(request.headers.get("cookie") ?? "");
const user = await resolveSession(cookies["session"]);
// onRequest DOES forward thrown Responses — use it for auth rejection.
if (!user) throw new Response("Unauthorized", { status: 401 });
},
},
});
async function resolveSession(token: string | undefined) {
if (!token) return null;
return { id: "user-123" };
}
```
### Disable thread-name generation to avoid a per-thread LLM call
```typescript
new CopilotRuntime({
agents,
intelligence,
identifyUser: (req) => ({ id: req.headers.get("x-user-id")! }),
generateThreadNames: false,
});
```
### Frontend — no config change
The frontend reads `GET /info` on mount. When the runtime reports `mode: "intelligence"`
and an `intelligence.wsUrl`, `CopilotKitCore` auto-switches from SSE to the websocket
transport. The React integration just points at the runtime URL:
```tsx
import { CopilotKit } from "@copilotkit/react-core/v2";
export function App({ children }: { children: React.ReactNode }) {
return <CopilotKit runtimeUrl="/api/copilotkit">{children}</CopilotKit>;
}
```
## Common Mistakes
### CRITICAL Missing identifyUser
Wrong:
```typescript
new CopilotRuntime({ agents, intelligence });
```
Correct:
```typescript
new CopilotRuntime({
agents,
intelligence,
identifyUser: (req) => ({ id: req.headers.get("x-user-id")! }),
});
```
`identifyUser` is required on `CopilotIntelligenceRuntimeOptions` — omitting it is a
TypeScript error and (if suppressed) crashes handlers at request time. Every thread is
scoped to a user ID.
Source: `packages/runtime/src/v2/runtime/core/runtime.ts:156-160`.
### CRITICAL Adding /api or /socket suffixes, or pointing at an unsupported self-hosted server
Wrong:
```typescript
new CopilotKitIntelligence({
apiUrl: "https://api.copilotkit.ai/api", // double /api prefix
wsUrl: "wss://api.copilotkit.ai/socket", // /socket is not a real path
apiKey,
organizationId,
});
new CopilotKitIntelligence({
apiUrl: "https://internal.myco.com/intelligence", // self-hosting is not yet supported
wsUrl: "wss://internal.myco.com/intelligence",
apiKey,
organizationId,
});
```
Correct:
```typescript
new CopilotKitIntelligence({
apiUrl: "https://api.copilotkit.ai",
wsUrl: "wss://api.copilotkit.ai",
apiKey: process.env.COPILOTKIT_INTELLIGENCE_API_KEY!,
organizationId: process.env.COPILOTKIT_INTELLIGENCE_ORG_ID!,
});
// For on-prem durability without Intelligence: SSE mode + SqliteAgentRunner.
```
Two failure modes to avoid:
1. The client prepends `/api/...` to every REST call (`#request` at line 356-357) and
the websocket layer derives `/runner` / `/client` suffixes from `wsUrl` internally.
Passing `apiUrl: ".../api"` produces double-prefixed `/api/api/threads`; passing
`wsUrl: ".../socket"` produces a broken `.../socket/runner` upgrade path.
2. Self-hosting Intelligence is not yet supported. The `ɵ`-prefixed runtime internals
and REST/WebSocket contract are still stabilizing. `organizationId` is reserved for
future self-hosted instances. For on-prem durable threads today, use SSE mode +
`SqliteAgentRunner` (see `copilotkit/agent-runners`).
Source: `packages/runtime/src/v2/runtime/intelligence-platform/client.ts:41-46, 68-69,
259, 356-357, 437, 682-708`.
### HIGH Setting runner alongside intelligence
Wrong:
```typescript
import { SqliteAgentRunner } from "@copilotkit/sqlite-runner";
new CopilotRuntime({
agents,
intelligence,
runner: new SqliteAgentRunner({ dbPath: "./threads.db" }),
});
```
Correct:
```typescript
new CopilotRuntime({
agents,
intelligence,
identifyUser,
});
```
`CopilotIntelligenceRuntimeOptions` excludes `runner` at the type level. Intelligence
forces its own `IntelligenceAgentRunner` tied to the Intelligence service WebSocket; a user-supplied
runner is rejected.
Source: `packages/runtime/src/v2/runtime/core/runtime.ts:149-173,285-294`.
### HIGH Calling /threads against an SSE-mode runtime
Wrong:
```typescript
// SSE-only runtime (no `intelligence` configured)
await fetch("/api/copilotkit/threads");
```
Correct:
```typescript
// Enable Intelligence mode first, OR don't call thread routes.
// Client-side, the useThreads hook errors with "Runtime URL is not configured" when
// the runtime isn't in Intelligence mode.
```
The `/threads`, `/threads/subscribe`, `PATCH /threads/:id`, `POST /threads/:id/archive`,
`DELETE /threads/:id`, and `/threads/:id/messages` routes always resolve in the router,
but the handlers call `requireIntelligenceRuntime(runtime)` first and return HTTP 422
("Missing CopilotKitIntelligence configuration. Thread operations require a
CopilotKitIntelligence instance to be provided in CopilotRuntime options.") when the
runtime isn't an `IntelligenceRuntime`.
Source: `packages/runtime/src/v2/runtime/handlers/intelligence/threads.ts:37-48`;
route table in `dev-docs/architecture/setup-intelligence.md:179-183`.
### LOW Over-clamping lockTtlSeconds
Wrong:
```typescript
new CopilotRuntime({
agents,
intelligence,
identifyUser,
lockTtlSeconds: 86400, // "I want 1-day lock"
});
```
Correct:
```typescript
new CopilotRuntime({
agents,
intelligence,
identifyUser,
lockTtlSeconds: 3600, // max is 1 hour
});
// Rethink long-running workflows if 1 hour is insufficient.
```
`lockTtlSeconds` is silently `Math.min(value, 3600)`; `lockHeartbeatIntervalSeconds` is
`Math.min(value, 3000)`. Requests over the cap are clamped without warning.
Source: `packages/runtime/src/v2/runtime/core/runtime.ts:281-307`.
### MEDIUM generateThreadNames unset expecting no LLM cost
Wrong:
```typescript
new CopilotRuntime({ agents, intelligence, identifyUser });
// assumes no extra LLM spend
```
Correct:
```typescript
new CopilotRuntime({
agents,
intelligence,
identifyUser,
generateThreadNames: false,
});
```
`generateThreadNames` defaults to `true`. Every newly created thread triggers an extra
LLM call on the Intelligence service side to generate a short name, billed against your Intelligence quota.
Source: `packages/runtime/src/v2/runtime/core/runtime.ts` (generateThreadNames default).
## See also
- `copilotkit/agent-runners` — Intelligence forces `IntelligenceAgentRunner`
- `copilotkit/setup-endpoint``/threads/*` routes flip on with Intelligence
- `copilotkit/threads` (react-core) — `useThreads` depends on Intelligence routes
@@ -0,0 +1,376 @@
# CopilotKit Runtime Middleware
Two coexisting middleware surfaces:
- **`hooks`** (preferred, newer) — pass to `createCopilotRuntimeHandler({ hooks })`.
Route-aware via `onBeforeHandler({ route })`. Throw a `Response` to short-circuit.
- **`beforeRequestMiddleware` / `afterRequestMiddleware`** (legacy) — pass to
`new CopilotRuntime({ ... })`. Runs **after `hooks.onRequest` but before routing** (see
`fetch-handler.ts:136-147` for exact order). Pre-routing only.
Use **hooks** for new code.
## Setup
```typescript
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
const runtime = new CopilotRuntime({
agents: {
/* ... */
} as any,
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
hooks: {
onRequest: async ({ request }) => {
const token = request.headers.get("authorization");
if (!token) throw new Response("Unauthorized", { status: 401 });
},
onBeforeHandler: async ({ route, request }) => {
if (route.method === "agent/run" && route.agentId === "admin") {
const user = await verifyAdminToken(
request.headers.get("authorization"),
);
if (!user) throw new Response("Forbidden", { status: 403 });
}
},
onResponse: async ({ response }) => {
const headers = new Headers(response.headers);
headers.set("x-copilot-version", "2.0");
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
},
onError: async ({ error, route }) => {
console.error("[copilotkit]", route?.method, error);
},
},
});
async function verifyAdminToken(
header: string | null,
): Promise<{ id: string } | null> {
if (!header) return null;
// delegate to your auth lib
return { id: "admin" };
}
export default { fetch: handler };
```
## Core Patterns
### Reject unauthenticated requests at the runtime boundary
```typescript
createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
hooks: {
onRequest: ({ request }) => {
const token = request.headers.get("authorization");
if (!token?.startsWith("Bearer ")) {
throw new Response(JSON.stringify({ error: "unauthorized" }), {
status: 401,
headers: { "content-type": "application/json" },
});
}
},
},
});
```
### Route-aware authorization
Use `onBeforeHandler` — the `route` object carries `method`, `agentId`, and (for thread/stop
methods) `threadId`.
```typescript
createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
hooks: {
onBeforeHandler: async ({ route, request }) => {
if (route.method === "agent/run" && route.agentId === "billing") {
const ok = await canAccessBilling(request);
if (!ok) throw new Response("Forbidden", { status: 403 });
}
},
},
});
async function canAccessBilling(request: Request): Promise<boolean> {
// delegate to your policy engine
return true;
}
```
### Rate-limit by calling an external limiter from the hook
Delegate to a dedicated lib — do not implement a rate limiter inline.
```typescript
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(60, "1 m"),
});
createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
hooks: {
onRequest: async ({ request }) => {
const userId = request.headers.get("x-user-id") ?? "anon";
const { success } = await ratelimit.limit(userId);
if (!success) throw new Response("Too Many Requests", { status: 429 });
},
},
});
```
### Non-blocking telemetry on response
`afterRequestMiddleware` runs non-blocking (errors inside only log). Do not await heavy
work that the user's response waits on.
```typescript
import { CopilotRuntime } from "@copilotkit/runtime/v2";
const runtime = new CopilotRuntime({
agents: {
/* ... */
} as any,
afterRequestMiddleware: async ({ threadId, messages }) => {
// fire-and-forget; do not await heavy work that blocks response
void queue.enqueue({ type: "chat", threadId, messages });
},
});
```
## Common Mistakes
### HIGH Returning a Response instead of throwing
Wrong:
```typescript
new CopilotRuntime({
agents,
beforeRequestMiddleware: async () =>
new Response("Unauthorized", { status: 401 }),
});
```
Correct:
```typescript
new CopilotRuntime({
agents,
beforeRequestMiddleware: async ({ request }) => {
if (!request.headers.get("authorization")) {
throw new Response("Unauthorized", { status: 401 });
}
},
});
```
The middleware contract returns `Request | void`. Returning a Response corrupts the
request object — `fetch-handler.ts:140-147` assigns any truthy return value back to
`request`, so the router then tries to read `request.method` / `request.headers.get(...)`
from the Response and downstream handling blows up. Always `throw` a Response to
short-circuit; never return one.
Source: `packages/runtime/src/v2/runtime/core/fetch-handler.ts:140-156`.
### MEDIUM Defaulting to beforeRequestMiddleware when hooks are preferred
Wrong:
```typescript
new CopilotRuntime({
agents,
beforeRequestMiddleware: async ({ request, path }) => {
if (path.includes("/agent/admin/")) {
/* check admin auth */
}
},
});
```
Correct:
```typescript
const runtime = new CopilotRuntime({ agents });
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
hooks: {
onBeforeHandler: ({ route, request }) => {
if (route.method === "agent/run" && route.agentId === "admin") {
/* ... */
}
},
},
});
```
Both surfaces coexist. For new code the hook API on `createCopilotRuntimeHandler` is
preferred — `onBeforeHandler` receives typed `route` info, so you don't string-match paths.
Source: `packages/runtime/src/v2/runtime/core/hooks.ts:84-117`; maintainer Phase 4c.
### MEDIUM Route-specific auth in global beforeRequestMiddleware
Wrong:
```typescript
new CopilotRuntime({
agents,
beforeRequestMiddleware: async ({ path, request }) => {
if (path.includes("/agent/admin/")) {
/* ... */
}
},
});
```
Correct:
```typescript
createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
hooks: {
onBeforeHandler: ({ route, request }) => {
if (route.method === "agent/run" && route.agentId === "admin") {
/* ... */
}
},
},
});
```
`beforeRequestMiddleware` fires before routing, so no route info exists yet — string-matching
paths is fragile. `onBeforeHandler` fires after routing with typed `route.method`, `route.agentId`.
Source: `packages/runtime/src/v2/runtime/core/hooks.ts:94-103`.
### MEDIUM Blocking on afterRequestMiddleware
Wrong:
```typescript
new CopilotRuntime({
agents,
afterRequestMiddleware: async ({ response, threadId, messages }) => {
await heavyAnalytics(response, threadId, messages);
},
});
```
Correct:
```typescript
new CopilotRuntime({
agents,
afterRequestMiddleware: async ({ response, threadId, messages }) => {
void queue.enqueue({ type: "chat", threadId, messages, response });
},
});
```
The `afterRequestMiddleware` callback receives
`{ runtime, response, path, messages?, threadId?, runId? }` — all these fields are always
available (`messages`/`threadId`/`runId` are populated from the SSE stream when present,
undefined otherwise). The hook runs non-blocking via `.catch()` so errors only log and any
heavy awaited work can be lost on process exit — fire-and-forget is the intended shape.
Source: `packages/runtime/src/v2/runtime/core/fetch-handler.ts:225-234`.
### MEDIUM Passing a webhook URL string as middleware
Wrong:
```typescript
new CopilotRuntime({
agents,
beforeRequestMiddleware: "https://hooks.example/auth" as any,
});
```
Correct:
```typescript
new CopilotRuntime({
agents,
beforeRequestMiddleware: async ({ request }) => {
await fetch("https://hooks.example/auth", {
method: "POST",
body: request.headers.get("authorization") ?? "",
});
},
});
```
Webhook-URL middleware is dead code in v2 — the runtime logs
`"Unsupported beforeRequestMiddleware value skipped"` and does nothing. Only function
middleware is wired.
Source: `packages/runtime/src/v2/runtime/core/middleware.ts:72-87`.
### HIGH Implementing auth / rate-limit inside CopilotKit middleware
Wrong:
```typescript
new CopilotRuntime({
agents,
beforeRequestMiddleware: async ({ request }) => {
// hand-rolling a token-bucket rate limiter inline with Redis calls...
},
});
```
Correct:
```typescript
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(60, "1 m"),
});
new CopilotRuntime({
agents,
beforeRequestMiddleware: async ({ request }) => {
const { success } = await ratelimit.limit(
request.headers.get("x-user-id") ?? "anon",
);
if (!success) throw new Response("Too Many Requests", { status: 429 });
},
});
```
Auth, rate-limiting, and observability are server-framework concerns. CopilotKit middleware
is the hook to invoke them, not a replacement.
Source: maintainer interview (Phase 2c).
## See also
- `copilotkit/setup-endpoint``hooks` are passed to `createCopilotRuntimeHandler`
- `copilotkit/go-to-production` — production checklist lists auth/rate-limit wiring
- `copilotkit/debug-and-troubleshoot``onError` telemetry pattern
@@ -0,0 +1,414 @@
# CopilotKit Server-Side Tools
Server-side tools run in the runtime process. They are the right choice when the tool needs
to touch server-only state: DB connections, API keys, filesystem, signed URLs.
`defineTool` returns a `ToolDefinition`. Pass an array of them to the Simple-Mode
`BuiltInAgent.config.tools`, or into the `tools:` option of `chat()` / `streamText()` inside
a Factory Mode factory.
## Setup
```typescript
import {
CopilotRuntime,
createCopilotRuntimeHandler,
BuiltInAgent,
defineTool,
} from "@copilotkit/runtime/v2";
import { z } from "zod";
const getInventory = defineTool({
name: "getInventory",
description: "Look up stock for a product SKU.",
parameters: z.object({ sku: z.string() }),
execute: async ({ sku }) => {
const row = await db.product.findUnique({ where: { sku } });
return { sku, inStock: row?.inStock ?? 0 };
},
});
const runtime = new CopilotRuntime({
agents: {
default: new BuiltInAgent({
model: "openai/gpt-4o",
maxSteps: 5,
tools: [getInventory],
}),
},
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export default { fetch: handler };
declare const db: {
product: { findUnique: (q: any) => Promise<{ inStock: number } | null> };
};
```
## Core Patterns
### Zod parameters (most common)
```typescript
import { defineTool } from "@copilotkit/runtime/v2";
import { z } from "zod";
const searchDocs = defineTool({
name: "searchDocs",
description: "Search the internal docs index.",
parameters: z.object({
query: z.string().min(1),
limit: z.number().int().min(1).max(20).default(5),
}),
execute: async ({ query, limit }) => {
const results = await searchIndex(query, limit);
return { results };
},
});
declare const searchIndex: (q: string, n: number) => Promise<unknown[]>;
```
### Valibot parameters (Standard Schema V1)
```typescript
import { defineTool } from "@copilotkit/runtime/v2";
import * as v from "valibot";
const translate = defineTool({
name: "translate",
description: "Translate text between languages.",
parameters: v.object({
text: v.pipe(v.string(), v.minLength(1)),
target: v.picklist(["en", "es", "fr", "de"]),
}),
execute: async ({ text, target }) => ({ translated: `[${target}] ${text}` }),
});
```
### Graceful error handling inside execute
```typescript
import { defineTool } from "@copilotkit/runtime/v2";
import { z } from "zod";
const runQuery = defineTool({
name: "runQuery",
description: "Run an analytics query.",
parameters: z.object({ sql: z.string() }),
execute: async ({ sql }) => {
try {
return { rows: await warehouse.query(sql) };
} catch (e) {
return { error: String(e), retryable: true };
}
},
});
declare const warehouse: { query: (sql: string) => Promise<unknown[]> };
```
### Server tool + client tool side by side
Server tools for I/O, client tools for UI. Both can coexist.
```typescript
// server
import { defineTool } from "@copilotkit/runtime/v2";
import { z } from "zod";
export const fetchOrder = defineTool({
name: "fetchOrder",
description: "Fetch order details from the orders service.",
parameters: z.object({ orderId: z.string() }),
execute: async ({ orderId }) => fetchOrderFromService(orderId),
});
declare const fetchOrderFromService: (id: string) => Promise<unknown>;
```
```tsx
// client — a render-only tool lets the LLM display a modal
import { useComponent } from "@copilotkit/react-core/v2";
import { z } from "zod";
useComponent({
name: "showOrderDetails",
parameters: z.object({ orderId: z.string(), status: z.string() }),
// Schema fields arrive DIRECTLY as props (InferRenderProps<TSchema>) —
// no { args } wrapper. See packages/react-core/src/v2/hooks/use-component.tsx.
render: ({ orderId, status }) => (
<div className="modal">
Order {orderId} {status}
</div>
),
});
```
### Factory Mode — pass tools into the factory
Simple-Mode `config.tools` is ignored in Factory Mode.
```typescript
import {
BuiltInAgent,
convertToolDefinitionsToVercelAITools,
convertMessagesToVercelAISDKMessages,
defineTool,
} from "@copilotkit/runtime/v2";
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
const searchDocs = defineTool({
name: "searchDocs",
description: "Search the internal docs index.",
parameters: z.object({ query: z.string() }),
execute: async ({ query }) => ({ results: [] }),
});
new BuiltInAgent({
type: "aisdk",
factory: ({ input, abortSignal }) => {
const serverTools = convertToolDefinitionsToVercelAITools([searchDocs]);
return streamText({
model: openai("gpt-4o"),
messages: convertMessagesToVercelAISDKMessages(input.messages),
tools: serverTools,
abortSignal,
});
},
});
```
## Common Mistakes
### HIGH Using defineTool for tools that should render UI
Wrong:
```typescript
defineTool({
name: "showModal",
description: "Show a confirmation modal to the user.",
parameters: z.object({ title: z.string() }),
execute: async () => "rendered",
});
```
Correct:
```tsx
// Keep UI on the client — frontend tool with a renderer
import { useFrontendTool } from "@copilotkit/react-core/v2";
import { z } from "zod";
useFrontendTool({
name: "showModal",
parameters: z.object({ title: z.string() }),
handler: async (args) => ({ confirmed: true }),
});
```
Server tools execute on the server and stream only results back. The browser never sees a
`TOOL_CALL_START` for a server tool, so there is nothing to mount a renderer against.
Source: `dev-docs/architecture/plugin-points.md:36-77`;
`docs/content/docs/integrations/built-in-agent/server-tools.mdx:9-14`.
### MEDIUM Redefining AG-UI reserved names
Wrong:
```typescript
defineTool({
name: "AGUISendStateSnapshot",
description: "My own snapshot tool.",
parameters: z.object({ snapshot: z.any() }),
execute: async () => ({ success: true }),
});
```
Correct:
```typescript
defineTool({
name: "mySnapshotExport",
description: "Export a user-facing state snapshot.",
parameters: z.object({ snapshot: z.any() }),
execute: async () => ({ success: true }),
});
```
`AGUISendStateSnapshot` and `AGUISendStateDelta` are auto-injected by BuiltInAgent in
Simple Mode — redefining them silently overwrites the built-ins.
Source: `packages/runtime/src/agent/index.ts:1139-1177`.
### MEDIUM Throwing from execute without a result
Wrong:
```typescript
defineTool({
name: "runQuery",
description: "Run a database query.",
parameters: z.object({ sql: z.string() }),
execute: async () => {
throw new Error("db down");
},
});
```
Correct:
```typescript
defineTool({
name: "runQuery",
description: "Run a database query.",
parameters: z.object({ sql: z.string() }),
execute: async ({ sql }) => {
try {
return await db.query(sql);
} catch (e) {
return { error: String(e), retryable: true };
}
},
});
```
Thrown errors kill the run; unserializable results (class instances, circular refs) become
the string `"[Unserializable tool result from X]"`. Return a plain-object error shape
instead and let the LLM retry.
Source: `packages/runtime/src/agent/index.ts:1469-1474`.
### MEDIUM Passing a JSON-schema object as parameters
Wrong:
```typescript
defineTool({
name: "x",
description: "...",
parameters: {
type: "object",
properties: { q: { type: "string" } },
required: ["q"],
} as any,
execute: async ({ q }) => q,
});
```
Correct:
```typescript
import { z } from "zod";
defineTool({
name: "x",
description: "...",
parameters: z.object({ q: z.string() }),
execute: async ({ q }) => q,
});
```
`parameters` must be a Standard Schema V1 validator (Zod, Valibot, ArkType, ...). Plain
JSON Schema throws in `schemaToJsonSchema()`. Also, Standard Schema V1 preserves static
types — `execute`'s arg type is inferred.
Source: `packages/runtime/src/agent/index.ts:633-659`.
### HIGH Unavailable in Factory Mode via config.tools
Wrong:
```typescript
new BuiltInAgent({
type: "tanstack",
factory: myFactory,
tools: [searchDocs], // ignored in Factory Mode
} as any);
```
Correct:
```typescript
// Factory Mode — AI SDK factory: convert defineTool → Vercel AI SDK tools
import {
BuiltInAgent,
convertToolDefinitionsToVercelAITools,
convertMessagesToVercelAISDKMessages,
} from "@copilotkit/runtime/v2";
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";
new BuiltInAgent({
type: "aisdk",
factory: ({ input, abortSignal }) => {
const tools = convertToolDefinitionsToVercelAITools([searchDocs]);
return streamText({
model: openai("gpt-4o"),
messages: convertMessagesToVercelAISDKMessages(input.messages),
tools,
abortSignal,
});
},
});
// Factory Mode — TanStack AI factory: defineTool output is NOT a TanStack tool.
// There is no built-in converter in @copilotkit/runtime for TanStack. Either
// redefine the tool with TanStack's `toolDefinition()` API from `@tanstack/ai`,
// or write a small adapter that translates your `defineTool` output into
// TanStack's tool shape before passing it into `chat({ tools })`.
```
Factory Mode ignores `config.tools`. Wire server tools through the factory's LLM call —
AI SDK has `convertToolDefinitionsToVercelAITools([...])` out of the box; TanStack AI has
its own `toolDefinition()` API you need to build the tools with directly.
Source: `packages/runtime/src/agent/index.ts:1581-1671`.
### MEDIUM Shared name between client and server tool
Wrong:
```tsx
// frontend
useFrontendTool({
name: "getWeather",
parameters: z.object({ city: z.string() }),
handler,
});
// server
defineTool({
name: "getWeather",
parameters: z.object({ city: z.string() }),
execute,
});
// Server silently wins on the merge — handler never fires
```
Correct:
```tsx
// Pick one side and give tools distinct names if both sides need their own
useFrontendTool({ name: "getWeatherClientSide" /* ... */ });
defineTool({ name: "getWeatherServer" /* ... */ });
```
On collisions, `config.tools` (server) overwrites frontend-registered tools. The LLM sees
only one `getWeather` — the server version.
Source: `packages/runtime/src/agent/index.ts` (tool merge).
## See also
- `copilotkit/built-in-agent``config.tools` only applies in Simple Mode
- `copilotkit/client-side-tools` (react-core) — browser-side tools, paired decision
- `copilotkit/rendering-tool-calls` (react-core) — rendering tool invocations in chat
@@ -0,0 +1,503 @@
# CopilotKit Runtime Endpoint
`createCopilotRuntimeHandler` is the strongly-preferred primitive. It returns a
`(Request) => Promise<Response>` that works in every fetch-native runtime and can be
delegated to from Express/Hono/Node. Avoid `createCopilotExpressHandler` and
`createCopilotHonoHandler` in new code.
## Setup
Minimal runtime on any fetch server (Bun, Deno, Cloudflare Workers, Vercel Edge):
```typescript
import {
CopilotRuntime,
createCopilotRuntimeHandler,
BuiltInAgent,
convertInputToTanStackAI,
} from "@copilotkit/runtime/v2";
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const runtime = new CopilotRuntime({
agents: {
default: new BuiltInAgent({
type: "tanstack",
factory: ({ input, abortController }) => {
const { messages, systemPrompts } = convertInputToTanStackAI(input);
return chat({
adapter: openaiText("gpt-4o"),
messages,
systemPrompts,
abortController,
});
},
}),
},
});
export const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
cors: true,
});
// Bun / Deno / Vercel Edge:
// Bun.serve({ fetch: handler });
// Deno.serve(handler);
// Cloudflare Workers:
// export default { fetch: handler };
```
## Core Patterns
### React Router v7 framework mode
```typescript
// app/routes/api.copilotkit.$.tsx
import type { Route } from "./+types/api.copilotkit.$";
import {
CopilotRuntime,
createCopilotRuntimeHandler,
BuiltInAgent,
convertInputToTanStackAI,
} from "@copilotkit/runtime/v2";
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const runtime = new CopilotRuntime({
agents: {
default: new BuiltInAgent({
type: "tanstack",
factory: ({ input, abortController }) => {
const { messages, systemPrompts } = convertInputToTanStackAI(input);
return chat({
adapter: openaiText("gpt-4o"),
messages,
systemPrompts,
abortController,
});
},
}),
},
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export async function loader({ request }: Route.LoaderArgs) {
return handler(request);
}
export async function action({ request }: Route.ActionArgs) {
return handler(request);
}
```
### Next.js App Router
```typescript
// app/api/copilotkit/[...slug]/route.ts
import {
CopilotRuntime,
createCopilotRuntimeHandler,
BuiltInAgent,
convertInputToTanStackAI,
} from "@copilotkit/runtime/v2";
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const runtime = new CopilotRuntime({
agents: {
default: new BuiltInAgent({
type: "tanstack",
factory: ({ input, abortController }) => {
const { messages, systemPrompts } = convertInputToTanStackAI(input);
return chat({
adapter: openaiText("gpt-4o"),
messages,
systemPrompts,
abortController,
});
},
}),
},
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export const GET = handler;
export const POST = handler;
export const OPTIONS = handler;
```
### Cloudflare Workers with env-sourced keys
Workers don't expose `env` at module scope, so build the runtime + handler lazily on the
first request and cache them in module-scoped variables. `openaiText(model, config)` does
NOT accept an `apiKey` in its config (it auto-reads `OPENAI_API_KEY` from env) — for an
explicit key, use `createOpenaiChat(model, apiKey, config?)`.
```typescript
// worker.ts
import {
CopilotRuntime,
createCopilotRuntimeHandler,
BuiltInAgent,
convertInputToTanStackAI,
} from "@copilotkit/runtime/v2";
import { chat } from "@tanstack/ai";
import { createOpenaiChat } from "@tanstack/ai-openai";
interface Env {
OPENAI_API_KEY: string;
}
type Handler = (request: Request) => Promise<Response>;
let handler: Handler | undefined;
function getHandler(env: Env): Handler {
if (handler) return handler;
const runtime = new CopilotRuntime({
agents: {
default: new BuiltInAgent({
type: "tanstack",
factory: ({ input, abortController }) => {
const { messages, systemPrompts } = convertInputToTanStackAI(input);
return chat({
adapter: createOpenaiChat("gpt-4o", env.OPENAI_API_KEY),
messages,
systemPrompts,
abortController,
});
},
}),
},
});
handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
cors: true,
});
return handler;
}
export default {
fetch(request: Request, env: Env) {
return getHandler(env)(request);
},
};
```
### Delegate from Express / Hono to the fetch primitive
Do not use `createCopilotExpressHandler` / `createCopilotHonoHandler`.
```typescript
// Express — requires Node 18.17+ for Readable.fromWeb + fetch body: req
import express from "express";
import { Readable } from "node:stream";
import type { ReadableStream as WebReadableStream } from "node:stream/web";
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
const app = express();
const runtime = new CopilotRuntime({
agents: {
/* ... */
} as any,
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
app.all("/api/copilotkit/*", async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
// `body: req` + `duplex: "half"` lets us stream the Node IncomingMessage
// into a Web Request without buffering (Node 18.17+).
const webReq = new Request(url, {
method: req.method,
headers: req.headers as any,
body: ["GET", "HEAD"].includes(req.method!) ? undefined : req,
duplex: "half",
} as any);
const webRes = await handler(webReq);
res.status(webRes.status);
webRes.headers.forEach((v, k) => res.setHeader(k, v));
// Stream the response body through — required for SSE on
// /agent/*/run and /agent/*/connect. Buffering via arrayBuffer()
// would collapse the stream and deliver all events at end-of-stream.
if (webRes.body) {
Readable.fromWeb(webRes.body as unknown as WebReadableStream).pipe(res);
} else {
res.end();
}
});
app.listen(3000);
```
```typescript
// Hono — already speaks Request/Response
import { Hono } from "hono";
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
const app = new Hono();
const runtime = new CopilotRuntime({
agents: {
/* ... */
} as any,
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
app.all("/api/copilotkit/*", (c) => handler(c.req.raw));
export default app;
```
### Route table
Multi-route mode (default) exposes: `GET /info`, `POST /agent/:agentId/run`,
`GET /agent/:agentId/connect`, `POST /agent/:agentId/stop/:threadId`, `POST /transcribe`,
`GET/POST /threads`, `GET /threads/subscribe`, `PATCH /threads/:threadId`,
`POST /threads/:threadId/archive`, `DELETE /threads/:threadId`,
`GET /threads/:threadId/messages`. Thread routes are only wired when Intelligence mode
is configured.
Single-route mode exposes a single `POST basePath` that accepts
`{ method, params, body }` envelopes — use when behind a strict reverse proxy.
## Common Mistakes
### CRITICAL Using createCopilotExpressHandler / createCopilotHonoHandler in new code
Wrong:
```typescript
import { createCopilotExpressHandler } from "@copilotkit/runtime/v2/express";
app.use(
"/api/copilotkit",
createCopilotExpressHandler({ runtime, basePath: "/api/copilotkit" }),
);
```
Correct:
```typescript
import { Readable } from "node:stream";
import type { ReadableStream as WebReadableStream } from "node:stream/web";
import { createCopilotRuntimeHandler } from "@copilotkit/runtime/v2";
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
app.all("/api/copilotkit/*", async (req, res) => {
// Requires Node 18.17+ (Readable.fromWeb + duplex: "half")
const webReq = new Request(new URL(req.url, `http://${req.headers.host}`), {
method: req.method,
headers: req.headers as any,
body: ["GET", "HEAD"].includes(req.method!) ? undefined : req,
duplex: "half",
} as any);
const webRes = await handler(webReq);
res.status(webRes.status);
webRes.headers.forEach((v, k) => res.setHeader(k, v));
// Stream, don't buffer — /agent/*/run is SSE.
if (webRes.body) {
Readable.fromWeb(webRes.body as unknown as WebReadableStream).pipe(res);
} else {
res.end();
}
});
```
The Express and Hono adapters are a discouraged surface — the maintainer flags them as
"avoid at all costs." They pull in heavier dependencies, add framework binding, and make
it harder to port. The fetch handler works from any Express/Hono route.
Source: `packages/runtime/src/v2/runtime/core/fetch-handler.ts:1-27`; maintainer Phase 4d.
### CRITICAL Instantiating Express handler without basePath
Wrong:
```typescript
app.use(createCopilotExpressHandler({ runtime }));
```
Correct:
```typescript
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
app.all("/api/copilotkit/*", (req, res) => {
/* delegate as shown above */
});
```
`normalizeBasePath` throws `"basePath must be provided for Express endpoint"` at mount time
and crashes the server.
Source: `packages/runtime/src/v2/runtime/endpoints/express.ts:161`.
### HIGH Using framework adapter on Workers / Bun / Deno
Wrong:
```typescript
// Cloudflare Worker
import { createCopilotHonoHandler } from "@copilotkit/runtime/v2/hono";
export default app;
```
Correct:
```typescript
import { createCopilotRuntimeHandler } from "@copilotkit/runtime/v2";
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export default { fetch: (req: Request) => handler(req) };
```
Adapters bundle Node polyfills unnecessarily in fetch-native runtimes.
Source: `packages/runtime/src/v2/runtime/core/fetch-handler.ts:1-27`.
### HIGH Returning a Response from beforeRequestMiddleware
Wrong:
```typescript
new CopilotRuntime({
agents,
beforeRequestMiddleware: async () =>
new Response("Unauthorized", { status: 401 }),
});
```
Correct:
```typescript
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
hooks: {
onRequest: ({ request }) => {
if (!request.headers.get("authorization")) {
throw new Response("Unauthorized", { status: 401 });
}
},
},
});
```
Only `Request | void` returns are honored. Any other return is ignored. Responses must be
thrown.
Source: `packages/runtime/src/v2/runtime/core/fetch-handler.ts:148-156`.
### MEDIUM Calling multi-route paths against a single-route handler
Wrong:
```typescript
// handler = createCopilotRuntimeHandler({ mode: "single-route", ... })
fetch("/api/copilotkit/agent/x/run", {
method: "POST",
body: JSON.stringify(input),
});
```
Correct:
```typescript
fetch("/api/copilotkit", {
method: "POST",
body: JSON.stringify({
method: "agent/run",
params: { agentId: "x" },
body: input,
}),
});
// On the client, pair with <CopilotKit useSingleEndpoint /> from "@copilotkit/react-core/v2".
```
Single-route expects a POST envelope with `{ method, params, body }`; URL-pattern calls 404.
Source: `packages/runtime/src/v2/runtime/core/fetch-handler.ts:86-90,350-401`.
### MEDIUM Double-layering CORS in Express
Wrong:
```typescript
import cors from "cors";
app.use(cors());
app.use(
createCopilotExpressHandler({ runtime, basePath, cors: { origin: "..." } }),
);
```
Correct:
```typescript
// Pick one — handler's cors option OR your own cors(), not both:
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
cors: { origin: "https://my.app" },
});
app.all("/api/copilotkit/*", (req, res) => {
/* delegate as above */
});
```
Both layers add CORS headers and the duplicates break strict browser enforcement.
Source: `packages/runtime/src/v2/runtime/endpoints/express.ts:100-143`.
### HIGH Mixing v1 and v2 import paths
Wrong:
```typescript
import { CopilotRuntime } from "@copilotkit/runtime";
import { createCopilotRuntimeHandler } from "@copilotkit/runtime/v2";
```
Correct:
```typescript
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
```
Both v1 and v2 APIs compile together but route through different implementations. Always
use the `/v2` subpath in v2 code.
Source: `packages/runtime/src/v2/index.ts`.
## See also
- `copilotkit/middleware` — hook lifecycle into this handler
- `copilotkit/agent-runners` — pair with a persistent runner for production
- `copilotkit/intelligence-mode` — thread routes flip on when Intelligence is configured
@@ -0,0 +1,287 @@
# CopilotKit Transcription
Subclass `TranscriptionService`, pass an instance to `CopilotRuntime({ transcriptionService })`,
and the `POST /transcribe` endpoint lights up. The service has a single method,
`transcribeFile`, that returns the transcript as a plain string.
## Setup
```typescript
import {
CopilotRuntime,
createCopilotRuntimeHandler,
TranscriptionService,
type TranscribeFileOptions,
} from "@copilotkit/runtime/v2";
import OpenAI from "openai";
class OpenAIWhisperTranscription extends TranscriptionService {
private client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
async transcribeFile({ audioFile }: TranscribeFileOptions): Promise<string> {
const result = await this.client.audio.transcriptions.create({
file: audioFile,
model: "whisper-1",
});
return result.text;
}
}
const runtime = new CopilotRuntime({
agents: {
/* ... */
} as any,
transcriptionService: new OpenAIWhisperTranscription(),
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export default { fetch: handler };
```
## Core Patterns
### Abstract contract
```typescript
// packages/runtime/src/v2/runtime/transcription-service/transcription-service.ts
export interface TranscribeFileOptions {
audioFile: File;
mimeType?: string;
size?: number;
}
export abstract class TranscriptionService {
abstract transcribeFile(options: TranscribeFileOptions): Promise<string>;
}
```
### Supported request shapes
Multipart (REST mode):
```typescript
const form = new FormData();
form.append("audio", blob, "recording.webm");
await fetch("/api/copilotkit/transcribe", { method: "POST", body: form });
```
JSON (works in both multi-route and single-endpoint modes — dispatch is by
`Content-Type: application/json`; `mimeType` is required in the payload):
```typescript
await fetch("/api/copilotkit/transcribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
audio: base64String,
mimeType: "audio/webm",
filename: "recording.webm", // optional
}),
});
```
### Reject oversize audio with a graceful 400
```typescript
class OpenAIWhisperTranscription extends TranscriptionService {
private client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
async transcribeFile({
audioFile,
size,
}: TranscribeFileOptions): Promise<string> {
const max = 25 * 1024 * 1024; // 25 MB
if ((size ?? audioFile.size) > max) {
// "too long" keyword → audio_too_long response
throw new Error("Audio duration too long — max 25MB per upload");
}
const result = await this.client.audio.transcriptions.create({
file: audioFile,
model: "whisper-1",
});
return result.text;
}
}
```
### Error auto-categorization
The runtime inspects `String(error).toLowerCase()` thrown by your service and maps keywords
to error codes. Let the provider error bubble up — do not re-categorize inside the service.
| Keyword substrings | Maps to |
| ---------------------------------------- | -------------------------------- |
| `rate`, `429`, `too many` | `rate_limited` (retryable) |
| `auth`, `401`, `api key`, `unauthorized` | `auth_failed` (not retryable) |
| `too long`, `duration`, `length` | `audio_too_long` (not retryable) |
| (anything else) | `provider_error` (retryable) |
Full error-code enum:
```typescript
// packages/shared/src/transcription-errors.ts
export enum TranscriptionErrorCode {
SERVICE_NOT_CONFIGURED = "service_not_configured",
INVALID_AUDIO_FORMAT = "invalid_audio_format",
AUDIO_TOO_LONG = "audio_too_long",
AUDIO_TOO_SHORT = "audio_too_short",
RATE_LIMITED = "rate_limited",
AUTH_FAILED = "auth_failed",
PROVIDER_ERROR = "provider_error",
NETWORK_ERROR = "network_error",
INVALID_REQUEST = "invalid_request",
}
```
## Common Mistakes
### HIGH Calling /transcribe without configuring transcriptionService
Wrong:
```typescript
new CopilotRuntime({ agents });
// client calls /api/copilotkit/transcribe → 503
```
Correct:
```typescript
new CopilotRuntime({
agents,
transcriptionService: new MyWhisperService(),
});
```
Unconfigured runtime returns HTTP 503 with
`{ error: "service_not_configured" }`. The frontend gets no transcript with no obvious
server-side failure.
Source: `packages/runtime/src/v2/runtime/handlers/handle-transcribe.ts:203-207`.
### MEDIUM Form field named "file" instead of "audio"
Wrong:
```typescript
const form = new FormData();
form.append("file", blob, "recording.webm");
await fetch("/api/copilotkit/transcribe", { method: "POST", body: form });
```
Correct:
```typescript
const form = new FormData();
form.append("audio", blob, "recording.webm");
await fetch("/api/copilotkit/transcribe", { method: "POST", body: form });
```
The handler reads `formData.get("audio")` — any other field name yields `null` and returns
`invalid_request`.
Source: `packages/runtime/src/v2/runtime/handlers/handle-transcribe.ts:91-97`.
### MEDIUM Base64 payload missing mimeType
Wrong:
```typescript
await fetch("/api/copilotkit/transcribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ audio: b64 }),
});
```
Correct:
```typescript
await fetch("/api/copilotkit/transcribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ audio: b64, mimeType: "audio/webm" }),
});
```
JSON mode requires `mimeType` — the handler explicitly rejects payloads missing it with
`invalid_request`.
Source: `packages/runtime/src/v2/runtime/handlers/handle-transcribe.ts:131-136`.
### LOW Re-categorizing errors inside the service
Wrong:
```typescript
class MyService extends TranscriptionService {
async transcribeFile(opts: TranscribeFileOptions): Promise<string> {
try {
return await doTranscribe(opts);
} catch (e) {
// trying to hand-pick error codes
throw new Error("RATE_LIMITED");
}
}
}
```
Correct:
```typescript
class MyService extends TranscriptionService {
async transcribeFile(opts: TranscribeFileOptions): Promise<string> {
return doTranscribe(opts); // let provider errors bubble up verbatim
}
}
```
The runtime scans `String(error).toLowerCase()` for `"rate"`, `"429"`, `"auth"`, `"too long"`
etc. Provider-native messages (`"OpenAI returned 429 rate limited"`) auto-map to the right
code. Hand-crafted codes bypass the keyword matcher and end up as `provider_error`.
Source: `packages/runtime/src/v2/runtime/handlers/handle-transcribe.ts:160-196`.
### MEDIUM Returning a rich object instead of a string
Wrong:
```typescript
class MyService extends TranscriptionService {
async transcribeFile(opts: TranscribeFileOptions): Promise<string> {
// @ts-expect-error returning the wrong shape
return {
text: "hi",
segments: [
/* ... */
],
};
}
}
```
Correct:
```typescript
class MyService extends TranscriptionService {
async transcribeFile(opts: TranscribeFileOptions): Promise<string> {
const result = await provider.transcribe(opts.audioFile);
return result.text;
}
}
```
`transcribeFile` returns `Promise<string>`. The handler sends
`{ transcription: string }` back to the client — any other shape is a TypeScript error and
would be JSON-stringified wrongly at runtime.
Source: `packages/runtime/src/v2/runtime/transcription-service/transcription-service.ts:9-11`.
## See also
- `copilotkit/setup-endpoint``/transcribe` is one of the routes the handler mounts
- `copilotkit/debug-and-troubleshoot``TranscriptionErrorCode` catalog
@@ -0,0 +1,40 @@
A2A (Agent2Agent) — wired via `@ag-ui/a2a`. Requires a pre-built `A2AClient` (not a URL).
## Install
```bash
pnpm add @ag-ui/a2a @a2a-js/sdk
```
## Minimal wire-up
```typescript
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { A2AAgent } from "@ag-ui/a2a";
import { A2AClient } from "@a2a-js/sdk/client";
const a2aClient = new A2AClient(process.env.A2A_URL!);
const runtime = new CopilotRuntime({
agents: {
default: new A2AAgent({ a2aClient }),
},
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export default { fetch: handler };
```
## Gotcha — do NOT pass `{ url }`
`A2AAgent` takes `{ a2aClient }`. The A2A protocol has its own handshake; the client
object handles it. Passing `{ url: "..." }` is a type error and will fail at runtime.
Source: `examples/integrations/a2a-a2ui/app/api/copilotkit/[[...slug]]/route.tsx:12`.
@@ -0,0 +1,45 @@
Google ADK (Agent Development Kit) — wired via the bare `HttpAgent` from `@ag-ui/client`.
## Install
```bash
pnpm add @ag-ui/client
```
## Minimal wire-up
```typescript
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { HttpAgent } from "@ag-ui/client";
const runtime = new CopilotRuntime({
agents: {
default: new HttpAgent({
url: process.env.ADK_URL ?? "http://localhost:8000/",
}),
},
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export default { fetch: handler };
```
## Server side
Your ADK Python agent must speak AG-UI. ADK ships an AG-UI FastAPI adapter — use it
and point `HttpAgent({ url })` at the FastAPI route.
## Gotcha — env-sourced credentials
ADK typically authenticates to Google Cloud via service-account credentials
(`GOOGLE_APPLICATION_CREDENTIALS`). Those live on the ADK Python server, not in the
CopilotKit runtime. The runtime just forwards AG-UI events.
Source: `docs/content/docs/integrations/adk/quickstart.mdx`.
@@ -0,0 +1,41 @@
AG2 — wired via the bare `HttpAgent` from `@ag-ui/client`. No dedicated `@ag-ui/ag2`
package exists; AG2 is a standard HTTP AG-UI framework.
## Install
```bash
pnpm add @ag-ui/client
```
## Minimal wire-up
```typescript
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { HttpAgent } from "@ag-ui/client";
const runtime = new CopilotRuntime({
agents: {
default: new HttpAgent({
url: process.env.AG2_URL ?? "http://localhost:8000/",
}),
},
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export default { fetch: handler };
```
## Gotcha — no dedicated package
Unlike Mastra / LangGraph / CrewAI / LlamaIndex / Agno, AG2 has no `@ag-ui/ag2` package.
Always use the generic `HttpAgent`. If an older doc references a dedicated AG2 package,
treat it as stale.
Source: `docs/content/docs/integrations/ag2/quickstart.mdx`; maintainer Phase 4 resolution.
@@ -0,0 +1,40 @@
Agno — wired via the generic `HttpAgent` from `@ag-ui/client`. Requires an `/agui` URL
suffix.
## Install
```bash
pnpm add @ag-ui/client
```
## Minimal wire-up
```typescript
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { HttpAgent } from "@ag-ui/client";
const runtime = new CopilotRuntime({
agents: {
default: new HttpAgent({
url: process.env.AGNO_URL ?? "http://localhost:8000/agui",
}),
},
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export default { fetch: handler };
```
## Gotcha — the `/agui` suffix is mandatory
Agno's AG-UI FastAPI app mounts at `/agui`. Pointing `url` at the server root
(`http://localhost:8000`) returns 404. Always include `/agui`.
Source: `docs/content/docs/integrations/agno/quickstart.mdx:215`.
@@ -0,0 +1,59 @@
AWS Strands — wired via the bare `HttpAgent` from `@ag-ui/client`.
## Install
```bash
pnpm add @ag-ui/client
```
## Minimal wire-up
```typescript
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { HttpAgent } from "@ag-ui/client";
const runtime = new CopilotRuntime({
agents: {
default: new HttpAgent({
url: process.env.STRANDS_URL ?? "http://localhost:8000",
}),
},
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export default { fetch: handler };
```
## Server side
Strands agents run on AWS and typically expose an AG-UI-speaking endpoint (API Gateway or
Lambda Function URL). Point `HttpAgent({ url })` at that endpoint.
## Gotcha — AWS auth
Strands deployments often require IAM SigV4 or a custom header. `HttpAgent` accepts a
`headers: Record<string, string>` option that is attached to every outbound runtime →
Strands call:
```typescript
new HttpAgent({
url: process.env.STRANDS_URL!,
headers: { Authorization: `Bearer ${process.env.STRANDS_TOKEN!}` },
});
```
`hooks.onBeforeHandler` will NOT work for this — those hooks run on the inbound frontend
→ runtime request, not on the outbound runtime → Strands call that `HttpAgent` issues.
For SigV4 (which needs a per-request signature over the body), front Strands with a
lightweight Lambda / API Gateway authorizer that strips client credentials and adds the
IAM signing, then point `HttpAgent({ url })` at that shim.
Source: `node_modules/@ag-ui/client/dist/index.d.ts` (`HttpAgentConfig.headers`);
`docs/content/docs/integrations/aws-strands/quickstart.mdx`.
@@ -0,0 +1,51 @@
CrewAI Crews — multi-agent crews wired via `@ag-ui/crewai`.
## Install
```bash
pnpm add @ag-ui/crewai
```
## Minimal wire-up
```typescript
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { CrewAIAgent } from "@ag-ui/crewai";
const runtime = new CopilotRuntime({
agents: {
default: new CrewAIAgent({
url: process.env.CREWAI_URL ?? "http://localhost:8000/",
}),
},
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export default { fetch: handler };
```
## Crews vs Flows
CrewAI ships two products:
- **Crews** — multi-agent orchestration. Use `CrewAIAgent` from `@ag-ui/crewai`.
- **Flows** — event-driven pipelines. Use the generic `HttpAgent` from `@ag-ui/client`
(there's no framework-specific wrapper). See [crewai-flows.md](crewai-flows.md).
## Gotcha — trailing slash
The `url` for `CrewAIAgent` traditionally ends with a trailing slash
(`http://localhost:8000/`). Follow whatever your CrewAI server exposes — don't strip it.
Source: `@ag-ui/crewai` package types (`CrewAIAgent` constructor);
`docs/content/docs/reference/v1/sdk/python/CrewAIAgent.mdx` for v1 Python-side
reference. The v2 integrations docs currently ship only a Flows quickstart at
`docs/content/docs/integrations/crewai-flows/quickstart.mdx` — there is no dedicated
Crews quickstart yet.
@@ -0,0 +1,45 @@
CrewAI Flows — wired via the bare `HttpAgent` from `@ag-ui/client`. No dedicated wrapper.
## Install
```bash
pnpm add @ag-ui/client
```
## Minimal wire-up
```typescript
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { HttpAgent } from "@ag-ui/client";
const runtime = new CopilotRuntime({
agents: {
default: new HttpAgent({
url: process.env.CREWAI_FLOWS_URL ?? "http://localhost:8000/",
}),
},
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export default { fetch: handler };
```
## Flows vs Crews
Flows is the event-driven pipeline product. For multi-agent Crews orchestration, use
`CrewAIAgent` from `@ag-ui/crewai` instead — see [crewai-crews.md](crewai-crews.md).
## Gotcha — AG-UI compatibility
Your CrewAI Flows server must speak AG-UI over HTTP. If you control the server, use the
official CrewAI Python AG-UI adapter. `HttpAgent` is a thin bridge — any server that
emits AG-UI events at the URL works.
Source: `docs/content/docs/integrations/crewai-flows/quickstart.mdx`.
@@ -0,0 +1,348 @@
# CopilotKit — Wire External Agent Frameworks
`CopilotRuntime` takes any `AbstractAgent` subclass. Every framework below ships a
ready-made subclass you construct and hand to `agents: { ... }`.
| Framework | Package | Construct |
| ------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| Mastra | `@ag-ui/mastra` | `MastraAgent.getLocalAgents({ mastra, resourceId? })` (record; `resourceId` required only when the agent has Memory enabled) |
| LangGraph | `@copilotkit/runtime/langgraph` | `new LangGraphAgent({ deploymentUrl, graphId })` |
| CrewAI Crews | `@ag-ui/crewai` | `new CrewAIAgent({ url })` |
| CrewAI Flows | `@ag-ui/client` (HttpAgent) | `new HttpAgent({ url })` |
| PydanticAI | `@ag-ui/client` (HttpAgent) | `new HttpAgent({ url })` |
| Google ADK | `@ag-ui/client` (HttpAgent) | `new HttpAgent({ url })` |
| LlamaIndex | `@ag-ui/llamaindex` | `new LlamaIndexAgent({ url: ".../run" })` (`/run` suffix) |
| Agno | `@ag-ui/client` (HttpAgent) | `new HttpAgent({ url: ".../agui" })` (`/agui` suffix) |
| AWS Strands | `@ag-ui/client` (HttpAgent) | `new HttpAgent({ url })` |
| Microsoft Agent Framework | `@ag-ui/client` (HttpAgent) | `new HttpAgent({ url })` |
| AG2 | `@ag-ui/client` (HttpAgent) | `new HttpAgent({ url })` |
| A2A | `@ag-ui/a2a` | `new A2AAgent({ a2aClient })` (pre-built `A2AClient`, not a URL) |
MCP Apps is NOT a framework — it's a runtime middleware:
`new CopilotRuntime({ agents, mcpApps: { servers: [...] } })`. See
[wiring-mcp-apps-middleware.md](wiring-mcp-apps-middleware.md).
## Setup
Generic shape for every framework:
```typescript
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { HttpAgent } from "@ag-ui/client";
const runtime = new CopilotRuntime({
agents: {
default: new HttpAgent({ url: process.env.AGENT_URL! }),
},
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export default { fetch: handler };
```
## Core Patterns
### Mastra (local agents)
```typescript
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { MastraAgent } from "@ag-ui/mastra";
import { mastra } from "./mastra";
const runtime = new CopilotRuntime({
// resourceId scopes Mastra Memory's working-memory buckets. Required when
// the Mastra agent has Memory enabled (the runtime always supplies a
// threadId, so Memory-enabled agents effectively always need it). Agents
// without Memory can omit it — `examples/integrations/mastra` calls
// `getLocalAgents({ mastra })` with no resourceId. See wiring-mastra.md.
agents: MastraAgent.getLocalAgents({ mastra, resourceId: "default" }),
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export default { fetch: handler };
```
See [wiring-mastra.md](wiring-mastra.md).
### LangGraph
```typescript
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
const runtime = new CopilotRuntime({
agents: {
supportAgent: new LangGraphAgent({
deploymentUrl: process.env.LANGGRAPH_URL!,
graphId: "support",
langsmithApiKey: process.env.LANGSMITH_API_KEY,
}),
},
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export default { fetch: handler };
```
See [wiring-langgraph.md](wiring-langgraph.md).
### Multi-framework single runtime
```typescript
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
import { CrewAIAgent } from "@ag-ui/crewai";
import { HttpAgent } from "@ag-ui/client";
const runtime = new CopilotRuntime({
agents: {
research: new LangGraphAgent({
deploymentUrl: process.env.LANGGRAPH_URL!,
graphId: "research",
}),
writer: new CrewAIAgent({ url: process.env.CREWAI_URL! }),
translator: new HttpAgent({ url: process.env.PYDANTIC_AI_URL! }),
},
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export default { fetch: handler };
```
### MCP Apps (runtime middleware, not an agent)
```typescript
import {
CopilotRuntime,
createCopilotRuntimeHandler,
BuiltInAgent,
} from "@copilotkit/runtime/v2";
const runtime = new CopilotRuntime({
agents: {
default: new BuiltInAgent({ model: "openai/gpt-4o" }),
},
mcpApps: {
servers: [{ type: "http", url: "https://mcp.example.com/mcp" }],
},
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export default { fetch: handler };
```
## Common Mistakes
### HIGH Using runtimeUrl as the agent URL
Wrong:
```typescript
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
new LangGraphAgent({ deploymentUrl: "/api/copilotkit", graphId: "agent" });
```
Correct:
```typescript
new LangGraphAgent({
deploymentUrl: process.env.LANGGRAPH_URL!,
graphId: "agent",
});
```
External agents take their own upstream URL — the framework's server or deployment. The
CopilotKit runtime URL (`/api/copilotkit`) is the frontend↔runtime hop, not the
runtime↔agent hop.
Source: `docs/integrations/langgraph/quickstart.mdx:355`.
### HIGH Wrapping MastraAgent.getLocalAgents in a key
Wrong:
```typescript
new CopilotRuntime({
agents: {
mastra: MastraAgent.getLocalAgents({ mastra, resourceId: "default" }),
},
});
```
Correct:
```typescript
new CopilotRuntime({
agents: MastraAgent.getLocalAgents({ mastra, resourceId: "default" }),
});
```
`MastraAgent.getLocalAgents` already returns a `Record<string, AbstractAgent>`. Wrapping
it turns the record into a nested value on one key, which fails the registry's shape check.
Source: `docs/integrations/mastra/quickstart.mdx:213-220`.
### MEDIUM Missing /run or /agui suffix on LlamaIndex / Agno
Wrong:
```typescript
import { LlamaIndexAgent } from "@ag-ui/llamaindex";
import { HttpAgent } from "@ag-ui/client";
new LlamaIndexAgent({ url: "http://localhost:8000" });
new HttpAgent({ url: "http://localhost:8000" });
```
Correct:
```typescript
new LlamaIndexAgent({ url: "http://localhost:8000/run" });
new HttpAgent({ url: "http://localhost:8000/agui" });
```
LlamaIndex requires a `/run` suffix, Agno requires `/agui`. The generic HttpAgent fallback
would 404 without these.
Source: `docs/integrations/llamaindex/quickstart.mdx:258`;
`docs/integrations/agno/quickstart.mdx:215`.
### MEDIUM Passing a URL to A2AAgent instead of an A2AClient
Wrong:
```typescript
import { A2AAgent } from "@ag-ui/a2a";
new A2AAgent({ url: "https://a2a.example" } as any);
```
Correct:
```typescript
import { A2AAgent } from "@ag-ui/a2a";
import { A2AClient } from "@a2a-js/sdk/client";
const a2aClient = new A2AClient("https://a2a.example");
new A2AAgent({ a2aClient });
```
`A2AAgent` expects a pre-built `A2AClient` instance — A2A has its own handshake that
the client handles.
Source: `examples/integrations/a2a-a2ui/app/api/copilotkit/[[...slug]]/route.tsx:12`.
### HIGH Treating MCP Apps as an agent
Wrong:
```typescript
new CopilotRuntime({
agents: {
mcpApps: new MCPAppsAgent({
/* ... */
} as any),
} as any,
});
```
Correct:
```typescript
new CopilotRuntime({
agents: {
/* your real agents */
},
mcpApps: {
servers: [{ type: "http", url: "https://mcp.example.com/mcp" }],
},
});
```
MCP Apps is runtime-level middleware auto-applied to all agents. Configure via
`runtime.mcpApps`, not `agents`.
Source: `packages/runtime/src/v2/runtime/core/runtime.ts:39-63`.
### MEDIUM Passing a framework `client` instead of its Agent wrapper
Wrong:
```typescript
import { mastraClient } from "./mastra"; // a Mastra client object
new CopilotRuntime({
agents: { default: mastraClient as any },
});
```
Correct:
```typescript
import { MastraAgent } from "@ag-ui/mastra";
import { mastra } from "./mastra";
new CopilotRuntime({
agents: MastraAgent.getLocalAgents({ mastra, resourceId: "default" }),
});
```
`CopilotRuntime` expects `AbstractAgent` subclasses. Framework SDK clients are not
AbstractAgent instances — always pass the `@ag-ui/<framework>` wrapper or `HttpAgent`.
Source: `packages/runtime/src/v2/runtime/core/runtime.ts:111-128`.
## References
- [Mastra](wiring-mastra.md)
- [LangGraph](wiring-langgraph.md)
- [CrewAI Crews](wiring-crewai-crews.md)
- [CrewAI Flows](wiring-crewai-flows.md)
- [PydanticAI](wiring-pydantic-ai.md)
- [Google ADK](wiring-adk.md)
- [LlamaIndex](wiring-llamaindex.md)
- [Agno](wiring-agno.md)
- [AWS Strands](wiring-aws-strands.md)
- [Microsoft Agent Framework](wiring-ms-agent-framework.md)
- [AG2](wiring-ag2.md)
- [A2A](wiring-a2a.md)
- [MCP Apps middleware](wiring-mcp-apps-middleware.md)
## See also
- `copilotkit/setup-endpoint` — mount the runtime that fronts these agents
- `copilotkit/built-in-agent` — alternative when you want an in-tree agent
- `copilotkit/agent-runners` — runner choice is independent of framework
@@ -0,0 +1,49 @@
LangGraph — wired via `@copilotkit/runtime/langgraph`. Supports LangGraph Platform
deployments and self-hosted LangGraph servers.
## Install
`LangGraphAgent` (and `LangGraphHttpAgent` for self-hosted AG-UI LangGraph servers)
ship with `@copilotkit/runtime` — no separate install needed.
## Minimal wire-up
```typescript
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
const runtime = new CopilotRuntime({
agents: {
default: new LangGraphAgent({
deploymentUrl: process.env.LANGGRAPH_URL!,
graphId: "agent",
langsmithApiKey: process.env.LANGSMITH_API_KEY, // optional
}),
},
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export default { fetch: handler };
```
## Config fields
| Field | Notes |
| ----------------- | --------------------------------------------------------------------- |
| `deploymentUrl` | Base URL of the LangGraph Platform deployment (or self-hosted server) |
| `graphId` | The graph name registered with the deployment (e.g. `"agent"`) |
| `langsmithApiKey` | Optional — enables LangSmith tracing |
## Gotcha — `deploymentUrl` is NOT the CopilotKit runtime URL
The most common mistake is setting `deploymentUrl` to `/api/copilotkit`. That is the
frontend↔runtime URL, not the LangGraph server. Use the actual LangGraph deployment URL.
Source: `docs/content/docs/integrations/langgraph/quickstart.mdx:355`.
@@ -0,0 +1,39 @@
LlamaIndex — wired via `@ag-ui/llamaindex`. Requires a `/run` URL suffix.
## Install
```bash
pnpm add @ag-ui/llamaindex
```
## Minimal wire-up
```typescript
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { LlamaIndexAgent } from "@ag-ui/llamaindex";
const runtime = new CopilotRuntime({
agents: {
default: new LlamaIndexAgent({
url: process.env.LLAMAINDEX_URL ?? "http://localhost:8000/run",
}),
},
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export default { fetch: handler };
```
## Gotcha — the `/run` suffix is mandatory
LlamaIndex's AG-UI adapter mounts its workflow endpoint at `/run`. Pointing `url` at the
server root (`http://localhost:8000`) returns 404. Always include `/run`.
Source: `docs/content/docs/integrations/llamaindex/quickstart.mdx:258`.
@@ -0,0 +1,70 @@
Mastra — local-first TypeScript agent framework wired via `@ag-ui/mastra`.
## Install
```bash
pnpm add @ag-ui/mastra
# plus your Mastra SDK:
pnpm add @mastra/core
```
## Minimal wire-up
```typescript
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { MastraAgent } from "@ag-ui/mastra";
import { Mastra } from "@mastra/core";
import { weatherAgent } from "./agents/weather"; // your Mastra agent
const mastra = new Mastra({
agents: { weather: weatherAgent },
});
const runtime = new CopilotRuntime({
agents: MastraAgent.getLocalAgents({
mastra,
// Required ONLY when your Mastra agent has Memory enabled AND a threadId
// is supplied (the runtime always supplies a threadId, so Memory-enabled
// agents effectively always need this). Passing an empty string throws
// AGENT_MEMORY_MISSING_RESOURCE_ID on every turn. Agents without Memory
// can omit resourceId — e.g. the in-tree `examples/integrations/mastra`
// quickstart calls `MastraAgent.getLocalAgents({ mastra })` with no
// resourceId. Pick a stable per-user identifier in production; a literal
// "default" is fine as a placeholder for single-tenant demos.
resourceId: "default",
}),
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export default { fetch: handler };
```
## Remote Mastra server
If Mastra is running as a separate HTTP service, the simplest wiring is the
generic `HttpAgent` from `@ag-ui/client` — it speaks the AG-UI protocol that
Mastra's HTTP server already emits:
```typescript
import { HttpAgent } from "@ag-ui/client";
const runtime = new CopilotRuntime({
agents: {
weather: new HttpAgent({ url: `${process.env.MASTRA_URL!}/weather` }),
},
});
```
## Gotcha — do NOT wrap the record
`getLocalAgents` already returns `Record<string, AbstractAgent>`. Wrapping it in a key
(`{ mastra: getLocalAgents(...) }`) breaks the registry.
Source: `docs/content/docs/integrations/mastra/quickstart.mdx:213-220`.
@@ -0,0 +1,68 @@
MCP Apps — runtime-level middleware that auto-applies to all agents. NOT an agent.
MCP Apps lets any agent in the runtime access tools from external MCP servers. It is
configured on `CopilotRuntime` directly, not as an entry in `agents`.
## Install
MCP support is provided by the runtime directly — no extra package install for the
middleware itself. Your MCP servers are separate services you point at.
## Minimal wire-up
```typescript
import {
CopilotRuntime,
createCopilotRuntimeHandler,
BuiltInAgent,
} from "@copilotkit/runtime/v2";
const runtime = new CopilotRuntime({
agents: {
default: new BuiltInAgent({ model: "openai/gpt-4o", maxSteps: 5 }),
},
mcpApps: {
servers: [
{
type: "http",
url: "https://mcp.example.com/mcp",
},
{
type: "http",
url: "https://another-mcp.example.com/mcp",
agentId: "default", // scope this server to one agent only
},
],
},
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export default { fetch: handler };
```
## Per-agent scoping
Each server entry accepts an optional `agentId`. When set, the server's tools are only
exposed to that agent. Omit it to expose to all agents.
## Gotcha — do NOT put MCP under agents
```typescript
// WRONG
new CopilotRuntime({
agents: {
mcpApps: new MCPAppsAgent({
/* ... */
}),
} as any,
});
```
There is no `MCPAppsAgent`. MCP Apps is runtime middleware and belongs on the top-level
`CopilotRuntime` options.
Source: `packages/runtime/src/v2/runtime/core/runtime.ts:39-63`.
@@ -0,0 +1,41 @@
Microsoft Agent Framework — wired via the bare `HttpAgent` from `@ag-ui/client`.
Both Python and .NET variants use the same HTTP bridge.
## Install
```bash
pnpm add @ag-ui/client
```
## Minimal wire-up
```typescript
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { HttpAgent } from "@ag-ui/client";
const runtime = new CopilotRuntime({
agents: {
default: new HttpAgent({
url: process.env.MS_AGENT_URL!, // Python or .NET AG-UI endpoint
}),
},
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export default { fetch: handler };
```
## Gotcha — pick the right endpoint path
Microsoft Agent Framework ships both Python and .NET adapters. The AG-UI mount path
depends on which adapter you chose and how you configured it — follow the framework's
quickstart to find the exact URL. `HttpAgent` simply forwards.
Source: `docs/content/docs/integrations/microsoft-agent-framework/quickstart.mdx`.
@@ -0,0 +1,45 @@
PydanticAI — wired via the bare `HttpAgent` from `@ag-ui/client`.
## Install
```bash
pnpm add @ag-ui/client
```
## Minimal wire-up
```typescript
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { HttpAgent } from "@ag-ui/client";
const runtime = new CopilotRuntime({
agents: {
default: new HttpAgent({
url: process.env.PYDANTIC_AI_URL ?? "http://localhost:8000/",
}),
},
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export default { fetch: handler };
```
## Server side
On the Python side, PydanticAI exposes an AG-UI-compliant endpoint via its built-in
ASGI adapter. The URL you pass to `HttpAgent` is that endpoint (e.g. an FastAPI route
that returns a streaming response of AG-UI events).
## Gotcha — no suffix required
Unlike LlamaIndex (`/run`) and Agno (`/agui`), PydanticAI does not mandate a specific
sub-path. Whatever path your Python server mounts the AG-UI app at — use that.
Source: `docs/content/docs/integrations/pydantic-ai/quickstart.mdx`.
@@ -0,0 +1,683 @@
/**
* Shared test utilities for BuiltInAgent factory-mode tests.
*
* Re-exports everything from the existing test-helpers module and adds
* BuiltInAgent-specific factories, mock stream builders, and assertion helpers.
*/
import { EventType } from "@ag-ui/client";
import type { BaseEvent, RunAgentInput } from "@ag-ui/client";
import type { Observable } from "rxjs";
import { MockLanguageModelV3, simulateReadableStream } from "ai/test";
import { BuiltInAgent } from "../index";
import type {
AgentFactoryContext,
BuiltInAgentFactoryConfig,
ToolDefinition,
} from "../index";
import type { MockStreamEvent } from "./test-helpers";
// Re-export everything from existing test helpers
export {
type MockStreamEvent,
mockStreamTextResponse,
textStart,
textDelta,
toolCallStreamingStart,
toolCallDelta,
toolCall,
toolResult,
reasoningStart,
reasoningDelta,
reasoningEnd,
finish,
abort,
error,
collectEvents,
} from "./test-helpers";
// Re-export for test files that need to construct agents directly
export {
BuiltInAgent,
type AgentFactoryContext,
type BuiltInAgentFactoryConfig,
};
// ---------------------------------------------------------------------------
// Default input factory
// ---------------------------------------------------------------------------
/**
* Returns a valid `RunAgentInput` with sensible defaults.
* Any field can be overridden via the `overrides` parameter.
*/
export function createDefaultInput(overrides?: Partial<RunAgentInput>) {
return {
threadId: "test-thread",
runId: "test-run",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: {},
...overrides,
} as RunAgentInput;
}
// ---------------------------------------------------------------------------
// AI SDK native human-in-the-loop stream part
// ---------------------------------------------------------------------------
/**
* AI SDK `fullStream` part emitted when a tool declared `needsApproval: true`
* is called. In the real SDK the tool name + args arrive in a preceding
* `tool-call` part; this part only carries the id to pause on.
*/
export function aisdkToolApprovalRequest(toolCallId: string): MockStreamEvent {
return {
type: "tool-approval-request",
toolCallId,
approvalId: `approval-${toolCallId}`,
};
}
// ---------------------------------------------------------------------------
// TanStack mock stream chunk builders
// ---------------------------------------------------------------------------
/** TanStack text content chunk */
export function tanstackTextChunk(delta: string) {
return { type: "TEXT_MESSAGE_CONTENT", delta } as const;
}
/** TanStack tool call start chunk */
export function tanstackToolCallStart(
toolCallId: string,
toolCallName: string,
) {
return { type: "TOOL_CALL_START", toolCallId, toolCallName } as const;
}
/** TanStack tool call args chunk */
export function tanstackToolCallArgs(toolCallId: string, delta: string) {
return { type: "TOOL_CALL_ARGS", toolCallId, delta } as const;
}
/** TanStack tool call end chunk */
export function tanstackToolCallEnd(toolCallId: string) {
return { type: "TOOL_CALL_END", toolCallId } as const;
}
/** TanStack tool-call result chunk. `content` is what the tool's `execute()` returned. */
export function tanstackToolCallResult(toolCallId: string, content: unknown) {
return { type: "TOOL_CALL_RESULT", toolCallId, content } as const;
}
/**
* TanStack native human-in-the-loop chunk: the CUSTOM "approval-requested"
* event emitted by `chat()` when a tool declared `needsApproval: true` is called.
*/
export function tanstackApprovalRequested(
toolCallId: string,
toolName: string,
input?: unknown,
) {
return {
type: "CUSTOM",
name: "approval-requested",
value: {
toolCallId,
toolName,
input,
approval: { id: `approval-${toolCallId}`, needsApproval: true },
},
} as const;
}
/** TanStack reasoning lifecycle chunk builders */
export function tanstackReasoningStart(messageId: string) {
return { type: "REASONING_START", messageId } as const;
}
export function tanstackReasoningMessageStart(messageId: string) {
return {
type: "REASONING_MESSAGE_START",
messageId,
role: "reasoning",
} as const;
}
export function tanstackReasoningMessageContent(
messageId: string,
delta: string,
) {
return { type: "REASONING_MESSAGE_CONTENT", messageId, delta } as const;
}
export function tanstackReasoningMessageEnd(messageId: string) {
return { type: "REASONING_MESSAGE_END", messageId } as const;
}
export function tanstackReasoningEnd(messageId: string) {
return { type: "REASONING_END", messageId } as const;
}
// ---------------------------------------------------------------------------
// Mock async iterable builders
// ---------------------------------------------------------------------------
/**
* Creates an `AsyncIterable<unknown>` from an array of TanStack-style chunks.
*/
export function mockTanStackStream(chunks: Record<string, unknown>[]) {
return {
[Symbol.asyncIterator]: async function* () {
for (const chunk of chunks) {
yield chunk;
}
},
};
}
/**
* Creates an `AsyncIterable<BaseEvent>` from an array of AG-UI events.
*/
export function mockCustomStream(
events: BaseEvent[],
): AsyncIterable<BaseEvent> {
return {
[Symbol.asyncIterator]: async function* () {
for (const event of events) {
yield event;
}
},
};
}
// ---------------------------------------------------------------------------
// BuiltInAgent factories
// ---------------------------------------------------------------------------
export type AgentType = "aisdk" | "tanstack" | "custom";
/**
* Creates a BuiltInAgent backed by a mock factory that yields the given stream data.
*
* Overloaded for each supported agent type:
* - `"aisdk"` expects `MockStreamEvent[]` (AI SDK fullStream events)
* - `"tanstack"` expects `Record<string, unknown>[]` (TanStack chunks)
* - `"custom"` expects `BaseEvent[]` (AG-UI events directly)
*/
export function createAgent(
type: "aisdk",
streamData: MockStreamEvent[],
): BuiltInAgent;
export function createAgent(
type: "tanstack",
streamData: Record<string, unknown>[],
): BuiltInAgent;
export function createAgent(
type: "custom",
streamData: BaseEvent[],
): BuiltInAgent;
export function createAgent(
type: AgentType,
streamData: MockStreamEvent[] | Record<string, unknown>[] | BaseEvent[],
): BuiltInAgent;
export function createAgent(
type: AgentType,
streamData: MockStreamEvent[] | Record<string, unknown>[] | BaseEvent[],
): BuiltInAgent {
switch (type) {
case "aisdk": {
// Cast needed: TypeScript's control-flow narrowing doesn't propagate
// through overload signatures to narrow the union parameter type.
const events = streamData as MockStreamEvent[];
return new BuiltInAgent({
type: "aisdk",
factory: () => ({
fullStream: (async function* () {
for (const event of events) {
yield event;
}
})(),
}),
});
}
case "tanstack": {
// Cast needed: same overload-narrowing limitation as above.
const chunks = streamData as Record<string, unknown>[];
return new BuiltInAgent({
type: "tanstack",
factory: () => mockTanStackStream(chunks),
});
}
case "custom": {
// Cast needed: same overload-narrowing limitation as above.
const events = streamData as BaseEvent[];
return new BuiltInAgent({
type: "custom",
factory: () => mockCustomStream(events),
});
}
}
}
// ---------------------------------------------------------------------------
// Classic (model-based) agent factory with a capturing mock model
// ---------------------------------------------------------------------------
/**
* The element type of the `ReadableStream` returned by a V3 model's `doStream`.
* Derived from `MockLanguageModelV3` so we never need a direct (non-resolvable
* from this package) `@ai-sdk/provider` import.
*/
type V3StreamResult = Awaited<ReturnType<MockLanguageModelV3["doStream"]>>;
type V3StreamPart =
V3StreamResult["stream"] extends ReadableStream<infer P> ? P : never;
type V3FinishPart = Extract<V3StreamPart, { type: "finish" }>;
/** The shape of the options object the model's `doStream` is invoked with. */
type V3CallOptions = MockLanguageModelV3["doStreamCalls"][number];
function toV3FinishReason(value: unknown): V3FinishPart["finishReason"] {
if (value && typeof value === "object" && "unified" in value) {
return value as V3FinishPart["finishReason"];
}
const raw = typeof value === "string" ? value : undefined;
switch (raw) {
case "length":
case "content-filter":
case "tool-calls":
case "error":
case "other":
case "stop":
return { unified: raw, raw };
default:
return { unified: "stop", raw };
}
}
/** A BuiltInAgent augmented with a test-only seam exposing the messages the
* model received on its most recent `doStream` invocation. */
export type ClassicAgentWithCapture = BuiltInAgent & {
/** The `prompt` (provider-level message list) passed to the mock model on its
* most recent `doStream` call, or `undefined` if the model was never run. */
readonly __lastModelMessages: V3CallOptions["prompt"] | undefined;
};
/**
* Translates the existing fullStream-style `MockStreamEvent` chunks (built via
* `textDelta`, `toolCall`, `finish`, …) into the provider-level
* `LanguageModelV3` stream parts that the REAL `streamText` consumes from a
* model's `doStream`.
*
* Only the chunk kinds needed by the classic-path interrupt tests are
* translated; anything else is passed through unchanged so it can be extended
* later without breaking existing callers.
*/
function toV3StreamParts(chunks: MockStreamEvent[]): V3StreamPart[] {
const parts: V3StreamPart[] = [];
let openTextId: string | undefined;
const closeTextIfOpen = () => {
if (openTextId !== undefined) {
parts.push({ type: "text-end", id: openTextId } as V3StreamPart);
openTextId = undefined;
}
};
let autoId = 0;
for (const chunk of chunks) {
switch (chunk.type) {
case "text-start": {
const id = (chunk.id as string | undefined) ?? `text-${autoId++}`;
openTextId = id;
parts.push({ type: "text-start", id } as V3StreamPart);
break;
}
case "text-delta": {
if (openTextId === undefined) {
openTextId = `text-${autoId++}`;
parts.push({ type: "text-start", id: openTextId } as V3StreamPart);
}
parts.push({
type: "text-delta",
id: openTextId,
delta: String(chunk.text ?? ""),
} as V3StreamPart);
break;
}
case "tool-call": {
closeTextIfOpen();
// Provider-level tool calls carry a stringified-JSON `input`; the
// fullStream builder accepts an object, so stringify it here.
const input =
typeof chunk.input === "string"
? chunk.input
: JSON.stringify(chunk.input ?? {});
parts.push({
type: "tool-call",
toolCallId: String(chunk.toolCallId),
toolName: String(chunk.toolName),
input,
} as V3StreamPart);
break;
}
case "finish": {
closeTextIfOpen();
const finishPart: V3FinishPart = {
type: "finish",
finishReason: toV3FinishReason(chunk.finishReason),
usage: {
inputTokens: {
total: 0,
noCache: 0,
cacheRead: 0,
cacheWrite: 0,
},
outputTokens: { total: 0, text: 0, reasoning: 0 },
},
};
parts.push(finishPart);
break;
}
default: {
// Pass through any already-provider-shaped part unchanged.
parts.push(chunk as unknown as V3StreamPart);
}
}
}
closeTextIfOpen();
return parts;
}
/**
* Creates a CLASSIC (model-based) `BuiltInAgent` driven by a mock
* `LanguageModelV3`, wired with the given `tools`.
*
* The mock model surfaces `streamChunks` (the same fullStream-style chunks used
* by the AI-SDK factory tests — `toolCall`, `textDelta`, `finish`, …) through
* the real `streamText` pipeline, and RECORDS the messages it was invoked with.
* Read them via `agent.__lastModelMessages` after running the agent.
*/
export function createClassicAgentWithTools(
streamChunks: MockStreamEvent[],
tools: ToolDefinition[],
): ClassicAgentWithCapture {
const model = new MockLanguageModelV3({
doStream: async () => ({
stream: simulateReadableStream({
chunks: toV3StreamParts(streamChunks),
}),
}),
});
const agent = new BuiltInAgent({ model, tools }) as ClassicAgentWithCapture;
Object.defineProperty(agent, "__lastModelMessages", {
configurable: true,
enumerable: false,
get() {
const calls = model.doStreamCalls;
return calls.length > 0 ? calls[calls.length - 1].prompt : undefined;
},
});
return agent;
}
// ---------------------------------------------------------------------------
// Error agent factories
// ---------------------------------------------------------------------------
/**
* Creates a BuiltInAgent whose factory immediately throws.
*/
export function createThrowingAgent(
type: AgentType,
errorMessage: string,
): BuiltInAgent {
// All three factory signatures accept (ctx) and can throw before returning.
// Since the factory throws, the return type is irrelevant — TypeScript's
// `never` return satisfies all three config shapes.
const thrower = (): never => {
throw new Error(errorMessage);
};
switch (type) {
case "aisdk":
return new BuiltInAgent({ type: "aisdk", factory: thrower });
case "tanstack":
return new BuiltInAgent({ type: "tanstack", factory: thrower });
case "custom":
return new BuiltInAgent({ type: "custom", factory: thrower });
}
}
/**
* Creates a BuiltInAgent that yields one partial event and then throws.
*
* - `"aisdk"`: yields `{ type: "text-delta", text: "partial" }` then throws
* - `"tanstack"`: yields `{ type: "TEXT_MESSAGE_CONTENT", delta: "partial" }` then throws
* - `"custom"`: yields a `TEXT_MESSAGE_CHUNK` BaseEvent then throws
*/
export function createMidStreamErrorAgent(
type: AgentType,
errorMessage: string,
): BuiltInAgent {
switch (type) {
case "aisdk": {
return new BuiltInAgent({
type: "aisdk",
factory: () => ({
fullStream: (async function* () {
yield { type: "text-delta", text: "partial" };
throw new Error(errorMessage);
})(),
}),
});
}
case "tanstack": {
return new BuiltInAgent({
type: "tanstack",
factory: () => ({
[Symbol.asyncIterator]: async function* () {
yield {
type: "TEXT_MESSAGE_CONTENT",
delta: "partial",
};
throw new Error(errorMessage);
},
}),
});
}
case "custom": {
return new BuiltInAgent({
type: "custom",
factory: () => ({
[Symbol.asyncIterator]: async function* () {
yield {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: "partial",
} as const as BaseEvent;
throw new Error(errorMessage);
},
}),
});
}
}
}
// ---------------------------------------------------------------------------
// Event collection utilities
// ---------------------------------------------------------------------------
/**
* Result of collecting events from an observable that may error.
*/
export interface CollectedEventsResult {
events: BaseEvent[];
/** Whether the observable completed via error (true) or normal completion (false) */
errored: boolean;
/** Whether the safety timeout fired (indicates a hung observable) */
timedOut: boolean;
}
/**
* Like `collectEvents` but resolves (instead of rejecting) when the
* observable errors. Returns the events collected up to and including
* the error point, along with whether it errored or completed normally.
*/
export async function collectEventsIncludingErrors(
observable: Observable<BaseEvent>,
): Promise<CollectedEventsResult> {
return new Promise((resolve) => {
const events: BaseEvent[] = [];
const timeoutId = setTimeout(() => {
subscription.unsubscribe();
resolve({ events, errored: false, timedOut: true });
}, 5000);
const subscription = observable.subscribe({
next: (event) => events.push(event),
error: () => {
clearTimeout(timeoutId);
resolve({ events, errored: true, timedOut: false });
},
complete: () => {
clearTimeout(timeoutId);
resolve({ events, errored: false, timedOut: false });
},
});
});
}
// ---------------------------------------------------------------------------
// Typed event field accessors (avoids `as any` casts in tests)
// ---------------------------------------------------------------------------
/**
* Reads a field from a BaseEvent. AG-UI's BaseEvent uses `.passthrough()` so
* extra fields exist at runtime but aren't in the static type. This helper
* provides typed access without casts.
*/
export function eventField<T = unknown>(event: BaseEvent, field: string): T {
return (event as Record<string, unknown>)[field] as T;
}
// ---------------------------------------------------------------------------
// Assertion helpers
// ---------------------------------------------------------------------------
/**
* Asserts that the events are wrapped with RUN_STARTED as the first event
* and RUN_FINISHED as the last event. Optionally checks threadId and runId.
*/
export function expectLifecycleWrapped(
events: BaseEvent[],
threadId?: string,
runId?: string,
): void {
if (events.length < 2) {
throw new Error(
`Expected at least 2 events (RUN_STARTED + RUN_FINISHED), got ${events.length}`,
);
}
const first = events[0];
const last = events[events.length - 1];
if (first.type !== EventType.RUN_STARTED) {
throw new Error(
`Expected first event to be RUN_STARTED, got ${first.type}`,
);
}
if (last.type !== EventType.RUN_FINISHED) {
throw new Error(`Expected last event to be RUN_FINISHED, got ${last.type}`);
}
if (threadId !== undefined) {
if (eventField<string>(first, "threadId") !== threadId) {
throw new Error(
`Expected RUN_STARTED threadId to be "${threadId}", got "${eventField(first, "threadId")}"`,
);
}
if (eventField<string>(last, "threadId") !== threadId) {
throw new Error(
`Expected RUN_FINISHED threadId to be "${threadId}", got "${eventField(last, "threadId")}"`,
);
}
}
if (runId !== undefined) {
if (eventField<string>(first, "runId") !== runId) {
throw new Error(
`Expected RUN_STARTED runId to be "${runId}", got "${eventField(first, "runId")}"`,
);
}
if (eventField<string>(last, "runId") !== runId) {
throw new Error(
`Expected RUN_FINISHED runId to be "${runId}", got "${eventField(last, "runId")}"`,
);
}
}
}
/**
* Asserts that the event types match the expected sequence exactly.
*/
export function expectEventSequence(
events: BaseEvent[],
expectedTypes: EventType[],
): void {
const actualTypes = events.map((e) => e.type);
if (actualTypes.length !== expectedTypes.length) {
throw new Error(
`Event count mismatch: expected ${expectedTypes.length}, got ${actualTypes.length}.\n` +
`Expected: [${expectedTypes.join(", ")}]\n` +
`Actual: [${actualTypes.join(", ")}]`,
);
}
for (let i = 0; i < expectedTypes.length; i++) {
if (actualTypes[i] !== expectedTypes[i]) {
throw new Error(
`Event type mismatch at index ${i}: expected ${expectedTypes[i]}, got ${actualTypes[i]}.\n` +
`Expected: [${expectedTypes.join(", ")}]\n` +
`Actual: [${actualTypes.join(", ")}]`,
);
}
}
}
/**
* Asserts that no content events appear after the specified terminal event type.
*
* "Content events" are everything except RUN_STARTED, RUN_FINISHED, and RUN_ERROR.
*/
export function expectNoEventAfter(
events: BaseEvent[],
terminalType: EventType,
): void {
const terminalIndex = events.findIndex((e) => e.type === terminalType);
if (terminalIndex === -1) {
throw new Error(`Terminal event type ${terminalType} not found in events`);
}
const lifecycleTypes = new Set([
EventType.RUN_STARTED,
EventType.RUN_FINISHED,
EventType.RUN_ERROR,
]);
const eventsAfter = events.slice(terminalIndex + 1);
const contentEventsAfter = eventsAfter.filter(
(e) => !lifecycleTypes.has(e.type),
);
if (contentEventsAfter.length > 0) {
throw new Error(
`Found ${contentEventsAfter.length} content event(s) after ${terminalType}: ` +
`[${contentEventsAfter.map((e) => e.type).join(", ")}]`,
);
}
}
@@ -0,0 +1,987 @@
import { describe, it, expect } from "vitest";
import { EventType } from "@ag-ui/client";
import type {
BaseEvent,
Interrupt,
ResumeEntry,
RunAgentInput,
} from "@ag-ui/client";
import { z } from "zod";
import {
BuiltInAgent,
createDefaultInput,
createAgent,
createThrowingAgent,
createMidStreamErrorAgent,
createClassicAgentWithTools,
collectEvents,
collectEventsIncludingErrors,
expectLifecycleWrapped,
eventField,
textDelta,
finish,
toolCall,
tanstackTextChunk,
tanstackToolCallStart,
tanstackToolCallEnd,
tanstackApprovalRequested,
aisdkToolApprovalRequest,
} from "./agent-test-helpers";
import type {
AgentFactoryContext,
BuiltInAgentFactoryConfig,
AgentType,
MockStreamEvent,
} from "./agent-test-helpers";
// ---------------------------------------------------------------------------
// Local helpers for parameterized tests
// ---------------------------------------------------------------------------
const allTypes: AgentType[] = ["aisdk", "tanstack", "custom"];
function minimalStreamData(
type: AgentType,
): MockStreamEvent[] | Record<string, unknown>[] | BaseEvent[] {
switch (type) {
case "aisdk":
return [textDelta("hi"), finish()];
case "tanstack":
return [tanstackTextChunk("hi")];
case "custom":
return [
{
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: "hi",
} as BaseEvent,
];
}
}
function emptyStreamData(
type: AgentType,
): MockStreamEvent[] | Record<string, unknown>[] | BaseEvent[] {
switch (type) {
case "aisdk":
return [finish()];
case "tanstack":
return [];
case "custom":
return [];
}
}
// ---------------------------------------------------------------------------
// Parameterized test suites
// ---------------------------------------------------------------------------
describe.each(allTypes)("Agent [%s]", (type) => {
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
describe("lifecycle", () => {
it("emits RUN_STARTED as the first event with correct threadId/runId", async () => {
const agent = createAgent(type, minimalStreamData(type));
const input = createDefaultInput({ threadId: "t1", runId: "r1" });
const events = await collectEvents(agent.run(input));
expect(events.length).toBeGreaterThanOrEqual(2);
const first = events[0];
expect(first.type).toBe(EventType.RUN_STARTED);
expect(eventField<string>(first, "threadId")).toBe("t1");
expect(eventField<string>(first, "runId")).toBe("r1");
});
it("emits RUN_FINISHED as the last event with correct threadId/runId", async () => {
const agent = createAgent(type, minimalStreamData(type));
const input = createDefaultInput({ threadId: "t2", runId: "r2" });
const events = await collectEvents(agent.run(input));
const last = events[events.length - 1];
expect(last.type).toBe(EventType.RUN_FINISHED);
expect(eventField<string>(last, "threadId")).toBe("t2");
expect(eventField<string>(last, "runId")).toBe("r2");
});
it("emits RUN_FINISHED for an empty stream", async () => {
const agent = createAgent(type, emptyStreamData(type));
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expect(events.length).toBeGreaterThanOrEqual(2);
expect(events[0].type).toBe(EventType.RUN_STARTED);
expect(events[events.length - 1].type).toBe(EventType.RUN_FINISHED);
});
it("wraps content with lifecycle events", async () => {
const agent = createAgent(type, minimalStreamData(type));
const input = createDefaultInput({ threadId: "wrap-t", runId: "wrap-r" });
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events, "wrap-t", "wrap-r");
// There should be content events between the lifecycle bookends
const contentEvents = events.slice(1, -1);
expect(contentEvents.length).toBeGreaterThan(0);
for (const e of contentEvents) {
expect(e.type).not.toBe(EventType.RUN_STARTED);
expect(e.type).not.toBe(EventType.RUN_FINISHED);
}
});
});
// -------------------------------------------------------------------------
// RUN_ERROR
// -------------------------------------------------------------------------
describe("RUN_ERROR", () => {
it("emits RUN_ERROR when factory throws", async () => {
const agent = createThrowingAgent(type, "factory-boom");
const input = createDefaultInput();
const { events, errored } = await collectEventsIncludingErrors(
agent.run(input),
);
expect(errored).toBe(true);
const errorEvents = events.filter((e) => e.type === EventType.RUN_ERROR);
expect(errorEvents.length).toBe(1);
expect(eventField<string>(errorEvents[0], "message")).toBe(
"factory-boom",
);
});
it("emits RUN_ERROR when stream throws mid-iteration", async () => {
const agent = createMidStreamErrorAgent(type, "mid-stream-boom");
const input = createDefaultInput();
const { events, errored } = await collectEventsIncludingErrors(
agent.run(input),
);
expect(errored).toBe(true);
const errorEvents = events.filter((e) => e.type === EventType.RUN_ERROR);
expect(errorEvents.length).toBe(1);
expect(eventField<string>(errorEvents[0], "message")).toBe(
"mid-stream-boom",
);
});
it("does not emit RUN_FINISHED after RUN_ERROR", async () => {
const agent = createThrowingAgent(type, "no-finish");
const input = createDefaultInput();
const { events } = await collectEventsIncludingErrors(agent.run(input));
const errorIdx = events.findIndex((e) => e.type === EventType.RUN_ERROR);
expect(errorIdx).toBeGreaterThanOrEqual(0);
const eventsAfterError = events.slice(errorIdx + 1);
const finishAfterError = eventsAfterError.filter(
(e) => e.type === EventType.RUN_FINISHED,
);
expect(finishAfterError.length).toBe(0);
});
});
// -------------------------------------------------------------------------
// Abort
// -------------------------------------------------------------------------
describe("abort", () => {
it("completes without error after abortRun()", async () => {
// Use a signal to synchronize: abort after the first chunk is emitted
let emittedFirstChunk: () => void;
const firstChunkEmitted = new Promise<void>(
(r) => (emittedFirstChunk = r),
);
let config: BuiltInAgentFactoryConfig;
switch (type) {
case "aisdk":
config = {
type: "aisdk",
factory: ({ abortSignal }: AgentFactoryContext) => ({
fullStream: (async function* () {
yield { type: "text-delta", text: "tick" };
emittedFirstChunk();
// Wait for abort — use a promise that resolves on abort
await new Promise<void>((r) => {
if (abortSignal.aborted) return r();
abortSignal.addEventListener("abort", () => r(), {
once: true,
});
});
})(),
}),
};
break;
case "tanstack":
config = {
type: "tanstack",
factory: ({ abortSignal }: AgentFactoryContext) => ({
[Symbol.asyncIterator]: async function* () {
yield { type: "TEXT_MESSAGE_CONTENT", delta: "tick" };
emittedFirstChunk();
await new Promise<void>((r) => {
if (abortSignal.aborted) return r();
abortSignal.addEventListener("abort", () => r(), {
once: true,
});
});
},
}),
};
break;
case "custom":
config = {
type: "custom",
factory: ({ abortSignal }: AgentFactoryContext) => ({
[Symbol.asyncIterator]: async function* () {
yield {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: "tick",
} as BaseEvent;
emittedFirstChunk();
await new Promise<void>((r) => {
if (abortSignal.aborted) return r();
abortSignal.addEventListener("abort", () => r(), {
once: true,
});
});
},
}),
};
break;
}
const agent = new BuiltInAgent(config);
const input = createDefaultInput();
const completed = await new Promise<boolean>((resolve) => {
agent.run(input).subscribe({
next: () => {},
error: () => resolve(false),
complete: () => resolve(true),
});
// Wait for the first chunk to be emitted, then abort
firstChunkEmitted.then(() => agent.abortRun());
});
expect(completed).toBe(true);
});
});
// -------------------------------------------------------------------------
// Factory Context
// -------------------------------------------------------------------------
describe("factory context", () => {
it("receives correct input with threadId, runId, and forwardedProps", async () => {
let capturedCtx: AgentFactoryContext | null = null;
let config: BuiltInAgentFactoryConfig;
switch (type) {
case "aisdk":
config = {
type: "aisdk",
factory: (ctx: AgentFactoryContext) => {
capturedCtx = ctx;
return {
fullStream: (async function* () {
yield { type: "finish", finishReason: "stop" };
})(),
};
},
};
break;
case "tanstack":
config = {
type: "tanstack",
factory: (ctx: AgentFactoryContext) => {
capturedCtx = ctx;
return (async function* () {
// empty stream
})();
},
};
break;
case "custom":
config = {
type: "custom",
factory: (ctx: AgentFactoryContext) => {
capturedCtx = ctx;
return (async function* () {
// empty stream
})();
},
};
break;
}
const agent = new BuiltInAgent(config);
const input = createDefaultInput({
threadId: "ctx-thread",
runId: "ctx-run",
forwardedProps: { model: "gpt-4" },
});
await collectEvents(agent.run(input));
expect(capturedCtx).not.toBeNull();
expect(capturedCtx!.input.threadId).toBe("ctx-thread");
expect(capturedCtx!.input.runId).toBe("ctx-run");
expect(capturedCtx!.input.forwardedProps).toEqual({ model: "gpt-4" });
});
it("receives abortController and abortSignal", async () => {
let capturedCtx: AgentFactoryContext | null = null;
let config: BuiltInAgentFactoryConfig;
switch (type) {
case "aisdk":
config = {
type: "aisdk",
factory: (ctx: AgentFactoryContext) => {
capturedCtx = ctx;
return {
fullStream: (async function* () {
yield { type: "finish", finishReason: "stop" };
})(),
};
},
};
break;
case "tanstack":
config = {
type: "tanstack",
factory: (ctx: AgentFactoryContext) => {
capturedCtx = ctx;
return (async function* () {
// empty
})();
},
};
break;
case "custom":
config = {
type: "custom",
factory: (ctx: AgentFactoryContext) => {
capturedCtx = ctx;
return (async function* () {
// empty
})();
},
};
break;
}
const agent = new BuiltInAgent(config);
const input = createDefaultInput();
await collectEvents(agent.run(input));
expect(capturedCtx!.abortController).toBeInstanceOf(AbortController);
expect(capturedCtx!.abortSignal).toBe(
capturedCtx!.abortController.signal,
);
});
});
// -------------------------------------------------------------------------
// clone()
// -------------------------------------------------------------------------
describe("clone()", () => {
it("returns a new Agent instance (not the same reference)", () => {
const agent = createAgent(type, minimalStreamData(type));
const cloned = agent.clone();
expect(cloned).toBeInstanceOf(BuiltInAgent);
expect(cloned).not.toBe(agent);
});
it("produces correct lifecycle events from a cloned agent", async () => {
const agent = createAgent(type, minimalStreamData(type));
const cloned = agent.clone();
const input = createDefaultInput({
threadId: "clone-t",
runId: "clone-r",
});
const events = await collectEvents(cloned.run(input));
expectLifecycleWrapped(events, "clone-t", "clone-r");
});
});
});
// ---------------------------------------------------------------------------
// Type Discrimination (NOT parameterized)
// ---------------------------------------------------------------------------
describe("Agent type discrimination", () => {
it('"aisdk" routes to AI SDK converter and produces text content', async () => {
const agent = createAgent("aisdk", [
textDelta("hello from aisdk"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents.length).toBe(1);
expect(eventField<string>(textEvents[0], "delta")).toBe("hello from aisdk");
});
it('"tanstack" routes to TanStack converter and produces text content', async () => {
const agent = createAgent("tanstack", [
tanstackTextChunk("hello from tanstack"),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents.length).toBe(1);
expect(eventField<string>(textEvents[0], "delta")).toBe(
"hello from tanstack",
);
});
it('"custom" forwards events directly without conversion', async () => {
const customEvent: BaseEvent = {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: "hello from custom",
} as BaseEvent;
const agent = createAgent("custom", [customEvent]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents.length).toBe(1);
expect(eventField<string>(textEvents[0], "delta")).toBe(
"hello from custom",
);
});
});
// ---------------------------------------------------------------------------
// Async Factory (Promise-returning)
// ---------------------------------------------------------------------------
describe("Async factory (Promise-returning)", () => {
it("aisdk: async factory resolves and streams correctly", async () => {
const agent = new BuiltInAgent({
type: "aisdk",
factory: async () => {
// Simulate async setup (e.g., fetching API key)
await new Promise((r) => setTimeout(r, 5));
return {
fullStream: (async function* () {
yield { type: "text-delta", text: "async-aisdk" };
yield { type: "finish", finishReason: "stop" };
})(),
};
},
});
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents).toHaveLength(1);
expect(eventField<string>(textEvents[0], "delta")).toBe("async-aisdk");
});
it("tanstack: async factory resolves and streams correctly", async () => {
const agent = new BuiltInAgent({
type: "tanstack",
factory: async () => {
await new Promise((r) => setTimeout(r, 5));
return (async function* () {
yield { type: "TEXT_MESSAGE_CONTENT", delta: "async-tanstack" };
})();
},
});
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents).toHaveLength(1);
expect(eventField<string>(textEvents[0], "delta")).toBe("async-tanstack");
});
it("custom: async factory resolves and streams correctly", async () => {
const agent = new BuiltInAgent({
type: "custom",
factory: async () => {
await new Promise((r) => setTimeout(r, 5));
return (async function* () {
yield {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: "async-custom",
} as BaseEvent;
})();
},
});
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents).toHaveLength(1);
expect(eventField<string>(textEvents[0], "delta")).toBe("async-custom");
});
});
// ---------------------------------------------------------------------------
// RUN_ERROR includes threadId and runId
// ---------------------------------------------------------------------------
describe("RUN_ERROR correlation fields", () => {
it("RUN_ERROR includes threadId and runId for run correlation", async () => {
const agent = new BuiltInAgent({
type: "aisdk",
factory: () => {
throw new Error("test-error");
},
});
const input = createDefaultInput({
threadId: "err-thread",
runId: "err-run",
});
const { events, errored } = await collectEventsIncludingErrors(
agent.run(input),
);
expect(errored).toBe(true);
const errorEvents = events.filter((e) => e.type === EventType.RUN_ERROR);
expect(errorEvents).toHaveLength(1);
expect(eventField<string>(errorEvents[0], "threadId")).toBe("err-thread");
expect(eventField<string>(errorEvents[0], "runId")).toBe("err-run");
});
});
// ---------------------------------------------------------------------------
// Concurrent run guard
// ---------------------------------------------------------------------------
describe("Concurrent run guard", () => {
it("throws when run() is called while another run is in progress", async () => {
let resolveFactory: () => void;
const factoryBlocked = new Promise<void>((r) => (resolveFactory = r));
const agent = new BuiltInAgent({
type: "custom",
factory: async function* ({ abortSignal }) {
// Block until resolved externally
await new Promise<void>((r) => {
if (abortSignal.aborted) return r();
abortSignal.addEventListener("abort", () => r(), { once: true });
factoryBlocked.then(() => r());
});
},
});
const input = createDefaultInput();
// Start first run — abortController is now set synchronously in run()
const sub = agent.run(input).subscribe({ next: () => {} });
// Second run should throw immediately (no timing dependency)
expect(() => agent.run(input)).toThrow("Agent is already running");
// Cleanup
resolveFactory!();
sub.unsubscribe();
});
});
// ---------------------------------------------------------------------------
// BuiltInAgent factory interrupt() primitive
// ---------------------------------------------------------------------------
describe("BuiltInAgent factory interrupt() primitive", () => {
const INT: Interrupt = {
id: "int-1",
reason: "confirmation",
message: "Approve?",
};
function makeCustomInterruptAgent() {
return new BuiltInAgent({
type: "custom",
factory: async function* (ctx) {
const responses = await ctx.interrupt([INT]); // pauses on fresh run
// resume run continues here:
yield {
type: EventType.TEXT_MESSAGE_START,
messageId: "m1",
role: "assistant",
} as any;
yield {
type: EventType.TEXT_MESSAGE_CONTENT,
messageId: "m1",
delta: `resolved:${JSON.stringify(responses)}`,
} as any;
yield { type: EventType.TEXT_MESSAGE_END, messageId: "m1" } as any;
},
});
}
it("emits RUN_FINISHED with outcome:interrupt on a fresh run", async () => {
const agent = makeCustomInterruptAgent();
const events = await collectEvents(agent.run(createDefaultInput()));
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
) as any;
expect(finished).toBeDefined();
expect(finished.outcome).toEqual({ type: "interrupt", interrupts: [INT] });
// No text emitted on the paused run:
expect(events.some((e) => e.type === EventType.TEXT_MESSAGE_CONTENT)).toBe(
false,
);
});
it("continues past interrupt() on a resume run, returning resume payloads", async () => {
const agent = makeCustomInterruptAgent();
const resume: ResumeEntry[] = [
{ interruptId: "int-1", status: "resolved", payload: { ok: true } },
];
const events = await collectEvents(
agent.run(createDefaultInput({ resume })),
);
const content = events.find(
(e) => e.type === EventType.TEXT_MESSAGE_CONTENT,
) as any;
expect(content.delta).toContain("resolved:");
expect(content.delta).toContain('"ok":true');
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
) as any;
expect(finished.outcome).toBeUndefined(); // normal completion
});
});
// ---------------------------------------------------------------------------
// BuiltInAgent classic interrupt tools
// ---------------------------------------------------------------------------
describe("BuiltInAgent classic interrupt tools", () => {
const interruptTool = {
name: "confirm_action",
description: "Ask the human to confirm",
parameters: z.object({ summary: z.string() }),
interrupt: true as const,
interruptReason: "confirmation",
interruptMessage: "Please confirm",
};
it("pauses + emits outcome:interrupt (keyed by toolCallId), withholding the tool result", async () => {
// mock stream: assistant calls the interrupt tool, then 'finish'
const agent = createClassicAgentWithTools(
[toolCall("tc-1", "confirm_action", { summary: "delete X" }), finish()],
[interruptTool],
);
const events = await collectEvents(
agent.run(
createDefaultInput({
messages: [{ id: "u1", role: "user", content: "do it" }] as any,
}),
),
);
expect(events.some((e) => e.type === EventType.TOOL_CALL_START)).toBe(true);
expect(events.some((e) => e.type === EventType.TOOL_CALL_END)).toBe(true);
// result withheld:
expect(events.some((e) => e.type === EventType.TOOL_CALL_RESULT)).toBe(
false,
);
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
) as any;
expect(finished.outcome.type).toBe("interrupt");
expect(finished.outcome.interrupts[0]).toMatchObject({
id: "tc-1",
toolCallId: "tc-1",
reason: "confirmation",
message: "Please confirm",
});
});
it("on resume, injects the resume payload as the interrupt tool's result and continues", async () => {
// Resume run: the model just replies after the injected tool result.
const agent = createClassicAgentWithTools(
[textDelta("done"), finish()],
[interruptTool],
);
const input = createDefaultInput({
resume: [
{
interruptId: "tc-1",
status: "resolved",
payload: { approved: true },
},
],
messages: [
{ id: "u1", role: "user", content: "do it" },
{
id: "a1",
role: "assistant",
content: "",
toolCalls: [
{
id: "tc-1",
type: "function",
function: {
name: "confirm_action",
arguments: '{"summary":"delete X"}',
},
},
],
},
] as any,
});
const events = await collectEvents(agent.run(input));
// The run completes normally (no interrupt outcome on the resume run):
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
) as any;
expect(finished.outcome).toBeUndefined();
// The synthesized tool result reached the model: a tool-role message
// addressing tc-1 with the resume payload must be present.
expect(agent.__lastModelMessages).toEqual(
expect.arrayContaining([expect.objectContaining({ role: "tool" })]),
);
});
});
// ---------------------------------------------------------------------------
// BuiltInAgent factory-mode NATIVE approval interrupts (aisdk + tanstack)
//
// A tool declared with the SDK's native `needsApproval: true` pauses the run:
// AI SDK emits a `tool-approval-request` fullStream part; TanStack emits a
// CUSTOM `approval-requested` chunk. Both surface as RUN_FINISHED
// outcome:interrupt, and on resume the payload is injected as that tool call's
// native tool-result so the factory continues.
// ---------------------------------------------------------------------------
describe("BuiltInAgent factory native approval interrupts", () => {
it("aisdk: tool-approval-request → outcome:interrupt keyed by toolCallId", async () => {
const agent = createAgent("aisdk", [
toolCall("tc-1", "bookFlight", { dest: "NRT" }),
aisdkToolApprovalRequest("tc-1"),
finish(),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
// The tool call is surfaced so the client knows what it's approving.
expect(events.some((e) => e.type === EventType.TOOL_CALL_START)).toBe(true);
expect(events.some((e) => e.type === EventType.TOOL_CALL_END)).toBe(true);
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
) as any;
expect(finished.outcome.type).toBe("interrupt");
expect(finished.outcome.interrupts[0]).toMatchObject({
id: "tc-1",
toolCallId: "tc-1",
});
});
it("aisdk: multiple needsApproval tool calls → one interrupt each", async () => {
const agent = createAgent("aisdk", [
toolCall("tc-1", "bookFlight", { dest: "NRT" }),
aisdkToolApprovalRequest("tc-1"),
toolCall("tc-2", "bookFlight", { dest: "CDG" }),
aisdkToolApprovalRequest("tc-2"),
finish(),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
) as any;
expect(finished.outcome.type).toBe("interrupt");
expect(finished.outcome.interrupts.map((i: any) => i.toolCallId)).toEqual([
"tc-1",
"tc-2",
]);
});
it("multiple resume entries each inject their own tool-role message", async () => {
let captured: RunAgentInput | undefined;
const agent = new BuiltInAgent({
type: "aisdk",
factory: (ctx) => {
captured = ctx.input;
return {
fullStream: (async function* () {
yield textDelta("done");
yield finish();
})(),
};
},
});
const resume: ResumeEntry[] = [
{ interruptId: "tc-1", status: "resolved", payload: { approved: true } },
{ interruptId: "tc-2", status: "cancelled" },
];
await collectEvents(agent.run(createDefaultInput({ resume })));
const toolMsgs = captured!.messages.filter(
(m) => m.role === "tool",
) as any[];
expect(toolMsgs.map((m) => m.toolCallId)).toEqual(["tc-1", "tc-2"]);
expect(toolMsgs[0].content).toContain("approved");
expect(toolMsgs[1].content).toContain("cancelled");
});
it("resume injection is idempotent — does not double-answer a tool call the client already recorded", async () => {
// The client (useInterrupt) persists the resolution as a tool message so the
// thread stays well-formed across turns. The runtime must NOT then inject a
// second tool-result for the same toolCallId.
let captured: RunAgentInput | undefined;
const agent = new BuiltInAgent({
type: "aisdk",
factory: (ctx) => {
captured = ctx.input;
return {
fullStream: (async function* () {
yield textDelta("ok");
yield finish();
})(),
};
},
});
const resume: ResumeEntry[] = [
{ interruptId: "tc-1", status: "resolved", payload: { approved: true } },
];
await collectEvents(
agent.run(
createDefaultInput({
resume,
messages: [
{ id: "u1", role: "user", content: "go" },
{
id: "a1",
role: "assistant",
content: "",
toolCalls: [
{
id: "tc-1",
type: "function",
function: { name: "bookFlight", arguments: "{}" },
},
],
},
{
id: "t1",
role: "tool",
toolCallId: "tc-1",
content: JSON.stringify({ approved: true }),
},
] as RunAgentInput["messages"],
}),
),
);
const toolMsgs = captured!.messages.filter(
(m) => m.role === "tool" && (m as any).toolCallId === "tc-1",
);
expect(toolMsgs).toHaveLength(1);
});
it("tanstack: CUSTOM approval-requested (even after RUN_FINISHED) → outcome:interrupt", async () => {
// The approval chunk is built from the finish event and can arrive AFTER
// RUN_FINISHED — assert it's still captured (ordering-robust).
const agent = createAgent("tanstack", [
tanstackToolCallStart("tc-9", "bookFlight"),
tanstackToolCallEnd("tc-9"),
{ type: "RUN_FINISHED" },
tanstackApprovalRequested("tc-9", "bookFlight", { dest: "NRT" }),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
) as any;
expect(finished.outcome.type).toBe("interrupt");
expect(finished.outcome.interrupts[0]).toMatchObject({
id: "tc-9",
toolCallId: "tc-9",
message: 'Approve "bookFlight"?',
});
});
it("aisdk: resume injects the payload as a tool-role message the factory sees", async () => {
let captured: RunAgentInput | undefined;
const agent = new BuiltInAgent({
type: "aisdk",
factory: (ctx) => {
captured = ctx.input;
return {
fullStream: (async function* () {
yield textDelta("booked");
yield finish();
})(),
};
},
});
const resume: ResumeEntry[] = [
{ interruptId: "tc-1", status: "resolved", payload: { approved: true } },
];
const events = await collectEvents(
agent.run(createDefaultInput({ resume })),
);
// Normal completion on the resume run (no re-interrupt).
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
) as any;
expect(finished.outcome).toBeUndefined();
const toolMsg = captured!.messages.find(
(m) => m.role === "tool" && (m as any).toolCallId === "tc-1",
) as any;
expect(toolMsg).toBeDefined();
expect(toolMsg.content).toContain("approved");
});
it("tanstack: cancelled resume injects a cancellation tool-role message", async () => {
let captured: RunAgentInput | undefined;
const agent = new BuiltInAgent({
type: "tanstack",
factory: (ctx) => {
captured = ctx.input;
return {
[Symbol.asyncIterator]: async function* () {
yield tanstackTextChunk("ok");
},
};
},
});
const resume: ResumeEntry[] = [
{ interruptId: "tc-9", status: "cancelled" },
];
await collectEvents(agent.run(createDefaultInput({ resume })));
const toolMsg = captured!.messages.find(
(m) => m.role === "tool" && (m as any).toolCallId === "tc-9",
) as any;
expect(toolMsg).toBeDefined();
expect(toolMsg.content).toContain("cancelled");
});
});
@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { BuiltInAgent } from "../index";
import { EventType, type RunAgentInput } from "@ag-ui/client";
import { streamText } from "ai";
import {
mockStreamTextResponse,
textDelta,
finish,
collectEvents,
} from "./test-helpers";
// Mock the ai module
vi.mock("ai", () => ({
streamText: vi.fn(),
tool: vi.fn((config) => config),
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
}));
// Mock SDK providers (not used directly, but resolveModel imports them)
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
specificationVersion: "v3",
modelId,
provider: "openai",
})),
}));
vi.mock("@ai-sdk/anthropic", () => ({
createAnthropic: vi.fn(() => (modelId: string) => ({
specificationVersion: "v3",
modelId,
provider: "anthropic",
})),
}));
vi.mock("@ai-sdk/google", () => ({
createGoogleGenerativeAI: vi.fn(() => (modelId: string) => ({
specificationVersion: "v3",
modelId,
provider: "google",
})),
}));
describe("AI SDK v6 Compatibility", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
});
afterEach(() => {
process.env = originalEnv;
});
it("should accept a LanguageModelV3 instance (specificationVersion 'v3')", async () => {
// Simulate what @ai-sdk/openai@^3 (AI SDK v6) returns:
// a model object with specificationVersion: "v3"
const v3Model = {
specificationVersion: "v3" as const,
modelId: "gpt-4o-mini",
provider: "openai",
supportedUrls: {},
doGenerate: vi.fn(),
doStream: vi.fn(),
};
// After upgrading to ai@^6, LanguageModel = string | LanguageModelV2 | LanguageModelV3.
// No 'as any' cast needed — the type accepts V3 models natively.
const agent = new BuiltInAgent({
model: v3Model,
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
textDelta("Hello from V3 model"),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread-v3",
runId: "run-v3",
messages: [{ id: "1", role: "user", content: "Hi" }],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
// Verify the model was passed through to streamText
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.model).toBe(v3Model);
// Verify normal event emission still works
expect(events[0]).toMatchObject({
type: EventType.RUN_STARTED,
threadId: "thread-v3",
runId: "run-v3",
});
const textEvents = events.filter(
(e: any) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents).toHaveLength(1);
expect(textEvents[0]).toMatchObject({
delta: "Hello from V3 model",
});
expect(events[events.length - 1]).toMatchObject({
type: EventType.RUN_FINISHED,
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
import { describe, it, expect } from "vitest";
import { BuiltInAgent } from "../index";
describe("BuiltInAgent.getCapabilities", () => {
it("should return default inferred capabilities", async () => {
const agent = new BuiltInAgent({
model: "openai/gpt-5.5",
});
const capabilities = await agent.getCapabilities();
expect(capabilities).toEqual({
tools: {
supported: true,
clientProvided: true,
},
transport: {
streaming: true,
},
humanInTheLoop: {
interrupts: true,
},
});
});
it("should merge explicit overrides with inferred defaults", async () => {
const agent = new BuiltInAgent({
model: "openai/gpt-5.5",
capabilities: {
reasoning: {
supported: true,
streaming: true,
},
identity: {
name: "my-agent",
type: "custom",
},
},
});
const capabilities = await agent.getCapabilities();
expect(capabilities).toEqual({
tools: {
supported: true,
clientProvided: true,
},
transport: {
streaming: true,
},
humanInTheLoop: {
interrupts: true,
},
reasoning: {
supported: true,
streaming: true,
},
identity: {
name: "my-agent",
type: "custom",
},
});
});
it("should allow overrides to replace entire categories", async () => {
const agent = new BuiltInAgent({
model: "openai/gpt-5.5",
capabilities: {
tools: {
supported: true,
clientProvided: true,
parallelCalls: true,
},
},
});
const capabilities = await agent.getCapabilities();
expect(capabilities.tools).toEqual({
supported: true,
clientProvided: true,
parallelCalls: true,
});
// transport still inferred
expect(capabilities.transport).toEqual({ streaming: true });
});
});
@@ -0,0 +1,519 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { z } from "zod";
import { BasicAgent, defineTool } from "../index";
import { EventType } from "@ag-ui/client";
import type { RunAgentInput } from "@ag-ui/client";
import { streamText } from "ai";
import {
mockStreamTextResponse,
toolCallStreamingStart,
toolCall,
toolResult,
finish,
collectEvents,
} from "./test-helpers";
// Mock the ai module
vi.mock("ai", () => ({
streamText: vi.fn(),
tool: vi.fn((config) => config),
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
}));
// Mock the SDK clients
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
modelId,
provider: "openai",
})),
}));
describe("Config Tools Server-Side Execution", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
});
afterEach(() => {
process.env = originalEnv;
});
describe("Tool Definition with Execute", () => {
it("should pass execute function to streamText tools", async () => {
const executeFn = vi.fn().mockResolvedValue({ result: "executed" });
const weatherTool = defineTool({
name: "getWeather",
description: "Get weather for a city",
parameters: z.object({
city: z.string().describe("The city name"),
}),
execute: executeFn,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
tools: [weatherTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
// Verify streamText was called with tools that have execute functions
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("getWeather");
expect(callArgs.tools.getWeather).toHaveProperty("execute");
expect(typeof callArgs.tools.getWeather.execute).toBe("function");
});
it("should include all tool properties in the Vercel AI SDK tool", async () => {
const executeFn = vi.fn().mockResolvedValue({ temperature: 72 });
const weatherTool = defineTool({
name: "getWeather",
description: "Get weather for a city",
parameters: z.object({
city: z.string(),
units: z.enum(["celsius", "fahrenheit"]).optional(),
}),
execute: executeFn,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
tools: [weatherTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
const tool = callArgs.tools.getWeather;
expect(tool.description).toBe("Get weather for a city");
expect(tool.inputSchema).toBeDefined();
expect(tool.execute).toBe(executeFn);
});
it("should handle multiple config tools with execute functions", async () => {
const weatherExecute = vi.fn().mockResolvedValue({ temp: 72 });
const searchExecute = vi.fn().mockResolvedValue({ results: [] });
const weatherTool = defineTool({
name: "getWeather",
description: "Get weather",
parameters: z.object({ city: z.string() }),
execute: weatherExecute,
});
const searchTool = defineTool({
name: "search",
description: "Search the web",
parameters: z.object({ query: z.string() }),
execute: searchExecute,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
tools: [weatherTool, searchTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools.getWeather.execute).toBe(weatherExecute);
expect(callArgs.tools.search.execute).toBe(searchExecute);
});
});
describe("Config Tools vs Input Tools", () => {
it("config tools should have execute, input tools should not", async () => {
const configExecute = vi.fn().mockResolvedValue({ result: "server" });
const configTool = defineTool({
name: "serverTool",
description: "Runs on server",
parameters: z.object({ data: z.string() }),
execute: configExecute,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
tools: [configTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [
{
name: "clientTool",
description: "Runs on client",
parameters: {
type: "object",
properties: { input: { type: "string" } },
},
},
],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
// Config tool has execute
expect(callArgs.tools.serverTool.execute).toBe(configExecute);
// Input tool does NOT have execute (client-side execution)
expect(callArgs.tools.clientTool.execute).toBeUndefined();
});
});
describe("Execute Function Invocation", () => {
it("execute function can be called with correct arguments", async () => {
const executeFn = vi
.fn()
.mockResolvedValue({ weather: "sunny", temp: 72 });
const weatherTool = defineTool({
name: "getWeather",
description: "Get weather",
parameters: z.object({
city: z.string(),
units: z.enum(["celsius", "fahrenheit"]),
}),
execute: executeFn,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
tools: [weatherTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
// Get the execute function that was passed to streamText
const callArgs = vi.mocked(streamText).mock.calls[0][0];
const passedExecute = callArgs.tools.getWeather.execute;
// Manually invoke it to verify it works correctly
const result = await passedExecute(
{ city: "New York", units: "fahrenheit" },
{ toolCallId: "tool-call-1", messages: [] },
);
expect(executeFn).toHaveBeenCalledWith(
{ city: "New York", units: "fahrenheit" },
{ toolCallId: "tool-call-1", messages: [] },
);
expect(result).toEqual({ weather: "sunny", temp: 72 });
});
it("execute function errors are propagated", async () => {
const executeFn = vi.fn().mockRejectedValue(new Error("API unavailable"));
const failingTool = defineTool({
name: "failingTool",
description: "A tool that fails",
parameters: z.object({}),
execute: executeFn,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
tools: [failingTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
const passedExecute = callArgs.tools.failingTool.execute;
await expect(
passedExecute({}, { toolCallId: "tool-call-1", messages: [] }),
).rejects.toThrow("API unavailable");
});
});
describe("Built-in State Tools Still Work", () => {
it("AGUISendStateSnapshot should have execute alongside config tools", async () => {
const configExecute = vi.fn().mockResolvedValue({});
const configTool = defineTool({
name: "myTool",
description: "My tool",
parameters: z.object({}),
execute: configExecute,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
tools: [configTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: { value: 1 },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
// Both config tool and state tools should have execute
expect(callArgs.tools.myTool.execute).toBe(configExecute);
expect(callArgs.tools.AGUISendStateSnapshot.execute).toBeDefined();
expect(callArgs.tools.AGUISendStateDelta.execute).toBeDefined();
});
});
describe("Message ID Generation", () => {
it("should use messageId from text-start event", async () => {
const executeFn = vi.fn().mockResolvedValue({ result: "ok" });
const tool = defineTool({
name: "myTool",
description: "My tool",
parameters: z.object({}),
execute: executeFn,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
tools: [tool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
{ type: "text-start", id: "msg-1" },
{ type: "text-delta", text: "Before " },
{ type: "text-delta", text: "tool" },
toolCallStreamingStart("call1", "myTool"),
toolCall("call1", "myTool"),
toolResult("call1", "myTool", { result: "ok" }),
{ type: "text-start", id: "msg-2" },
{ type: "text-delta", text: "After " },
{ type: "text-delta", text: "tool" },
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
const textEvents = events.filter(
(e: any) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
// First two text chunks should have messageId from first text-start
expect(textEvents[0].messageId).toBe("msg-1");
expect(textEvents[1].messageId).toBe("msg-1");
// After tool result, text chunks should have messageId from second text-start
expect(textEvents[2].messageId).toBe("msg-2");
expect(textEvents[3].messageId).toBe("msg-2");
});
});
describe("Multi-Step Execution (maxSteps)", () => {
it("should pass stopWhen with stepCountIs when maxSteps is configured", async () => {
const executeFn = vi.fn().mockResolvedValue({ result: "ok" });
const tool = defineTool({
name: "myTool",
description: "My tool",
parameters: z.object({}),
execute: executeFn,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
maxSteps: 5,
tools: [tool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
// stopWhen should be set with stepCountIs(5)
expect(callArgs.stopWhen).toEqual({ type: "stepCount", count: 5 });
});
it("should not set stopWhen when maxSteps is not configured", async () => {
const executeFn = vi.fn().mockResolvedValue({ result: "ok" });
const tool = defineTool({
name: "myTool",
description: "My tool",
parameters: z.object({}),
execute: executeFn,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
// maxSteps not set
tools: [tool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
// stopWhen should be undefined (defaults to stepCountIs(1) in SDK)
expect(callArgs.stopWhen).toBeUndefined();
});
it("should allow high maxSteps for complex tool chains", async () => {
const executeFn = vi.fn().mockResolvedValue({});
const tool = defineTool({
name: "chainTool",
description: "Tool for chaining",
parameters: z.object({}),
execute: executeFn,
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
maxSteps: 10,
tools: [tool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.stopWhen).toEqual({ type: "stepCount", count: 10 });
});
});
});
@@ -0,0 +1,692 @@
import { describe, it, expect } from "vitest";
import { EventType } from "@ag-ui/client";
import {
createAgent,
createDefaultInput,
collectEvents,
collectEventsIncludingErrors,
expectLifecycleWrapped,
expectEventSequence,
eventField,
textStart,
textDelta,
toolCallStreamingStart,
toolCallDelta,
toolCall,
toolResult,
reasoningStart,
reasoningDelta,
reasoningEnd,
finish,
} from "./agent-test-helpers";
// ---------------------------------------------------------------------------
// Basic Event Emission
// ---------------------------------------------------------------------------
describe("AI SDK Converter", () => {
describe("Basic Event Emission", () => {
it("text delta emits TEXT_MESSAGE_CHUNK with correct role, messageId, and delta", async () => {
const agent = createAgent("aisdk", [textDelta("Hello"), finish()]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
const textChunks = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textChunks).toHaveLength(1);
expect(eventField<string>(textChunks[0], "role")).toBe("assistant");
expect(eventField<string>(textChunks[0], "delta")).toBe("Hello");
expect(eventField<string>(textChunks[0], "messageId")).toBeDefined();
expect(typeof eventField<string>(textChunks[0], "messageId")).toBe(
"string",
);
});
it("text-start with provider id uses that id as messageId", async () => {
const agent = createAgent("aisdk", [
textStart("custom-msg-id"),
textDelta("Hi"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const chunk = events.find(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
)!;
expect(eventField<string>(chunk, "messageId")).toBe("custom-msg-id");
});
it('text-start with "0" generates a unique messageId (not "0")', async () => {
const agent = createAgent("aisdk", [
textStart("0"),
textDelta("Hi"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const chunk = events.find(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
)!;
expect(eventField<string>(chunk, "messageId")).not.toBe("0");
expect(eventField<string>(chunk, "messageId")).toBeDefined();
expect(eventField<string>(chunk, "messageId").length).toBeGreaterThan(0);
});
it("multiple text deltas share the same messageId", async () => {
const agent = createAgent("aisdk", [
textDelta("Hello "),
textDelta("world"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const textChunks = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textChunks).toHaveLength(2);
expect(eventField<string>(textChunks[0], "messageId")).toBe(
eventField<string>(textChunks[1], "messageId"),
);
});
it("empty stream (only finish) emits only RUN_STARTED + RUN_FINISHED", async () => {
const agent = createAgent("aisdk", [finish()]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.RUN_FINISHED,
]);
});
});
// ---------------------------------------------------------------------------
// Tool Call Events
// ---------------------------------------------------------------------------
describe("Tool Call Events", () => {
it("streamed tool call emits correct START/ARGS/END/RESULT events", async () => {
const agent = createAgent("aisdk", [
toolCallStreamingStart("tc-1", "myTool"),
toolCallDelta("tc-1", '{"key":'),
toolCallDelta("tc-1", '"value"}'),
toolCall("tc-1", "myTool"),
toolResult("tc-1", "myTool", { result: "ok" }),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
// Check the sequence of tool events
const toolEvents = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_START ||
e.type === EventType.TOOL_CALL_ARGS ||
e.type === EventType.TOOL_CALL_END ||
e.type === EventType.TOOL_CALL_RESULT,
);
expectEventSequence(toolEvents, [
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_END,
EventType.TOOL_CALL_RESULT,
]);
// Verify TOOL_CALL_START details
expect(eventField<string>(toolEvents[0], "toolCallId")).toBe("tc-1");
expect(eventField<string>(toolEvents[0], "toolCallName")).toBe("myTool");
// Verify TOOL_CALL_ARGS deltas
const argsEvts = toolEvents.filter(
(e) => e.type === EventType.TOOL_CALL_ARGS,
);
expect(eventField<string>(argsEvts[0], "delta")).toBe('{"key":');
expect(eventField<string>(argsEvts[1], "delta")).toBe('"value"}');
// Verify TOOL_CALL_END
expect(eventField<string>(toolEvents[2 + 1], "toolCallId")).toBe("tc-1");
// Verify TOOL_CALL_RESULT
expect(eventField<string>(toolEvents[4], "toolCallId")).toBe("tc-1");
expect(JSON.parse(eventField<string>(toolEvents[4], "content"))).toEqual({
result: "ok",
});
});
it("non-streamed tool call (tool-call with input, no prior tool-input-start) emits START + ARGS + END", async () => {
const agent = createAgent("aisdk", [
toolCall("tc-2", "directTool", { foo: "bar" }),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
const toolEvents = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_START ||
e.type === EventType.TOOL_CALL_ARGS ||
e.type === EventType.TOOL_CALL_END,
);
expectEventSequence(toolEvents, [
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_END,
]);
expect(eventField<string>(toolEvents[0], "toolCallId")).toBe("tc-2");
expect(eventField<string>(toolEvents[0], "toolCallName")).toBe(
"directTool",
);
expect(JSON.parse(eventField<string>(toolEvents[1], "delta"))).toEqual({
foo: "bar",
});
});
it("no duplicate START after tool-input-start followed by tool-call", async () => {
const agent = createAgent("aisdk", [
toolCallStreamingStart("tc-3", "myTool"),
toolCallDelta("tc-3", "{}"),
toolCall("tc-3", "myTool"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const startEvents = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_START &&
eventField<string>(e, "toolCallId") === "tc-3",
);
expect(startEvents).toHaveLength(1);
});
it("multiple concurrent tool calls have events correctly paired by toolCallId", async () => {
const agent = createAgent("aisdk", [
toolCallStreamingStart("tc-a", "toolA"),
toolCallStreamingStart("tc-b", "toolB"),
toolCallDelta("tc-a", '{"a":1}'),
toolCallDelta("tc-b", '{"b":2}'),
toolCall("tc-a", "toolA"),
toolCall("tc-b", "toolB"),
toolResult("tc-a", "toolA", "resultA"),
toolResult("tc-b", "toolB", "resultB"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
// Verify each tool call has its own START
const startsA = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_START &&
eventField<string>(e, "toolCallId") === "tc-a",
);
const startsB = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_START &&
eventField<string>(e, "toolCallId") === "tc-b",
);
expect(startsA).toHaveLength(1);
expect(startsB).toHaveLength(1);
// Verify args are correctly paired
const argsA = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_ARGS &&
eventField<string>(e, "toolCallId") === "tc-a",
);
const argsB = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_ARGS &&
eventField<string>(e, "toolCallId") === "tc-b",
);
expect(eventField<string>(argsA[0], "delta")).toBe('{"a":1}');
expect(eventField<string>(argsB[0], "delta")).toBe('{"b":2}');
// Verify results are correctly paired
const resultsA = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_RESULT &&
eventField<string>(e, "toolCallId") === "tc-a",
);
const resultsB = events.filter(
(e) =>
e.type === EventType.TOOL_CALL_RESULT &&
eventField<string>(e, "toolCallId") === "tc-b",
);
expect(JSON.parse(eventField<string>(resultsA[0], "content"))).toBe(
"resultA",
);
expect(JSON.parse(eventField<string>(resultsB[0], "content"))).toBe(
"resultB",
);
});
});
// ---------------------------------------------------------------------------
// Reasoning Events
// ---------------------------------------------------------------------------
describe("Reasoning Events", () => {
it("full reasoning lifecycle emits correct REASONING_START/MESSAGE_START/CONTENT/MESSAGE_END/END events", async () => {
const agent = createAgent("aisdk", [
reasoningStart("r-1"),
reasoningDelta("thinking..."),
reasoningEnd(),
textDelta("Answer"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
const reasoningEvents = events.filter(
(e) =>
e.type === EventType.REASONING_START ||
e.type === EventType.REASONING_MESSAGE_START ||
e.type === EventType.REASONING_MESSAGE_CONTENT ||
e.type === EventType.REASONING_MESSAGE_END ||
e.type === EventType.REASONING_END,
);
expectEventSequence(reasoningEvents, [
EventType.REASONING_START,
EventType.REASONING_MESSAGE_START,
EventType.REASONING_MESSAGE_CONTENT,
EventType.REASONING_MESSAGE_END,
EventType.REASONING_END,
]);
// Verify messageId consistency
expect(eventField<string>(reasoningEvents[0], "messageId")).toBe("r-1");
expect(eventField<string>(reasoningEvents[1], "messageId")).toBe("r-1");
expect(eventField<string>(reasoningEvents[1], "role")).toBe("reasoning");
expect(eventField<string>(reasoningEvents[2], "messageId")).toBe("r-1");
expect(eventField<string>(reasoningEvents[2], "delta")).toBe(
"thinking...",
);
expect(eventField<string>(reasoningEvents[3], "messageId")).toBe("r-1");
expect(eventField<string>(reasoningEvents[4], "messageId")).toBe("r-1");
});
it("empty reasoning deltas are skipped", async () => {
const agent = createAgent("aisdk", [
reasoningStart(),
reasoningDelta(""),
reasoningDelta("actual content"),
reasoningDelta(""),
reasoningEnd(),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const contentEvents = events.filter(
(e) => e.type === EventType.REASONING_MESSAGE_CONTENT,
);
expect(contentEvents).toHaveLength(1);
expect(eventField<string>(contentEvents[0], "delta")).toBe(
"actual content",
);
});
it("auto-close reasoning before text-delta", async () => {
// No explicit reasoning-end — the converter should auto-close
const agent = createAgent("aisdk", [
reasoningStart("r-auto"),
reasoningDelta("thinking"),
textDelta("Answer"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
// Reasoning should be closed before the text event
const types = events.map((e) => e.type);
const msgEndIdx = types.indexOf(EventType.REASONING_MESSAGE_END);
const reasoningEndIdx = types.indexOf(EventType.REASONING_END);
const textIdx = types.indexOf(EventType.TEXT_MESSAGE_CHUNK);
expect(msgEndIdx).toBeGreaterThan(-1);
expect(reasoningEndIdx).toBeGreaterThan(-1);
expect(textIdx).toBeGreaterThan(reasoningEndIdx);
});
it("auto-close reasoning before tool-input-start", async () => {
const agent = createAgent("aisdk", [
reasoningStart(),
reasoningDelta("thinking about tools"),
toolCallStreamingStart("tc-r", "someTool"),
toolCallDelta("tc-r", "{}"),
toolCall("tc-r", "someTool"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
const types = events.map((e) => e.type);
const reasoningEndIdx = types.indexOf(EventType.REASONING_END);
const toolStartIdx = types.indexOf(EventType.TOOL_CALL_START);
expect(reasoningEndIdx).toBeGreaterThan(-1);
expect(toolStartIdx).toBeGreaterThan(reasoningEndIdx);
});
it("auto-close reasoning before finish", async () => {
const agent = createAgent("aisdk", [
reasoningStart(),
reasoningDelta("deep thought"),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
// Should contain reasoning close events
const types = events.map((e) => e.type);
expect(types).toContain(EventType.REASONING_MESSAGE_END);
expect(types).toContain(EventType.REASONING_END);
// They should appear before RUN_FINISHED
const reasoningEndIdx = types.indexOf(EventType.REASONING_END);
const runFinishedIdx = types.indexOf(EventType.RUN_FINISHED);
expect(reasoningEndIdx).toBeLessThan(runFinishedIdx);
});
});
// ---------------------------------------------------------------------------
// State Management Tool Results
// ---------------------------------------------------------------------------
describe("State Management Tool Results", () => {
it("AGUISendStateSnapshot tool result emits STATE_SNAPSHOT event", async () => {
const agent = createAgent("aisdk", [
toolCallStreamingStart("tc-state", "AGUISendStateSnapshot"),
toolCallDelta("tc-state", '{"snapshot":{"count":1}}'),
toolCall("tc-state", "AGUISendStateSnapshot"),
toolResult("tc-state", "AGUISendStateSnapshot", {
snapshot: { count: 1 },
}),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const stateSnapshots = events.filter(
(e) => e.type === EventType.STATE_SNAPSHOT,
);
expect(stateSnapshots).toHaveLength(1);
expect(eventField(stateSnapshots[0], "snapshot")).toEqual({ count: 1 });
});
it("AGUISendStateDelta tool result emits STATE_DELTA event", async () => {
const delta = [{ op: "replace", path: "/count", value: 2 }];
const agent = createAgent("aisdk", [
toolCallStreamingStart("tc-delta", "AGUISendStateDelta"),
toolCallDelta("tc-delta", JSON.stringify({ delta })),
toolCall("tc-delta", "AGUISendStateDelta"),
toolResult("tc-delta", "AGUISendStateDelta", { delta }),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const stateDeltas = events.filter(
(e) => e.type === EventType.STATE_DELTA,
);
expect(stateDeltas).toHaveLength(1);
expect(eventField(stateDeltas[0], "delta")).toEqual(delta);
});
it("state tool result also emits TOOL_CALL_RESULT event", async () => {
const agent = createAgent("aisdk", [
toolCallStreamingStart("tc-state2", "AGUISendStateSnapshot"),
toolCallDelta("tc-state2", "{}"),
toolCall("tc-state2", "AGUISendStateSnapshot"),
toolResult("tc-state2", "AGUISendStateSnapshot", {
snapshot: { x: 1 },
}),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const toolResults = events.filter(
(e) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(toolResults).toHaveLength(1);
expect(eventField<string>(toolResults[0], "toolCallId")).toBe(
"tc-state2",
);
});
it("does not emit STATE_SNAPSHOT when snapshot field is undefined", async () => {
const agent = createAgent("aisdk", [
toolCallStreamingStart("tc-no-snap", "AGUISendStateSnapshot"),
toolCallDelta("tc-no-snap", "{}"),
toolCall("tc-no-snap", "AGUISendStateSnapshot"),
toolResult("tc-no-snap", "AGUISendStateSnapshot", {
success: true,
}),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const stateSnapshots = events.filter(
(e) => e.type === EventType.STATE_SNAPSHOT,
);
expect(stateSnapshots).toHaveLength(0);
// Should still emit the TOOL_CALL_RESULT
const toolResults = events.filter(
(e) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(toolResults).toHaveLength(1);
});
it("does not emit STATE_DELTA when delta field is undefined", async () => {
const agent = createAgent("aisdk", [
toolCallStreamingStart("tc-no-delta", "AGUISendStateDelta"),
toolCallDelta("tc-no-delta", "{}"),
toolCall("tc-no-delta", "AGUISendStateDelta"),
toolResult("tc-no-delta", "AGUISendStateDelta", {
success: true,
}),
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const stateDeltas = events.filter(
(e) => e.type === EventType.STATE_DELTA,
);
expect(stateDeltas).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Tool Result Property Compatibility
// ---------------------------------------------------------------------------
describe("Tool Result Property Compatibility", () => {
it("reads tool result from 'result' property when 'output' is absent", async () => {
// Simulate older AI SDK that uses "result" instead of "output"
const agent = createAgent("aisdk", [
toolCall("tc-compat", "myTool"),
{
type: "tool-result",
toolCallId: "tc-compat",
toolName: "myTool",
result: { data: "from-result-prop" },
},
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const resultEvents = events.filter(
(e) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(resultEvents).toHaveLength(1);
expect(
JSON.parse(eventField<string>(resultEvents[0], "content")),
).toEqual({ data: "from-result-prop" });
});
it("prefers 'output' over 'result' when both are present", async () => {
const agent = createAgent("aisdk", [
toolCall("tc-both", "myTool"),
{
type: "tool-result",
toolCallId: "tc-both",
toolName: "myTool",
output: { source: "output" },
result: { source: "result" },
},
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
const resultEvents = events.filter(
(e) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(
JSON.parse(eventField<string>(resultEvents[0], "content")),
).toEqual({ source: "output" });
});
});
// ---------------------------------------------------------------------------
// Error Event Handling
// ---------------------------------------------------------------------------
describe("Error Event Handling", () => {
it("wraps undefined error in a descriptive Error", async () => {
const agent = createAgent("aisdk", [
textDelta("before error"),
{ type: "error" }, // no .error property
]);
const input = createDefaultInput();
const { events, errored } = await collectEventsIncludingErrors(
agent.run(input),
);
expect(errored).toBe(true);
const errorEvents = events.filter((e) => e.type === EventType.RUN_ERROR);
expect(errorEvents).toHaveLength(1);
// Should contain a useful message, not just "undefined"
expect(eventField<string>(errorEvents[0], "message")).not.toBe(
"undefined",
);
expect(
eventField<string>(errorEvents[0], "message").length,
).toBeGreaterThan(5);
});
it("preserves Error instances from error event", async () => {
const agent = createAgent("aisdk", [
{ type: "error", error: new Error("rate limit exceeded") },
]);
const input = createDefaultInput();
const { events, errored } = await collectEventsIncludingErrors(
agent.run(input),
);
expect(errored).toBe(true);
const errorEvents = events.filter((e) => e.type === EventType.RUN_ERROR);
expect(eventField<string>(errorEvents[0], "message")).toBe(
"rate limit exceeded",
);
});
it("handles string error from error event", async () => {
const agent = createAgent("aisdk", [
{ type: "error", message: "auth failed" },
]);
const input = createDefaultInput();
const { events, errored } = await collectEventsIncludingErrors(
agent.run(input),
);
expect(errored).toBe(true);
const errorEvents = events.filter((e) => e.type === EventType.RUN_ERROR);
expect(eventField<string>(errorEvents[0], "message")).toBe("auth failed");
});
});
// ---------------------------------------------------------------------------
// Edge Cases
// ---------------------------------------------------------------------------
describe("Edge Cases", () => {
it("unknown event types are silently ignored", async () => {
const agent = createAgent("aisdk", [
{ type: "some-unknown-event", data: "hello" },
textDelta("text after unknown"),
{ type: "another-mystery-event" },
finish(),
]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
// Should still have the text chunk
const textChunks = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textChunks).toHaveLength(1);
// No events for unknown types — only RUN_STARTED, TEXT_MESSAGE_CHUNK, RUN_FINISHED
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TEXT_MESSAGE_CHUNK,
EventType.RUN_FINISHED,
]);
});
it("large text deltas (100k chars) are passed through", async () => {
const largeText = "x".repeat(100_000);
const agent = createAgent("aisdk", [textDelta(largeText), finish()]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events);
const chunk = events.find(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
)!;
expect(eventField<string>(chunk, "delta")).toBe(largeText);
expect(eventField<string>(chunk, "delta").length).toBe(100_000);
});
});
});
@@ -0,0 +1,319 @@
import { describe, it, expect } from "vitest";
import { EventType, type BaseEvent } from "@ag-ui/client";
import { BuiltInAgent } from "../index";
import {
createAgent,
createDefaultInput,
collectEvents,
expectLifecycleWrapped,
expectEventSequence,
eventField,
mockCustomStream,
} from "./agent-test-helpers";
describe("Custom Converter (passthrough)", () => {
// -----------------------------------------------------------------------
// Event Forwarding
// -----------------------------------------------------------------------
describe("Event Forwarding", () => {
it("should forward a single TEXT_MESSAGE_CHUNK as-is between lifecycle events", async () => {
const chunk: BaseEvent = {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: "Hello world",
} as BaseEvent;
const agent = createAgent("custom", [chunk]);
const input = createDefaultInput();
const events = await collectEvents(agent.run(input));
expectLifecycleWrapped(events, "test-thread", "test-run");
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TEXT_MESSAGE_CHUNK,
EventType.RUN_FINISHED,
]);
expect(eventField<string>(events[1], "delta")).toBe("Hello world");
expect(eventField<string>(events[1], "role")).toBe("assistant");
});
it("should forward multiple event types in order", async () => {
const userEvents: BaseEvent[] = [
{ type: EventType.TEXT_MESSAGE_START, role: "assistant" } as BaseEvent,
{
type: EventType.TEXT_MESSAGE_CONTENT,
role: "assistant",
content: "Hi",
} as BaseEvent,
{ type: EventType.TEXT_MESSAGE_END } as BaseEvent,
];
const agent = createAgent("custom", userEvents);
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TEXT_MESSAGE_START,
EventType.TEXT_MESSAGE_CONTENT,
EventType.TEXT_MESSAGE_END,
EventType.RUN_FINISHED,
]);
});
it("should forward TOOL_CALL_START + TOOL_CALL_ARGS + TOOL_CALL_END", async () => {
const toolEvents: BaseEvent[] = [
{
type: EventType.TOOL_CALL_START,
toolCallId: "tc-1",
toolCallName: "myTool",
} as BaseEvent,
{
type: EventType.TOOL_CALL_ARGS,
toolCallId: "tc-1",
delta: '{"key":"value"}',
} as BaseEvent,
{
type: EventType.TOOL_CALL_END,
toolCallId: "tc-1",
} as BaseEvent,
];
const agent = createAgent("custom", toolEvents);
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_END,
EventType.RUN_FINISHED,
]);
expect(eventField<string>(events[1], "toolCallId")).toBe("tc-1");
expect(eventField<string>(events[1], "toolCallName")).toBe("myTool");
expect(eventField<string>(events[2], "toolCallId")).toBe("tc-1");
expect(eventField<string>(events[2], "delta")).toBe('{"key":"value"}');
expect(eventField<string>(events[3], "toolCallId")).toBe("tc-1");
});
it("should forward a STATE_SNAPSHOT event", async () => {
const snapshot: BaseEvent = {
type: EventType.STATE_SNAPSHOT,
snapshot: { counter: 42, items: ["a", "b"] },
} as BaseEvent;
const agent = createAgent("custom", [snapshot]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.STATE_SNAPSHOT,
EventType.RUN_FINISHED,
]);
expect(
eventField<Record<string, unknown>>(events[1], "snapshot"),
).toEqual({ counter: 42, items: ["a", "b"] });
});
it("should forward a STATE_DELTA event", async () => {
const delta: BaseEvent = {
type: EventType.STATE_DELTA,
delta: [{ op: "replace", path: "/counter", value: 43 }],
} as BaseEvent;
const agent = createAgent("custom", [delta]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.STATE_DELTA,
EventType.RUN_FINISHED,
]);
expect(eventField<unknown[]>(events[1], "delta")).toEqual([
{ op: "replace", path: "/counter", value: 43 },
]);
});
it("should forward reasoning events in order", async () => {
const reasoningEvents: BaseEvent[] = [
{ type: EventType.REASONING_START } as BaseEvent,
{ type: EventType.REASONING_MESSAGE_START } as BaseEvent,
{
type: EventType.REASONING_MESSAGE_CONTENT,
content: "Thinking step 1",
} as BaseEvent,
{
type: EventType.REASONING_MESSAGE_CONTENT,
content: "Thinking step 2",
} as BaseEvent,
{ type: EventType.REASONING_MESSAGE_END } as BaseEvent,
{ type: EventType.REASONING_END } as BaseEvent,
];
const agent = createAgent("custom", reasoningEvents);
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.REASONING_START,
EventType.REASONING_MESSAGE_START,
EventType.REASONING_MESSAGE_CONTENT,
EventType.REASONING_MESSAGE_CONTENT,
EventType.REASONING_MESSAGE_END,
EventType.REASONING_END,
EventType.RUN_FINISHED,
]);
expect(eventField<string>(events[3], "content")).toBe("Thinking step 1");
expect(eventField<string>(events[4], "content")).toBe("Thinking step 2");
});
});
// -----------------------------------------------------------------------
// Lifecycle Boundary
// -----------------------------------------------------------------------
describe("Lifecycle Boundary", () => {
it("should result in duplicate RUN_STARTED when user emits one in custom stream", async () => {
const userEvents: BaseEvent[] = [
{
type: EventType.RUN_STARTED,
threadId: "user-thread",
runId: "user-run",
} as BaseEvent,
{
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: "Hello",
} as BaseEvent,
];
const agent = createAgent("custom", userEvents);
const events = await collectEvents(agent.run(createDefaultInput()));
// Agent emits its own RUN_STARTED, then the user's RUN_STARTED is forwarded
const runStartedEvents = events.filter(
(e) => e.type === EventType.RUN_STARTED,
);
expect(runStartedEvents).toHaveLength(2);
// First is from the Agent lifecycle
expect(runStartedEvents[0]).toMatchObject({
type: EventType.RUN_STARTED,
threadId: "test-thread",
runId: "test-run",
});
// Second is the user-emitted one, forwarded as-is
expect(eventField<string>(runStartedEvents[1], "threadId")).toBe(
"user-thread",
);
expect(eventField<string>(runStartedEvents[1], "runId")).toBe("user-run");
});
});
// -----------------------------------------------------------------------
// Edge Cases
// -----------------------------------------------------------------------
describe("Edge Cases", () => {
it("should emit only lifecycle events for an empty async iterable", async () => {
const agent = createAgent("custom", []);
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.RUN_FINISHED,
]);
expectLifecycleWrapped(events, "test-thread", "test-run");
});
it("should work correctly with an async generator factory", async () => {
const agent = new BuiltInAgent({
type: "custom",
factory: async function* () {
yield {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: "from generator",
} as BaseEvent;
yield {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: " factory",
} as BaseEvent;
},
});
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TEXT_MESSAGE_CHUNK,
EventType.TEXT_MESSAGE_CHUNK,
EventType.RUN_FINISHED,
]);
expect(eventField<string>(events[1], "delta")).toBe("from generator");
expect(eventField<string>(events[2], "delta")).toBe(" factory");
});
it("should pass through events with extra/unknown fields", async () => {
const eventWithExtras: BaseEvent = {
type: EventType.CUSTOM,
customField: "custom-value",
nestedData: { deep: { value: 123 } },
arrayField: [1, 2, 3],
} as BaseEvent;
const agent = createAgent("custom", [eventWithExtras]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.CUSTOM,
EventType.RUN_FINISHED,
]);
expect(eventField<string>(events[1], "customField")).toBe("custom-value");
expect(
eventField<{ deep: { value: number } }>(events[1], "nestedData"),
).toEqual({ deep: { value: 123 } });
expect(eventField<number[]>(events[1], "arrayField")).toEqual([1, 2, 3]);
});
it("should forward 1000+ events without loss", async () => {
const count = 1500;
const manyEvents: BaseEvent[] = Array.from({ length: count }, (_, i) => ({
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
delta: `chunk-${i}`,
})) as BaseEvent[];
const agent = createAgent("custom", manyEvents);
const events = await collectEvents(agent.run(createDefaultInput()));
// Total = RUN_STARTED + 1500 chunks + RUN_FINISHED
expect(events).toHaveLength(count + 2);
// First and last are lifecycle
expect(events[0].type).toBe(EventType.RUN_STARTED);
expect(events[events.length - 1].type).toBe(EventType.RUN_FINISHED);
// All content events are TEXT_MESSAGE_CHUNK
const contentEvents = events.slice(1, -1);
expect(contentEvents).toHaveLength(count);
// Verify order preservation
for (let i = 0; i < count; i++) {
expect(contentEvents[i].type).toBe(EventType.TEXT_MESSAGE_CHUNK);
expect(eventField<string>(contentEvents[i], "delta")).toBe(
`chunk-${i}`,
);
}
});
});
});
@@ -0,0 +1,291 @@
import { describe, it, expect } from "vitest";
import { convertInputToTanStackAI } from "../converters/tanstack";
import { createDefaultInput } from "./agent-test-helpers";
describe("convertInputToTanStackAI", () => {
// -------------------------------------------------------------------------
// Message filtering
// -------------------------------------------------------------------------
describe("message filtering", () => {
it("filters out system messages from the messages array", () => {
const input = createDefaultInput({
messages: [
{ id: "msg-1", role: "system", content: "You are helpful" },
{ id: "msg-2", role: "user", content: "Hello" },
],
});
const { messages } = convertInputToTanStackAI(input);
expect(messages).toHaveLength(1);
expect(messages[0].role).toBe("user");
expect(messages[0].content).toBe("Hello");
});
it("filters out developer messages from the messages array", () => {
const input = createDefaultInput({
messages: [
{ id: "msg-3", role: "developer", content: "Internal instruction" },
{ id: "msg-4", role: "user", content: "Hi" },
],
});
const { messages } = convertInputToTanStackAI(input);
expect(messages).toHaveLength(1);
expect(messages[0].role).toBe("user");
});
it("extracts system and developer messages into systemPrompts", () => {
const input = createDefaultInput({
messages: [
{ id: "msg-5", role: "system", content: "System prompt" },
{ id: "msg-6", role: "developer", content: "Dev instruction" },
{ id: "msg-7", role: "user", content: "Hello" },
],
});
const { systemPrompts } = convertInputToTanStackAI(input);
expect(systemPrompts).toContain("System prompt");
expect(systemPrompts).toContain("Dev instruction");
});
it("preserves user and assistant messages in order", () => {
const input = createDefaultInput({
messages: [
{ id: "msg-8", role: "user", content: "Question 1" },
{ id: "msg-9", role: "assistant", content: "Answer 1" },
{ id: "msg-10", role: "user", content: "Question 2" },
],
});
const { messages } = convertInputToTanStackAI(input);
expect(messages).toHaveLength(3);
expect(messages[0]).toMatchObject({
role: "user",
content: "Question 1",
});
expect(messages[1]).toMatchObject({
role: "assistant",
content: "Answer 1",
});
expect(messages[2]).toMatchObject({
role: "user",
content: "Question 2",
});
});
});
// -------------------------------------------------------------------------
// Tool call mapping
// -------------------------------------------------------------------------
describe("tool call mapping", () => {
it("maps assistant message toolCalls to TanStack format", () => {
const input = createDefaultInput({
messages: [
{
id: "msg-14",
role: "assistant",
content: null,
toolCalls: [
{
id: "tc-1",
type: "function",
function: { name: "getWeather", arguments: '{"city":"NYC"}' },
},
],
},
],
});
const { messages } = convertInputToTanStackAI(input);
expect(messages).toHaveLength(1);
expect(messages[0].toolCalls).toHaveLength(1);
expect(messages[0].toolCalls![0]).toEqual({
id: "tc-1",
type: "function",
function: { name: "getWeather", arguments: '{"city":"NYC"}' },
});
});
it("maps tool messages with toolCallId", () => {
const input = createDefaultInput({
messages: [
{
id: "msg-15",
role: "tool",
content: '{"temp": 72}',
toolCallId: "tc-1",
},
],
});
const { messages } = convertInputToTanStackAI(input);
expect(messages).toHaveLength(1);
expect(messages[0].role).toBe("tool");
expect(messages[0].toolCallId).toBe("tc-1");
});
});
// -------------------------------------------------------------------------
// Client tools (frontend-provided tools → TanStack client tools)
// -------------------------------------------------------------------------
describe("client tools", () => {
it("converts input.tools to TanStack client tools (no executor)", () => {
const params = {
type: "object" as const,
properties: { issues: { type: "array" as const } },
required: ["issues"],
};
const input = createDefaultInput({
tools: [
{
name: "issue_list",
description: "Render a list of issues",
parameters: params,
},
],
});
const { tools } = convertInputToTanStackAI(input);
expect(tools).toHaveLength(1);
expect(tools[0]).toEqual({
__toolSide: "client",
name: "issue_list",
description: "Render a list of issues",
inputSchema: params,
});
// Client tools must NOT carry an executor — TanStack pauses and hands
// the call back to the AG-UI client instead of running it.
expect("execute" in tools[0]).toBe(false);
});
it("returns an empty tools array when input has no tools", () => {
const { tools } = convertInputToTanStackAI(createDefaultInput());
expect(tools).toEqual([]);
});
it("closes open objects (additionalProperties → false) for OpenAI compatibility", () => {
const input = createDefaultInput({
tools: [
{
name: "render_chart",
description: "Render a chart",
parameters: {
type: "object",
properties: {
// z.record(z.string(), z.any()) → additionalProperties: {}
options: { type: "object", additionalProperties: {} },
data: {
type: "array",
// .passthrough() → additionalProperties: true on items
items: {
type: "object",
properties: { v: { type: "number" } },
additionalProperties: true,
},
},
},
additionalProperties: false,
},
},
],
});
const { tools } = convertInputToTanStackAI(input);
const schema = tools[0].inputSchema as Record<string, any>;
expect(schema.properties.options.additionalProperties).toBe(false);
expect(schema.properties.data.items.additionalProperties).toBe(false);
// Nested defined properties are preserved.
expect(schema.properties.data.items.properties.v).toEqual({
type: "number",
});
});
});
// -------------------------------------------------------------------------
// Context injection
// -------------------------------------------------------------------------
describe("context injection", () => {
it("appends context entries to systemPrompts", () => {
const input = createDefaultInput({
context: [
{ description: "User preferences", value: "Dark mode enabled" },
{ description: "Current page", value: "/dashboard" },
],
});
const { systemPrompts } = convertInputToTanStackAI(input);
expect(systemPrompts).toContain("User preferences:\nDark mode enabled");
expect(systemPrompts).toContain("Current page:\n/dashboard");
});
it("does not add context when context array is empty", () => {
const input = createDefaultInput({ context: [] });
const { systemPrompts } = convertInputToTanStackAI(input);
expect(systemPrompts).toHaveLength(0);
});
});
// -------------------------------------------------------------------------
// State serialization
// -------------------------------------------------------------------------
describe("state serialization", () => {
it("serializes non-empty state into systemPrompts", () => {
const input = createDefaultInput({
state: { count: 42, items: ["a", "b"] },
});
const { systemPrompts } = convertInputToTanStackAI(input);
const statePrompt = systemPrompts.find((p) =>
p.startsWith("Application State:"),
);
expect(statePrompt).toBeDefined();
expect(statePrompt).toContain('"count": 42');
});
it("does not add state when state is empty object", () => {
const input = createDefaultInput({ state: {} });
const { systemPrompts } = convertInputToTanStackAI(input);
expect(
systemPrompts.find((p) => p.startsWith("Application State:")),
).toBeUndefined();
});
it("does not add state when state is null", () => {
const input = createDefaultInput({ state: null });
const { systemPrompts } = convertInputToTanStackAI(input);
expect(
systemPrompts.find((p) => p.startsWith("Application State:")),
).toBeUndefined();
});
});
// -------------------------------------------------------------------------
// Empty input
// -------------------------------------------------------------------------
describe("empty input", () => {
it("returns empty messages and systemPrompts for default input", () => {
const input = createDefaultInput();
const { messages, systemPrompts } = convertInputToTanStackAI(input);
expect(messages).toHaveLength(0);
expect(systemPrompts).toHaveLength(0);
});
});
});
@@ -0,0 +1,688 @@
import { describe, it, expect } from "vitest";
import { EventType } from "@ag-ui/client";
import {
createAgent,
createDefaultInput,
collectEvents,
collectEventsIncludingErrors,
expectLifecycleWrapped,
expectEventSequence,
eventField,
tanstackTextChunk,
tanstackToolCallStart,
tanstackToolCallArgs,
tanstackToolCallEnd,
tanstackToolCallResult,
tanstackReasoningStart,
tanstackReasoningMessageStart,
tanstackReasoningMessageContent,
tanstackReasoningMessageEnd,
tanstackReasoningEnd,
} from "./agent-test-helpers";
describe("TanStack AI converter (via Agent)", () => {
// -------------------------------------------------------------------------
// Text Events
// -------------------------------------------------------------------------
describe("Text Events", () => {
it("TEXT_MESSAGE_CONTENT chunk produces TEXT_MESSAGE_CHUNK with role assistant and correct delta", async () => {
const agent = createAgent("tanstack", [tanstackTextChunk("Hello world")]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents).toHaveLength(1);
expect(eventField<string>(textEvents[0], "role")).toBe("assistant");
expect(eventField<string>(textEvents[0], "delta")).toBe("Hello world");
expect(eventField<string>(textEvents[0], "messageId")).toBeDefined();
expect(typeof eventField<string>(textEvents[0], "messageId")).toBe(
"string",
);
expect(
eventField<string>(textEvents[0], "messageId").length,
).toBeGreaterThan(0);
});
it("multiple text chunks share the same messageId", async () => {
const agent = createAgent("tanstack", [
tanstackTextChunk("Hello "),
tanstackTextChunk("world"),
tanstackTextChunk("!"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents).toHaveLength(3);
const messageIds = new Set(
textEvents.map((e) => eventField<string>(e, "messageId")),
);
expect(messageIds.size).toBe(1);
});
it("empty stream produces only RUN_STARTED + RUN_FINISHED", async () => {
const agent = createAgent("tanstack", []);
const events = await collectEvents(agent.run(createDefaultInput()));
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.RUN_FINISHED,
]);
});
});
// -------------------------------------------------------------------------
// Tool Call Events
// -------------------------------------------------------------------------
describe("Tool Call Events", () => {
it("full tool call lifecycle produces START, ARGS, END events in order", async () => {
const agent = createAgent("tanstack", [
tanstackToolCallStart("tc-1", "myTool"),
tanstackToolCallArgs("tc-1", '{"key":'),
tanstackToolCallArgs("tc-1", '"value"}'),
tanstackToolCallEnd("tc-1"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_END,
EventType.RUN_FINISHED,
]);
expect(eventField<string>(events[1], "toolCallId")).toBe("tc-1");
expect(eventField<string>(events[1], "toolCallName")).toBe("myTool");
expect(eventField<string>(events[2], "toolCallId")).toBe("tc-1");
expect(eventField<string>(events[2], "delta")).toBe('{"key":');
expect(eventField<string>(events[3], "toolCallId")).toBe("tc-1");
expect(eventField<string>(events[3], "delta")).toBe('"value"}');
expect(eventField<string>(events[4], "toolCallId")).toBe("tc-1");
});
it("TOOL_CALL_START sets parentMessageId", async () => {
const agent = createAgent("tanstack", [
tanstackTextChunk("before"),
tanstackToolCallStart("tc-1", "myTool"),
tanstackToolCallEnd("tc-1"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const textEvent = events.find(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
)!;
const toolStartEvent = events.find(
(e) => e.type === EventType.TOOL_CALL_START,
)!;
expect(
eventField<string>(toolStartEvent, "parentMessageId"),
).toBeDefined();
expect(eventField<string>(toolStartEvent, "parentMessageId")).toBe(
eventField<string>(textEvent, "messageId"),
);
});
it("multiple tool calls in sequence each get correct events", async () => {
const agent = createAgent("tanstack", [
tanstackToolCallStart("tc-1", "toolA"),
tanstackToolCallArgs("tc-1", '{"a":1}'),
tanstackToolCallEnd("tc-1"),
tanstackToolCallStart("tc-2", "toolB"),
tanstackToolCallArgs("tc-2", '{"b":2}'),
tanstackToolCallEnd("tc-2"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_END,
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_END,
EventType.RUN_FINISHED,
]);
// Verify first tool call
expect(eventField<string>(events[1], "toolCallId")).toBe("tc-1");
expect(eventField<string>(events[1], "toolCallName")).toBe("toolA");
expect(eventField<string>(events[2], "toolCallId")).toBe("tc-1");
expect(eventField<string>(events[3], "toolCallId")).toBe("tc-1");
// Verify second tool call
expect(eventField<string>(events[4], "toolCallId")).toBe("tc-2");
expect(eventField<string>(events[4], "toolCallName")).toBe("toolB");
expect(eventField<string>(events[5], "toolCallId")).toBe("tc-2");
expect(eventField<string>(events[6], "toolCallId")).toBe("tc-2");
});
it("tool call with no ARGS chunks produces only START + END", async () => {
const agent = createAgent("tanstack", [
tanstackToolCallStart("tc-1", "noArgsTool"),
tanstackToolCallEnd("tc-1"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_END,
EventType.RUN_FINISHED,
]);
});
});
// -------------------------------------------------------------------------
// Tool Call Result Events
// -------------------------------------------------------------------------
describe("Tool Call Result Events", () => {
it("TOOL_CALL_RESULT chunk produces TOOL_CALL_RESULT event with correct content", async () => {
const agent = createAgent("tanstack", [
tanstackToolCallStart("tc-1", "myTool"),
tanstackToolCallArgs("tc-1", '{"key":"value"}'),
tanstackToolCallEnd("tc-1"),
{
type: "TOOL_CALL_RESULT",
toolCallId: "tc-1",
content: JSON.stringify({ result: "ok" }),
},
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
const resultEvents = events.filter(
(e) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(resultEvents).toHaveLength(1);
expect(eventField<string>(resultEvents[0], "toolCallId")).toBe("tc-1");
expect(eventField<string>(resultEvents[0], "role")).toBe("tool");
expect(
JSON.parse(eventField<string>(resultEvents[0], "content")),
).toEqual({ result: "ok" });
});
it("TOOL_CALL_RESULT with object content serializes to JSON", async () => {
const agent = createAgent("tanstack", [
tanstackToolCallStart("tc-2", "myTool"),
tanstackToolCallEnd("tc-2"),
{
type: "TOOL_CALL_RESULT",
toolCallId: "tc-2",
result: { data: 42 },
},
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const resultEvents = events.filter(
(e) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(resultEvents).toHaveLength(1);
expect(
JSON.parse(eventField<string>(resultEvents[0], "content")),
).toEqual({ data: 42 });
});
});
// -------------------------------------------------------------------------
// Mixed Content
// -------------------------------------------------------------------------
describe("Mixed Content", () => {
it("text interleaved with tool calls produces correct event types and order", async () => {
const agent = createAgent("tanstack", [
tanstackTextChunk("Let me help. "),
tanstackToolCallStart("tc-1", "search"),
tanstackToolCallArgs("tc-1", '{"q":"test"}'),
tanstackToolCallEnd("tc-1"),
tanstackTextChunk("Here are the results."),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TEXT_MESSAGE_CHUNK,
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_END,
EventType.TEXT_MESSAGE_CHUNK,
EventType.RUN_FINISHED,
]);
// Verify content of text events
const textEvents = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(eventField<string>(textEvents[0], "delta")).toBe("Let me help. ");
expect(eventField<string>(textEvents[1], "delta")).toBe(
"Here are the results.",
);
});
});
// -------------------------------------------------------------------------
// Edge Cases
// -------------------------------------------------------------------------
describe("Edge Cases", () => {
it("unknown chunk types are silently ignored", async () => {
const agent = createAgent("tanstack", [
tanstackTextChunk("hello"),
{ type: "SOME_UNKNOWN_TYPE", data: "foo" },
{ type: "ANOTHER_MYSTERY", value: 42 },
tanstackTextChunk(" world"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TEXT_MESSAGE_CHUNK,
EventType.TEXT_MESSAGE_CHUNK,
EventType.RUN_FINISHED,
]);
});
it("large deltas (100k chars) are passed through", async () => {
const largeDelta = "x".repeat(100_000);
const agent = createAgent("tanstack", [tanstackTextChunk(largeDelta)]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
const textEvent = events.find(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
)!;
expect(eventField<string>(textEvent, "delta")).toBe(largeDelta);
expect(eventField<string>(textEvent, "delta").length).toBe(100_000);
});
});
});
describe("TanStack AI converter — multi-turn agent loop", () => {
// chat() emits a RUN_STARTED / RUN_FINISHED pair PER model turn and executes
// server-side tools (MCP / provider tools) itself between turns. The run
// lifecycle is owned by the Agent wrapper, so per-turn markers must be
// dropped and every turn's content converted — a regression here truncated
// the run at the first tool turn, dropping the tool result and final answer.
it("does NOT truncate at a per-turn RUN_FINISHED — tool result + final text still convert", async () => {
const agent = createAgent("tanstack", [
// Turn 1: model emits a tool call, then the turn finishes.
tanstackToolCallStart("tc-1", "list_issues"),
tanstackToolCallArgs("tc-1", '{"team":"CPK"}'),
tanstackToolCallEnd("tc-1"),
{ type: "RUN_FINISHED" },
// Between turns: chat() executes the MCP tool itself.
tanstackToolCallResult("tc-1", { issues: [] }),
// Turn 2: model produces the final answer.
{ type: "RUN_STARTED" },
tanstackTextChunk("No open issues."),
{ type: "RUN_FINISHED" },
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_END,
EventType.TOOL_CALL_RESULT,
EventType.TEXT_MESSAGE_CHUNK,
EventType.RUN_FINISHED,
]);
// Exactly one RUN_FINISHED — the outer wrapper's, not TanStack's per-turn ones.
expect(
events.filter((e) => e.type === EventType.RUN_FINISHED),
).toHaveLength(1);
// The final answer survived the per-turn RUN_FINISHED.
const textEvent = events.find(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
)!;
expect(eventField<string>(textEvent, "delta")).toBe("No open issues.");
});
it("de-duplicates a tool call re-announced after execution", async () => {
const agent = createAgent("tanstack", [
tanstackToolCallStart("tc-1", "search"),
tanstackToolCallEnd("tc-1"),
{ type: "RUN_FINISHED" },
tanstackToolCallResult("tc-1", { ok: true }),
// chat() re-announces the same call when it re-prompts — must be dropped.
tanstackToolCallStart("tc-1", "search"),
tanstackToolCallArgs("tc-1", '{"q":"x"}'),
tanstackToolCallEnd("tc-1"),
{ type: "RUN_STARTED" },
tanstackTextChunk("done"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
expectEventSequence(events, [
EventType.RUN_STARTED,
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_END,
EventType.TOOL_CALL_RESULT,
EventType.TEXT_MESSAGE_CHUNK,
EventType.RUN_FINISHED,
]);
});
it("surfaces a RUN_ERROR chunk as a terminal RUN_ERROR event", async () => {
const agent = createAgent("tanstack", [
tanstackTextChunk("partial"),
{ type: "RUN_ERROR", message: "provider 400" },
// Anything after the error must never be converted.
tanstackTextChunk("should not appear"),
]);
const { events, errored } = await collectEventsIncludingErrors(
agent.run(createDefaultInput()),
);
expect(errored).toBe(true);
const errorEvent = events.find((e) => e.type === EventType.RUN_ERROR)!;
expect(errorEvent).toBeDefined();
expect(eventField<string>(errorEvent, "message")).toBe("provider 400");
// The post-error text chunk was not emitted.
const texts = events.filter((e) => e.type === EventType.TEXT_MESSAGE_CHUNK);
expect(texts).toHaveLength(1);
expect(eventField<string>(texts[0], "delta")).toBe("partial");
});
});
describe("TanStack AI converter — state tools", () => {
it("emits STATE_SNAPSHOT before TOOL_CALL_RESULT for AGUISendStateSnapshot", async () => {
const snapshot = { counter: 5, items: ["x", "y"] };
const agent = createAgent("tanstack", [
tanstackToolCallStart("call1", "AGUISendStateSnapshot"),
tanstackToolCallEnd("call1"),
tanstackToolCallResult("call1", { success: true, snapshot }),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
const snapshotIdx = events.findIndex(
(e) => e.type === EventType.STATE_SNAPSHOT,
);
const resultIdx = events.findIndex(
(e) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(snapshotIdx).toBeGreaterThanOrEqual(0);
expect(resultIdx).toBeGreaterThanOrEqual(0);
expect(snapshotIdx).toBeLessThan(resultIdx);
expect(eventField<unknown>(events[snapshotIdx], "snapshot")).toEqual(
snapshot,
);
});
it("emits STATE_DELTA before TOOL_CALL_RESULT for AGUISendStateDelta", async () => {
const delta = [{ op: "replace", path: "/counter", value: 7 }];
const agent = createAgent("tanstack", [
tanstackToolCallStart("call1", "AGUISendStateDelta"),
tanstackToolCallEnd("call1"),
tanstackToolCallResult("call1", { success: true, delta }),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
const deltaIdx = events.findIndex((e) => e.type === EventType.STATE_DELTA);
const resultIdx = events.findIndex(
(e) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(deltaIdx).toBeGreaterThanOrEqual(0);
expect(deltaIdx).toBeLessThan(resultIdx);
expect(eventField<unknown>(events[deltaIdx], "delta")).toEqual(delta);
});
it("emits STATE_SNAPSHOT when payload arrives in raw.result instead of raw.content", async () => {
// Regression: serialization fell back to raw.result (`?? raw.result ?? null`)
// but state-tool detection only inspected raw.content, so STATE_* events
// were silently dropped if upstream used `result` for the state-tool body.
// See the "TOOL_CALL_RESULT with object content serializes to JSON" test
// above which already exercises the `result` field on a non-state tool.
const snapshot = { counter: 99 };
const agent = createAgent("tanstack", [
tanstackToolCallStart("call1", "AGUISendStateSnapshot"),
tanstackToolCallEnd("call1"),
{
type: "TOOL_CALL_RESULT",
toolCallId: "call1",
result: { success: true, snapshot },
},
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
const snapshotIdx = events.findIndex(
(e) => e.type === EventType.STATE_SNAPSHOT,
);
expect(snapshotIdx).toBeGreaterThanOrEqual(0);
expect(eventField<unknown>(events[snapshotIdx], "snapshot")).toEqual(
snapshot,
);
});
it("does NOT emit STATE_* for non-state tool results", async () => {
const agent = createAgent("tanstack", [
tanstackToolCallStart("call1", "getWeather"),
tanstackToolCallEnd("call1"),
tanstackToolCallResult("call1", {
snapshot: { spoofed: true },
delta: [{ op: "x" }],
}),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
expect(
events.find(
(e) =>
e.type === EventType.STATE_SNAPSHOT ||
e.type === EventType.STATE_DELTA,
),
).toBeUndefined();
expect(
events.find((e) => e.type === EventType.TOOL_CALL_RESULT),
).toBeDefined();
});
});
describe("TanStack AI converter — reasoning", () => {
it("emits the full REASONING lifecycle for reasoning chunks", async () => {
const agent = createAgent("tanstack", [
tanstackReasoningStart("r1"),
tanstackReasoningMessageStart("r1"),
tanstackReasoningMessageContent("r1", "thinking"),
tanstackReasoningMessageEnd("r1"),
tanstackReasoningEnd("r1"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
expectLifecycleWrapped(events);
// Strip the lifecycle wrap and inspect the inner sequence.
const inner = events.slice(1, -1).map((e) => e.type);
expect(inner).toEqual([
EventType.REASONING_START,
EventType.REASONING_MESSAGE_START,
EventType.REASONING_MESSAGE_CONTENT,
EventType.REASONING_MESSAGE_END,
EventType.REASONING_END,
]);
});
it("auto-closes an open reasoning lifecycle when text starts", async () => {
const agent = createAgent("tanstack", [
tanstackReasoningStart("r1"),
tanstackReasoningMessageStart("r1"),
tanstackReasoningMessageContent("r1", "thinking"),
// No REASONING_MESSAGE_END / REASONING_END before text
tanstackTextChunk("Hi"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const types = events.map((e) => e.type);
const reasonEndIdx = types.indexOf(EventType.REASONING_END);
const reasonMsgEndIdx = types.indexOf(EventType.REASONING_MESSAGE_END);
const textIdx = types.indexOf(EventType.TEXT_MESSAGE_CHUNK);
expect(reasonMsgEndIdx).toBeGreaterThan(-1);
expect(reasonEndIdx).toBeGreaterThan(-1);
expect(reasonEndIdx).toBeLessThan(textIdx);
});
it("auto-closes an open reasoning lifecycle when a tool call starts", async () => {
const agent = createAgent("tanstack", [
tanstackReasoningStart("r1"),
tanstackReasoningMessageStart("r1"),
tanstackReasoningMessageContent("r1", "..."),
tanstackToolCallStart("t1", "x"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const types = events.map((e) => e.type);
expect(types).toContain(EventType.REASONING_MESSAGE_END);
expect(types).toContain(EventType.REASONING_END);
expect(types.indexOf(EventType.REASONING_END)).toBeLessThan(
types.indexOf(EventType.TOOL_CALL_START),
);
});
it("auto-closes when the stream ends without explicit reasoning end", async () => {
const agent = createAgent("tanstack", [
tanstackReasoningStart("r1"),
tanstackReasoningMessageStart("r1"),
tanstackReasoningMessageContent("r1", "x"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const types = events.map((e) => e.type);
expect(types).toContain(EventType.REASONING_MESSAGE_END);
expect(types).toContain(EventType.REASONING_END);
});
it("emits REASONING_MESSAGE_END before REASONING_END when upstream sends END with message still open", async () => {
// Regression: if the converter received REASONING_END while a message
// was still open, it cleared run-open and emitted END only — leaving
// message-open true. The next non-reasoning chunk then triggered
// closeReasoningIfOpen, which emitted MSG_END AFTER END (wrong order).
// Fix: REASONING_END handler closes any open message first.
const agent = createAgent("tanstack", [
tanstackReasoningStart("r1"),
tanstackReasoningMessageStart("r1"),
tanstackReasoningMessageContent("r1", "thinking"),
// Upstream skips MSG_END and goes straight to END
tanstackReasoningEnd("r1"),
tanstackTextChunk("Hi"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const types = events.map((e) => e.type);
const msgEndIdx = types.indexOf(EventType.REASONING_MESSAGE_END);
const endIdx = types.indexOf(EventType.REASONING_END);
expect(msgEndIdx).toBeGreaterThan(-1);
expect(endIdx).toBeGreaterThan(-1);
expect(msgEndIdx).toBeLessThan(endIdx);
// No duplicate MSG_END or END from auto-close on the text chunk
expect(
types.filter((t) => t === EventType.REASONING_MESSAGE_END),
).toHaveLength(1);
expect(types.filter((t) => t === EventType.REASONING_END)).toHaveLength(1);
});
it("auto-closes prior reasoning run when a new REASONING_START arrives without END", async () => {
// Regression: REASONING_START used to overwrite reasoningMessageId
// unconditionally, orphaning the prior run's MSG_END / END.
// Fix: REASONING_START handler calls closeReasoningIfOpen() first.
const agent = createAgent("tanstack", [
tanstackReasoningStart("r1"),
tanstackReasoningMessageStart("r1"),
tanstackReasoningMessageContent("r1", "first"),
// Second START with no intervening END
tanstackReasoningStart("r2"),
tanstackReasoningMessageStart("r2"),
tanstackReasoningMessageContent("r2", "second"),
tanstackReasoningMessageEnd("r2"),
tanstackReasoningEnd("r2"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const types = events.map((e) => e.type);
// Two complete START → MSG_START → ... → MSG_END → END sequences
expect(types.filter((t) => t === EventType.REASONING_START)).toHaveLength(
2,
);
expect(types.filter((t) => t === EventType.REASONING_END)).toHaveLength(2);
expect(
types.filter((t) => t === EventType.REASONING_MESSAGE_END),
).toHaveLength(2);
// First START's prior message gets closed BEFORE the second START
const firstEndIdx = types.indexOf(EventType.REASONING_END);
const secondStartIdx = types.indexOf(
EventType.REASONING_START,
firstEndIdx + 1,
);
expect(firstEndIdx).toBeLessThan(secondStartIdx);
});
it("does NOT duplicate REASONING_MESSAGE_END when upstream emits it explicitly before text", async () => {
// Regression: a single isInReasoning flag conflated message-open with
// run-open, so closeReasoningIfOpen on TEXT_MESSAGE_CONTENT emitted a
// second REASONING_MESSAGE_END after upstream's own. Track message-open
// and run-open separately so closeReasoningIfOpen owes only what's still
// open.
const agent = createAgent("tanstack", [
tanstackReasoningStart("r1"),
tanstackReasoningMessageStart("r1"),
tanstackReasoningMessageContent("r1", "thinking"),
tanstackReasoningMessageEnd("r1"),
// No explicit REASONING_END before text — closeReasoningIfOpen should
// emit REASONING_END but NOT a second REASONING_MESSAGE_END.
tanstackTextChunk("Hi"),
]);
const events = await collectEvents(agent.run(createDefaultInput()));
const types = events.map((e) => e.type);
const msgEndCount = types.filter(
(t) => t === EventType.REASONING_MESSAGE_END,
).length;
const endCount = types.filter((t) => t === EventType.REASONING_END).length;
expect(msgEndCount).toBe(1);
expect(endCount).toBe(1);
expect(types.indexOf(EventType.REASONING_END)).toBeLessThan(
types.indexOf(EventType.TEXT_MESSAGE_CHUNK),
);
});
});
@@ -0,0 +1,160 @@
/**
* Integration tests for factory-mode native interrupts driven by the REAL
* AI SDK `streamText` (via a mock LanguageModelV3 — no network). These verify
* the two things synthetic-stream tests can't:
* 1. a tool declared `needsApproval: true` actually makes `streamText` emit a
* `tool-approval-request` part → our converter turns it into outcome:interrupt;
* 2. on resume, the tool-result the runtime injects from the resume payload is
* accepted by `streamText` so the run continues (no re-approval loop).
*/
import { describe, it, expect } from "vitest";
import { EventType } from "@ag-ui/client";
import type { RunAgentInput } from "@ag-ui/client";
import { streamText, tool } from "ai";
import type { FlexibleSchema } from "ai";
import { MockLanguageModelV3, simulateReadableStream } from "ai/test";
import { z } from "zod";
import { BuiltInAgent, convertMessagesToVercelAISDKMessages } from "../index";
import { collectEvents, createDefaultInput } from "./agent-test-helpers";
const USAGE = {
inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 0, text: 0, reasoning: 0 },
} as const;
type BookFlightInput = { destination: string };
const bookFlightInputSchema = z.object({
destination: z.string(),
}) as unknown as FlexibleSchema<BookFlightInput>;
function finishReason(unified: "stop" | "tool-calls") {
return { unified, raw: unified };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function modelEmitting(chunks: any[]): MockLanguageModelV3 {
return new MockLanguageModelV3({
doStream: async () => ({
stream: simulateReadableStream({ chunks }),
}),
});
}
function aisdkApprovalAgent(model: MockLanguageModelV3): BuiltInAgent {
return new BuiltInAgent({
type: "aisdk",
factory: ({ input, abortSignal }) =>
streamText({
model,
messages: convertMessagesToVercelAISDKMessages(input.messages),
tools: {
bookFlight: tool<BookFlightInput, never>({
description: "Book a flight. Requires approval.",
inputSchema: bookFlightInputSchema,
needsApproval: true,
}),
},
abortSignal,
}),
});
}
describe("factory aisdk native interrupt (real streamText)", () => {
it("a needsApproval tool call → RUN_FINISHED outcome:interrupt", async () => {
const model = modelEmitting([
{
type: "tool-call",
toolCallId: "tc-1",
toolName: "bookFlight",
input: JSON.stringify({ destination: "Tokyo" }),
},
{
type: "finish",
finishReason: finishReason("tool-calls"),
usage: USAGE,
},
]);
const agent = aisdkApprovalAgent(model);
const events = await collectEvents(
agent.run(
createDefaultInput({
messages: [
{ id: "u1", role: "user", content: "Book a flight to Tokyo" },
] as RunAgentInput["messages"],
}),
),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any;
expect(finished.outcome?.type).toBe("interrupt");
expect(finished.outcome.interrupts[0]).toMatchObject({
id: "tc-1",
toolCallId: "tc-1",
});
});
it("on resume, the injected tool-result is accepted and the run completes", async () => {
// Resume run: model now just replies (the tool call is already answered by
// the injected result, so streamText must NOT re-request approval).
const model = modelEmitting([
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: "Booked your flight to Tokyo!" },
{ type: "text-end", id: "t1" },
{ type: "finish", finishReason: finishReason("stop"), usage: USAGE },
]);
const agent = aisdkApprovalAgent(model);
const events = await collectEvents(
agent.run(
createDefaultInput({
resume: [
{
interruptId: "tc-1",
status: "resolved",
payload: { approved: true },
},
],
messages: [
{ id: "u1", role: "user", content: "Book a flight to Tokyo" },
{
id: "a1",
role: "assistant",
content: "",
toolCalls: [
{
id: "tc-1",
type: "function",
function: {
name: "bookFlight",
arguments: JSON.stringify({ destination: "Tokyo" }),
},
},
],
},
] as RunAgentInput["messages"],
}),
),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const finished = events.find(
(e) => e.type === EventType.RUN_FINISHED,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any;
// Normal completion — no re-interrupt.
expect(finished.outcome).toBeUndefined();
const text = events
.filter((e) => e.type === EventType.TEXT_MESSAGE_CHUNK)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map((e) => (e as any).delta)
.join("");
expect(text).toContain("Booked your flight");
});
});
@@ -0,0 +1,252 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { BasicAgent } from "../index";
import type { MCPClientProvider } from "../index";
import { EventType } from "@ag-ui/client";
import type { RunAgentInput } from "@ag-ui/client";
import { streamText } from "ai";
import type { ToolSet } from "ai";
import {
mockStreamTextResponse,
textDelta,
finish,
collectEvents,
} from "./test-helpers";
// Mock the ai module
vi.mock("ai", () => ({
streamText: vi.fn(),
tool: vi.fn((config) => config),
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
}));
// Mock the SDK clients
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
modelId,
provider: "openai",
})),
}));
// Mock MCP imports so mcpServers code path doesn't fail when tested alongside mcpClients
vi.mock("@ai-sdk/mcp", () => ({
createMCPClient: vi.fn(),
}));
describe("mcpClients — user-managed MCP clients", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
});
afterEach(() => {
process.env = originalEnv;
});
const baseInput: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
function makeMockProvider(
tools: Record<string, any>,
): MCPClientProvider & { close: ReturnType<typeof vi.fn> } {
return {
tools: vi.fn().mockResolvedValue(tools),
close: vi.fn(),
};
}
it("tools from mcpClients are passed to streamText", async () => {
const mockTools = {
listEnvelopes: { description: "List envelopes", execute: vi.fn() },
};
const provider = makeMockProvider(mockTools);
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpClients: [provider],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("Hello"), finish()]) as any,
);
await collectEvents(agent["run"](baseInput));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("listEnvelopes");
expect(callArgs.tools.listEnvelopes.description).toBe("List envelopes");
expect(provider.tools).toHaveBeenCalledOnce();
});
it("mcpClients are NOT closed after run completes", async () => {
const provider = makeMockProvider({
myTool: { description: "A tool", execute: vi.fn() },
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpClients: [provider],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
await collectEvents(agent["run"](baseInput));
expect(provider.close).not.toHaveBeenCalled();
});
it("mcpServers tools override mcpClients tools on name collision", async () => {
const clientExecute = vi.fn();
const serverExecute = vi.fn();
const provider = makeMockProvider({
sharedTool: { description: "from client", execute: clientExecute },
});
// Mock mcpServers flow: createMCPClient returns a client with tools()
const { createMCPClient } = await import("@ai-sdk/mcp");
vi.mocked(createMCPClient).mockResolvedValue({
tools: vi.fn().mockResolvedValue({
sharedTool: { description: "from server", execute: serverExecute },
}),
close: vi.fn(),
} as any);
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpClients: [provider],
mcpServers: [{ type: "http", url: "http://localhost:9999" }],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
await collectEvents(agent["run"](baseInput));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
// mcpServers runs after mcpClients, so "from server" should win
expect(callArgs.tools.sharedTool.description).toBe("from server");
});
it("multiple mcpClients merge in order (later overrides earlier)", async () => {
const provider1 = makeMockProvider({
toolA: { description: "from provider 1", execute: vi.fn() },
shared: { description: "from provider 1", execute: vi.fn() },
});
const provider2 = makeMockProvider({
toolB: { description: "from provider 2", execute: vi.fn() },
shared: { description: "from provider 2", execute: vi.fn() },
});
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpClients: [provider1, provider2],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
await collectEvents(agent["run"](baseInput));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("toolA");
expect(callArgs.tools).toHaveProperty("toolB");
expect(callArgs.tools.shared.description).toBe("from provider 2");
});
it("empty mcpClients array is a no-op", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpClients: [],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("Hi"), finish()]) as any,
);
const events = await collectEvents(agent["run"](baseInput));
// Should still work normally
const textEvents = events.filter(
(e: any) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textEvents.length).toBeGreaterThan(0);
});
it("mcpClients .tools() rejection emits RUN_ERROR", async () => {
const failingProvider: MCPClientProvider = {
tools: vi.fn().mockRejectedValue(new Error("MCP connection lost")),
};
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpClients: [failingProvider],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
// Collect events manually so we can capture RUN_ERROR before the rejection
const events: any[] = [];
try {
await new Promise((resolve, reject) => {
agent["run"](baseInput).subscribe({
next: (event: any) => events.push(event),
error: (err: any) => reject(err),
complete: () => resolve(events),
});
});
} catch {
// Expected — Observable errors after emitting RUN_ERROR
}
// streamText should NOT have been called (error before reaching it)
expect(streamText).not.toHaveBeenCalled();
// A RUN_ERROR event should have been emitted
expect(events.some((e) => e.type === EventType.RUN_ERROR)).toBe(true);
});
it("clone() shares the same mcpClients references", () => {
const provider = makeMockProvider({});
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpClients: [provider],
});
const cloned = agent.clone();
// Access the config to verify same reference
// Both agents share the same config object (by reference)
expect((cloned as any).config.mcpClients[0]).toBe(provider);
});
it("type compatibility: @ai-sdk/mcp MCPClient satisfies MCPClientProvider", async () => {
// Compile-time check that `MCPClientProvider` is structurally compatible
// with `@ai-sdk/mcp`'s `MCPClient`. After the refactor `MCPClientProvider`
// is an alias for `Pick<MCPClient, "tools">`, so this is trivially true —
// but keeping the test guards against future divergence.
type MCPClient = Awaited<
ReturnType<typeof import("@ai-sdk/mcp").createMCPClient>
>;
const client = {} as MCPClient;
const _assignable: MCPClientProvider = {
tools: () => client.tools() as unknown as Promise<ToolSet>,
};
void _assignable;
expect(true).toBe(true);
});
});
@@ -0,0 +1,381 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { BasicAgent } from "../index";
import { EventType } from "@ag-ui/client";
import { streamText } from "ai";
import { LLMock, MCPMock } from "@copilotkit/aimock";
import {
mockStreamTextResponse,
textDelta,
finish,
collectEvents,
toolCall,
toolResult,
} from "./test-helpers";
// Mock the ai module — we don't want real LLM calls
vi.mock("ai", () => ({
streamText: vi.fn(),
tool: vi.fn((config) => config),
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
}));
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
modelId,
provider: "openai",
})),
}));
// Do NOT mock @ai-sdk/mcp or @modelcontextprotocol/sdk transports —
// we want real HTTP connections to the MCPMock server.
describe("mcpServers — real MCP server integration", () => {
const originalEnv = process.env;
let llm: LLMock;
let mcpMock: MCPMock;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
});
afterEach(async () => {
process.env = originalEnv;
if (llm) {
await llm.stop().catch(() => {});
}
});
const baseInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
/**
* Start an LLMock with an MCPMock mounted at /mcp.
* Returns the full MCP endpoint URL.
*/
async function startMcpServer(
tools: Array<{ name: string; description?: string }>,
): Promise<{ mcpUrl: string; llm: LLMock; mcpMock: MCPMock }> {
const mock = new MCPMock();
for (const t of tools) {
mock.addTool({
name: t.name,
description: t.description ?? `${t.name} tool`,
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
},
});
mock.onToolCall(t.name, () => `result from ${t.name}`);
}
const server = new LLMock({ port: 0 });
server.mount("/mcp", mock);
await server.start();
return {
mcpUrl: `${server.url}/mcp`,
llm: server,
mcpMock: mock,
};
}
it("HTTP transport fetches tools from MCPMock", async () => {
const result = await startMcpServer([
{ name: "get_weather", description: "Get the weather" },
]);
llm = result.llm;
mcpMock = result.mcpMock;
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [{ type: "http", url: result.mcpUrl }],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("Hello"), finish()]) as any,
);
await collectEvents(agent["run"](baseInput));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("get_weather");
});
it("an unreachable MCP server (SSE vs HTTP-only mock) is skipped, not fatal", async () => {
// MCPMock only supports Streamable HTTP, not SSE — so the SSE client
// fails to connect. The run must degrade gracefully: no RUN_ERROR, and
// streamText still runs (just without that server's tools).
const result = await startMcpServer([
{ name: "get_weather", description: "Get the weather" },
]);
llm = result.llm;
mcpMock = result.mcpMock;
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [{ type: "sse", url: result.mcpUrl }],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("ok"), finish()]) as any,
);
const events = await collectEvents(agent["run"](baseInput));
expect(events.some((e: any) => e.type === EventType.RUN_ERROR)).toBe(false);
expect(vi.mocked(streamText).mock.calls.length).toBeGreaterThan(0);
// The failed server contributed no tools.
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools ?? {}).not.toHaveProperty("get_weather");
});
it("a down MCP server is skipped while a healthy one still loads its tools", async () => {
const result = await startMcpServer([
{ name: "get_weather", description: "Get the weather" },
]);
llm = result.llm;
mcpMock = result.mcpMock;
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [
{ type: "http", url: "http://127.0.0.1:1/mcp" }, // unreachable
{ type: "http", url: result.mcpUrl }, // healthy
],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("ok"), finish()]) as any,
);
const events = await collectEvents(agent["run"](baseInput));
// The dead server is skipped; the run completes and the healthy server's
// tools are present.
expect(events.some((e: any) => e.type === EventType.RUN_ERROR)).toBe(false);
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("get_weather");
});
it("tool call round-trip emits TOOL_CALL_START, TOOL_CALL_RESULT, and TEXT_MESSAGE_CHUNK", async () => {
const result = await startMcpServer([
{ name: "get_weather", description: "Get the weather" },
]);
llm = result.llm;
mcpMock = result.mcpMock;
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [{ type: "http", url: result.mcpUrl }],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCall("tc1", "get_weather", { query: "NYC" }),
toolResult("tc1", "get_weather", "Sunny 72F"),
textDelta("The weather is sunny."),
finish(),
]) as any,
);
const events = await collectEvents(agent["run"](baseInput));
const types = events.map((e: any) => e.type);
expect(types).toContain(EventType.TOOL_CALL_START);
expect(types).toContain(EventType.TOOL_CALL_RESULT);
expect(types).toContain(EventType.TEXT_MESSAGE_CHUNK);
// Verify the tool call result content
const resultEvent = events.find(
(e: any) => e.type === EventType.TOOL_CALL_RESULT,
) as any;
expect(resultEvent.toolCallId).toBe("tc1");
expect(resultEvent.content).toContain("Sunny 72F");
});
it("MCP clients are cleaned up after completion — second run creates fresh connections", async () => {
const result = await startMcpServer([
{ name: "get_weather", description: "Get the weather" },
]);
llm = result.llm;
mcpMock = result.mcpMock;
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [{ type: "http", url: result.mcpUrl }],
});
// First run
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("Run 1"), finish()]) as any,
);
const events1 = await collectEvents(agent["run"](baseInput));
expect(events1.some((e: any) => e.type === EventType.RUN_FINISHED)).toBe(
true,
);
// Second run — should succeed with fresh MCP client connections
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("Run 2"), finish()]) as any,
);
const events2 = await collectEvents(agent["run"](baseInput));
expect(events2.some((e: any) => e.type === EventType.RUN_FINISHED)).toBe(
true,
);
// streamText was called twice (once per run), each time with MCP tools
expect(vi.mocked(streamText).mock.calls).toHaveLength(2);
expect(vi.mocked(streamText).mock.calls[0][0].tools).toHaveProperty(
"get_weather",
);
expect(vi.mocked(streamText).mock.calls[1][0].tools).toHaveProperty(
"get_weather",
);
});
it("the only MCP server being unreachable does NOT fail the run", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [{ type: "http", url: "http://localhost:59999" }],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("ok"), finish()]) as any,
);
const events = await collectEvents(agent["run"](baseInput));
// Graceful degradation: the unreachable server is skipped, no RUN_ERROR,
// and the run still proceeds (just with no MCP tools).
expect(events.some((e: any) => e.type === EventType.RUN_ERROR)).toBe(false);
expect(vi.mocked(streamText).mock.calls.length).toBeGreaterThan(0);
});
it("MCP clients are cleaned up after streamText error — subsequent run still works", async () => {
const result = await startMcpServer([
{ name: "get_weather", description: "Get the weather" },
]);
llm = result.llm;
mcpMock = result.mcpMock;
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [{ type: "http", url: result.mcpUrl }],
});
// First run — streamText throws an error
vi.mocked(streamText).mockImplementation(() => {
throw new Error("LLM connection failed");
});
const events1: any[] = [];
try {
await new Promise((resolve, reject) => {
agent["run"](baseInput).subscribe({
next: (event: any) => events1.push(event),
error: (err: any) => reject(err),
complete: () => resolve(events1),
});
});
} catch {
// Expected — streamText threw
}
// Should have emitted RUN_ERROR
expect(events1.some((e) => e.type === EventType.RUN_ERROR)).toBe(true);
// Second run — streamText works normally, proving MCP cleanup happened
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("Recovery"), finish()]) as any,
);
const events2 = await collectEvents(agent["run"](baseInput));
expect(events2.some((e: any) => e.type === EventType.RUN_FINISHED)).toBe(
true,
);
// The second run should have MCP tools available
const secondCallArgs = vi.mocked(streamText).mock.calls[1][0];
expect(secondCallArgs.tools).toHaveProperty("get_weather");
});
it("MCP tool descriptions are passed to streamText tools config", async () => {
const result = await startMcpServer([
{ name: "get_weather", description: "Get the weather" },
]);
llm = result.llm;
mcpMock = result.mcpMock;
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [{ type: "http", url: result.mcpUrl }],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([textDelta("Hello"), finish()]) as any,
);
await collectEvents(agent["run"](baseInput));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("get_weather");
// The MCP tool should include the description from the MCPMock server
expect(callArgs.tools.get_weather.description).toBe("Get the weather");
});
it("multiple MCP servers merge tools from both", async () => {
// First server with get_weather
const result1 = await startMcpServer([
{ name: "get_weather", description: "Get the weather" },
]);
llm = result1.llm;
// Second server with search_docs
const mock2 = new MCPMock();
mock2.addTool({
name: "search_docs",
description: "Search documentation",
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
},
});
mock2.onToolCall("search_docs", () => "doc results");
const llm2 = new LLMock({ port: 0 });
llm2.mount("/mcp", mock2);
await llm2.start();
try {
const agent = new BasicAgent({
model: "openai/gpt-4o",
mcpServers: [
{ type: "http", url: result1.mcpUrl },
{ type: "http", url: `${llm2.url}/mcp` },
],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
textDelta("Both tools available"),
finish(),
]) as any,
);
await collectEvents(agent["run"](baseInput));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("get_weather");
expect(callArgs.tools).toHaveProperty("search_docs");
} finally {
await llm2.stop().catch(() => {});
}
});
});
@@ -0,0 +1,284 @@
import { describe, it, expect } from "vitest";
import { convertInputToTanStackAI } from "../converters/tanstack";
import { createDefaultInput } from "./agent-test-helpers";
import type { Message, InputContent } from "@ag-ui/client";
/**
* Helper: build a user message with the given content parts, run through
* convertInputToTanStackAI, and return the first converted message for assertion.
*/
function convertUserContent(content: string | InputContent[]) {
const input = createDefaultInput({
messages: [{ role: "user", content } as Message],
});
const { messages } = convertInputToTanStackAI(input);
return messages[0];
}
/** Shorthand factories for AG-UI input parts */
function dataSource(value: string, mimeType: string) {
return { type: "data" as const, value, mimeType };
}
function urlSource(value: string, mimeType?: string) {
return { type: "url" as const, value, ...(mimeType ? { mimeType } : {}) };
}
describe("convertInputToTanStackAI — multimodal", () => {
it("passes through plain string user content", () => {
const result = convertUserContent("Hello");
expect(result.content).toBe("Hello");
});
it("converts text-only InputContent[] to TanStack TextPart array", () => {
const result = convertUserContent([{ type: "text", text: "Hello world" }]);
expect(result.content).toEqual([{ type: "text", content: "Hello world" }]);
});
it("converts ImageInputPart with data source", () => {
const result = convertUserContent([
{ type: "text", text: "What is this?" },
{ type: "image", source: dataSource("iVBORw0KGgo=", "image/png") },
]);
expect(result.content).toEqual([
{ type: "text", content: "What is this?" },
{
type: "image",
source: { type: "data", value: "iVBORw0KGgo=", mimeType: "image/png" },
},
]);
});
it("converts ImageInputPart with url source", () => {
const result = convertUserContent([
{
type: "image",
source: urlSource("https://example.com/photo.jpg", "image/jpeg"),
},
]);
expect(result.content).toEqual([
{
type: "image",
source: {
type: "url",
value: "https://example.com/photo.jpg",
mimeType: "image/jpeg",
},
},
]);
});
it("converts AudioInputPart", () => {
const result = convertUserContent([
{ type: "audio", source: dataSource("base64audiodata", "audio/mp3") },
]);
expect(result.content).toEqual([
{
type: "audio",
source: {
type: "data",
value: "base64audiodata",
mimeType: "audio/mp3",
},
},
]);
});
it("converts VideoInputPart with url source", () => {
const result = convertUserContent([
{
type: "video",
source: urlSource("https://example.com/video.mp4", "video/mp4"),
},
]);
expect(result.content).toEqual([
{
type: "video",
source: {
type: "url",
value: "https://example.com/video.mp4",
mimeType: "video/mp4",
},
},
]);
});
it("converts DocumentInputPart", () => {
const result = convertUserContent([
{
type: "document",
source: dataSource("base64pdfdata", "application/pdf"),
},
]);
expect(result.content).toEqual([
{
type: "document",
source: {
type: "data",
value: "base64pdfdata",
mimeType: "application/pdf",
},
},
]);
});
it("handles mixed text and multimodal parts", () => {
const result = convertUserContent([
{ type: "text", text: "Analyze these:" },
{ type: "image", source: dataSource("imgdata", "image/png") },
{
type: "document",
source: dataSource("docdata", "application/pdf"),
},
]);
expect(result.content).toEqual([
{ type: "text", content: "Analyze these:" },
{
type: "image",
source: { type: "data", value: "imgdata", mimeType: "image/png" },
},
{
type: "document",
source: {
type: "data",
value: "docdata",
mimeType: "application/pdf",
},
},
]);
});
it("returns empty string for empty content array", () => {
const result = convertUserContent([]);
expect(result.content).toBe("");
});
it("preserves empty string text parts", () => {
const result = convertUserContent([{ type: "text", text: "" }]);
expect(result.content).toEqual([{ type: "text", content: "" }]);
});
it("skips parts with missing source without crashing", () => {
const result = convertUserContent([
{ type: "text", text: "check this" },
{ type: "image" } as any,
]);
expect(result.content).toEqual([{ type: "text", content: "check this" }]);
});
it("silently skips unknown part types", () => {
const result = convertUserContent([
{ type: "text", text: "hello" },
{ type: "spreadsheet", source: dataSource("data", "text/csv") } as any,
]);
expect(result.content).toEqual([{ type: "text", content: "hello" }]);
});
it("returns null for null or undefined content", () => {
const nullInput = createDefaultInput({
messages: [{ role: "user", content: null } as unknown as Message],
});
const { messages: nullMessages } = convertInputToTanStackAI(nullInput);
expect(nullMessages[0].content).toBeNull();
const undefinedInput = createDefaultInput({
messages: [{ role: "user", content: undefined } as unknown as Message],
});
const { messages: undefinedMessages } =
convertInputToTanStackAI(undefinedInput);
expect(undefinedMessages[0].content).toBeNull();
});
describe("legacy BinaryInputContent backward compat", () => {
it("converts binary with image mimeType and data", () => {
const legacyPart = {
type: "binary",
mimeType: "image/jpeg",
data: "legacybase64",
};
const input = createDefaultInput({
messages: [
{
role: "user",
content: [legacyPart] as unknown as InputContent[],
} as Message,
],
});
const { messages } = convertInputToTanStackAI(input);
expect(messages[0].content).toEqual([
{
type: "image",
source: {
type: "data",
value: "legacybase64",
mimeType: "image/jpeg",
},
},
]);
});
it("skips binary with neither data nor url", () => {
const legacyPart = {
type: "binary",
mimeType: "image/png",
};
const input = createDefaultInput({
messages: [
{
role: "user",
content: [legacyPart] as unknown as InputContent[],
} as Message,
],
});
const { messages } = convertInputToTanStackAI(input);
expect(messages[0].content).toBe("");
});
it("converts binary with non-image mimeType and url", () => {
const legacyPart = {
type: "binary",
mimeType: "application/pdf",
url: "https://example.com/doc.pdf",
};
const input = createDefaultInput({
messages: [
{
role: "user",
content: [legacyPart] as unknown as InputContent[],
} as Message,
],
});
const { messages } = convertInputToTanStackAI(input);
expect(messages[0].content).toEqual([
{
type: "document",
source: {
type: "url",
value: "https://example.com/doc.pdf",
mimeType: "application/pdf",
},
},
]);
});
});
it("only converts user message content, not assistant messages", () => {
const input = createDefaultInput({
messages: [
{
role: "user",
content: [
{ type: "text", text: "Look at this" },
{ type: "image", source: dataSource("imgdata", "image/png") },
],
} as Message,
{ role: "assistant", content: "I see an image" } as Message,
],
});
const { messages } = convertInputToTanStackAI(input);
// User message should have array content
expect(Array.isArray(messages[0].content)).toBe(true);
// Assistant message should keep string content
expect(messages[1].content).toBe("I see an image");
});
});
@@ -0,0 +1,176 @@
import { describe, it, expect } from "vitest";
import { convertMessagesToVercelAISDKMessages } from "../index";
import type { Message, InputContent } from "@ag-ui/client";
import type { UserModelMessage } from "ai";
/**
* Helper: build a user message with the given content parts and convert it.
* Returns the converted UserModelMessage for assertion.
*/
function convertUserContent(content: string | InputContent[]) {
const messages: Message[] = [{ id: "1", role: "user", content }];
const result = convertMessagesToVercelAISDKMessages(messages);
return result[0] as UserModelMessage;
}
/** Shorthand factories for AG-UI input parts */
function dataSource(value: string, mimeType: string) {
return { type: "data" as const, value, mimeType };
}
function urlSource(value: string, mimeType?: string) {
return { type: "url" as const, value, ...(mimeType ? { mimeType } : {}) };
}
describe("convertMessagesToVercelAISDKMessages — multimodal", () => {
it("passes through plain string user content", () => {
const result = convertUserContent("Hello");
expect(result).toEqual({ role: "user", content: "Hello" });
});
it("converts text-only InputContent[] to parts array", () => {
const result = convertUserContent([{ type: "text", text: "Hello world" }]);
expect(result.role).toBe("user");
expect(result.content).toEqual([{ type: "text", text: "Hello world" }]);
});
it("converts ImageInputPart with data source to ImagePart", () => {
const result = convertUserContent([
{ type: "text", text: "What is this?" },
{ type: "image", source: dataSource("iVBORw0KGgo=", "image/png") },
]);
expect(result.content).toEqual([
{ type: "text", text: "What is this?" },
{ type: "image", image: "iVBORw0KGgo=", mediaType: "image/png" },
]);
});
it("converts ImageInputPart with url source to ImagePart with URL", () => {
const result = convertUserContent([
{
type: "image",
source: urlSource("https://example.com/photo.jpg", "image/jpeg"),
},
]);
expect(result.content).toEqual([
{
type: "image",
image: new URL("https://example.com/photo.jpg"),
mediaType: "image/jpeg",
},
]);
});
it("converts AudioInputPart to FilePart", () => {
const result = convertUserContent([
{ type: "audio", source: dataSource("base64audiodata", "audio/mp3") },
]);
expect(result.content).toEqual([
{ type: "file", data: "base64audiodata", mediaType: "audio/mp3" },
]);
});
it("converts VideoInputPart with url source to FilePart", () => {
const result = convertUserContent([
{
type: "video",
source: urlSource("https://example.com/video.mp4", "video/mp4"),
},
]);
expect(result.content).toEqual([
{
type: "file",
data: new URL("https://example.com/video.mp4"),
mediaType: "video/mp4",
},
]);
});
it("converts DocumentInputPart to FilePart", () => {
const result = convertUserContent([
{
type: "document",
source: dataSource("base64pdfdata", "application/pdf"),
},
]);
expect(result.content).toEqual([
{ type: "file", data: "base64pdfdata", mediaType: "application/pdf" },
]);
});
it("handles mixed text and multimodal parts", () => {
const result = convertUserContent([
{ type: "text", text: "Analyze these:" },
{ type: "image", source: dataSource("imgdata", "image/png") },
{ type: "document", source: dataSource("docdata", "application/pdf") },
]);
expect(result.content).toEqual([
{ type: "text", text: "Analyze these:" },
{ type: "image", image: "imgdata", mediaType: "image/png" },
{ type: "file", data: "docdata", mediaType: "application/pdf" },
]);
});
it("returns empty string for empty content array", () => {
const result = convertUserContent([]);
expect(result.content).toBe("");
});
it("skips image parts with malformed URLs without crashing", () => {
const result = convertUserContent([
{ type: "text", text: "check this" },
{ type: "image", source: { type: "url", value: "not-a-url" } },
]);
// Malformed URL part is skipped, text part preserved
expect(result.content).toEqual([{ type: "text", text: "check this" }]);
});
// Legacy backward compat — BinaryInputContent is not in the current schema
// but older clients may still send it. We intentionally construct untyped
// objects here to simulate that scenario.
describe("legacy BinaryInputContent backward compat", () => {
it("converts binary with image mimeType and data to ImagePart", () => {
const legacyPart = {
type: "binary",
mimeType: "image/jpeg",
data: "legacybase64",
};
const messages: Message[] = [
{
id: "1",
role: "user",
content: [legacyPart] as unknown as InputContent[],
},
];
const result = convertMessagesToVercelAISDKMessages(messages);
const userMsg = result[0] as UserModelMessage;
expect(userMsg.content).toEqual([
{ type: "image", image: "legacybase64", mediaType: "image/jpeg" },
]);
});
it("converts binary with non-image mimeType and url to FilePart", () => {
const legacyPart = {
type: "binary",
mimeType: "application/pdf",
url: "https://example.com/doc.pdf",
};
const messages: Message[] = [
{
id: "1",
role: "user",
content: [legacyPart] as unknown as InputContent[],
},
];
const result = convertMessagesToVercelAISDKMessages(messages);
const userMsg = result[0] as UserModelMessage;
expect(userMsg.content).toEqual([
{
type: "file",
data: new URL("https://example.com/doc.pdf"),
mediaType: "application/pdf",
},
]);
});
});
});
@@ -0,0 +1,599 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { BasicAgent } from "../index";
import type { RunAgentInput } from "@ag-ui/client";
import { streamText } from "ai";
import { mockStreamTextResponse, finish, collectEvents } from "./test-helpers";
// Mock the ai module
vi.mock("ai", () => ({
streamText: vi.fn(),
tool: vi.fn((config) => config),
}));
// Mock the SDK clients
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
modelId,
provider: "openai",
})),
}));
vi.mock("@ai-sdk/anthropic", () => ({
createAnthropic: vi.fn(() => (modelId: string) => ({
modelId,
provider: "anthropic",
})),
}));
describe("Property Overrides - Edge Cases", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
process.env.ANTHROPIC_API_KEY = "test-key";
});
afterEach(() => {
process.env = originalEnv;
});
describe("Model Override", () => {
it("should override model when allowed", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
overridableProperties: ["model"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { model: "anthropic/claude-sonnet-4.5" },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
const model = callArgs.model as { provider: string; modelId: string };
expect(model.provider).toBe("anthropic");
expect(model.modelId).toBe("claude-sonnet-4.5");
});
it("should accept LanguageModel instance for override", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
overridableProperties: ["model"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const customModel = {
modelId: "custom-model",
provider: "custom",
};
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { model: customModel },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.model).toBe(customModel);
});
it("should ignore invalid model override types", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
overridableProperties: ["model"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { model: 123 }, // Invalid type
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect((callArgs.model as { modelId: string }).modelId).toBe("gpt-4o"); // Original value
});
});
describe("ToolChoice Override", () => {
it("should override with 'auto'", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
toolChoice: "required",
overridableProperties: ["toolChoice"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { toolChoice: "auto" },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.toolChoice).toBe("auto");
});
it("should override with 'required'", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
toolChoice: "auto",
overridableProperties: ["toolChoice"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { toolChoice: "required" },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.toolChoice).toBe("required");
});
it("should override with 'none'", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
toolChoice: "auto",
overridableProperties: ["toolChoice"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { toolChoice: "none" },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.toolChoice).toBe("none");
});
it("should override with specific tool selection", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
toolChoice: "auto",
overridableProperties: ["toolChoice"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: {
toolChoice: { type: "tool", toolName: "specificTool" },
},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.toolChoice).toEqual({
type: "tool",
toolName: "specificTool",
});
});
it("should ignore invalid toolChoice values", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
toolChoice: "auto",
overridableProperties: ["toolChoice"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { toolChoice: "invalid" },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.toolChoice).toBe("auto"); // Original value
});
});
describe("StopSequences Override", () => {
it("should override stopSequences with valid array", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
stopSequences: ["STOP"],
overridableProperties: ["stopSequences"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { stopSequences: ["END", "FINISH"] },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.stopSequences).toEqual(["END", "FINISH"]);
});
it("should ignore stopSequences with non-string elements", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
stopSequences: ["STOP"],
overridableProperties: ["stopSequences"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { stopSequences: ["STOP", 123, "END"] as any },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.stopSequences).toEqual(["STOP"]); // Original value
});
it("should ignore non-array stopSequences", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
stopSequences: ["STOP"],
overridableProperties: ["stopSequences"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { stopSequences: "STOP" as any },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.stopSequences).toEqual(["STOP"]); // Original value
});
});
describe("Numeric Property Overrides", () => {
it("should override all numeric properties when allowed", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
maxOutputTokens: 100,
temperature: 0.5,
topP: 0.9,
topK: 50,
presencePenalty: 0.0,
frequencyPenalty: 0.0,
seed: 123,
maxRetries: 3,
overridableProperties: [
"maxOutputTokens",
"temperature",
"topP",
"topK",
"presencePenalty",
"frequencyPenalty",
"seed",
"maxRetries",
],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: {
maxOutputTokens: 500,
temperature: 0.8,
topP: 0.95,
topK: 100,
presencePenalty: 0.5,
frequencyPenalty: 0.5,
seed: 456,
maxRetries: 5,
},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.maxOutputTokens).toBe(500);
expect(callArgs.temperature).toBe(0.8);
expect(callArgs.topP).toBe(0.95);
expect(callArgs.topK).toBe(100);
expect(callArgs.presencePenalty).toBe(0.5);
expect(callArgs.frequencyPenalty).toBe(0.5);
expect(callArgs.seed).toBe(456);
expect(callArgs.maxRetries).toBe(5);
});
it("should ignore non-numeric values for numeric properties", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
temperature: 0.5,
overridableProperties: ["temperature"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { temperature: "high" as any },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.temperature).toBe(0.5); // Original value
});
});
describe("Multiple Property Overrides", () => {
it("should only override allowed properties", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
temperature: 0.5,
topP: 0.9,
maxOutputTokens: 100,
overridableProperties: ["temperature"], // Only temperature is overridable
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: {
temperature: 0.9,
topP: 0.5,
maxOutputTokens: 500,
},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.temperature).toBe(0.9); // Overridden
expect(callArgs.topP).toBe(0.9); // Original
expect(callArgs.maxOutputTokens).toBe(100); // Original
});
it("should handle undefined forwardedProps", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
temperature: 0.5,
overridableProperties: ["temperature"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
// No forwardedProps
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.temperature).toBe(0.5); // Original value
});
it("should handle non-object forwardedProps", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
temperature: 0.5,
overridableProperties: ["temperature"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: "not an object" as any,
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.temperature).toBe(0.5); // Original value
});
it("should handle undefined property values in forwardedProps", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
temperature: 0.5,
overridableProperties: ["temperature"],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: { temperature: undefined },
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.temperature).toBe(0.5); // Original value (undefined is ignored)
});
});
describe("canOverride method", () => {
it("should return true for overridable properties", () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
overridableProperties: ["temperature", "topP"],
});
expect(agent.canOverride("temperature")).toBe(true);
expect(agent.canOverride("topP")).toBe(true);
});
it("should return false for non-overridable properties", () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
overridableProperties: ["temperature"],
});
expect(agent.canOverride("topP")).toBe(false);
expect(agent.canOverride("seed")).toBe(false);
});
it("should return false when overridableProperties is undefined", () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
expect(agent.canOverride("temperature")).toBe(false);
expect(agent.canOverride("topP")).toBe(false);
});
it("should return false when overridableProperties is empty array", () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
overridableProperties: [],
});
expect(agent.canOverride("temperature")).toBe(false);
});
});
});
@@ -0,0 +1,195 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { BuiltInAgent } from "../index";
import { EventType, type RunAgentInput } from "@ag-ui/client";
import { streamText } from "ai";
import {
mockStreamTextResponse,
textDelta,
finish,
collectEvents,
} from "./test-helpers";
// Mock the ai module
vi.mock("ai", () => ({
streamText: vi.fn(),
tool: vi.fn((config) => config),
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
}));
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
specificationVersion: "v3",
modelId,
provider: "openai",
})),
}));
vi.mock("@ai-sdk/anthropic", () => ({
createAnthropic: vi.fn(() => (modelId: string) => ({
specificationVersion: "v3",
modelId,
provider: "anthropic",
})),
}));
vi.mock("@ai-sdk/google", () => ({
createGoogleGenerativeAI: vi.fn(() => (modelId: string) => ({
specificationVersion: "v3",
modelId,
provider: "google",
})),
}));
describe("Provider ID collision (#3410, #3623)", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
});
afterEach(() => {
process.env = originalEnv;
});
it('should replace text-start providedId "txt-0" with a UUID', async () => {
const agent = new BuiltInAgent({ model: "openai:gpt-4o-mini" });
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
{ type: "text-start", id: "txt-0" },
textDelta("Hello"),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread-1",
runId: "run-1",
messages: [{ id: "1", role: "user", content: "Hi" }],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
// Find the TEXT_MESSAGE_CHUNK event and check its messageId
const textChunks = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textChunks.length).toBeGreaterThan(0);
const messageId = (textChunks[0] as any).messageId;
// The messageId should NOT be "txt-0" — it should be a UUID
expect(messageId).not.toBe("txt-0");
// UUID v4 pattern
expect(messageId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
});
it('should replace reasoning-start providedId "reasoning-0" with a UUID', async () => {
const agent = new BuiltInAgent({ model: "openai:gpt-4o-mini" });
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
{ type: "reasoning-start", id: "reasoning-0" },
{ type: "reasoning-delta", text: "Thinking..." },
{ type: "reasoning-end" },
{ type: "text-start", id: "txt-0" },
textDelta("Answer"),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread-2",
runId: "run-2",
messages: [{ id: "1", role: "user", content: "Hi" }],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
// Find REASONING_START event
const reasoningStarts = events.filter(
(e) => e.type === EventType.REASONING_START,
);
expect(reasoningStarts.length).toBeGreaterThan(0);
const reasoningId = (reasoningStarts[0] as any).messageId;
// Should NOT be "reasoning-0"
expect(reasoningId).not.toBe("reasoning-0");
expect(reasoningId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
});
it('should replace providedId "msg-0" with a UUID', async () => {
const agent = new BuiltInAgent({ model: "openai:gpt-4o-mini" });
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
{ type: "text-start", id: "msg-0" },
textDelta("Hello"),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread-3",
runId: "run-3",
messages: [{ id: "1", role: "user", content: "Hi" }],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
const textChunks = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textChunks.length).toBeGreaterThan(0);
const messageId = (textChunks[0] as any).messageId;
expect(messageId).not.toBe("msg-0");
expect(messageId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
});
it("should preserve legitimate provider IDs", async () => {
const agent = new BuiltInAgent({ model: "openai:gpt-4o-mini" });
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
{ type: "text-start", id: "custom-msg-id-123" },
textDelta("Hello"),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread-4",
runId: "run-4",
messages: [{ id: "1", role: "user", content: "Hi" }],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
const textChunks = events.filter(
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
);
expect(textChunks.length).toBeGreaterThan(0);
// Legitimate IDs should be preserved
expect((textChunks[0] as any).messageId).toBe("custom-msg-id-123");
});
});
@@ -0,0 +1,317 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { z } from "zod";
import * as v from "valibot";
import { toStandardJsonSchema } from "@valibot/to-json-schema";
import { type } from "arktype";
import type { StandardSchemaV1 } from "@copilotkit/shared";
import {
defineTool,
convertToolDefinitionsToVercelAITools,
BuiltInAgent,
} from "../index";
import type { RunAgentInput } from "@ag-ui/client";
import { streamText } from "ai";
import { mockStreamTextResponse, finish, collectEvents } from "./test-helpers";
const arktype = <Out>(def: Record<string, string>) =>
(type as unknown as (d: unknown) => StandardSchemaV1<unknown, Out>)(def);
// Mock the ai module — keep jsonSchema real for non-Zod path
vi.mock("ai", async () => {
const actual = await vi.importActual("ai");
return {
...actual,
streamText: vi.fn(),
tool: vi.fn((config) => config),
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
};
});
// Mock the SDK clients
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
modelId,
provider: "openai",
})),
}));
describe("Standard Schema support in agent tools", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
});
afterEach(() => {
process.env = originalEnv;
});
describe("defineTool with different schema libraries", () => {
it("accepts a Zod schema (existing behavior)", () => {
const tool = defineTool({
name: "zodTool",
description: "A tool with Zod schema",
parameters: z.object({
city: z.string(),
units: z.enum(["celsius", "fahrenheit"]).optional(),
}),
execute: async (args) => {
return { city: args.city };
},
});
expect(tool.name).toBe("zodTool");
expect(tool.parameters["~standard"].vendor).toBe("zod");
});
it("accepts a Valibot schema (via toStandardJsonSchema)", () => {
const tool = defineTool({
name: "valibotTool",
description: "A tool with Valibot schema",
parameters: toStandardJsonSchema(
v.object({
query: v.string(),
limit: v.optional(v.number()),
}),
),
execute: async (args) => {
return { query: args.query };
},
});
expect(tool.name).toBe("valibotTool");
expect(tool.parameters["~standard"].vendor).toBe("valibot");
});
it("accepts an ArkType schema", () => {
const tool = defineTool({
name: "arktypeTool",
description: "A tool with ArkType schema",
parameters: arktype<{ query: string; limit: number }>({
query: "string",
limit: "number",
}),
execute: async (args) => {
return { query: args.query };
},
});
expect(tool.name).toBe("arktypeTool");
expect(tool.parameters["~standard"].vendor).toBe("arktype");
});
});
describe("convertToolDefinitionsToVercelAITools", () => {
it("converts Zod tool definitions to AI SDK tools (passes Zod directly)", () => {
const tools = [
defineTool({
name: "zodTool",
description: "Zod test",
parameters: z.object({ name: z.string() }),
execute: async () => ({}),
}),
];
const aiTools = convertToolDefinitionsToVercelAITools(tools);
expect(aiTools).toHaveProperty("zodTool");
expect(aiTools.zodTool).toHaveProperty("description", "Zod test");
expect(aiTools.zodTool).toHaveProperty("execute");
expect(aiTools.zodTool.inputSchema).toBeDefined();
});
it("converts Valibot tool definitions to AI SDK tools", () => {
const tools = [
defineTool({
name: "valibotTool",
description: "Valibot test",
parameters: toStandardJsonSchema(v.object({ name: v.string() })),
execute: async () => ({}),
}),
];
const aiTools = convertToolDefinitionsToVercelAITools(tools);
expect(aiTools).toHaveProperty("valibotTool");
expect(aiTools.valibotTool).toHaveProperty("description", "Valibot test");
expect(aiTools.valibotTool).toHaveProperty("execute");
expect(aiTools.valibotTool.inputSchema).toBeDefined();
});
it("converts ArkType tool definitions to AI SDK tools", () => {
const tools = [
defineTool({
name: "arktypeTool",
description: "ArkType test",
parameters: arktype<{ name: string }>({ name: "string" }),
execute: async () => ({}),
}),
];
const aiTools = convertToolDefinitionsToVercelAITools(tools);
expect(aiTools).toHaveProperty("arktypeTool");
expect(aiTools.arktypeTool).toHaveProperty("description", "ArkType test");
expect(aiTools.arktypeTool).toHaveProperty("execute");
expect(aiTools.arktypeTool.inputSchema).toBeDefined();
});
it("converts mixed schema types in the same tool set", () => {
const tools = [
defineTool({
name: "zodTool",
description: "Zod",
parameters: z.object({ a: z.string() }),
execute: async () => ({}),
}),
defineTool({
name: "valibotTool",
description: "Valibot",
parameters: toStandardJsonSchema(v.object({ b: v.string() })),
execute: async () => ({}),
}),
defineTool({
name: "arktypeTool",
description: "ArkType",
parameters: arktype<{ c: string }>({ c: "string" }),
execute: async () => ({}),
}),
];
const aiTools = convertToolDefinitionsToVercelAITools(tools);
expect(Object.keys(aiTools)).toEqual([
"zodTool",
"valibotTool",
"arktypeTool",
]);
for (const key of Object.keys(aiTools)) {
expect(aiTools[key]).toHaveProperty("execute");
expect(aiTools[key]).toHaveProperty("inputSchema");
}
});
});
describe("BuiltInAgent with non-Zod schemas", () => {
it("includes Valibot-based config tools alongside input tools", async () => {
const executeFn = vi.fn().mockResolvedValue({ result: "ok" });
const valibotTool = defineTool({
name: "searchValibot",
description: "Search with Valibot schema",
parameters: toStandardJsonSchema(v.object({ query: v.string() })),
execute: executeFn,
});
const agent = new BuiltInAgent({
model: "openai/gpt-4o",
tools: [valibotTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("searchValibot");
expect(typeof callArgs.tools?.searchValibot.execute).toBe("function");
});
it("includes ArkType-based config tools alongside input tools", async () => {
const executeFn = vi.fn().mockResolvedValue({ result: "ok" });
const arktypeTool = defineTool({
name: "searchArktype",
description: "Search with ArkType schema",
parameters: arktype<{ query: string }>({ query: "string" }),
execute: executeFn,
});
const agent = new BuiltInAgent({
model: "openai/gpt-4o",
tools: [arktypeTool],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("searchArktype");
expect(typeof callArgs.tools?.searchArktype.execute).toBe("function");
});
it("mixes Zod, Valibot, and ArkType tools in one agent", async () => {
const agent = new BuiltInAgent({
model: "openai/gpt-4o",
tools: [
defineTool({
name: "zodTool",
description: "Zod",
parameters: z.object({ a: z.string() }),
execute: async () => ({}),
}),
defineTool({
name: "valibotTool",
description: "Valibot",
parameters: toStandardJsonSchema(v.object({ b: v.string() })),
execute: async () => ({}),
}),
defineTool({
name: "arktypeTool",
description: "ArkType",
parameters: arktype<{ c: string }>({ c: "string" }),
execute: async () => ({}),
}),
],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("zodTool");
expect(callArgs.tools).toHaveProperty("valibotTool");
expect(callArgs.tools).toHaveProperty("arktypeTool");
// State tools should also be present
expect(callArgs.tools).toHaveProperty("AGUISendStateSnapshot");
expect(callArgs.tools).toHaveProperty("AGUISendStateDelta");
});
});
});
@@ -0,0 +1,162 @@
import { describe, it, expectTypeOf } from "vitest";
import { z } from "zod";
import * as v from "valibot";
import { type } from "arktype";
import type { StandardSchemaV1 } from "@copilotkit/shared";
import { defineTool } from "../index";
import type { ToolDefinition } from "../index";
const arktype = <Out>(def: Record<string, string>) =>
(type as unknown as (d: unknown) => StandardSchemaV1<unknown, Out>)(def);
describe("ToolDefinition type inference", () => {
describe("defineTool with Zod", () => {
it("infers execute args from Zod schema", () => {
const tool = defineTool({
name: "weather",
description: "Get weather",
parameters: z.object({
city: z.string(),
units: z.enum(["celsius", "fahrenheit"]),
}),
execute: async (args) => {
// args should be fully typed
expectTypeOf(args).toEqualTypeOf<{
city?: string;
units?: "celsius" | "fahrenheit";
}>();
return {};
},
});
expectTypeOf(tool.name).toBeString();
expectTypeOf(tool.description).toBeString();
expectTypeOf(tool.parameters).toMatchTypeOf<StandardSchemaV1>();
});
it("infers execute args with optional Zod fields", () => {
defineTool({
name: "search",
description: "Search",
parameters: z.object({
query: z.string(),
limit: z.number().optional(),
}),
execute: async (args) => {
expectTypeOf(args).toEqualTypeOf<{
query?: string;
limit?: number;
}>();
return {};
},
});
});
});
describe("defineTool with Valibot", () => {
it("infers execute args from Valibot schema", () => {
defineTool({
name: "search",
description: "Search",
parameters: v.object({
query: v.string(),
limit: v.number(),
}),
execute: async (args) => {
expectTypeOf(args).toEqualTypeOf<{
query: string;
limit: number;
}>();
return {};
},
});
});
it("infers execute args with optional Valibot fields", () => {
defineTool({
name: "search",
description: "Search",
parameters: v.object({
query: v.string(),
limit: v.optional(v.number()),
}),
execute: async (args) => {
expectTypeOf(args).toEqualTypeOf<{
query: string;
limit?: number | undefined;
}>();
return {};
},
});
});
});
describe("defineTool with ArkType", () => {
it("infers execute args from ArkType schema", () => {
defineTool({
name: "search",
description: "Search",
parameters: arktype<{ query: string; limit: number }>({
query: "string",
limit: "number",
}),
execute: async (args) => {
expectTypeOf(args).toEqualTypeOf<{
query: string;
limit: number;
}>();
return {};
},
});
});
it("infers execute args with optional ArkType fields", () => {
defineTool({
name: "profile",
description: "Profile",
parameters: arktype<{ name: string; age?: number }>({
name: "string",
"age?": "number",
}),
execute: async (args) => {
expectTypeOf(args).toEqualTypeOf<{
name: string;
age?: number;
}>();
return {};
},
});
});
});
describe("ToolDefinition interface", () => {
it("accepts Zod schema as generic parameter", () => {
type ZodTool = ToolDefinition<z.ZodObject<{ city: z.ZodString }>>;
expectTypeOf<ZodTool["execute"]>().toBeFunction();
expectTypeOf<Parameters<ZodTool["execute"]>[0]>().toEqualTypeOf<{
city?: string;
}>();
});
it("default generic parameter is StandardSchemaV1", () => {
type DefaultTool = ToolDefinition;
expectTypeOf<
DefaultTool["parameters"]
>().toMatchTypeOf<StandardSchemaV1>();
});
it("preserves schema type through defineTool return", () => {
const schema = z.object({ x: z.number() });
const tool = defineTool({
name: "t",
description: "d",
parameters: schema,
execute: async () => ({}),
});
expectTypeOf(tool.parameters).toEqualTypeOf<typeof schema>();
});
});
});
@@ -0,0 +1,436 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { BasicAgent } from "../index";
import { EventType, type RunAgentInput } from "@ag-ui/client";
import { streamText } from "ai";
import {
mockStreamTextResponse,
toolCallStreamingStart,
toolCall,
toolResult,
finish,
collectEvents,
} from "./test-helpers";
// Mock the ai module
vi.mock("ai", () => ({
streamText: vi.fn(),
tool: vi.fn((config) => config),
}));
// Mock the SDK clients
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
modelId,
provider: "openai",
})),
}));
describe("State Update Tools", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
});
afterEach(() => {
process.env = originalEnv;
});
describe("AGUISendStateSnapshot", () => {
it("should emit STATE_SNAPSHOT event when tool is called", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
const newState = { counter: 5, items: ["x", "y"] };
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateSnapshot"),
toolCall("call1", "AGUISendStateSnapshot"),
toolResult("call1", "AGUISendStateSnapshot", {
success: true,
snapshot: newState,
}),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: { counter: 0 },
};
const events = await collectEvents(agent["run"](input));
// Find STATE_SNAPSHOT event
const snapshotEvent = events.find(
(e: any) => e.type === EventType.STATE_SNAPSHOT,
);
expect(snapshotEvent).toBeDefined();
expect(snapshotEvent).toMatchObject({
type: EventType.STATE_SNAPSHOT,
snapshot: newState,
});
});
it("should still emit TOOL_CALL_RESULT for the LLM", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateSnapshot"),
toolCall("call1", "AGUISendStateSnapshot"),
toolResult("call1", "AGUISendStateSnapshot", {
success: true,
snapshot: { value: 1 },
}),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
// Should have both STATE_SNAPSHOT and TOOL_CALL_RESULT
const snapshotEvent = events.find(
(e: any) => e.type === EventType.STATE_SNAPSHOT,
);
const toolResultEvent = events.find(
(e: any) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(snapshotEvent).toBeDefined();
expect(toolResultEvent).toBeDefined();
expect(toolResultEvent).toMatchObject({
type: EventType.TOOL_CALL_RESULT,
toolCallId: "call1",
});
});
});
describe("AGUISendStateDelta", () => {
it("should emit STATE_DELTA event when tool is called", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
const delta = [
{ op: "replace", path: "/counter", value: 10 },
{ op: "add", path: "/newField", value: "test" },
];
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateDelta"),
toolCall("call1", "AGUISendStateDelta"),
toolResult("call1", "AGUISendStateDelta", { success: true, delta }),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: { counter: 0 },
};
const events = await collectEvents(agent["run"](input));
// Find STATE_DELTA event
const deltaEvent = events.find(
(e: any) => e.type === EventType.STATE_DELTA,
);
expect(deltaEvent).toBeDefined();
expect(deltaEvent).toMatchObject({
type: EventType.STATE_DELTA,
delta,
});
});
it("should handle add operations", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
const delta = [{ op: "add", path: "/items/0", value: "new item" }];
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateDelta"),
toolCall("call1", "AGUISendStateDelta"),
toolResult("call1", "AGUISendStateDelta", { success: true, delta }),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: { items: [] },
};
const events = await collectEvents(agent["run"](input));
const deltaEvent = events.find(
(e: any) => e.type === EventType.STATE_DELTA,
);
expect(deltaEvent?.delta).toEqual(delta);
});
it("should handle replace operations", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
const delta = [{ op: "replace", path: "/status", value: "active" }];
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateDelta"),
toolCall("call1", "AGUISendStateDelta"),
toolResult("call1", "AGUISendStateDelta", { success: true, delta }),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: { status: "inactive" },
};
const events = await collectEvents(agent["run"](input));
const deltaEvent = events.find(
(e: any) => e.type === EventType.STATE_DELTA,
);
expect(deltaEvent?.delta).toEqual(delta);
});
it("should handle remove operations", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
const delta = [{ op: "remove", path: "/oldField" }];
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateDelta"),
toolCall("call1", "AGUISendStateDelta"),
toolResult("call1", "AGUISendStateDelta", { success: true, delta }),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: { oldField: "value", keepField: "keep" },
};
const events = await collectEvents(agent["run"](input));
const deltaEvent = events.find(
(e: any) => e.type === EventType.STATE_DELTA,
);
expect(deltaEvent?.delta).toEqual(delta);
});
it("should handle multiple operations in a single delta", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
const delta = [
{ op: "replace", path: "/counter", value: 5 },
{ op: "add", path: "/items/-", value: "new" },
{ op: "remove", path: "/temp" },
];
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateDelta"),
toolCall("call1", "AGUISendStateDelta"),
toolResult("call1", "AGUISendStateDelta", { success: true, delta }),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: { counter: 0, items: [], temp: "remove me" },
};
const events = await collectEvents(agent["run"](input));
const deltaEvent = events.find(
(e: any) => e.type === EventType.STATE_DELTA,
);
expect(deltaEvent?.delta).toEqual(delta);
});
it("should still emit TOOL_CALL_RESULT for the LLM", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
const delta = [{ op: "replace", path: "/value", value: 1 }];
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateDelta"),
toolCall("call1", "AGUISendStateDelta"),
toolResult("call1", "AGUISendStateDelta", { success: true, delta }),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
// Should have both STATE_DELTA and TOOL_CALL_RESULT
const deltaEvent = events.find(
(e: any) => e.type === EventType.STATE_DELTA,
);
const toolResultEvent = events.find(
(e: any) => e.type === EventType.TOOL_CALL_RESULT,
);
expect(deltaEvent).toBeDefined();
expect(toolResultEvent).toBeDefined();
expect(toolResultEvent).toMatchObject({
type: EventType.TOOL_CALL_RESULT,
toolCallId: "call1",
});
});
});
describe("State Tools Integration", () => {
it("should handle both snapshot and delta in same run", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "AGUISendStateSnapshot"),
toolCall("call1", "AGUISendStateSnapshot"),
toolResult("call1", "AGUISendStateSnapshot", {
success: true,
snapshot: { value: 1 },
}),
toolCallStreamingStart("call2", "AGUISendStateDelta"),
toolCall("call2", "AGUISendStateDelta"),
toolResult("call2", "AGUISendStateDelta", {
success: true,
delta: [{ op: "replace", path: "/value", value: 2 }],
}),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
const snapshotEvents = events.filter(
(e: any) => e.type === EventType.STATE_SNAPSHOT,
);
const deltaEvents = events.filter(
(e: any) => e.type === EventType.STATE_DELTA,
);
expect(snapshotEvents).toHaveLength(1);
expect(deltaEvents).toHaveLength(1);
});
it("should not emit state events for non-state tools", async () => {
const agent = new BasicAgent({
model: "openai/gpt-4o",
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([
toolCallStreamingStart("call1", "otherTool"),
toolCall("call1", "otherTool"),
toolResult("call1", "otherTool", { result: "data" }),
finish(),
]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [
{
name: "otherTool",
description: "Other tool",
parameters: { type: "object", properties: {} },
},
],
context: [],
state: {},
};
const events = await collectEvents(agent["run"](input));
const stateEvents = events.filter(
(e: any) =>
e.type === EventType.STATE_SNAPSHOT ||
e.type === EventType.STATE_DELTA,
);
expect(stateEvents).toHaveLength(0);
});
});
});
@@ -0,0 +1,197 @@
/**
* Test helpers for mocking streamText responses
*/
import type { streamText } from "ai";
import type { Observable } from "rxjs";
import type { BaseEvent } from "@ag-ui/client";
export interface MockStreamEvent {
type: string;
[key: string]: unknown;
}
/**
* Creates a mock streamText response with controlled events.
*/
export function mockStreamTextResponse(
events: MockStreamEvent[],
): ReturnType<typeof streamText> {
return {
fullStream: (async function* () {
for (const event of events) {
yield event;
}
})(),
} as unknown as ReturnType<typeof streamText>;
}
/**
* Helper to create a text-start event
*/
export function textStart(id?: string): MockStreamEvent {
const event: MockStreamEvent = {
type: "text-start",
};
if (id !== undefined) {
event.id = id;
}
return event;
}
/**
* Helper to create a text delta event
*/
export function textDelta(text: string): MockStreamEvent {
return {
type: "text-delta",
text,
};
}
/**
* Helper to create a tool call streaming start event
*/
export function toolCallStreamingStart(
toolCallId: string,
toolName: string,
): MockStreamEvent {
return {
type: "tool-input-start",
id: toolCallId,
toolName,
};
}
/**
* Helper to create a tool call delta event
*/
export function toolCallDelta(
toolCallId: string,
argsTextDelta: string,
): MockStreamEvent {
return {
type: "tool-input-delta",
id: toolCallId,
delta: argsTextDelta,
};
}
/**
* Helper to create a tool call event
*/
export function toolCall(
toolCallId: string,
toolName: string,
input: unknown = {},
): MockStreamEvent {
return {
type: "tool-call",
toolCallId,
toolName,
input,
};
}
/**
* Helper to create a tool result event
*/
export function toolResult(
toolCallId: string,
toolName: string,
output: unknown,
): MockStreamEvent {
return {
type: "tool-result",
toolCallId,
toolName,
output,
};
}
/**
* Helper to create a reasoning-start event
*/
export function reasoningStart(id?: string): MockStreamEvent {
const event: MockStreamEvent = {
type: "reasoning-start",
};
if (id !== undefined) {
event.id = id;
}
return event;
}
/**
* Helper to create a reasoning-delta event
*/
export function reasoningDelta(text: string): MockStreamEvent {
return {
type: "reasoning-delta",
text,
};
}
/**
* Helper to create a reasoning-end event
*/
export function reasoningEnd(): MockStreamEvent {
return {
type: "reasoning-end",
};
}
/**
* Helper to create a finish event
*/
export function finish(): MockStreamEvent {
return {
type: "finish",
finishReason: "stop",
};
}
/**
* Helper to create an abort event
*/
export function abort(): MockStreamEvent {
return {
type: "abort",
};
}
/**
* Helper to create an error event
*/
export function error(errorMessage: string): MockStreamEvent {
return {
type: "error",
error: new Error(errorMessage),
};
}
/**
* Collects all events from an Observable<BaseEvent> into an array.
*/
export async function collectEvents(
observable: Observable<BaseEvent>,
): Promise<BaseEvent[]> {
return new Promise((resolve, reject) => {
const events: BaseEvent[] = [];
const timeoutId = setTimeout(() => {
subscription.unsubscribe();
reject(new Error("Observable did not complete within timeout"));
}, 5000);
const subscription = observable.subscribe({
next: (event) => events.push(event),
error: (err: unknown) => {
clearTimeout(timeoutId);
reject(err);
},
complete: () => {
clearTimeout(timeoutId);
resolve(events);
},
});
});
}
@@ -0,0 +1,542 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { z } from "zod";
import {
resolveModel,
convertMessagesToVercelAISDKMessages,
convertJsonSchemaToZodSchema,
convertToolsToVercelAITools,
convertToolDefinitionsToVercelAITools,
defineTool,
} from "../index";
import type { ToolDefinition } from "../index";
import type { Message } from "@ag-ui/client";
describe("resolveModel", () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-openai-key";
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";
process.env.GOOGLE_API_KEY = "test-google-key";
});
afterEach(() => {
process.env = originalEnv;
});
it("should resolve OpenAI models with / separator", () => {
const model = resolveModel("openai/gpt-4o");
expect(model).toBeDefined();
expect((model as { modelId: string }).modelId).toBe("gpt-4o");
});
it("should resolve OpenAI models with : separator", () => {
const model = resolveModel("openai:gpt-4o-mini");
expect(model).toBeDefined();
expect((model as { modelId: string }).modelId).toBe("gpt-4o-mini");
});
it("should resolve Anthropic models", () => {
const model = resolveModel("anthropic/claude-sonnet-4.5");
expect(model).toBeDefined();
expect((model as { modelId: string }).modelId).toBe("claude-sonnet-4.5");
});
it("should resolve Google models", () => {
const model = resolveModel("google/gemini-2.5-pro");
expect(model).toBeDefined();
expect((model as { modelId: string }).modelId).toBe("gemini-2.5-pro");
});
it("should handle gemini provider alias", () => {
const model = resolveModel("gemini/gemini-2.5-flash");
expect(model).toBeDefined();
expect((model as { modelId: string }).modelId).toBe("gemini-2.5-flash");
});
it("should throw error for invalid format", () => {
expect(() => resolveModel("invalid")).toThrow("Invalid model string");
});
it("should throw error for unknown provider", () => {
expect(() => resolveModel("unknown/model")).toThrow("Unknown provider");
});
it("should pass through LanguageModel instances", () => {
const mockModel = resolveModel("openai/gpt-4o");
const result = resolveModel(mockModel as any);
expect(result).toBe(mockModel);
});
});
describe("convertMessagesToVercelAISDKMessages", () => {
it("should convert user messages", () => {
const messages: Message[] = [
{
id: "1",
role: "user",
content: "Hello",
},
];
const result = convertMessagesToVercelAISDKMessages(messages);
expect(result).toEqual([
{
role: "user",
content: "Hello",
},
]);
});
it("should convert assistant messages with text content", () => {
const messages: Message[] = [
{
id: "1",
role: "assistant",
content: "Hello there",
},
];
const result = convertMessagesToVercelAISDKMessages(messages);
expect(result).toEqual([
{
role: "assistant",
content: [{ type: "text", text: "Hello there" }],
},
]);
});
it("should convert assistant messages with tool calls", () => {
const messages: Message[] = [
{
id: "1",
role: "assistant",
content: "Let me help",
toolCalls: [
{
id: "call1",
type: "function",
function: {
name: "getTool",
arguments: '{"arg":"value"}',
},
},
],
},
];
const result = convertMessagesToVercelAISDKMessages(messages);
expect(result[0]).toEqual({
role: "assistant",
content: [
{ type: "text", text: "Let me help" },
{
type: "tool-call",
toolCallId: "call1",
toolName: "getTool",
input: { arg: "value" },
},
],
});
});
it("should convert tool messages", () => {
const messages: Message[] = [
{
id: "1",
role: "assistant",
toolCalls: [
{
id: "call1",
type: "function",
function: {
name: "getTool",
arguments: "{}",
},
},
],
},
{
id: "2",
role: "tool",
content: "result",
toolCallId: "call1",
},
];
const result = convertMessagesToVercelAISDKMessages(messages);
expect(result[1]).toEqual({
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call1",
toolName: "getTool",
output: {
type: "text",
value: "result",
},
},
],
});
});
it("should handle multiple messages", () => {
const messages: Message[] = [
{ id: "1", role: "user", content: "Hi" },
{ id: "2", role: "assistant", content: "Hello" },
{ id: "3", role: "user", content: "How are you?" },
];
const result = convertMessagesToVercelAISDKMessages(messages);
expect(result).toHaveLength(3);
expect(result[0].role).toBe("user");
expect(result[1].role).toBe("assistant");
expect(result[2].role).toBe("user");
});
});
describe("convertJsonSchemaToZodSchema", () => {
it("should convert string schema", () => {
const jsonSchema = {
type: "string" as const,
description: "A string field",
};
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, true);
expect(zodSchema.parse("test")).toBe("test");
expect(() => zodSchema.parse(123)).toThrow();
});
it("should convert number schema", () => {
const jsonSchema = {
type: "number" as const,
description: "A number field",
};
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, true);
expect(zodSchema.parse(123)).toBe(123);
expect(() => zodSchema.parse("test")).toThrow();
});
it("should convert boolean schema", () => {
const jsonSchema = {
type: "boolean" as const,
description: "A boolean field",
};
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, true);
expect(zodSchema.parse(true)).toBe(true);
expect(() => zodSchema.parse("true")).toThrow();
});
it("should convert array schema", () => {
const jsonSchema = {
type: "array" as const,
description: "An array field",
items: {
type: "string" as const,
},
};
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, true);
expect(zodSchema.parse(["a", "b"])).toEqual(["a", "b"]);
expect(() => zodSchema.parse([1, 2])).toThrow();
});
it("should convert object schema with properties", () => {
const jsonSchema = {
type: "object" as const,
description: "An object",
properties: {
name: { type: "string" as const },
age: { type: "number" as const },
},
required: ["name"],
};
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, true);
const valid = zodSchema.parse({ name: "John", age: 30 });
expect(valid).toEqual({ name: "John", age: 30 });
expect(() => zodSchema.parse({ age: 30 })).toThrow();
});
it("should make schema optional when required is false", () => {
const jsonSchema = {
type: "string" as const,
description: "Optional string",
};
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, false);
expect(zodSchema.parse(undefined)).toBeUndefined();
expect(zodSchema.parse("test")).toBe("test");
});
it("should preserve string enum constraints", () => {
const jsonSchema = {
type: "object" as const,
properties: {
status: {
type: "string" as const,
description: "The status",
enum: ["todo", "done"],
},
},
required: ["status"],
};
const result = convertJsonSchemaToZodSchema(jsonSchema, true);
const statusSchema = (result as z.ZodObject<any>).shape.status;
expect(statusSchema._def.typeName).toBe("ZodEnum");
expect(statusSchema._def.values).toEqual(["todo", "done"]);
});
it("should handle nested object schemas", () => {
const jsonSchema = {
type: "object" as const,
properties: {
user: {
type: "object" as const,
properties: {
name: { type: "string" as const },
},
required: ["name"],
},
},
required: ["user"],
};
const zodSchema = convertJsonSchemaToZodSchema(jsonSchema, true);
const valid = zodSchema.parse({ user: { name: "John" } });
expect(valid).toEqual({ user: { name: "John" } });
});
});
describe("convertToolsToVercelAITools", () => {
it("should convert AG-UI tools to Vercel AI tools", () => {
const tools = [
{
name: "testTool",
description: "A test tool",
parameters: {
type: "object" as const,
properties: {
input: { type: "string" as const },
},
required: ["input"],
},
},
];
const result = convertToolsToVercelAITools(tools);
expect(result).toHaveProperty("testTool");
expect(result.testTool).toBeDefined();
});
it("should throw error for invalid JSON schema", () => {
const tools = [
{
name: "testTool",
description: "A test tool",
parameters: { invalid: "schema" } as any,
},
];
expect(() => convertToolsToVercelAITools(tools)).toThrow(
"Invalid JSON schema",
);
});
it("should handle multiple tools", () => {
const tools = [
{
name: "tool1",
description: "First tool",
parameters: {
type: "object" as const,
properties: {},
},
},
{
name: "tool2",
description: "Second tool",
parameters: {
type: "object" as const,
properties: {},
},
},
];
const result = convertToolsToVercelAITools(tools);
expect(result).toHaveProperty("tool1");
expect(result).toHaveProperty("tool2");
});
});
describe("convertToolDefinitionsToVercelAITools", () => {
it("should convert ToolDefinitions to Vercel AI tools", () => {
const tools: ToolDefinition[] = [
{
name: "testTool",
description: "A test tool",
parameters: z.object({
input: z.string(),
}),
execute: async () => undefined,
},
];
const result = convertToolDefinitionsToVercelAITools(tools);
expect(result).toHaveProperty("testTool");
expect(result.testTool).toBeDefined();
});
it("should handle multiple tool definitions", () => {
const tools: ToolDefinition[] = [
{
name: "tool1",
description: "First tool",
parameters: z.object({}),
execute: async () => undefined,
},
{
name: "tool2",
description: "Second tool",
parameters: z.object({ value: z.number() }),
execute: async () => undefined,
},
];
const result = convertToolDefinitionsToVercelAITools(tools);
expect(result).toHaveProperty("tool1");
expect(result).toHaveProperty("tool2");
expect(Object.keys(result)).toHaveLength(2);
});
});
describe("ensureObjectArgs via convertMessagesToVercelAISDKMessages", () => {
function makeAssistantWithToolArgs(argsJson: string): Message[] {
return [
{
id: "1",
role: "assistant",
content: "calling tool",
toolCalls: [
{
id: "call1",
type: "function",
function: {
name: "myTool",
arguments: argsJson,
},
},
],
},
];
}
it("should pass through valid object arguments", () => {
const result = convertMessagesToVercelAISDKMessages(
makeAssistantWithToolArgs('{"key":"value"}'),
);
const toolCall = (result[0] as any).content[1];
expect(toolCall.input).toEqual({ key: "value" });
});
it("should replace a string argument with an empty object", () => {
const result = convertMessagesToVercelAISDKMessages(
makeAssistantWithToolArgs('""'),
);
const toolCall = (result[0] as any).content[1];
expect(toolCall.input).toEqual({});
});
it("should replace an array argument with an empty object", () => {
const result = convertMessagesToVercelAISDKMessages(
makeAssistantWithToolArgs("[1,2,3]"),
);
const toolCall = (result[0] as any).content[1];
expect(toolCall.input).toEqual({});
});
it("should replace a null argument with an empty object", () => {
const result = convertMessagesToVercelAISDKMessages(
makeAssistantWithToolArgs("null"),
);
const toolCall = (result[0] as any).content[1];
expect(toolCall.input).toEqual({});
});
it("should replace a numeric argument with an empty object", () => {
const result = convertMessagesToVercelAISDKMessages(
makeAssistantWithToolArgs("42"),
);
const toolCall = (result[0] as any).content[1];
expect(toolCall.input).toEqual({});
});
it("should replace a boolean argument with an empty object", () => {
const result = convertMessagesToVercelAISDKMessages(
makeAssistantWithToolArgs("true"),
);
const toolCall = (result[0] as any).content[1];
expect(toolCall.input).toEqual({});
});
it("should replace unparseable JSON with an empty object", () => {
const result = convertMessagesToVercelAISDKMessages(
makeAssistantWithToolArgs("{broken"),
);
const toolCall = (result[0] as any).content[1];
expect(toolCall.input).toEqual({});
});
});
describe("defineTool", () => {
it("should create a ToolDefinition", () => {
const tool = defineTool({
name: "myTool",
description: "My test tool",
parameters: z.object({
input: z.string(),
}),
execute: async () => undefined,
});
expect(tool).toEqual({
name: "myTool",
description: "My test tool",
parameters: expect.any(Object),
execute: expect.any(Function),
});
expect(tool.name).toBe("myTool");
expect(tool.description).toBe("My test tool");
});
it("should preserve type information", () => {
const tool = defineTool({
name: "calc",
description: "Calculate",
parameters: z.object({
a: z.number(),
b: z.number(),
}),
execute: async () => undefined,
});
// Should be able to parse valid input
const result = tool.parameters.parse({ a: 1, b: 2 });
expect(result).toEqual({ a: 1, b: 2 });
});
});
@@ -0,0 +1,356 @@
/**
* Regression tests proving that the agent package's defineTool and
* convertToolDefinitionsToVercelAITools still work identically with
* Zod schemas after the Standard Schema migration.
*
* Covers:
* 1. defineTool with complex Zod schemas
* 2. convertToolDefinitionsToVercelAITools passes Zod schemas directly
* (not through JSON Schema conversion — critical behavioral regression)
* 3. BuiltInAgent with Zod tools still works end-to-end
* 4. execute callback receives correctly shaped args
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { z } from "zod";
import {
defineTool,
convertToolDefinitionsToVercelAITools,
BuiltInAgent,
} from "../index";
import type { RunAgentInput } from "@ag-ui/client";
import { streamText } from "ai";
import { mockStreamTextResponse, finish, collectEvents } from "./test-helpers";
vi.mock("ai", async () => {
const actual = await vi.importActual("ai");
return {
...actual,
streamText: vi.fn(),
tool: vi.fn((config) => config),
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
};
});
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => (modelId: string) => ({
modelId,
provider: "openai",
})),
}));
describe("defineTool Zod regression", () => {
it("complex Zod schema with nested objects, arrays, unions", () => {
const tool = defineTool({
name: "complexTool",
description: "A complex tool",
parameters: z.object({
query: z.string().describe("Search query"),
filters: z
.object({
category: z.enum(["books", "movies", "music"]).optional(),
minRating: z.number().min(0).max(5).optional(),
tags: z.array(z.string()).optional(),
})
.optional(),
pagination: z
.object({
page: z.number().int().positive().default(1),
perPage: z.number().int().positive().default(20),
})
.optional(),
}),
execute: async (args) => {
return { query: args.query };
},
});
expect(tool.name).toBe("complexTool");
expect(tool.parameters["~standard"].vendor).toBe("zod");
expect(tool.parameters["~standard"].version).toBe(1);
});
it("Zod schema with discriminated union", () => {
const tool = defineTool({
name: "actionTool",
description: "An action tool",
parameters: z.object({
action: z.discriminatedUnion("type", [
z.object({ type: z.literal("search"), query: z.string() }),
z.object({ type: z.literal("navigate"), url: z.string().url() }),
z.object({
type: z.literal("execute"),
code: z.string(),
language: z.enum(["javascript", "python"]),
}),
]),
}),
execute: async (args) => {
return { action: args.action.type };
},
});
expect(tool.name).toBe("actionTool");
expect(tool.parameters["~standard"].vendor).toBe("zod");
});
it("Zod schema with nullable and record fields", () => {
const tool = defineTool({
name: "flexTool",
description: "Flexible tool",
parameters: z.object({
title: z.string(),
description: z.string().nullable(),
metadata: z.record(z.string(), z.unknown()).optional(),
}),
execute: async (args) => {
return { title: args.title };
},
});
expect(tool.name).toBe("flexTool");
expect(tool.parameters["~standard"].vendor).toBe("zod");
});
it("execute callback receives correctly typed args", async () => {
const receivedArgs: unknown[] = [];
const tool = defineTool({
name: "argCapture",
description: "Captures args",
parameters: z.object({
city: z.string(),
units: z.enum(["celsius", "fahrenheit"]),
}),
execute: async (args) => {
receivedArgs.push(args);
return { temp: 22 };
},
});
await tool.execute({ city: "Berlin", units: "celsius" });
expect(receivedArgs).toHaveLength(1);
expect(receivedArgs[0]).toEqual({ city: "Berlin", units: "celsius" });
});
});
describe("convertToolDefinitionsToVercelAITools Zod regression", () => {
it("Zod schemas are passed directly to AI SDK (not converted via JSON Schema)", () => {
const zodSchema = z.object({
city: z.string(),
units: z.enum(["celsius", "fahrenheit"]).optional(),
});
const tools = [
defineTool({
name: "weather",
description: "Get weather",
parameters: zodSchema,
execute: async () => ({ temp: 22 }),
}),
];
const aiTools = convertToolDefinitionsToVercelAITools(tools);
expect(aiTools).toHaveProperty("weather");
// The inputSchema should be the original Zod schema (passed directly),
// NOT a jsonSchema() wrapper. We verify by checking the Zod-specific
// ~standard.vendor property is preserved on the schema.
const inputSchema = aiTools.weather.inputSchema;
expect(inputSchema).toBeDefined();
// For Zod schemas, the AI SDK receives the raw Zod object (which has ~standard.vendor === "zod")
// If it went through jsonSchema(), it would lose the ~standard property
expect(inputSchema["~standard"]?.vendor).toBe("zod");
});
it("multiple Zod tools all get direct pass-through", () => {
const tools = [
defineTool({
name: "tool1",
description: "Tool 1",
parameters: z.object({ a: z.string() }),
execute: async () => ({}),
}),
defineTool({
name: "tool2",
description: "Tool 2",
parameters: z.object({ b: z.number(), c: z.boolean().optional() }),
execute: async () => ({}),
}),
defineTool({
name: "tool3",
description: "Tool 3",
parameters: z.object({
nested: z.object({ x: z.number(), y: z.number() }),
}),
execute: async () => ({}),
}),
];
const aiTools = convertToolDefinitionsToVercelAITools(tools);
for (const name of ["tool1", "tool2", "tool3"]) {
expect(aiTools).toHaveProperty(name);
expect(aiTools[name].inputSchema["~standard"]?.vendor).toBe("zod");
expect(typeof aiTools[name].execute).toBe("function");
}
});
it("preserves tool description and execute function", () => {
const executeFn = vi.fn().mockResolvedValue({ result: "ok" });
const tools = [
defineTool({
name: "myTool",
description: "My custom tool description",
parameters: z.object({ input: z.string() }),
execute: executeFn,
}),
];
const aiTools = convertToolDefinitionsToVercelAITools(tools);
expect(aiTools.myTool.description).toBe("My custom tool description");
expect(aiTools.myTool.execute).toBeDefined();
});
});
describe("BuiltInAgent Zod regression", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.env.OPENAI_API_KEY = "test-key";
});
afterEach(() => {
process.env = originalEnv;
});
it("Zod tools are included in streamText call", async () => {
const agent = new BuiltInAgent({
model: "openai/gpt-4o",
tools: [
defineTool({
name: "weather",
description: "Get weather",
parameters: z.object({
city: z.string(),
units: z.enum(["celsius", "fahrenheit"]).optional(),
}),
execute: async () => ({ temp: 22 }),
}),
],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
expect(callArgs.tools).toHaveProperty("weather");
expect(typeof callArgs.tools?.weather.execute).toBe("function");
});
it("multiple Zod tools coexist with built-in state tools", async () => {
const agent = new BuiltInAgent({
model: "openai/gpt-4o",
tools: [
defineTool({
name: "search",
description: "Search",
parameters: z.object({ query: z.string() }),
execute: async () => ({}),
}),
defineTool({
name: "calculate",
description: "Calculate",
parameters: z.object({
expression: z.string(),
precision: z.number().int().optional(),
}),
execute: async () => ({}),
}),
],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
// User-defined Zod tools
expect(callArgs.tools).toHaveProperty("search");
expect(callArgs.tools).toHaveProperty("calculate");
// Built-in state tools
expect(callArgs.tools).toHaveProperty("AGUISendStateSnapshot");
expect(callArgs.tools).toHaveProperty("AGUISendStateDelta");
});
it("Zod tool execute is invocable after conversion", async () => {
const executeFn = vi.fn().mockResolvedValue({ temp: 22 });
const agent = new BuiltInAgent({
model: "openai/gpt-4o",
tools: [
defineTool({
name: "weather",
description: "Get weather",
parameters: z.object({ city: z.string() }),
execute: executeFn,
}),
],
});
vi.mocked(streamText).mockReturnValue(
mockStreamTextResponse([finish()]) as any,
);
const input: RunAgentInput = {
threadId: "thread1",
runId: "run1",
messages: [],
tools: [],
context: [],
state: {},
};
await collectEvents(agent["run"](input));
const callArgs = vi.mocked(streamText).mock.calls[0][0];
const weatherTool = callArgs.tools?.weather;
// Invoke the execute function to verify it's wired correctly
await weatherTool.execute(
{ city: "Berlin" },
{ toolCallId: "tool-call-1", messages: [] },
);
expect(executeFn).toHaveBeenCalledWith(
{ city: "Berlin" },
{ toolCallId: "tool-call-1", messages: [] },
);
});
});
@@ -0,0 +1,405 @@
import type {
BaseEvent,
Interrupt,
ReasoningEndEvent,
ReasoningMessageContentEvent,
ReasoningMessageEndEvent,
ReasoningMessageStartEvent,
ReasoningStartEvent,
TextMessageChunkEvent,
ToolCallArgsEvent,
ToolCallEndEvent,
ToolCallStartEvent,
ToolCallResultEvent,
StateSnapshotEvent,
StateDeltaEvent,
} from "@ag-ui/client";
import { EventType } from "@ag-ui/client";
import { randomUUID } from "@copilotkit/shared";
/**
* Converts an AI SDK `fullStream` into AG-UI `BaseEvent` objects.
*
* This is a pure converter — it does NOT emit lifecycle events
* (RUN_STARTED / RUN_FINISHED / RUN_ERROR). The caller (Agent class)
* is responsible for those.
*
* Terminal stream events (finish, error, abort) cause the generator to
* return so the caller can handle lifecycle appropriately.
*
* `pendingInterrupts`, when provided, is filled with one AG-UI Interrupt per
* AI SDK `tool-approval-request` part (a tool declared `needsApproval: true`).
* The caller turns a non-empty array into a RUN_FINISHED `outcome:interrupt`.
*/
export async function* convertAISDKStream(
fullStream: AsyncIterable<unknown>,
abortSignal: AbortSignal,
pendingInterrupts?: Interrupt[],
): AsyncGenerator<BaseEvent> {
let messageId = randomUUID();
let reasoningMessageId = randomUUID();
let isInReasoning = false;
const toolCallStates = new Map<
string,
{
started: boolean;
hasArgsDelta: boolean;
ended: boolean;
toolName?: string;
}
>();
const ensureToolCallState = (toolCallId: string) => {
let state = toolCallStates.get(toolCallId);
if (!state) {
state = { started: false, hasArgsDelta: false, ended: false };
toolCallStates.set(toolCallId, state);
}
return state;
};
/**
* Auto-close an open reasoning lifecycle.
* Some AI SDK providers (notably @ai-sdk/anthropic) never emit "reasoning-end",
* which leaves downstream state machines stuck. This helper emits the
* missing REASONING_MESSAGE_END + REASONING_END events so the stream
* can transition to text, tool-call, or finish phases.
*/
function* closeReasoningIfOpen(): Generator<BaseEvent> {
if (!isInReasoning) return;
isInReasoning = false;
const reasoningMsgEnd: ReasoningMessageEndEvent = {
type: EventType.REASONING_MESSAGE_END,
messageId: reasoningMessageId,
};
yield reasoningMsgEnd;
const reasoningEnd: ReasoningEndEvent = {
type: EventType.REASONING_END,
messageId: reasoningMessageId,
};
yield reasoningEnd;
}
try {
for await (const part of fullStream) {
const p = part as Record<string, unknown>;
// Close any open reasoning lifecycle on every event except
// reasoning-delta, which arrives mid-block and must not interrupt it.
if (p.type !== "reasoning-delta") {
yield* closeReasoningIfOpen();
}
switch (p.type) {
case "abort": {
// Terminal — let the caller handle lifecycle
return;
}
case "reasoning-start": {
// Use SDK-provided id, or generate a fresh UUID if id is falsy/"0"
// to prevent consecutive reasoning blocks from sharing a messageId
const providedId = "id" in p ? p.id : undefined;
reasoningMessageId =
providedId && providedId !== "0"
? (providedId as string)
: randomUUID();
const reasoningStartEvent: ReasoningStartEvent = {
type: EventType.REASONING_START,
messageId: reasoningMessageId,
};
yield reasoningStartEvent;
const reasoningMessageStart: ReasoningMessageStartEvent = {
type: EventType.REASONING_MESSAGE_START,
messageId: reasoningMessageId,
role: "reasoning",
};
yield reasoningMessageStart;
isInReasoning = true;
break;
}
case "reasoning-delta": {
const delta = (p.text as string) ?? "";
if (!delta) break; // skip — @ag-ui/core schema requires delta to be non-empty
const reasoningDeltaEvent: ReasoningMessageContentEvent = {
type: EventType.REASONING_MESSAGE_CONTENT,
messageId: reasoningMessageId,
delta,
};
yield reasoningDeltaEvent;
break;
}
case "reasoning-end": {
// closeReasoningIfOpen() already called before the switch — no-op here
// if the SDK never emits this event (e.g. @ai-sdk/anthropic).
break;
}
case "tool-input-start": {
const toolCallId = p.id as string;
const state = ensureToolCallState(toolCallId);
state.toolName = p.toolName as string;
if (!state.started) {
state.started = true;
const startEvent: ToolCallStartEvent = {
type: EventType.TOOL_CALL_START,
parentMessageId: messageId,
toolCallId,
toolCallName: p.toolName as string,
};
yield startEvent;
}
break;
}
case "tool-input-delta": {
const toolCallId = p.id as string;
const state = ensureToolCallState(toolCallId);
state.hasArgsDelta = true;
const argsEvent: ToolCallArgsEvent = {
type: EventType.TOOL_CALL_ARGS,
toolCallId,
delta: p.delta as string,
};
yield argsEvent;
break;
}
case "tool-input-end": {
// No direct event the subsequent "tool-call" part marks completion.
break;
}
case "text-start": {
// New text message starting - use the SDK-provided id
// Use randomUUID() if part.id is falsy or "0" to prevent message merging issues
const providedId = "id" in p ? p.id : undefined;
messageId =
providedId && providedId !== "0"
? (providedId as string)
: randomUUID();
break;
}
case "text-delta": {
// AI SDK text-delta events use 'text' (not 'delta')
const textDelta = "text" in p ? (p.text as string) : "";
const textEvent: TextMessageChunkEvent = {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
messageId,
delta: textDelta,
};
yield textEvent;
break;
}
case "tool-call": {
const toolCallId = p.toolCallId as string;
const state = ensureToolCallState(toolCallId);
state.toolName = (p.toolName as string) ?? state.toolName;
if (!state.started) {
state.started = true;
const startEvent: ToolCallStartEvent = {
type: EventType.TOOL_CALL_START,
parentMessageId: messageId,
toolCallId,
toolCallName: p.toolName as string,
};
yield startEvent;
}
if (!state.hasArgsDelta && "input" in p && p.input !== undefined) {
let serializedInput = "";
if (typeof p.input === "string") {
serializedInput = p.input;
} else {
try {
serializedInput = JSON.stringify(p.input);
} catch {
serializedInput = String(p.input);
}
}
if (serializedInput.length > 0) {
const argsEvent: ToolCallArgsEvent = {
type: EventType.TOOL_CALL_ARGS,
toolCallId,
delta: serializedInput,
};
yield argsEvent;
state.hasArgsDelta = true;
}
}
if (!state.ended) {
state.ended = true;
const endEvent: ToolCallEndEvent = {
type: EventType.TOOL_CALL_END,
toolCallId,
};
yield endEvent;
}
break;
}
case "tool-approval-request": {
// AI SDK native human-in-the-loop: a tool declared `needsApproval`
// was called. The preceding "tool-call" part already streamed the
// tool name + args; this part carries the toolCallId to pause on.
// (Some shapes nest the call under `toolCall` — read both.)
const nested = (p.toolCall ?? {}) as {
toolCallId?: string;
toolName?: string;
input?: unknown;
};
const toolCallId =
(p.toolCallId as string | undefined) ?? nested.toolCallId;
if (!toolCallId) {
throw new Error(
"AI SDK tool-approval-request is missing toolCallId",
);
}
const state = ensureToolCallState(toolCallId);
const toolName = state.toolName ?? nested.toolName;
// Defensive: emit the tool-call lifecycle if the "tool-call" part
// didn't precede this one, so the assistant tool-call is in history
// for the resume run (which keys the injected result by toolCallId).
if (!state.started) {
state.started = true;
const startEvent: ToolCallStartEvent = {
type: EventType.TOOL_CALL_START,
parentMessageId: messageId,
toolCallId,
toolCallName: toolName ?? "",
};
yield startEvent;
}
if (!state.hasArgsDelta && nested.input !== undefined) {
let serialized = "";
try {
serialized =
typeof nested.input === "string"
? nested.input
: JSON.stringify(nested.input);
} catch {
serialized = String(nested.input);
}
if (serialized.length > 0) {
const argsEvent: ToolCallArgsEvent = {
type: EventType.TOOL_CALL_ARGS,
toolCallId,
delta: serialized,
};
yield argsEvent;
state.hasArgsDelta = true;
}
}
if (!state.ended) {
state.ended = true;
const endEvent: ToolCallEndEvent = {
type: EventType.TOOL_CALL_END,
toolCallId,
};
yield endEvent;
}
pendingInterrupts?.push({
id: toolCallId,
toolCallId,
reason: "tool_approval",
message: toolName ? `Approve "${toolName}"?` : undefined,
...(toolName ? { metadata: { toolName } } : {}),
});
break;
}
case "tool-result": {
// AI SDK tool-result uses "output"; older versions used "result" — check both
const toolResult =
"output" in p ? p.output : "result" in p ? p.result : null;
const toolName = "toolName" in p ? (p.toolName as string) : "";
toolCallStates.delete(p.toolCallId as string);
// Check if this is a state update tool
if (
toolName === "AGUISendStateSnapshot" &&
toolResult &&
typeof toolResult === "object"
) {
const snapshot = (toolResult as Record<string, unknown>).snapshot;
if (snapshot !== undefined) {
const stateSnapshotEvent: StateSnapshotEvent = {
type: EventType.STATE_SNAPSHOT,
snapshot,
};
yield stateSnapshotEvent;
}
} else if (
toolName === "AGUISendStateDelta" &&
toolResult &&
typeof toolResult === "object"
) {
const delta = (toolResult as { delta?: StateDeltaEvent["delta"] })
.delta;
if (delta !== undefined) {
const stateDeltaEvent: StateDeltaEvent = {
type: EventType.STATE_DELTA,
delta,
};
yield stateDeltaEvent;
}
}
// Always emit the tool result event for the LLM
let serializedResult: string;
try {
serializedResult = JSON.stringify(toolResult);
} catch {
serializedResult = `[Unserializable tool result from ${toolName || "unknown tool"}]`;
}
const resultEvent: ToolCallResultEvent = {
type: EventType.TOOL_CALL_RESULT,
role: "tool",
messageId: randomUUID(),
toolCallId: p.toolCallId as string,
content: serializedResult,
};
yield resultEvent;
break;
}
case "finish": {
// Terminal — let the caller handle lifecycle
return;
}
case "error": {
if (abortSignal.aborted) {
return;
}
// Re-throw so the caller can emit RUN_ERROR
const err = p.error ?? p.message ?? p.cause;
if (err instanceof Error) throw err;
throw new Error(
typeof err === "string"
? err
: `AI SDK stream error: ${JSON.stringify(p)}`,
);
}
default:
// Unknown event types are silently ignored
break;
}
}
} finally {
// Always close reasoning on exit (normal or exceptional)
yield* closeReasoningIfOpen();
}
}
@@ -0,0 +1,7 @@
export { convertAISDKStream } from "./aisdk";
export {
convertTanStackStream,
convertInputToTanStackAI,
type TanStackChatMessage,
type TanStackInputResult,
} from "./tanstack";
@@ -0,0 +1,594 @@
import type {
BaseEvent,
Interrupt,
RunAgentInput,
Message,
TextMessageChunkEvent,
ToolCallArgsEvent,
ToolCallEndEvent,
ToolCallStartEvent,
ToolCallResultEvent,
StateSnapshotEvent,
StateDeltaEvent,
ReasoningStartEvent,
ReasoningMessageStartEvent,
ReasoningMessageContentEvent,
ReasoningMessageEndEvent,
ReasoningEndEvent,
} from "@ag-ui/client";
import { EventType } from "@ag-ui/client";
import { randomUUID } from "@copilotkit/shared";
type ContentPartSource =
| { type: "data"; value: string; mimeType: string }
| { type: "url"; value: string; mimeType?: string };
/**
* A TanStack AI content part (text, image, audio, video, or document).
*/
export type TanStackContentPart =
| { type: "text"; content: string }
| { type: "image"; source: ContentPartSource }
| { type: "audio"; source: ContentPartSource }
| { type: "video"; source: ContentPartSource }
| { type: "document"; source: ContentPartSource };
/**
* Message format expected by TanStack AI's `chat()`.
*
* Content is typed as `any[]` for the multimodal case so messages are directly
* passable to any adapter without casts — different adapters constrain which
* modalities they accept (e.g. OpenAI only allows text + image).
* Use `TanStackContentPart` to inspect individual parts if needed.
*/
export interface TanStackChatMessage {
role: "user" | "assistant" | "tool";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
content: string | null | any[];
name?: string;
toolCalls?: Array<{
id: string;
type: "function";
function: { name: string; arguments: string };
}>;
toolCallId?: string;
}
/**
* A TanStack AI client-side tool, derived from a frontend-provided AG-UI tool.
*
* Shaped to match `@tanstack/ai`'s `ClientTool` (`__toolSide: "client"`, no
* `execute`): the model may CALL it, but TanStack does not run it — it pauses
* the run and hands the call back to the AG-UI client (the CopilotKit frontend
* / bot) to execute, mirroring CopilotKit's client-tool round-trip. `chat()`
* accepts a JSON Schema directly as `inputSchema`, so the AG-UI tool's
* `parameters` pass through unchanged.
*/
export interface TanStackClientTool {
__toolSide: "client";
name: string;
description: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
inputSchema: any;
}
/**
* Result of converting RunAgentInput to TanStack AI format.
*/
export interface TanStackInputResult {
/** Chat messages (only user/assistant/tool roles; all others excluded) */
messages: TanStackChatMessage[];
/** System prompts extracted from system/developer messages, context, and state */
systemPrompts: string[];
/**
* Client-side tools derived from `input.tools` (the frontend-provided tools
* the CopilotKit client forwards on every run). Pass these into `chat()`
* alongside any server/provider tools so the model can call the frontend's
* generative-UI and human-in-the-loop tools; TanStack pauses the run on a
* client-tool call and the client executes it.
*/
tools: TanStackClientTool[];
}
/**
* Converts AG-UI user message content to TanStack AI format.
* Handles plain strings, multimodal parts (image/audio/video/document),
* and legacy BinaryInputContent for backward compatibility.
*/
function convertUserContent(
content: unknown,
): string | null | TanStackContentPart[] {
if (!content) return null;
if (typeof content === "string") return content;
if (!Array.isArray(content)) return null;
if (content.length === 0) return "";
const parts: TanStackContentPart[] = [];
for (const part of content) {
if (!part || typeof part !== "object" || !("type" in part)) continue;
switch ((part as { type: string }).type) {
case "text": {
const text = (part as { text?: string }).text;
if (text != null) parts.push({ type: "text", content: text });
break;
}
case "image":
case "audio":
case "video":
case "document": {
const source = (part as { source?: any }).source;
if (!source) break;
const partType = (part as { type: string }).type as
| "image"
| "audio"
| "video"
| "document";
if (source.type === "data") {
parts.push({
type: partType,
source: {
type: "data",
value: source.value,
mimeType: source.mimeType,
},
});
} else if (source.type === "url") {
parts.push({
type: partType,
source: {
type: "url",
value: source.value,
...(source.mimeType ? { mimeType: source.mimeType } : {}),
},
});
}
break;
}
// Legacy BinaryInputContent backward compatibility
case "binary": {
const legacy = part as {
mimeType?: string;
data?: string;
url?: string;
};
const mimeType = legacy.mimeType ?? "application/octet-stream";
const isImage = mimeType.startsWith("image/");
if (legacy.data) {
const partType = isImage ? "image" : "document";
parts.push({
type: partType,
source: { type: "data", value: legacy.data, mimeType },
});
} else if (legacy.url) {
const partType = isImage ? "image" : "document";
parts.push({
type: partType,
source: { type: "url", value: legacy.url, mimeType },
});
}
break;
}
}
}
return parts.length > 0 ? parts : "";
}
/**
* Recursively normalizes a frontend tool's JSON Schema so OpenAI accepts it as
* a function-tool schema.
*
* Frontend tools are often authored with permissive Zod (`z.any()`,
* `z.record(...)`, `.passthrough()`), which serialize to open objects —
* `additionalProperties: {}` (an empty sub-schema) or `additionalProperties:
* true`. OpenAI rejects both: strict mode requires `additionalProperties:
* false`, and an empty `{}` sub-schema fails base validation ("schema must
* have a 'type' key"). The classic (Vercel AI SDK) path sanitized these
* implicitly via a Zod round-trip; the TanStack path forwards the raw schema,
* so we close open objects here to match. (Models can't supply free-form extra
* keys either way — same as the classic path.)
*/
function sanitizeClientToolSchema(schema: unknown): unknown {
if (Array.isArray(schema)) {
return schema.map(sanitizeClientToolSchema);
}
if (!schema || typeof schema !== "object") {
return schema;
}
const node: Record<string, unknown> = {
...(schema as Record<string, unknown>),
};
// Any `additionalProperties` (empty `{}`, `true`, or a sub-schema) becomes
// `false` — the only form OpenAI accepts for strict function tools.
if ("additionalProperties" in node) {
node.additionalProperties = false;
}
if (node.properties && typeof node.properties === "object") {
const props: Record<string, unknown> = {};
for (const [key, value] of Object.entries(
node.properties as Record<string, unknown>,
)) {
props[key] = sanitizeClientToolSchema(value);
}
node.properties = props;
}
if ("items" in node) {
node.items = sanitizeClientToolSchema(node.items);
}
for (const combinator of ["anyOf", "allOf", "oneOf"] as const) {
if (Array.isArray(node[combinator])) {
node[combinator] = (node[combinator] as unknown[]).map(
sanitizeClientToolSchema,
);
}
}
return node;
}
/**
* Converts a RunAgentInput into the format expected by TanStack AI's `chat()`.
*
* - Keeps only user/assistant/tool messages (activity, reasoning, and other roles are also excluded)
* - Extracts system/developer messages into `systemPrompts`
* - Appends context entries and application state to `systemPrompts`
* - Preserves tool calls on assistant messages and toolCallId on tool messages
*/
export function convertInputToTanStackAI(
input: RunAgentInput,
): TanStackInputResult {
// Allowlist: only pass user/assistant/tool messages to TanStack.
// Other roles (system, developer, activity, reasoning) are either
// extracted into systemPrompts or not applicable.
const chatRoles = new Set(["user", "assistant", "tool"]);
const messages: TanStackChatMessage[] = input.messages
.filter((m: Message) => chatRoles.has(m.role))
.map((m: Message): TanStackChatMessage => {
const msg: TanStackChatMessage = {
role: m.role as "user" | "assistant" | "tool",
content:
m.role === "user"
? convertUserContent(m.content)
: typeof m.content === "string"
? m.content
: null,
};
if (m.role === "assistant" && "toolCalls" in m && m.toolCalls) {
msg.toolCalls = m.toolCalls.map((tc) => ({
id: tc.id,
type: "function" as const,
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
}));
}
if (m.role === "tool" && "toolCallId" in m) {
msg.toolCallId = (m as Record<string, unknown>).toolCallId as string;
}
return msg;
});
const systemPrompts: string[] = [];
for (const m of input.messages) {
if ((m.role === "system" || m.role === "developer") && m.content) {
systemPrompts.push(
typeof m.content === "string" ? m.content : JSON.stringify(m.content),
);
}
}
if (input.context?.length) {
for (const ctx of input.context) {
systemPrompts.push(`${ctx.description}:\n${ctx.value}`);
}
}
if (
input.state !== undefined &&
input.state !== null &&
typeof input.state === "object" &&
Object.keys(input.state).length > 0
) {
systemPrompts.push(
`Application State:\n\`\`\`json\n${JSON.stringify(input.state, null, 2)}\n\`\`\``,
);
}
// Frontend-provided tools become client-side TanStack tools (no executor):
// the model can call them, TanStack pauses the run, and the AG-UI client
// executes them and resumes — the CopilotKit client-tool round-trip.
const tools: TanStackClientTool[] = (input.tools ?? []).map((t) => ({
__toolSide: "client",
name: t.name,
description: t.description,
inputSchema: sanitizeClientToolSchema(t.parameters),
}));
return { messages, systemPrompts, tools };
}
/**
* Converts a TanStack AI stream into AG-UI `BaseEvent` objects.
*
* This is a pure converter — it does NOT emit lifecycle events
* (RUN_STARTED / RUN_FINISHED / RUN_ERROR). The caller (Agent class)
* is responsible for those.
*
* `pendingInterrupts`, when provided, is filled with one AG-UI Interrupt per
* CUSTOM "approval-requested" chunk (a tool declared `needsApproval: true`).
* The caller turns a non-empty array into a RUN_FINISHED `outcome:interrupt`.
*/
export async function* convertTanStackStream(
stream: AsyncIterable<unknown>,
abortSignal: AbortSignal,
pendingInterrupts?: Interrupt[],
): AsyncGenerator<BaseEvent> {
const messageId = randomUUID();
const toolNamesById = new Map<string, string>();
// Track the reasoning lifecycle at two granularities so closeReasoningIfOpen
// emits exactly the events still owed. A single boolean conflates the run
// (REASONING_START → REASONING_END) with the message
// (REASONING_MESSAGE_START → REASONING_MESSAGE_END) and produces a duplicate
// REASONING_MESSAGE_END when upstream emits MSG_END but not END before
// text/tools resume.
let reasoningRunOpen = false;
let reasoningMessageOpen = false;
let reasoningMessageId = randomUUID();
function* closeReasoningIfOpen(): Generator<BaseEvent> {
if (reasoningMessageOpen) {
reasoningMessageOpen = false;
const msgEnd: ReasoningMessageEndEvent = {
type: EventType.REASONING_MESSAGE_END,
messageId: reasoningMessageId,
};
yield msgEnd;
}
if (reasoningRunOpen) {
reasoningRunOpen = false;
const end: ReasoningEndEvent = {
type: EventType.REASONING_END,
messageId: reasoningMessageId,
};
yield end;
}
}
// TanStack's chat() engine runs a multi-turn agent loop and emits a
// RUN_STARTED / RUN_FINISHED pair PER model turn — not once for the whole
// run. When it executes a tool itself (an MCP server tool or a provider tool
// like web_search), it does so between turns and streams a TOOL_CALL_RESULT
// followed by the next turn's text. The overall run lifecycle is owned by the
// Agent wrapper (it emits exactly one outer RUN_STARTED / RUN_FINISHED), so
// we drop TanStack's per-turn lifecycle markers and convert every content
// event across all turns. (A previous version stopped converting at the first
// RUN_FINISHED — that truncated the run at the first tool turn and silently
// dropped both the tool result and the model's final answer.)
//
// chat() can re-announce a tool call when it re-prompts after executing it,
// so START / END are de-duplicated by toolCallId to avoid emitting a pair
// twice (which would violate the ag-ui verify middleware).
const startedToolCalls = new Set<string>();
const endedToolCalls = new Set<string>();
for await (const chunk of stream) {
if (abortSignal.aborted) break;
const raw = chunk as Record<string, unknown>;
const type = raw.type as string;
// TanStack native human-in-the-loop: a tool declared `needsApproval: true`
// emits a CUSTOM "approval-requested" chunk. These are built from the
// finish event and can arrive around lifecycle markers, so handle them
// before dropping TanStack's per-turn lifecycle events.
// The tool-call lifecycle was already streamed in the model pass.
if (type === "CUSTOM" && raw.name === "approval-requested") {
const value = (raw.value ?? {}) as {
toolCallId?: string;
toolName?: string;
};
const toolCallId = value.toolCallId;
if (toolCallId) {
pendingInterrupts?.push({
id: toolCallId,
toolCallId,
reason: "tool_approval",
message: value.toolName ? `Approve "${value.toolName}"?` : undefined,
...(value.toolName ? { metadata: { toolName: value.toolName } } : {}),
});
}
continue;
}
// Per-turn lifecycle markers are owned by the Agent wrapper, not forwarded.
if (type === "RUN_STARTED" || type === "RUN_FINISHED") {
continue;
}
// Surface engine errors instead of dropping them: throw so the Agent
// wrapper emits a terminal RUN_ERROR. Without this a failed run (e.g. a
// provider 4xx) would finish empty with no indication of what went wrong.
if (type === "RUN_ERROR") {
throw new Error(
typeof raw.message === "string" ? raw.message : "TanStack AI run error",
);
}
if (type === "TEXT_MESSAGE_CONTENT" && raw.delta != null) {
yield* closeReasoningIfOpen();
const textEvent: TextMessageChunkEvent = {
type: EventType.TEXT_MESSAGE_CHUNK,
role: "assistant",
messageId,
delta: raw.delta as string,
};
yield textEvent;
} else if (type === "TOOL_CALL_START") {
const toolCallId = raw.toolCallId as string;
if (startedToolCalls.has(toolCallId)) continue;
startedToolCalls.add(toolCallId);
yield* closeReasoningIfOpen();
toolNamesById.set(toolCallId, raw.toolCallName as string);
const startEvent: ToolCallStartEvent = {
type: EventType.TOOL_CALL_START,
parentMessageId: messageId,
toolCallId,
toolCallName: raw.toolCallName as string,
};
yield startEvent;
} else if (type === "TOOL_CALL_ARGS") {
// Drop args re-announced after the call has ended (the re-prompt pass);
// forwarding them would corrupt the already-closed call's accumulated args.
if (endedToolCalls.has(raw.toolCallId as string)) continue;
yield* closeReasoningIfOpen();
const argsEvent: ToolCallArgsEvent = {
type: EventType.TOOL_CALL_ARGS,
toolCallId: raw.toolCallId as string,
delta: raw.delta as string,
};
yield argsEvent;
} else if (type === "TOOL_CALL_END") {
const toolCallId = raw.toolCallId as string;
if (endedToolCalls.has(toolCallId)) continue;
endedToolCalls.add(toolCallId);
yield* closeReasoningIfOpen();
const endEvent: ToolCallEndEvent = {
type: EventType.TOOL_CALL_END,
toolCallId,
};
yield endEvent;
} else if (type === "TOOL_CALL_RESULT") {
yield* closeReasoningIfOpen();
const toolCallId = raw.toolCallId as string;
const toolName = toolNamesById.get(toolCallId);
// Accept the payload from either `content` (canonical TanStack shape)
// or `result` (alternate shape used by some adapters / tests). Both
// state-tool detection and the final TOOL_CALL_RESULT serialization
// must read the same field, otherwise STATE_SNAPSHOT/STATE_DELTA can
// be silently dropped when upstream uses `result`.
const rawPayload = raw.content ?? raw.result;
const parsedContent =
typeof rawPayload === "string" ? safeParse(rawPayload) : rawPayload;
if (
toolName === "AGUISendStateSnapshot" &&
parsedContent &&
typeof parsedContent === "object" &&
"snapshot" in parsedContent
) {
const stateSnapshotEvent: StateSnapshotEvent = {
type: EventType.STATE_SNAPSHOT,
snapshot: (parsedContent as Record<string, unknown>).snapshot,
};
yield stateSnapshotEvent;
}
if (
toolName === "AGUISendStateDelta" &&
parsedContent &&
typeof parsedContent === "object" &&
"delta" in parsedContent
) {
const stateDeltaEvent: StateDeltaEvent = {
type: EventType.STATE_DELTA,
delta: (parsedContent as Record<string, unknown>).delta as never,
};
yield stateDeltaEvent;
}
let serializedContent: string;
if (typeof rawPayload === "string") {
serializedContent = rawPayload;
} else {
try {
serializedContent = JSON.stringify(rawPayload ?? null);
} catch {
serializedContent = "[Unserializable tool result]";
}
}
const resultEvent: ToolCallResultEvent = {
type: EventType.TOOL_CALL_RESULT,
role: "tool",
messageId: randomUUID(),
toolCallId,
content: serializedContent,
};
yield resultEvent;
toolNamesById.delete(toolCallId);
} else if (type === "REASONING_START") {
// If a prior reasoning run is still open (no REASONING_END before this
// new START), close it cleanly first so MSG_END / END pair correctly.
yield* closeReasoningIfOpen();
reasoningRunOpen = true;
reasoningMessageId = (raw.messageId as string) ?? randomUUID();
const startEvt: ReasoningStartEvent = {
type: EventType.REASONING_START,
messageId: reasoningMessageId,
};
yield startEvt;
} else if (type === "REASONING_MESSAGE_START") {
reasoningMessageOpen = true;
const evt: ReasoningMessageStartEvent = {
type: EventType.REASONING_MESSAGE_START,
messageId: reasoningMessageId,
role: "reasoning",
};
yield evt;
} else if (type === "REASONING_MESSAGE_CONTENT") {
const evt: ReasoningMessageContentEvent = {
type: EventType.REASONING_MESSAGE_CONTENT,
messageId: reasoningMessageId,
delta: raw.delta as string,
};
yield evt;
} else if (type === "REASONING_MESSAGE_END") {
reasoningMessageOpen = false;
const evt: ReasoningMessageEndEvent = {
type: EventType.REASONING_MESSAGE_END,
messageId: reasoningMessageId,
};
yield evt;
} else if (type === "REASONING_END") {
// If upstream sends REASONING_END while a message is still open, emit
// the missing REASONING_MESSAGE_END FIRST so the closing pair stays in
// order (MSG_END before END). Otherwise the next non-reasoning chunk
// would trigger closeReasoningIfOpen and emit MSG_END after END.
if (reasoningMessageOpen) {
reasoningMessageOpen = false;
const msgEnd: ReasoningMessageEndEvent = {
type: EventType.REASONING_MESSAGE_END,
messageId: reasoningMessageId,
};
yield msgEnd;
}
reasoningRunOpen = false;
const evt: ReasoningEndEvent = {
type: EventType.REASONING_END,
messageId: reasoningMessageId,
};
yield evt;
}
}
yield* closeReasoningIfOpen();
}
function safeParse(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return value;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,256 @@
import { RemoteLangGraphEventSource } from "../event-source";
import { LangGraphEventTypes } from "../events";
/**
* Access private methods for testing via a helper.
* These are pure functions that extract data from LangGraph events.
*/
function getSource() {
return new RemoteLangGraphEventSource();
}
// Helper to call private methods. Returns `any` — type safety is traded for
// access to private implementation details that have no public test surface.
function callPrivate(
source: RemoteLangGraphEventSource,
method: string,
...args: any[]
) {
return (source as any)[method](...args);
}
describe("shouldEmitToolCall", () => {
const source = getSource();
it("returns true when shouldEmitToolCalls is true (boolean)", () => {
expect(callPrivate(source, "shouldEmitToolCall", true, "anyTool")).toBe(
true,
);
});
it("returns false when shouldEmitToolCalls is false (boolean)", () => {
expect(callPrivate(source, "shouldEmitToolCall", false, "anyTool")).toBe(
false,
);
});
it("returns true when tool name matches string", () => {
expect(
callPrivate(source, "shouldEmitToolCall", "SearchTool", "SearchTool"),
).toBe(true);
});
it("returns false when tool name does not match string", () => {
expect(
callPrivate(source, "shouldEmitToolCall", "SearchTool", "OtherTool"),
).toBe(false);
});
it("returns true when tool name is in array", () => {
expect(
callPrivate(
source,
"shouldEmitToolCall",
["SearchTool", "FetchTool"],
"FetchTool",
),
).toBe(true);
});
it("returns false when tool name is not in array", () => {
expect(
callPrivate(
source,
"shouldEmitToolCall",
["SearchTool", "FetchTool"],
"OtherTool",
),
).toBe(false);
});
});
describe("getCurrentMessageId", () => {
const source = getSource();
it("extracts id from standard kwargs layout", () => {
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: { kwargs: { id: "msg-std-123" } } },
};
expect(callPrivate(source, "getCurrentMessageId", event)).toBe(
"msg-std-123",
);
});
it("extracts id from LangGraph Platform layout (no kwargs)", () => {
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: { id: "msg-plat-456" } },
};
expect(callPrivate(source, "getCurrentMessageId", event)).toBe(
"msg-plat-456",
);
});
it("prefers kwargs layout when both present", () => {
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: { kwargs: { id: "kwargs-id" }, id: "direct-id" } },
};
expect(callPrivate(source, "getCurrentMessageId", event)).toBe("kwargs-id");
});
it("returns undefined when neither layout is present", () => {
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: {} },
};
expect(callPrivate(source, "getCurrentMessageId", event)).toBeUndefined();
});
it("handles missing data gracefully", () => {
const event = { event: LangGraphEventTypes.OnChatModelStream };
expect(callPrivate(source, "getCurrentMessageId", event)).toBeUndefined();
});
});
describe("getCurrentToolCallChunks", () => {
const source = getSource();
it("extracts chunks from standard kwargs layout", () => {
const chunks = [{ name: "tool1", args: "{}", id: "tc-1", index: 0 }];
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: { kwargs: { tool_call_chunks: chunks } } },
};
expect(callPrivate(source, "getCurrentToolCallChunks", event)).toEqual(
chunks,
);
});
it("extracts chunks from LangGraph Platform layout", () => {
const chunks = [{ name: "tool2", args: "{}", id: "tc-2", index: 0 }];
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: { tool_call_chunks: chunks } },
};
expect(callPrivate(source, "getCurrentToolCallChunks", event)).toEqual(
chunks,
);
});
it("returns undefined when no chunks present", () => {
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: {} },
};
expect(
callPrivate(source, "getCurrentToolCallChunks", event),
).toBeUndefined();
});
});
describe("getResponseMetadata", () => {
const source = getSource();
it("extracts metadata from standard kwargs layout", () => {
const meta = { finish_reason: "stop" };
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: { kwargs: { response_metadata: meta } } },
};
expect(callPrivate(source, "getResponseMetadata", event)).toEqual(meta);
});
it("extracts metadata from LangGraph Platform layout", () => {
const meta = { finish_reason: "tool_calls" };
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: { response_metadata: meta } },
};
expect(callPrivate(source, "getResponseMetadata", event)).toEqual(meta);
});
it("returns undefined when no metadata present", () => {
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: {} },
};
expect(callPrivate(source, "getResponseMetadata", event)).toBeUndefined();
});
});
describe("getCurrentContent", () => {
const source = getSource();
it("extracts string content from kwargs layout", () => {
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: { kwargs: { content: "hello world" } } },
};
expect(callPrivate(source, "getCurrentContent", event)).toBe("hello world");
});
it("extracts string content from Platform layout", () => {
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: { content: "platform content" } },
};
expect(callPrivate(source, "getCurrentContent", event)).toBe(
"platform content",
);
});
it("extracts text from array content (Anthropic format)", () => {
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: {
chunk: {
kwargs: {
content: [{ text: "array text", type: "text", index: 0 }],
},
},
},
};
expect(callPrivate(source, "getCurrentContent", event)).toBe("array text");
});
it("returns null when no content and no tool call chunks", () => {
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: { kwargs: {} } },
};
expect(callPrivate(source, "getCurrentContent", event)).toBeNull();
});
it("falls back to tool_call_chunks args when no content", () => {
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: {
chunk: {
kwargs: {
content: "",
tool_call_chunks: [{ args: '{"key":"val"}' }],
},
},
},
};
expect(callPrivate(source, "getCurrentContent", event)).toBe(
'{"key":"val"}',
);
});
it("handles missing data gracefully", () => {
const event = { event: LangGraphEventTypes.OnChatModelStream };
expect(callPrivate(source, "getCurrentContent", event)).toBeNull();
});
it("returns empty string when content is empty string", () => {
const event = {
event: LangGraphEventTypes.OnChatModelStream,
data: { chunk: { kwargs: { content: "" } } },
};
// Empty string is a valid string, so typeof === "string" returns it as-is
expect(callPrivate(source, "getCurrentContent", event)).toBe("");
});
});
@@ -0,0 +1,365 @@
import {
CopilotKitLowLevelError,
isStructuredCopilotKitError,
} from "@copilotkit/shared";
import { catchError, mergeMap, ReplaySubject, scan } from "rxjs";
import { generateHelpfulErrorMessage } from "../../lib/streaming";
import type { RuntimeEvent } from "../../service-adapters/events";
import {
RuntimeEventTypes,
RuntimeMetaEventName,
} from "../../service-adapters/events";
import type { LangGraphEvent } from "./events";
import { CustomEventNames, LangGraphEventTypes } from "./events";
interface LangGraphEventWithState {
event: LangGraphEvent | null;
isMessageStart: boolean;
isMessageEnd: boolean;
isToolCallStart: boolean;
isToolCallEnd: boolean;
isToolCall: boolean;
lastMessageId: string | null;
lastToolCallId: string | null;
lastToolCallName: string | null;
currentContent: string | null;
processedToolCallIds: Set<string>;
}
export class RemoteLangGraphEventSource {
public eventStream$ = new ReplaySubject<LangGraphEvent>();
private shouldEmitToolCall(
shouldEmitToolCalls: string | string[] | boolean,
toolCallName: string,
) {
if (typeof shouldEmitToolCalls === "boolean") {
return shouldEmitToolCalls;
}
if (Array.isArray(shouldEmitToolCalls)) {
return shouldEmitToolCalls.includes(toolCallName);
}
return shouldEmitToolCalls === toolCallName;
}
// LangGraph Platform implementation stores data outside of kwargs, and not
// every member of the LangGraphEvent union carries a `data` payload.
private getEventData(event: LangGraphEvent) {
return "data" in event ? event.data : undefined;
}
private getCurrentContent(event: LangGraphEvent) {
const data = this.getEventData(event);
const content = data?.chunk?.kwargs?.content ?? data?.chunk?.content;
if (!content) {
const toolCallChunks = this.getCurrentToolCallChunks(event) ?? [];
for (const chunk of toolCallChunks) {
if (chunk.args) {
return chunk.args;
}
}
}
if (typeof content === "string") {
return content;
} else if (Array.isArray(content) && content.length > 0) {
return content[0].text;
}
return null;
}
private getCurrentMessageId(event: LangGraphEvent) {
const data = this.getEventData(event);
return data?.chunk?.kwargs?.id ?? data?.chunk?.id;
}
private getCurrentToolCallChunks(event: LangGraphEvent) {
const data = this.getEventData(event);
return (
data?.chunk?.kwargs?.tool_call_chunks ?? data?.chunk?.tool_call_chunks
);
}
private getResponseMetadata(event: LangGraphEvent) {
const data = this.getEventData(event);
return (
data?.chunk?.kwargs?.response_metadata ?? data?.chunk?.response_metadata
);
}
processLangGraphEvents() {
let lastEventWithState: LangGraphEventWithState | null = null;
return this.eventStream$.pipe(
scan(
(acc, event) => {
if (event.event === LangGraphEventTypes.OnChatModelStream) {
const prevMessageId = acc.lastMessageId;
acc.currentContent = this.getCurrentContent(event);
acc.lastMessageId =
this.getCurrentMessageId(event) ?? acc.lastMessageId;
const toolCallChunks = this.getCurrentToolCallChunks(event) ?? [];
const responseMetadata = this.getResponseMetadata(event);
// Check if a given event is a tool call
const toolCallCheck = toolCallChunks && toolCallChunks.length > 0;
let isToolCallEnd =
responseMetadata?.finish_reason === "tool_calls";
acc.isToolCallStart = toolCallChunks.some(
(chunk: any) => chunk.name && chunk.id,
);
acc.isMessageStart =
prevMessageId !== acc.lastMessageId && !acc.isToolCallStart;
let previousRoundHadToolCall = acc.isToolCall;
acc.isToolCall = toolCallCheck;
// Previous "acc.isToolCall" was set but now it won't pass the check, it means the tool call just ended.
if (previousRoundHadToolCall && !toolCallCheck) {
isToolCallEnd = true;
}
acc.isToolCallEnd = isToolCallEnd;
acc.isMessageEnd = responseMetadata?.finish_reason === "stop";
({ name: acc.lastToolCallName, id: acc.lastToolCallId } =
toolCallChunks.find((chunk: any) => chunk.name && chunk.id) ?? {
name: acc.lastToolCallName,
id: acc.lastToolCallId,
});
}
acc.event = event;
lastEventWithState = acc; // Capture the state
return acc;
},
{
event: null,
isMessageStart: false,
isMessageEnd: false,
isToolCallStart: false,
isToolCallEnd: false,
isToolCall: false,
lastMessageId: null,
lastToolCallId: null,
lastToolCallName: null,
currentContent: null,
processedToolCallIds: new Set<string>(),
} as LangGraphEventWithState,
),
mergeMap((acc): RuntimeEvent[] => {
const events: RuntimeEvent[] = [];
let shouldEmitMessages = true;
let shouldEmitToolCalls: string | string[] | boolean = true;
if (acc.event.event == LangGraphEventTypes.OnChatModelStream) {
if ("copilotkit:emit-tool-calls" in (acc.event.metadata || {})) {
shouldEmitToolCalls =
acc.event.metadata["copilotkit:emit-tool-calls"];
}
if ("copilotkit:emit-messages" in (acc.event.metadata || {})) {
shouldEmitMessages = acc.event.metadata["copilotkit:emit-messages"];
}
}
if (acc.event.event === LangGraphEventTypes.OnInterrupt) {
events.push({
type: RuntimeEventTypes.MetaEvent,
name: RuntimeMetaEventName.LangGraphInterruptEvent,
value: acc.event.value,
});
}
if (acc.event.event === LangGraphEventTypes.OnCopilotKitInterrupt) {
events.push({
type: RuntimeEventTypes.MetaEvent,
name: RuntimeMetaEventName.CopilotKitLangGraphInterruptEvent,
data: acc.event.data,
});
}
// Handle CopilotKit error events with preserved semantic information
if (acc.event.event === LangGraphEventTypes.OnCopilotKitError) {
const errorData = acc.event.data.error;
// Create a structured error with the original semantic information
const preservedError = new CopilotKitLowLevelError({
error: new Error(errorData.message),
url: "langgraph agent",
message: `${errorData.type}: ${errorData.message}`,
});
// Add additional error context to the error object
if (errorData.status_code) {
(preservedError as any).statusCode = errorData.status_code;
}
if (errorData.response_data) {
(preservedError as any).responseData = errorData.response_data;
}
(preservedError as any).agentName = errorData.agent_name;
(preservedError as any).originalErrorType = errorData.type;
// Throw the structured error to be handled by the catchError operator
throw preservedError;
}
const responseMetadata = this.getResponseMetadata(acc.event);
// Tool call ended: emit ActionExecutionEnd
if (
acc.isToolCallEnd &&
this.shouldEmitToolCall(shouldEmitToolCalls, acc.lastToolCallName) &&
acc.lastToolCallId &&
!acc.processedToolCallIds.has(acc.lastToolCallId)
) {
acc.processedToolCallIds.add(acc.lastToolCallId);
events.push({
type: RuntimeEventTypes.ActionExecutionEnd,
actionExecutionId: acc.lastToolCallId,
});
}
// Message ended: emit TextMessageEnd
else if (
responseMetadata?.finish_reason === "stop" &&
shouldEmitMessages
) {
events.push({
type: RuntimeEventTypes.TextMessageEnd,
messageId: acc.lastMessageId,
});
}
switch (acc.event!.event) {
//
// Custom events
//
case LangGraphEventTypes.OnCustomEvent:
//
// Manually emit a message
//
if (
acc.event.name === CustomEventNames.CopilotKitManuallyEmitMessage
) {
events.push({
type: RuntimeEventTypes.TextMessageStart,
messageId: acc.event.data.message_id,
});
events.push({
type: RuntimeEventTypes.TextMessageContent,
messageId: acc.event.data.message_id,
content: acc.event.data.message,
});
events.push({
type: RuntimeEventTypes.TextMessageEnd,
messageId: acc.event.data.message_id,
});
}
//
// Manually emit a tool call
//
else if (
acc.event.name === CustomEventNames.CopilotKitManuallyEmitToolCall
) {
events.push({
type: RuntimeEventTypes.ActionExecutionStart,
actionExecutionId: acc.event.data.id,
actionName: acc.event.data.name,
parentMessageId: acc.event.data.id,
});
events.push({
type: RuntimeEventTypes.ActionExecutionArgs,
actionExecutionId: acc.event.data.id,
args: JSON.stringify(acc.event.data.args),
});
events.push({
type: RuntimeEventTypes.ActionExecutionEnd,
actionExecutionId: acc.event.data.id,
});
}
break;
case LangGraphEventTypes.OnCopilotKitStateSync:
events.push({
type: RuntimeEventTypes.AgentStateMessage,
threadId: acc.event.thread_id,
role: acc.event.role,
agentName: acc.event.agent_name,
nodeName: acc.event.node_name,
runId: acc.event.run_id,
active: acc.event.active,
state: JSON.stringify(acc.event.state),
running: acc.event.running,
});
break;
case LangGraphEventTypes.OnChatModelStream:
if (
acc.isToolCallStart &&
this.shouldEmitToolCall(shouldEmitToolCalls, acc.lastToolCallName)
) {
events.push({
type: RuntimeEventTypes.ActionExecutionStart,
actionExecutionId: acc.lastToolCallId,
actionName: acc.lastToolCallName,
parentMessageId: acc.lastMessageId,
});
}
// Message started: emit TextMessageStart
else if (acc.isMessageStart && shouldEmitMessages) {
acc.processedToolCallIds.clear();
events.push({
type: RuntimeEventTypes.TextMessageStart,
messageId: acc.lastMessageId,
});
}
// Tool call args: emit ActionExecutionArgs
if (
acc.isToolCall &&
acc.currentContent &&
this.shouldEmitToolCall(shouldEmitToolCalls, acc.lastToolCallName)
) {
events.push({
type: RuntimeEventTypes.ActionExecutionArgs,
actionExecutionId: acc.lastToolCallId,
args: acc.currentContent,
});
}
// Message content: emit TextMessageContent
else if (
!acc.isToolCall &&
acc.currentContent &&
shouldEmitMessages
) {
events.push({
type: RuntimeEventTypes.TextMessageContent,
messageId: acc.lastMessageId,
content: acc.currentContent,
});
}
break;
}
return events;
}),
catchError((error) => {
// If it's a structured CopilotKitError, re-throw it to be handled by the frontend error system
if (isStructuredCopilotKitError(error)) {
throw error;
}
// Determine a more helpful error message based on context
let helpfulMessage = generateHelpfulErrorMessage(
error,
"LangGraph agent connection",
);
// For all other errors, preserve the raw error information in a structured format
throw new CopilotKitLowLevelError({
error: error instanceof Error ? error : new Error(String(error)),
url: "langgraph event stream",
message: helpfulMessage,
});
}),
);
}
}
@@ -0,0 +1,394 @@
import {
ActionExecutionMessage,
ResultMessage,
TextMessage,
} from "../../graphql/types/converted";
export enum LangGraphEventTypes {
OnChainStart = "on_chain_start",
OnChainStream = "on_chain_stream",
OnChainEnd = "on_chain_end",
OnChatModelStart = "on_chat_model_start",
OnChatModelStream = "on_chat_model_stream",
OnChatModelEnd = "on_chat_model_end",
OnToolStart = "on_tool_start",
OnToolEnd = "on_tool_end",
OnCopilotKitStateSync = "on_copilotkit_state_sync",
OnCopilotKitEmitMessage = "on_copilotkit_emit_message",
OnCopilotKitEmitToolCall = "on_copilotkit_emit_tool_call",
OnCustomEvent = "on_custom_event",
OnInterrupt = "on_interrupt",
OnCopilotKitInterrupt = "on_copilotkit_interrupt",
OnCopilotKitError = "on_copilotkit_error",
}
export enum MetaEventNames {
LangGraphInterruptEvent = "LangGraphInterruptEvent",
CopilotKitLangGraphInterruptEvent = "CopilotKitLangGraphInterruptEvent",
}
export enum CustomEventNames {
CopilotKitManuallyEmitMessage = "copilotkit_manually_emit_message",
CopilotKitManuallyEmitToolCall = "copilotkit_manually_emit_tool_call",
CopilotKitManuallyEmitIntermediateState = "copilotkit_manually_emit_intermediate_state",
CopilotKitExit = "copilotkit_exit",
}
type LangGraphOnCopilotKitStateSyncEvent = {
event: LangGraphEventTypes.OnCopilotKitStateSync;
thread_id: string;
agent_name: string;
node_name: string;
run_id: string;
active: boolean;
role: string;
state: any;
running: boolean;
};
type LangGraphOnChainStartEvent = {
event: LangGraphEventTypes.OnChainStart;
run_id: string;
name: string;
tags: string[];
metadata: { thread_id: string };
data: {
input: any;
};
parent_ids: string[];
};
type LangGraphOnChainEndEvent = {
event: LangGraphEventTypes.OnChainEnd;
name: string;
run_id: string;
tags: string[];
metadata: {
thread_id: string;
langgraph_step: number;
langgraph_node: string;
langgraph_triggers: string[];
langgraph_task_idx: number;
thread_ts: string;
};
data: {
input: any;
output: any;
};
parent_ids: string[];
};
type LangGraphOnChatModelStartEvent = {
event: LangGraphEventTypes.OnChatModelStart;
name: string;
run_id: string;
tags: string[];
metadata: {
thread_id: string;
langgraph_step: number;
langgraph_node: string;
langgraph_triggers: string[];
langgraph_task_idx: number;
thread_ts: string;
ls_provider: string;
ls_model_name: string;
ls_model_type: string;
ls_temperature: number;
};
data: {
input: {
messages: {
lc: number;
type: string;
id: string[];
kwargs: {
content: string;
type: string;
id: string;
};
}[][];
};
};
parent_ids: string[];
};
type LangGraphOnChatModelStreamEvent = {
event: LangGraphEventTypes.OnChatModelStream;
name: string;
run_id: string;
tags: string[];
metadata: {
thread_id: string;
langgraph_step: number;
langgraph_node: string;
langgraph_triggers: string[];
langgraph_task_idx: number;
thread_ts: string;
ls_provider: string;
ls_model_name: string;
ls_model_type: string;
ls_temperature: number;
};
data: {
chunk: {
lc: number;
type: string;
id: string;
kwargs: {
content: string | { text: string; type: string; index: number }[];
additional_kwargs: {
tool_calls: {
index: number;
id: string;
function: { arguments: string; name: string };
type: string;
}[];
};
type: string;
id: string;
tool_calls: { name: string; args: {}; id: string; type: string }[];
tool_call_chunks: {
name: string;
args: string;
id: string;
index: number;
type: string;
}[];
invalid_tool_calls: any[];
};
};
};
parent_ids: string[];
};
type LangGraphOnChatModelEndEvent = {
event: LangGraphEventTypes.OnChatModelEnd;
name: string;
run_id: string;
tags: string[];
metadata: {
thread_id: string;
langgraph_step: number;
langgraph_node: string;
langgraph_triggers: string[];
langgraph_task_idx: number;
thread_ts: string;
ls_provider: string;
ls_model_name: string;
ls_model_type: string;
ls_temperature: number;
};
data: {
input: any;
output: {
generations: {
text: string;
generation_info: {
finish_reason: string;
model_name: string;
system_fingerprint: string;
};
type: string;
message: {
lc: number;
type: string;
id: string[];
kwargs: {
content: string;
additional_kwargs: {
tool_calls: {
index: number;
id: string;
function: { arguments: string; name: string };
type: string;
}[];
};
response_metadata: {
finish_reason: string;
model_name: string;
system_fingerprint: string;
};
type: string;
id: string;
tool_calls: {
name: string;
args: { query: string };
id: string;
type: string;
}[];
invalid_tool_calls: any[];
};
};
}[][];
llm_output: any;
run: any;
};
};
parent_ids: string[];
};
type LangGraphOnChainStreamEvent = {
event: LangGraphEventTypes.OnChainStream;
name: string;
run_id: string;
tags: string[];
metadata: {
thread_id: string;
langgraph_step?: number;
langgraph_node?: string;
langgraph_triggers?: string[];
langgraph_task_idx?: number;
thread_ts?: string;
};
data: {
chunk: {
messages: {
lc: number;
type: string;
id: string[];
kwargs: {
content: string;
additional_kwargs?: {
tool_calls?: {
index: number;
id: string;
function: { arguments: string; name: string };
type: string;
}[];
};
response_metadata?: {
finish_reason: string;
model_name: string;
system_fingerprint: string;
};
type: string;
id: string;
tool_calls?: {
name: string;
args: { query: string };
id: string;
type: string;
}[];
invalid_tool_calls?: any[];
};
}[];
};
};
parent_ids: string[];
};
type LangGraphOnToolStartEvent = {
event: LangGraphEventTypes.OnToolStart;
name: string;
run_id: string;
tags: string[];
metadata: {
thread_id: string;
langgraph_step: number;
langgraph_node: string;
langgraph_triggers: string[];
langgraph_task_idx: number;
thread_ts: string;
};
data: {
input: {
query: string;
};
};
parent_ids: string[];
};
type LangGraphOnToolEndEvent = {
event: LangGraphEventTypes.OnToolEnd;
name: string;
run_id: string;
tags: string[];
metadata: {
thread_id: string;
langgraph_step: number;
langgraph_node: string;
langgraph_triggers: string[];
langgraph_task_idx: number;
thread_ts: string;
};
data: {
input: {
query: string;
};
output: {
lc: number;
type: string;
id: string[];
kwargs: {
content: string[];
type: string;
name: string;
tool_call_id: string;
status: string;
};
};
};
parent_ids: string[];
};
type LangGraphOnCustomEvent = {
event: LangGraphEventTypes.OnCustomEvent;
run_id: string;
name: string;
tags: string[];
metadata: {
thread_id: string;
langgraph_step: number;
langgraph_node: string;
langgraph_triggers: string[];
langgraph_path: [string, string];
langgraph_checkpoint_ns: string;
checkpoint_ns: string;
};
data: any;
parent_ids: string[];
};
interface LangGraphInterruptEvent {
event: LangGraphEventTypes.OnInterrupt;
value: string;
}
interface CopilotKitLangGraphInterruptEvent {
event: LangGraphEventTypes.OnCopilotKitInterrupt;
data: {
value: string;
messages: (TextMessage | ActionExecutionMessage | ResultMessage)[];
};
}
interface CopilotKitLangGraphErrorEvent {
event: LangGraphEventTypes.OnCopilotKitError;
data: {
error: {
message: string;
type: string;
agent_name: string;
status_code?: number;
response_data?: any;
};
thread_id: string;
agent_name: string;
node_name: string;
};
}
export type LangGraphEvent =
| LangGraphOnChainStartEvent
| LangGraphOnChainStreamEvent
| LangGraphOnChainEndEvent
| LangGraphOnChatModelStartEvent
| LangGraphOnChatModelStreamEvent
| LangGraphOnChatModelEndEvent
| LangGraphOnToolStartEvent
| LangGraphOnToolEndEvent
| LangGraphOnCopilotKitStateSyncEvent
| LangGraphOnCustomEvent
| LangGraphInterruptEvent
| CopilotKitLangGraphInterruptEvent
| CopilotKitLangGraphErrorEvent;
@@ -0,0 +1,16 @@
import { Field, InputType } from "type-graphql";
import { ActionInputAvailability } from "../types/enums";
@InputType()
export class ActionInput {
@Field(() => String)
name: string;
@Field(() => String)
description: string;
@Field(() => String)
jsonSchema: string;
@Field(() => ActionInputAvailability, { nullable: true })
available?: ActionInputAvailability;
}
@@ -0,0 +1,13 @@
import { Field, InputType } from "type-graphql";
@InputType()
export class AgentSessionInput {
@Field(() => String)
agentName: string;
@Field(() => String, { nullable: true })
threadId?: string;
@Field(() => String, { nullable: true })
nodeName?: string;
}
@@ -0,0 +1,13 @@
import { Field, InputType } from "type-graphql";
@InputType()
export class AgentStateInput {
@Field(() => String)
agentName: string;
@Field(() => String)
state: string;
@Field(() => String, { nullable: true })
config?: string;
}
@@ -0,0 +1,16 @@
import { Field, InputType } from "type-graphql";
@InputType()
export class GuardrailsRuleInput {
@Field(() => [String], { nullable: true })
allowList?: string[] = [];
@Field(() => [String], { nullable: true })
denyList?: string[] = [];
}
@InputType()
export class GuardrailsInput {
@Field(() => GuardrailsRuleInput, { nullable: false })
inputValidationRules: GuardrailsRuleInput;
}
@@ -0,0 +1,8 @@
import { Field, InputType } from "type-graphql";
import { GuardrailsInput } from "./cloud-guardrails.input";
@InputType()
export class CloudInput {
@Field(() => GuardrailsInput, { nullable: true })
guardrails?: GuardrailsInput;
}
@@ -0,0 +1,10 @@
import { Field, InputType } from "type-graphql";
@InputType()
export class ContextPropertyInput {
@Field(() => String)
value: string;
@Field(() => String)
description: string;
}
@@ -0,0 +1,10 @@
import { Field, InputType } from "type-graphql";
@InputType()
export class CopilotContextInput {
@Field(() => String)
description: string;
@Field(() => String)
value: string;
}
@@ -0,0 +1,15 @@
import { Field, InputType, Int, createUnionType } from "type-graphql";
const PrimitiveUnion = createUnionType({
name: "Primitive",
types: () => [String, Number, Boolean] as const,
});
@InputType()
export class CustomPropertyInput {
@Field(() => String)
key: string;
@Field(() => PrimitiveUnion)
value: string;
}
@@ -0,0 +1,21 @@
import { Field, InputType } from "type-graphql";
/**
* The extensions input is used to pass additional information to the copilot runtime, specific to a
* service adapter or agent framework.
*/
@InputType()
export class OpenAIApiAssistantAPIInput {
@Field(() => String, { nullable: true })
runId?: string;
@Field(() => String, { nullable: true })
threadId?: string;
}
@InputType()
export class ExtensionsInput {
@Field(() => OpenAIApiAssistantAPIInput, { nullable: true })
openaiAssistantAPI?: OpenAIApiAssistantAPIInput;
}
@@ -0,0 +1,22 @@
import { Field, InputType } from "type-graphql";
@InputType()
export class ForwardedParametersInput {
@Field(() => String, { nullable: true })
model?: string;
@Field(() => Number, { nullable: true })
maxTokens?: number;
@Field(() => [String], { nullable: true })
stop?: string[];
@Field(() => String, { nullable: true })
toolChoice?: String;
@Field(() => String, { nullable: true })
toolChoiceFunctionName?: string;
@Field(() => Number, { nullable: true })
temperature?: number;
}
@@ -0,0 +1,14 @@
import { Field, InputType } from "type-graphql";
import { ActionInput } from "./action.input";
@InputType()
export class FrontendInput {
@Field(() => String, { nullable: true })
toDeprecate_fullContext?: string;
@Field(() => [ActionInput])
actions: ActionInput[];
@Field(() => String, { nullable: true })
url?: string;
}
@@ -0,0 +1,59 @@
import { Field, InputType } from "type-graphql";
import { MessageInput } from "./message.input";
import { FrontendInput } from "./frontend.input";
import { CloudInput } from "./cloud.input";
import { CopilotRequestType } from "../types/enums";
import { ForwardedParametersInput } from "./forwarded-parameters.input";
import { AgentSessionInput } from "./agent-session.input";
import { AgentStateInput } from "./agent-state.input";
import { ExtensionsInput } from "./extensions.input";
import { MetaEventInput } from "./meta-event.input";
import { CopilotContextInput } from "./copilot-context.input";
@InputType()
export class GenerateCopilotResponseMetadataInput {
@Field(() => CopilotRequestType, { nullable: true })
requestType: CopilotRequestType;
}
@InputType()
export class GenerateCopilotResponseInput {
@Field(() => GenerateCopilotResponseMetadataInput, { nullable: false })
metadata: GenerateCopilotResponseMetadataInput;
@Field(() => String, { nullable: true })
threadId?: string;
@Field(() => String, { nullable: true })
runId?: string;
@Field(() => [MessageInput])
messages: MessageInput[];
@Field(() => FrontendInput)
frontend: FrontendInput;
@Field(() => CloudInput, { nullable: true })
cloud?: CloudInput;
@Field(() => ForwardedParametersInput, { nullable: true })
forwardedParameters?: ForwardedParametersInput;
@Field(() => AgentSessionInput, { nullable: true })
agentSession?: AgentSessionInput;
@Field(() => AgentStateInput, { nullable: true })
agentState?: AgentStateInput;
@Field(() => [AgentStateInput], { nullable: true })
agentStates?: AgentStateInput[];
@Field(() => ExtensionsInput, { nullable: true })
extensions?: ExtensionsInput;
@Field(() => [MetaEventInput], { nullable: true })
metaEvents?: MetaEventInput[];
@Field(() => [CopilotContextInput], { nullable: true })
context?: CopilotContextInput[];
}
@@ -0,0 +1,10 @@
import { Field, InputType } from "type-graphql";
@InputType()
export class LoadAgentStateInput {
@Field(() => String)
threadId: string;
@Field(() => String)
agentName: string;
}
@@ -0,0 +1,110 @@
import { Field, InputType } from "type-graphql";
import { MessageRole } from "../types/enums";
import { BaseMessageInput } from "../types/base";
@InputType()
export class TextMessageInput {
@Field(() => String)
content: string;
@Field(() => String, { nullable: true })
parentMessageId?: string;
@Field(() => MessageRole)
role: MessageRole;
}
@InputType()
export class ActionExecutionMessageInput {
@Field(() => String)
name: string;
@Field(() => String)
arguments: string;
@Field(() => String, { nullable: true })
parentMessageId?: string;
@Field(() => String, {
nullable: true,
deprecationReason: "This field will be removed in a future version",
})
scope?: String;
}
@InputType()
export class ResultMessageInput {
@Field(() => String)
actionExecutionId: string;
@Field(() => String)
actionName: string;
@Field(() => String, { nullable: true })
parentMessageId?: string;
@Field(() => String)
result: string;
}
@InputType()
export class AgentStateMessageInput {
@Field(() => String)
threadId: string;
@Field(() => String)
agentName: string;
@Field(() => MessageRole)
role: MessageRole;
@Field(() => String)
state: string;
@Field(() => Boolean)
running: boolean;
@Field(() => String)
nodeName: string;
@Field(() => String)
runId: string;
@Field(() => Boolean)
active: boolean;
}
@InputType()
export class ImageMessageInput {
@Field(() => String)
format: string;
@Field(() => String)
bytes: string;
@Field(() => String, { nullable: true })
parentMessageId?: string;
@Field(() => MessageRole)
role: MessageRole;
}
// GraphQL does not support union types in inputs, so we need to use
// optional fields for the different subtypes.
@InputType()
export class MessageInput extends BaseMessageInput {
@Field(() => TextMessageInput, { nullable: true })
textMessage?: TextMessageInput;
@Field(() => ActionExecutionMessageInput, { nullable: true })
actionExecutionMessage?: ActionExecutionMessageInput;
@Field(() => ResultMessageInput, { nullable: true })
resultMessage?: ResultMessageInput;
@Field(() => AgentStateMessageInput, { nullable: true })
agentStateMessage?: AgentStateMessageInput;
@Field(() => ImageMessageInput, { nullable: true })
imageMessage?: ImageMessageInput;
}
@@ -0,0 +1,18 @@
import { Field, InputType } from "type-graphql";
import { MetaEventName } from "../types/meta-events.type";
import { MessageInput } from "./message.input";
@InputType()
export class MetaEventInput {
@Field(() => MetaEventName)
name: MetaEventName;
@Field(() => String)
value?: string;
@Field(() => String, { nullable: true })
response?: string;
@Field(() => [MessageInput], { nullable: true })
messages?: MessageInput[];
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,391 @@
import * as gql from "../types/converted/index";
import { MessageRole } from "../types/enums";
import * as agui from "@copilotkit/shared"; // named agui for clarity, but this only includes agui message types
// Helper function to extract agent name from message
function extractAgentName(message: agui.Message): string {
if (message.role !== "assistant") {
throw new Error(
`Cannot extract agent name from message with role ${message.role}`,
);
}
return message.agentName || "unknown";
}
// Type guard for agent state message
function isAgentStateMessage(message: agui.Message): boolean {
return (
message.role === "assistant" && "agentName" in message && "state" in message
);
}
// Type guard for messages with image property
function hasImageProperty(
message: agui.Message,
): message is agui.Message & { image: agui.ImageData } {
const canContainImage =
message.role === "assistant" || message.role === "user";
if (!canContainImage) {
return false;
}
const image: { format?: string; bytes?: string } | undefined =
"image" in message ? message.image : undefined;
if (image === undefined) {
return false;
}
const isMalformed = image.format === undefined || image.bytes === undefined;
if (isMalformed) {
return false;
}
return true;
}
function normalizeMessageContent(content: agui.Message["content"]): string {
if (typeof content === "string" || typeof content === "undefined") {
return content || "";
}
if (Array.isArray(content)) {
return content
.map((part) => {
if (part?.type === "text") {
return part.text;
}
if (part?.type === "binary") {
return (
part.data ||
part.url ||
part.filename ||
`[binary:${part.mimeType}]`
);
}
return "";
})
.filter(Boolean)
.join("\n");
}
if (content && typeof content === "object") {
try {
return JSON.stringify(content);
} catch (error) {
console.warn("Failed to serialize message content", error);
}
}
return String(content ?? "");
}
/*
----------------------------
AGUI Message -> GQL Message
----------------------------
*/
export function aguiToGQL(
messages: agui.Message[] | agui.Message,
actions?: Record<string, any>,
coAgentStateRenders?: Record<string, any>,
): gql.Message[] {
const gqlMessages: gql.Message[] = [];
messages = Array.isArray(messages) ? messages : [messages];
// Track tool call names by their IDs for use in result messages
const toolCallNames: Record<string, string> = {};
for (const message of messages) {
// Agent state message support
if (isAgentStateMessage(message)) {
const agentName = extractAgentName(message);
const state = "state" in message && message.state ? message.state : {};
gqlMessages.push(
new gql.AgentStateMessage({
id: message.id,
agentName,
state,
role: gql.Role.assistant,
}),
);
// Optionally preserve render function
if (
"generativeUI" in message &&
message.generativeUI &&
coAgentStateRenders
) {
coAgentStateRenders[agentName] = {
name: agentName,
render: message.generativeUI,
};
}
continue;
}
if (hasImageProperty(message)) {
gqlMessages.push(aguiMessageWithImageToGQLMessage(message));
continue;
}
// Action execution message support
if (message.role === "assistant" && message.toolCalls) {
gqlMessages.push(aguiTextMessageToGQLMessage(message));
for (const toolCall of message.toolCalls) {
// Track the tool call name by its ID
toolCallNames[toolCall.id] = toolCall.function.name;
const actionExecMsg = aguiToolCallToGQLActionExecution(
toolCall,
message.id,
);
// Preserve render function in actions context
if ("generativeUI" in message && message.generativeUI && actions) {
const actionName = toolCall.function.name;
// Check for specific action first, then wild card action
const specificAction = Object.values(actions).find(
(action: any) => action.name === actionName,
);
const wildcardAction = Object.values(actions).find(
(action: any) => action.name === "*",
);
// Assign render function to the matching action (specific takes priority)
if (specificAction) {
specificAction.render = message.generativeUI;
} else if (wildcardAction) {
wildcardAction.render = message.generativeUI;
}
}
gqlMessages.push(actionExecMsg);
}
continue;
}
// Reasoning messages are ephemeral display-only content with no GQL equivalent — skip them
if (message.role === "reasoning") {
continue;
}
// Regular text messages
if (
message.role === "developer" ||
message.role === "system" ||
message.role === "assistant" ||
message.role === "user"
) {
gqlMessages.push(aguiTextMessageToGQLMessage(message));
continue;
}
// Tool result message
if (message.role === "tool") {
gqlMessages.push(
aguiToolMessageToGQLResultMessage(message, toolCallNames),
);
continue;
}
throw new Error(
`Unknown message role: "${(message as any).role}" in message with id: ${(message as any).id}`,
);
}
return gqlMessages;
}
export function aguiTextMessageToGQLMessage(
message: agui.Message,
): gql.TextMessage {
if (
message.role !== "developer" &&
message.role !== "system" &&
message.role !== "assistant" &&
message.role !== "user"
) {
throw new Error(
`Cannot convert message with role ${message.role} to TextMessage`,
);
}
let roleValue: MessageRole;
if (message.role === "developer") {
roleValue = gql.Role.developer;
} else if (message.role === "system") {
roleValue = gql.Role.system;
} else if (message.role === "assistant") {
roleValue = gql.Role.assistant;
} else {
roleValue = gql.Role.user;
}
return new gql.TextMessage({
id: message.id,
content: normalizeMessageContent(message.content),
role: roleValue,
});
}
export function aguiToolCallToGQLActionExecution(
toolCall: agui.ToolCall,
parentMessageId: string,
): gql.ActionExecutionMessage {
if (toolCall.type !== "function") {
throw new Error(`Unsupported tool call type: ${toolCall.type}`);
}
// Handle arguments - they should be a JSON string in AGUI format,
// but we need to convert them to an object for GQL format
let argumentsObj: any;
if (typeof toolCall.function.arguments === "string") {
// Expected case: arguments is a JSON string
try {
argumentsObj = JSON.parse(toolCall.function.arguments);
} catch {
console.warn(
`[CopilotKit] Failed to parse tool arguments, falling back to empty object`,
);
// Provide fallback empty object to prevent application crash
argumentsObj = {};
}
} else if (
typeof toolCall.function.arguments === "object" &&
toolCall.function.arguments !== null
) {
// Backward compatibility: arguments is already an object
argumentsObj = toolCall.function.arguments;
} else {
// Fallback for undefined, null, or other types
console.warn(
`[CopilotKit] Tool arguments parsed to non-object (${typeof toolCall.function.arguments}), falling back to empty object`,
);
argumentsObj = {};
}
// Guard against successfully parsed non-object values (e.g. JSON.parse('""') → "")
if (
typeof argumentsObj !== "object" ||
argumentsObj === null ||
Array.isArray(argumentsObj)
) {
console.warn(
`[CopilotKit] Tool arguments parsed to non-object (${typeof argumentsObj}), falling back to empty object`,
);
argumentsObj = {};
}
// Always include name and arguments
return new gql.ActionExecutionMessage({
id: toolCall.id,
name: toolCall.function.name,
arguments: argumentsObj,
parentMessageId: parentMessageId,
});
}
export function aguiToolMessageToGQLResultMessage(
message: agui.Message,
toolCallNames: Record<string, string>,
): gql.ResultMessage {
if (message.role !== "tool") {
throw new Error(
`Cannot convert message with role ${message.role} to ResultMessage`,
);
}
if (!message.toolCallId) {
throw new Error("Tool message must have a toolCallId");
}
const actionName = toolCallNames[message.toolCallId] || "unknown";
// Handle result content - it could be a string or an object that needs serialization
let resultContent: string;
const messageContent = message.content || "";
if (typeof messageContent === "string") {
// Expected case: content is already a string
resultContent = messageContent;
} else if (typeof messageContent === "object" && messageContent !== null) {
// Handle case where content is an object that needs to be serialized
try {
resultContent = JSON.stringify(messageContent);
} catch (error) {
console.warn(`Failed to stringify tool result for ${actionName}:`, error);
resultContent = String(messageContent);
}
} else {
// Handle other types (number, boolean, etc.)
resultContent = String(messageContent);
}
return new gql.ResultMessage({
id: message.id,
result: resultContent,
actionExecutionId: message.toolCallId,
actionName: message.toolName || actionName,
});
}
// New function to handle AGUI messages with render functions
export function aguiMessageWithRenderToGQL(
message: agui.Message,
actions?: Record<string, any>,
coAgentStateRenders?: Record<string, any>,
): gql.Message[] {
// Handle the special case: assistant messages with render function but no tool calls
if (
message.role === "assistant" &&
"generativeUI" in message &&
message.generativeUI &&
!message.toolCalls
) {
const gqlMessages: gql.Message[] = [];
gqlMessages.push(
new gql.AgentStateMessage({
id: message.id,
agentName: "unknown",
state: {},
role: gql.Role.assistant,
}),
);
if (coAgentStateRenders) {
coAgentStateRenders.unknown = {
name: "unknown",
render: message.generativeUI,
};
}
return gqlMessages;
}
// For all other cases, delegate to aguiToGQL
return aguiToGQL([message], actions, coAgentStateRenders);
}
export function aguiMessageWithImageToGQLMessage(
message: agui.Message,
): gql.ImageMessage {
if (!hasImageProperty(message)) {
throw new Error(
`Cannot convert message to ImageMessage: missing format or bytes`,
);
}
let roleValue: MessageRole;
if (message.role === "assistant") {
roleValue = gql.Role.assistant;
} else {
roleValue = gql.Role.user;
}
if (message.role !== "assistant" && message.role !== "user") {
throw new Error(
`Cannot convert message with role ${message.role} to ImageMessage`,
);
}
return new gql.ImageMessage({
id: message.id,
format: message.image.format,
bytes: message.image.bytes,
role: roleValue,
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,297 @@
import * as gql from "../types/converted/index";
import * as agui from "@copilotkit/shared";
import { MessageStatusCode } from "../types/message-status.type";
// Define valid image formats based on the supported formats in the codebase
const VALID_IMAGE_FORMATS = ["jpeg", "png", "webp", "gif"] as const;
type ValidImageFormat = (typeof VALID_IMAGE_FORMATS)[number];
// Validation function for image format
function validateImageFormat(format: string): format is ValidImageFormat {
return VALID_IMAGE_FORMATS.includes(format as ValidImageFormat);
}
/*
----------------------------
GQL Message -> AGUI Message
----------------------------
*/
export function gqlToAGUI(
messages: gql.Message[] | gql.Message,
actions?: Record<string, any>,
coAgentStateRenders?: Record<string, any>,
): agui.Message[] {
let aguiMessages: agui.Message[] = [];
messages = Array.isArray(messages) ? messages : [messages];
// Create a map of action execution ID to result for completed actions
const actionResults = new Map<string, string>();
for (const message of messages) {
if (message.isResultMessage()) {
actionResults.set(message.actionExecutionId, message.result);
}
}
for (const message of messages) {
if (message.isTextMessage()) {
aguiMessages.push(gqlTextMessageToAGUIMessage(message));
} else if (message.isResultMessage()) {
aguiMessages.push(gqlResultMessageToAGUIMessage(message));
} else if (message.isActionExecutionMessage()) {
aguiMessages.push(
gqlActionExecutionMessageToAGUIMessage(message, actions, actionResults),
);
} else if (message.isAgentStateMessage()) {
aguiMessages.push(
gqlAgentStateMessageToAGUIMessage(message, coAgentStateRenders),
);
} else if (message.isImageMessage()) {
aguiMessages.push(gqlImageMessageToAGUIMessage(message));
} else {
throw new Error("Unknown message type");
}
}
return aguiMessages;
}
export function gqlActionExecutionMessageToAGUIMessage(
message: gql.ActionExecutionMessage,
actions?: Record<string, any>,
actionResults?: Map<string, string>,
): agui.Message {
// Check if we have actions and if there's a specific action or wild card action
const hasSpecificAction =
actions &&
Object.values(actions).some((action: any) => action.name === message.name);
const hasWildcardAction =
actions &&
Object.values(actions).some((action: any) => action.name === "*");
if (!actions || (!hasSpecificAction && !hasWildcardAction)) {
return {
id: message.id,
role: "assistant",
toolCalls: [actionExecutionMessageToAGUIMessage(message)],
name: message.name,
};
}
// Find the specific action first, then fall back to wild card action
const action =
Object.values(actions).find(
(action: any) => action.name === message.name,
) || Object.values(actions).find((action: any) => action.name === "*");
// Create render function wrapper that provides proper props
const createRenderWrapper = (originalRender: any) => {
if (!originalRender) return undefined;
return (props?: any) => {
// Determine the correct status based on the same logic as RenderActionExecutionMessage
let actionResult: any = actionResults?.get(message.id);
let status: "inProgress" | "executing" | "complete" = "inProgress";
if (actionResult !== undefined) {
status = "complete";
} else if (message.status?.code !== MessageStatusCode.Pending) {
status = "executing";
}
// if props.result is a string, parse it as JSON but don't throw an error if it's not valid JSON
if (typeof props?.result === "string") {
try {
props.result = JSON.parse(props.result);
} catch (e) {
/* do nothing */
}
}
// if actionResult is a string, parse it as JSON but don't throw an error if it's not valid JSON
if (typeof actionResult === "string") {
try {
actionResult = JSON.parse(actionResult);
} catch (e) {
/* do nothing */
}
}
// Base props that all actions receive
const baseProps = {
status: props?.status || status,
args: message.arguments || {},
result: props?.result || actionResult || undefined,
messageId: message.id,
};
// Add properties based on action type
if (action.name === "*") {
// Wildcard actions get the tool name; ensure it cannot be overridden by incoming props
return originalRender({
...baseProps,
...props,
name: message.name,
});
} else {
// Regular actions get respond (defaulting to a no-op if not provided)
const respond = props?.respond ?? (() => {});
return originalRender({
...baseProps,
...props,
respond,
});
}
};
};
return {
id: message.id,
role: "assistant",
content: "",
toolCalls: [actionExecutionMessageToAGUIMessage(message)],
generativeUI: createRenderWrapper(action.render),
name: message.name,
} as agui.AIMessage;
}
function gqlAgentStateMessageToAGUIMessage(
message: gql.AgentStateMessage,
coAgentStateRenders?: Record<string, any>,
): agui.Message {
if (
coAgentStateRenders &&
Object.values(coAgentStateRenders).some(
(render: any) => render.name === message.agentName,
)
) {
const render = Object.values(coAgentStateRenders).find(
(render: any) => render.name === message.agentName,
);
// Create render function wrapper that provides proper props
const createRenderWrapper = (originalRender: any) => {
if (!originalRender) return undefined;
return (props?: any) => {
// Determine the correct status based on the same logic as RenderActionExecutionMessage
const state = message.state;
// Provide the full props structure that the render function expects
const renderProps = {
state: state,
};
return originalRender(renderProps);
};
};
return {
id: message.id,
role: "assistant",
generativeUI: createRenderWrapper(render.render),
agentName: message.agentName,
state: message.state,
};
}
return {
id: message.id,
role: "assistant",
agentName: message.agentName,
state: message.state,
};
}
function actionExecutionMessageToAGUIMessage(
actionExecutionMessage: gql.ActionExecutionMessage,
): agui.ToolCall {
return {
id: actionExecutionMessage.id,
function: {
name: actionExecutionMessage.name,
arguments: JSON.stringify(actionExecutionMessage.arguments),
},
type: "function",
};
}
export function gqlTextMessageToAGUIMessage(
message: gql.TextMessage,
): agui.Message {
switch (message.role) {
case gql.Role.developer:
return {
id: message.id,
role: "developer",
content: message.content,
};
case gql.Role.system:
return {
id: message.id,
role: "system",
content: message.content,
};
case gql.Role.assistant:
return {
id: message.id,
role: "assistant",
content: message.content,
};
case gql.Role.user:
return {
id: message.id,
role: "user",
content: message.content,
};
default:
throw new Error("Unknown message role");
}
}
export function gqlResultMessageToAGUIMessage(
message: gql.ResultMessage,
): agui.Message {
return {
id: message.id,
role: "tool",
content: message.result,
toolCallId: message.actionExecutionId,
toolName: message.actionName,
};
}
export function gqlImageMessageToAGUIMessage(
message: gql.ImageMessage,
): agui.Message {
// Validate image format
if (!validateImageFormat(message.format)) {
throw new Error(
`Invalid image format: ${message.format}. Supported formats are: ${VALID_IMAGE_FORMATS.join(", ")}`,
);
}
// Validate that bytes is a non-empty string
if (
!message.bytes ||
typeof message.bytes !== "string" ||
message.bytes.trim() === ""
) {
throw new Error("Image bytes must be a non-empty string");
}
// Determine the role based on the message role
const role = message.role === gql.Role.assistant ? "assistant" : "user";
// Create the image message with proper typing
const imageMessage: agui.Message = {
id: message.id,
role,
content: "",
image: {
format: message.format,
bytes: message.bytes,
},
};
return imageMessage;
}
@@ -0,0 +1,2 @@
export * from "./agui-to-gql";
export * from "./gql-to-agui";
@@ -0,0 +1,561 @@
import { vi } from "vitest";
import * as gql from "../types/converted/index";
import * as agui from "@copilotkit/shared";
import { aguiToGQL } from "./agui-to-gql";
import { gqlToAGUI } from "./gql-to-agui";
// Helper to strip functions for deep equality
function stripFunctions(obj: any): any {
if (typeof obj === "function") return undefined;
if (Array.isArray(obj)) return obj.map(stripFunctions);
if (obj && typeof obj === "object") {
const out: any = {};
for (const k in obj) {
if (typeof obj[k] !== "function") {
out[k] = stripFunctions(obj[k]);
}
}
return out;
}
return obj;
}
describe("roundtrip message conversion", () => {
test("text message AGUI -> GQL -> AGUI", () => {
const aguiMsg: agui.Message = {
id: "user-1",
role: "user",
content: "Hello!",
};
const gqlMsgs = aguiToGQL(aguiMsg);
const aguiMsgs2 = gqlToAGUI(gqlMsgs);
expect(stripFunctions(aguiMsgs2[0])).toEqual(stripFunctions(aguiMsg));
});
test("text message GQL -> AGUI -> GQL", () => {
const gqlMsg = new gql.TextMessage({
id: "assistant-1",
content: "Hi!",
role: gql.Role.assistant,
});
const aguiMsgs = gqlToAGUI(gqlMsg);
const gqlMsgs2 = aguiToGQL(aguiMsgs);
// Should be equivalent in content, id, and role
expect(gqlMsgs2[0].id).toBe(gqlMsg.id);
expect((gqlMsgs2[0] as any).content).toBe(gqlMsg.content);
expect((gqlMsgs2[0] as any).role).toBe(gqlMsg.role);
});
test("tool message AGUI -> GQL -> AGUI", () => {
const aguiMsg: agui.Message = {
id: "tool-1",
role: "tool",
content: "Tool result",
toolCallId: "tool-call-1",
toolName: "testAction",
};
const gqlMsgs = aguiToGQL(aguiMsg);
const aguiMsgs2 = gqlToAGUI(gqlMsgs);
expect(stripFunctions(aguiMsgs2[0])).toEqual(stripFunctions(aguiMsg));
});
test("tool message GQL -> AGUI -> GQL", () => {
const gqlMsg = new gql.ResultMessage({
id: "tool-1",
result: "Tool result",
actionExecutionId: "tool-call-1",
actionName: "testAction",
});
const aguiMsgs = gqlToAGUI(gqlMsg);
const gqlMsgs2 = aguiToGQL(aguiMsgs);
expect(gqlMsgs2[0].id).toBe(gqlMsg.id);
expect((gqlMsgs2[0] as any).result).toBe(gqlMsg.result);
expect((gqlMsgs2[0] as any).actionExecutionId).toBe(
gqlMsg.actionExecutionId,
);
});
test("action execution AGUI -> GQL -> AGUI", () => {
const aguiMsg: agui.Message = {
id: "assistant-1",
role: "assistant",
content: "Running action",
toolCalls: [
{
id: "tool-call-1",
type: "function",
function: {
name: "doSomething",
arguments: JSON.stringify({ foo: "bar" }),
},
},
],
};
const gqlMsgs = aguiToGQL(aguiMsg);
const aguiMsgs2 = gqlToAGUI(gqlMsgs);
// Should have an assistant message and an action execution message
expect(aguiMsgs2[0].role).toBe("assistant");
expect(aguiMsgs2[1].role).toBe("assistant");
// Only check toolCalls if present
if ("toolCalls" in aguiMsgs2[1]) {
expect((aguiMsgs2[1] as any).toolCalls[0].function.name).toBe(
"doSomething",
);
}
});
test("action execution GQL -> AGUI -> GQL", () => {
const actionExecMsg = new gql.ActionExecutionMessage({
id: "tool-call-1",
name: "doSomething",
arguments: { foo: "bar" },
parentMessageId: "assistant-1",
});
const aguiMsgs = gqlToAGUI([actionExecMsg]);
const gqlMsgs2 = aguiToGQL(aguiMsgs);
// The ActionExecutionMessage is at index 1, not index 0
expect(gqlMsgs2[1].id).toBe("tool-call-1");
// The name should be extracted from the toolCall function name
expect((gqlMsgs2[1] as any).name).toBe("doSomething");
expect((gqlMsgs2[1] as any).arguments).toEqual({ foo: "bar" });
});
test("agent state GQL -> AGUI -> GQL", () => {
const agentStateMsg = new gql.AgentStateMessage({
id: "agent-state-1",
agentName: "testAgent",
state: { status: "running" },
role: gql.Role.assistant,
});
const aguiMsgs = gqlToAGUI([agentStateMsg]);
const gqlMsgs2 = aguiToGQL(aguiMsgs);
expect(gqlMsgs2[0].id).toBe("agent-state-1");
// The agentName should be preserved in the roundtrip
expect((gqlMsgs2[0] as any).agentName).toBe("testAgent");
});
test("action execution with render function roundtrip", () => {
const mockRender = vi.fn();
const aguiMsg: agui.Message = {
id: "assistant-1",
role: "assistant",
content: "Running action",
toolCalls: [
{
id: "tool-call-1",
type: "function",
function: {
name: "doSomething",
arguments: JSON.stringify({ foo: "bar" }),
},
},
],
generativeUI: mockRender,
};
const actions: Record<string, any> = {
doSomething: { name: "doSomething" },
};
const gqlMsgs = aguiToGQL(aguiMsg, actions);
const aguiMsgs2 = gqlToAGUI(gqlMsgs, actions);
// The render function should be preserved in actions context
expect(typeof actions.doSomething.render).toBe("function");
// The roundtripped message should have the same tool call
if ("toolCalls" in aguiMsgs2[1]) {
expect((aguiMsgs2[1] as any).toolCalls[0].function.name).toBe(
"doSomething",
);
}
});
test("image message GQL -> AGUI -> GQL", () => {
const gqlMsg = new gql.ImageMessage({
id: "img-1",
format: "jpeg",
bytes: "somebase64string",
role: gql.Role.user,
});
const aguiMsgs = gqlToAGUI(gqlMsg);
const gqlMsgs2 = aguiToGQL(aguiMsgs);
expect(gqlMsgs2[0].id).toBe(gqlMsg.id);
expect((gqlMsgs2[0] as any).format).toBe(gqlMsg.format);
expect((gqlMsgs2[0] as any).bytes).toBe(gqlMsg.bytes);
expect((gqlMsgs2[0] as any).role).toBe(gqlMsg.role);
});
test("image message AGUI -> GQL -> AGUI (assistant and user)", () => {
// Assistant image message
const aguiAssistantImageMsg: agui.Message = {
id: "img-assistant-1",
role: "assistant",
image: {
format: "jpeg",
bytes: "assistantbase64data",
},
content: "", // required for type
};
const gqlAssistantMsgs = aguiToGQL(aguiAssistantImageMsg);
const aguiAssistantMsgs2 = gqlToAGUI(gqlAssistantMsgs);
expect(aguiAssistantMsgs2[0].id).toBe(aguiAssistantImageMsg.id);
expect(aguiAssistantMsgs2[0].role).toBe("assistant");
expect((aguiAssistantMsgs2[0] as any).image.format).toBe("jpeg");
expect((aguiAssistantMsgs2[0] as any).image.bytes).toBe(
"assistantbase64data",
);
// User image message
const aguiUserImageMsg = {
id: "img-user-1",
role: "user",
image: {
format: "png",
bytes: "userbase64data",
},
content: "", // required for type
} as agui.Message;
const gqlUserMsgs = aguiToGQL(aguiUserImageMsg);
const aguiUserMsgs2 = gqlToAGUI(gqlUserMsgs);
expect(aguiUserMsgs2[0].id).toBe(aguiUserImageMsg.id);
expect(aguiUserMsgs2[0].role).toBe("user");
expect((aguiUserMsgs2[0] as any).image.format).toBe("png");
expect((aguiUserMsgs2[0] as any).image.bytes).toBe("userbase64data");
});
test("wild card action roundtrip conversion", () => {
const mockRender = vi.fn(
(props) => `Wildcard rendered: ${props.args.test}`,
);
const aguiMsg: agui.Message = {
id: "assistant-wildcard-1",
role: "assistant",
content: "Running wild card action",
toolCalls: [
{
id: "tool-call-wildcard-1",
type: "function",
function: {
name: "unknownAction",
arguments: JSON.stringify({ test: "wildcard-value" }),
},
},
],
generativeUI: mockRender,
};
const actions: Record<string, any> = {
"*": { name: "*" },
};
// AGUI -> GQL -> AGUI roundtrip
const gqlMsgs = aguiToGQL(aguiMsg, actions);
const aguiMsgs2 = gqlToAGUI(gqlMsgs, actions);
// Verify the wild card action preserved the render function
expect(typeof actions["*"].render).toBe("function");
expect(actions["*"].render).toBe(mockRender);
// Verify the roundtripped message structure
expect(aguiMsgs2).toHaveLength(2);
expect(aguiMsgs2[0].role).toBe("assistant");
expect(aguiMsgs2[1].role).toBe("assistant");
// Check that the tool call is preserved
if ("toolCalls" in aguiMsgs2[1]) {
expect((aguiMsgs2[1] as any).toolCalls[0].function.name).toBe(
"unknownAction",
);
expect((aguiMsgs2[1] as any).toolCalls[0].function.arguments).toBe(
'{"test":"wildcard-value"}',
);
}
});
test("wild card action with specific action priority roundtrip", () => {
const mockRender = vi.fn(
(props) => `Specific action rendered: ${props.args.test}`,
);
const aguiMsg: agui.Message = {
id: "assistant-priority-1",
role: "assistant",
content: "Running specific action",
toolCalls: [
{
id: "tool-call-priority-1",
type: "function",
function: {
name: "specificAction",
arguments: JSON.stringify({ test: "specific-value" }),
},
},
],
generativeUI: mockRender,
};
const actions: Record<string, any> = {
specificAction: { name: "specificAction" },
"*": { name: "*" },
};
// AGUI -> GQL -> AGUI roundtrip
const gqlMsgs = aguiToGQL(aguiMsg, actions);
const aguiMsgs2 = gqlToAGUI(gqlMsgs, actions);
// Verify the specific action preserved the render function (not wild card)
expect(typeof actions.specificAction.render).toBe("function");
expect(actions.specificAction.render).toBe(mockRender);
expect(actions["*"].render).toBeUndefined();
// Verify the roundtripped message structure
expect(aguiMsgs2).toHaveLength(2);
expect(aguiMsgs2[0].role).toBe("assistant");
expect(aguiMsgs2[1].role).toBe("assistant");
// Check that the tool call is preserved
if ("toolCalls" in aguiMsgs2[1]) {
expect((aguiMsgs2[1] as any).toolCalls[0].function.name).toBe(
"specificAction",
);
expect((aguiMsgs2[1] as any).toolCalls[0].function.arguments).toBe(
'{"test":"specific-value"}',
);
}
});
test("wild card action GQL -> AGUI -> GQL roundtrip", () => {
const actionExecMsg = new gql.ActionExecutionMessage({
id: "wildcard-action-1",
name: "unknownAction",
arguments: { test: "wildcard-gql-value" },
parentMessageId: "assistant-1",
});
const actions: Record<string, any> = {
"*": {
name: "*",
render: vi.fn((props) => `GQL wildcard rendered: ${props.args.test}`),
},
};
// GQL -> AGUI -> GQL roundtrip
const aguiMsgs = gqlToAGUI([actionExecMsg], actions);
const gqlMsgs2 = aguiToGQL(aguiMsgs, actions);
// When converting ActionExecutionMessage to AGUI and back, we get:
// 1. A TextMessage (assistant message with toolCalls)
// 2. An ActionExecutionMessage (the tool call itself)
expect(gqlMsgs2).toHaveLength(2);
expect(gqlMsgs2[0].id).toBe("wildcard-action-1");
expect((gqlMsgs2[0] as any).role).toBe(gql.Role.assistant);
expect(gqlMsgs2[1].id).toBe("wildcard-action-1");
expect((gqlMsgs2[1] as any).name).toBe("unknownAction");
expect((gqlMsgs2[1] as any).arguments).toEqual({
test: "wildcard-gql-value",
});
});
test("roundtrip conversion with result parsing edge cases", () => {
// Test with a tool result that contains a JSON string
const toolResultMsg: agui.Message = {
id: "tool-result-json",
role: "tool",
content: '{"status": "success", "data": {"value": 42}}',
toolCallId: "tool-call-json",
toolName: "jsonAction",
};
// Convert AGUI -> GQL -> AGUI
const gqlMsgs = aguiToGQL(toolResultMsg);
const aguiMsgs = gqlToAGUI(gqlMsgs);
expect(gqlMsgs).toHaveLength(1);
expect(gqlMsgs[0]).toBeInstanceOf(gql.ResultMessage);
expect((gqlMsgs[0] as any).result).toBe(
'{"status": "success", "data": {"value": 42}}',
);
expect(aguiMsgs).toHaveLength(1);
expect(aguiMsgs[0].role).toBe("tool");
expect(aguiMsgs[0].content).toBe(
'{"status": "success", "data": {"value": 42}}',
);
});
test("roundtrip conversion with object content in tool results", () => {
// Test with a tool result that has object content (edge case)
const toolResultMsg: agui.Message = {
id: "tool-result-object",
role: "tool",
content: { status: "success", data: { value: 42 } } as any,
toolCallId: "tool-call-object",
toolName: "objectAction",
};
// Convert AGUI -> GQL -> AGUI
const gqlMsgs = aguiToGQL(toolResultMsg);
const aguiMsgs = gqlToAGUI(gqlMsgs);
expect(gqlMsgs).toHaveLength(1);
expect(gqlMsgs[0]).toBeInstanceOf(gql.ResultMessage);
expect((gqlMsgs[0] as any).result).toBe(
'{"status":"success","data":{"value":42}}',
);
expect(aguiMsgs).toHaveLength(1);
expect(aguiMsgs[0].role).toBe("tool");
expect(aguiMsgs[0].content).toBe(
'{"status":"success","data":{"value":42}}',
);
});
test("roundtrip conversion with action execution and result parsing", () => {
const mockRender = vi.fn(
(props) => `Rendered: ${JSON.stringify(props.result)}`,
);
// Create action execution message
const actionExecMsg = new gql.ActionExecutionMessage({
id: "action-with-result",
name: "testAction",
arguments: { input: "test-value" },
parentMessageId: "parent-result",
});
// Create result message
const resultMsg = new gql.ResultMessage({
id: "result-with-json",
result: '{"output": "processed", "count": 5}',
actionExecutionId: "action-with-result",
actionName: "testAction",
});
const actions = {
testAction: {
name: "testAction",
render: mockRender,
},
};
// Convert GQL -> AGUI
const aguiMsgs = gqlToAGUI([actionExecMsg, resultMsg], actions);
// The action execution should have a generativeUI function that parses string results
expect(aguiMsgs).toHaveLength(2);
expect(aguiMsgs[0].role).toBe("assistant");
expect("generativeUI" in aguiMsgs[0]).toBe(true);
expect(aguiMsgs[1].role).toBe("tool");
expect(aguiMsgs[1].content).toBe('{"output": "processed", "count": 5}');
// Test that the render function receives parsed results
if ("generativeUI" in aguiMsgs[0] && aguiMsgs[0].generativeUI) {
aguiMsgs[0].generativeUI({ result: '{"parsed": true}' });
expect(mockRender).toHaveBeenCalledWith(
expect.objectContaining({
result: { parsed: true }, // Should be parsed from string
}),
);
}
// Convert back AGUI -> GQL
const gqlMsgs2 = aguiToGQL(aguiMsgs, actions);
// Should have 3 messages: TextMessage, ActionExecutionMessage, ResultMessage
expect(gqlMsgs2).toHaveLength(3);
expect(gqlMsgs2[0]).toBeInstanceOf(gql.TextMessage);
expect(gqlMsgs2[1]).toBeInstanceOf(gql.ActionExecutionMessage);
expect(gqlMsgs2[2]).toBeInstanceOf(gql.ResultMessage);
// Check that arguments roundtripped correctly
expect((gqlMsgs2[1] as any).arguments).toEqual({ input: "test-value" });
expect((gqlMsgs2[2] as any).result).toBe(
'{"output": "processed", "count": 5}',
);
});
test("roundtrip conversion verifies correct property distribution for regular actions", () => {
const mockRender = vi.fn(
(props) => `Regular action: ${JSON.stringify(props.args)}`,
);
const actionExecMsg = new gql.ActionExecutionMessage({
id: "regular-action-test",
name: "regularAction",
arguments: { test: "regular-value" },
parentMessageId: "parent-regular",
});
const actions = {
regularAction: {
name: "regularAction",
render: mockRender,
},
};
// GQL -> AGUI -> GQL roundtrip
const aguiMsgs = gqlToAGUI([actionExecMsg], actions);
const gqlMsgs2 = aguiToGQL(aguiMsgs, actions);
// Verify the roundtrip preserved the action
expect(gqlMsgs2).toHaveLength(2);
expect(gqlMsgs2[1]).toBeInstanceOf(gql.ActionExecutionMessage);
expect((gqlMsgs2[1] as any).name).toBe("regularAction");
// Test that regular actions do NOT receive the name property in render props
if ("generativeUI" in aguiMsgs[0] && aguiMsgs[0].generativeUI) {
aguiMsgs[0].generativeUI();
expect(mockRender).toHaveBeenCalledWith(
expect.objectContaining({
args: { test: "regular-value" },
// name property should NOT be present for regular actions
}),
);
// Verify name property is NOT present
const callArgs = mockRender.mock.calls[0][0];
expect(callArgs).not.toHaveProperty("name");
}
});
test("roundtrip conversion verifies correct property distribution for wildcard actions", () => {
const mockRender = vi.fn(
(props) =>
`Wildcard action: ${props.name} with ${JSON.stringify(props.args)}`,
);
const actionExecMsg = new gql.ActionExecutionMessage({
id: "wildcard-action-test",
name: "unknownAction",
arguments: { test: "wildcard-value" },
parentMessageId: "parent-wildcard",
});
const actions = {
"*": {
name: "*",
render: mockRender,
},
};
// GQL -> AGUI -> GQL roundtrip
const aguiMsgs = gqlToAGUI([actionExecMsg], actions);
const gqlMsgs2 = aguiToGQL(aguiMsgs, actions);
// Verify the roundtrip preserved the action
expect(gqlMsgs2).toHaveLength(2);
expect(gqlMsgs2[1]).toBeInstanceOf(gql.ActionExecutionMessage);
expect((gqlMsgs2[1] as any).name).toBe("unknownAction");
// Test that wildcard actions DO receive the name property in render props
if ("generativeUI" in aguiMsgs[0] && aguiMsgs[0].generativeUI) {
aguiMsgs[0].generativeUI();
expect(mockRender).toHaveBeenCalledWith(
expect.objectContaining({
args: { test: "wildcard-value" },
name: "unknownAction", // name property SHOULD be present for wildcard actions
}),
);
// Verify name property IS present
const callArgs = mockRender.mock.calls[0][0];
expect(callArgs).toHaveProperty("name", "unknownAction");
}
});
});
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { resolveMessageId } from "../resolve-message-id";
describe("resolveMessageId (#2118)", () => {
it("preserves a provided non-empty id verbatim", () => {
expect(resolveMessageId("msg-123")).toBe("msg-123");
});
it.each(["", null, undefined] as const)(
"falls back to a generated id when the event id is %p",
(input) => {
const id = resolveMessageId(input);
// randomId() always produces the "ck-<uuid>" shape; the important
// contract for #2118 is that the returned value is a non-empty string,
// never null/undefined.
expect(id).toMatch(/^ck-[0-9a-f-]{36}$/);
},
);
it("generates a fresh id on each fallback call", () => {
const a = resolveMessageId(undefined);
const b = resolveMessageId(undefined);
expect(a).not.toBe(b);
});
});
@@ -0,0 +1,785 @@
import { Arg, Ctx, Mutation, Query, Resolver } from "type-graphql";
import {
ReplaySubject,
Subject,
Subscription,
filter,
finalize,
firstValueFrom,
shareReplay,
skipWhile,
take,
takeWhile,
tap,
} from "rxjs";
import { GenerateCopilotResponseInput } from "../inputs/generate-copilot-response.input";
import { CopilotResponse } from "../types/copilot-response.type";
import {
CopilotKitLangGraphInterruptEvent,
LangGraphInterruptEvent,
} from "../types/meta-events.type";
import { ActionInputAvailability, MessageRole } from "../types/enums";
import { Repeater } from "graphql-yoga";
import type {
CopilotRequestContextProperties,
GraphQLContext,
} from "../../lib/integrations";
import {
RuntimeEvent,
RuntimeEventTypes,
RuntimeMetaEventName,
} from "../../service-adapters/events";
import {
FailedMessageStatus,
MessageStatusCode,
MessageStatusUnion,
SuccessMessageStatus,
} from "../types/message-status.type";
import {
ResponseStatusUnion,
SuccessResponseStatus,
} from "../types/response-status.type";
import { GraphQLJSONObject } from "graphql-scalars";
import { plainToInstance } from "class-transformer";
import { GuardrailsResult } from "../types/guardrails-result.type";
import { GraphQLError } from "graphql";
import {
GuardrailsValidationFailureResponse,
MessageStreamInterruptedResponse,
UnknownErrorResponse,
} from "../../utils";
import {
ActionExecutionMessage,
AgentStateMessage,
Message,
MessageType,
ResultMessage,
TextMessage,
} from "../types/converted";
import telemetry from "../../lib/telemetry-client";
import { randomId } from "@copilotkit/shared";
import { resolveMessageId } from "./resolve-message-id";
import { AgentsResponse } from "../types/agents-response.type";
import { LangGraphEventTypes } from "../../agents/langgraph/events";
import {
CopilotKitError,
CopilotKitLowLevelError,
isStructuredCopilotKitError,
} from "@copilotkit/shared";
import { CopilotRuntime } from "../../lib";
const invokeGuardrails = async ({
baseUrl,
copilotCloudPublicApiKey,
data,
onResult,
onError,
}: {
baseUrl: string;
copilotCloudPublicApiKey: string;
data: GenerateCopilotResponseInput;
onResult: (result: GuardrailsResult) => void;
onError: (err: Error) => void;
}) => {
if (
data.messages.length &&
data.messages[data.messages.length - 1].textMessage?.role ===
MessageRole.user
) {
const messages = data.messages
.filter(
(m) =>
m.textMessage !== undefined &&
(m.textMessage.role === MessageRole.user ||
m.textMessage.role === MessageRole.assistant),
)
.map((m) => ({
role: m.textMessage!.role,
content: m.textMessage.content,
}));
const lastMessage = messages[messages.length - 1];
const restOfMessages = messages.slice(0, -1);
const body = {
input: lastMessage.content,
validTopics: data.cloud.guardrails.inputValidationRules.allowList,
invalidTopics: data.cloud.guardrails.inputValidationRules.denyList,
messages: restOfMessages,
};
const guardrailsResult = await fetch(`${baseUrl}/guardrails/validate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CopilotCloud-Public-API-Key": copilotCloudPublicApiKey,
},
body: JSON.stringify(body),
});
if (guardrailsResult.ok) {
const resultJson: GuardrailsResult = await guardrailsResult.json();
onResult(resultJson);
} else {
onError(await guardrailsResult.json());
}
}
};
@Resolver(() => CopilotResponse)
export class CopilotResolver {
@Query(() => String)
async hello() {
return "Hello World";
}
@Query(() => AgentsResponse)
async availableAgents(@Ctx() ctx: GraphQLContext) {
let logger = ctx.logger.child({
component: "CopilotResolver.availableAgents",
});
logger.debug("Processing");
const agentsWithEndpoints = [];
logger.debug("Event source created, creating response");
return {
agents: agentsWithEndpoints.map(
({ endpoint, ...agentWithoutEndpoint }) => agentWithoutEndpoint,
),
};
}
@Mutation(() => CopilotResponse)
async generateCopilotResponse(
@Ctx() ctx: GraphQLContext,
@Arg("data") data: GenerateCopilotResponseInput,
@Arg("properties", () => GraphQLJSONObject, { nullable: true })
properties?: CopilotRequestContextProperties,
) {
telemetry.capture("oss.runtime.copilot_request_created", {
"cloud.guardrails.enabled": data.cloud?.guardrails !== undefined,
requestType: data.metadata.requestType,
"cloud.api_key_provided": !!ctx.request.headers.get(
"x-copilotcloud-public-api-key",
),
...(ctx.request.headers.get("x-copilotcloud-public-api-key")
? {
"cloud.public_api_key": ctx.request.headers.get(
"x-copilotcloud-public-api-key",
),
}
: {}),
...(ctx._copilotkit.baseUrl
? {
"cloud.base_url": ctx._copilotkit.baseUrl,
}
: {
"cloud.base_url": "https://api.cloud.copilotkit.ai",
}),
});
let logger = ctx.logger.child({
component: "CopilotResolver.generateCopilotResponse",
});
logger.debug({ data }, "Generating Copilot response");
if (properties) {
logger.debug("Properties provided, merging with context properties");
ctx.properties = { ...ctx.properties, ...properties };
}
const copilotRuntime = ctx._copilotkit.runtime as unknown as CopilotRuntime;
const serviceAdapter = ctx._copilotkit.serviceAdapter;
let copilotCloudPublicApiKey: string | null = null;
let copilotCloudBaseUrl: string;
// Extract publicApiKey from headers for both cloud and non-cloud requests
// This enables onTrace functionality regardless of cloud configuration
const publicApiKeyFromHeaders = ctx.request.headers.get(
"x-copilotcloud-public-api-key",
);
if (publicApiKeyFromHeaders) {
copilotCloudPublicApiKey = publicApiKeyFromHeaders;
}
if (data.cloud) {
logger = logger.child({ cloud: true });
logger.debug(
"Cloud configuration provided, checking for public API key in headers",
);
if (!copilotCloudPublicApiKey) {
logger.error("Public API key not found in headers");
throw new GraphQLError(
"X-CopilotCloud-Public-API-Key header is required",
);
}
if (process.env.COPILOT_CLOUD_BASE_URL) {
copilotCloudBaseUrl = process.env.COPILOT_CLOUD_BASE_URL;
} else if (ctx._copilotkit.cloud?.baseUrl) {
copilotCloudBaseUrl = ctx._copilotkit.cloud?.baseUrl;
} else {
copilotCloudBaseUrl = "https://api.cloud.copilotkit.ai";
}
logger = logger.child({ copilotCloudBaseUrl });
}
logger.debug("Setting up subjects");
const responseStatus$ = new ReplaySubject<typeof ResponseStatusUnion>();
const interruptStreaming$ = new ReplaySubject<{
reason: string;
messageId?: string;
}>();
const guardrailsResult$ = new ReplaySubject<GuardrailsResult>();
let outputMessages: Message[] = [];
let resolveOutputMessagesPromise: (messages: Message[]) => void;
let rejectOutputMessagesPromise: (err: Error) => void;
const outputMessagesPromise = new Promise<Message[]>((resolve, reject) => {
resolveOutputMessagesPromise = resolve;
rejectOutputMessagesPromise = reject;
});
if (copilotCloudPublicApiKey) {
ctx.properties["copilotCloudPublicApiKey"] = copilotCloudPublicApiKey;
}
logger.debug("Processing");
let runtimeResponse;
const {
eventSource,
threadId = randomId(),
runId,
serverSideActions,
actionInputsWithoutAgents,
extensions,
} = runtimeResponse;
logger.debug("Event source created, creating response");
// run and process the event stream
const eventStream = eventSource
.processRuntimeEvents({
serverSideActions,
guardrailsResult$: data.cloud?.guardrails ? guardrailsResult$ : null,
actionInputsWithoutAgents: actionInputsWithoutAgents.filter(
// TODO-AGENTS: do not exclude ALL server side actions
(action) =>
!serverSideActions.find(
(serverSideAction) => serverSideAction.name == action.name,
),
),
threadId,
})
.pipe(
// shareReplay() ensures that later subscribers will see the whole stream instead of
// just the events that were emitted after the subscriber was added.
shareReplay(),
finalize(() => {
logger.debug("Event stream finalized");
}),
);
const response = {
threadId,
runId,
status: firstValueFrom(responseStatus$),
extensions,
metaEvents: new Repeater(async (push, stop) => {
let eventStreamSubscription: Subscription;
eventStreamSubscription = eventStream.subscribe({
next: async (event) => {
if (event.type != RuntimeEventTypes.MetaEvent) {
return;
}
switch (event.name) {
// @ts-ignore
case LangGraphEventTypes.OnInterrupt:
push(
plainToInstance(LangGraphInterruptEvent, {
// @ts-ignore
type: event.type,
// @ts-ignore
name: RuntimeMetaEventName.LangGraphInterruptEvent,
// @ts-ignore
value: event.value,
}),
);
break;
case RuntimeMetaEventName.LangGraphInterruptEvent:
push(
plainToInstance(LangGraphInterruptEvent, {
type: event.type,
name: event.name,
value: event.value,
}),
);
break;
case RuntimeMetaEventName.CopilotKitLangGraphInterruptEvent:
push(
plainToInstance(CopilotKitLangGraphInterruptEvent, {
type: event.type,
name: event.name,
data: {
value: event.data.value,
messages: event.data.messages.map((message) => {
if (
message.type === "TextMessage" ||
("content" in message && "role" in message)
) {
return plainToInstance(TextMessage, {
id: message.id,
createdAt: new Date(),
content: [(message as TextMessage).content],
role: (message as TextMessage).role,
status: new SuccessMessageStatus(),
});
}
if ("arguments" in message) {
return plainToInstance(ActionExecutionMessage, {
name: message.name,
id: message.id,
arguments: [JSON.stringify(message.arguments)],
createdAt: new Date(),
status: new SuccessMessageStatus(),
});
}
throw new Error(
"Unknown message in metaEvents copilot resolver",
);
}),
},
}),
);
break;
}
},
error: (err) => {
// For structured CopilotKit errors, set proper error response status
if (
err?.name?.includes("CopilotKit") ||
err?.extensions?.visibility
) {
responseStatus$.next(
new UnknownErrorResponse({
description: err.message || "Agent error occurred",
}),
);
} else {
responseStatus$.next(
new UnknownErrorResponse({
description: `An unknown error has occurred in the event stream`,
}),
);
}
eventStreamSubscription?.unsubscribe();
stop();
},
complete: async () => {
logger.debug("Meta events stream completed");
responseStatus$.next(new SuccessResponseStatus());
eventStreamSubscription?.unsubscribe();
stop();
},
});
}),
messages: new Repeater(async (pushMessage, stopStreamingMessages) => {
logger.debug("Messages repeater created");
if (data.cloud?.guardrails) {
logger = logger.child({ guardrails: true });
logger.debug("Guardrails is enabled, validating input");
invokeGuardrails({
baseUrl: copilotCloudBaseUrl,
copilotCloudPublicApiKey,
data,
onResult: (result) => {
logger.debug(
{ status: result.status },
"Guardrails validation done",
);
guardrailsResult$.next(result);
// Guardrails validation failed
if (result.status === "denied") {
// send the reason to the client and interrupt streaming
responseStatus$.next(
new GuardrailsValidationFailureResponse({
guardrailsReason: result.reason,
}),
);
interruptStreaming$.next({
reason: `Interrupted due to Guardrails validation failure. Reason: ${result.reason}`,
});
// resolve messages promise to the middleware
outputMessages = [
plainToInstance(TextMessage, {
id: randomId(),
createdAt: new Date(),
content: result.reason,
role: MessageRole.assistant,
}),
];
resolveOutputMessagesPromise(outputMessages);
}
},
onError: (err) => {
logger.error({ err }, "Error in guardrails validation");
responseStatus$.next(
new UnknownErrorResponse({
description: `An unknown error has occurred in the guardrails validation`,
}),
);
interruptStreaming$.next({
reason: `Interrupted due to unknown error in guardrails validation`,
});
// reject the middleware promise
rejectOutputMessagesPromise(err);
},
});
}
let eventStreamSubscription: Subscription;
logger.debug("Event stream created, subscribing to event stream");
eventStreamSubscription = eventStream.subscribe({
next: async (event) => {
switch (event.type) {
case RuntimeEventTypes.MetaEvent:
break;
////////////////////////////////
// TextMessageStart
////////////////////////////////
case RuntimeEventTypes.TextMessageStart:
// create a sub stream that contains the message content
const textMessageContentStream = eventStream.pipe(
// skip until this message start event
skipWhile((e: RuntimeEvent) => e !== event),
// take until the message end event
takeWhile(
(e: RuntimeEvent) =>
!(
e.type === RuntimeEventTypes.TextMessageEnd &&
(e as any).messageId == event.messageId
),
),
// filter out any other message events or message ids
filter(
(e: RuntimeEvent) =>
e.type == RuntimeEventTypes.TextMessageContent &&
(e as any).messageId == event.messageId,
),
);
// signal when we are done streaming
const streamingTextStatus = new Subject<
typeof MessageStatusUnion
>();
const messageId = resolveMessageId(event.messageId);
// push the new message
pushMessage({
id: messageId,
parentMessageId: event.parentMessageId,
status: firstValueFrom(streamingTextStatus),
createdAt: new Date(),
role: MessageRole.assistant,
content: new Repeater(
async (pushTextChunk, stopStreamingText) => {
logger.debug("Text message content repeater created");
const textChunks: string[] = [];
let textSubscription: Subscription;
interruptStreaming$
.pipe(
shareReplay(),
take(1),
tap(({ reason, messageId }) => {
logger.debug(
{ reason, messageId },
"Text streaming interrupted",
);
streamingTextStatus.next(
plainToInstance(FailedMessageStatus, { reason }),
);
responseStatus$.next(
new MessageStreamInterruptedResponse({
messageId,
}),
);
stopStreamingText();
textSubscription?.unsubscribe();
}),
)
.subscribe();
logger.debug(
"Subscribing to text message content stream",
);
textSubscription = textMessageContentStream.subscribe({
next: async (e: RuntimeEvent) => {
if (e.type == RuntimeEventTypes.TextMessageContent) {
await pushTextChunk(e.content);
textChunks.push(e.content);
}
},
error: (err) => {
logger.error(
{ err },
"Error in text message content stream",
);
interruptStreaming$.next({
reason: "Error streaming message content",
messageId,
});
stopStreamingText();
textSubscription?.unsubscribe();
},
complete: () => {
logger.debug("Text message content stream completed");
streamingTextStatus.next(new SuccessMessageStatus());
stopStreamingText();
textSubscription?.unsubscribe();
outputMessages.push(
plainToInstance(TextMessage, {
id: messageId,
createdAt: new Date(),
content: textChunks.join(""),
role: MessageRole.assistant,
}),
);
},
});
},
),
});
break;
////////////////////////////////
// ActionExecutionStart
////////////////////////////////
case RuntimeEventTypes.ActionExecutionStart:
logger.debug("Action execution start event received");
const actionExecutionArgumentStream = eventStream.pipe(
skipWhile((e: RuntimeEvent) => e !== event),
// take until the action execution end event
takeWhile(
(e: RuntimeEvent) =>
!(
e.type === RuntimeEventTypes.ActionExecutionEnd &&
(e as any).actionExecutionId == event.actionExecutionId
),
),
// filter out any other action execution events or action execution ids
filter(
(e: RuntimeEvent) =>
e.type == RuntimeEventTypes.ActionExecutionArgs &&
(e as any).actionExecutionId == event.actionExecutionId,
),
);
const streamingArgumentsStatus = new Subject<
typeof MessageStatusUnion
>();
pushMessage({
id: event.actionExecutionId,
parentMessageId: event.parentMessageId,
status: firstValueFrom(streamingArgumentsStatus),
createdAt: new Date(),
name: event.actionName,
arguments: new Repeater(
async (pushArgumentsChunk, stopStreamingArguments) => {
logger.debug("Action execution argument stream created");
const argumentChunks: string[] = [];
let actionExecutionArgumentSubscription: Subscription;
actionExecutionArgumentSubscription =
actionExecutionArgumentStream.subscribe({
next: async (e: RuntimeEvent) => {
if (
e.type == RuntimeEventTypes.ActionExecutionArgs
) {
await pushArgumentsChunk(e.args);
argumentChunks.push(e.args);
}
},
error: (err) => {
logger.error(
{ err },
"Error in action execution argument stream",
);
streamingArgumentsStatus.next(
plainToInstance(FailedMessageStatus, {
reason:
"An unknown error has occurred in the action execution argument stream",
}),
);
stopStreamingArguments();
actionExecutionArgumentSubscription?.unsubscribe();
},
complete: () => {
logger.debug(
"Action execution argument stream completed",
);
streamingArgumentsStatus.next(
new SuccessMessageStatus(),
);
stopStreamingArguments();
actionExecutionArgumentSubscription?.unsubscribe();
outputMessages.push(
plainToInstance(ActionExecutionMessage, {
id: event.actionExecutionId,
createdAt: new Date(),
name: event.actionName,
arguments: argumentChunks.join(""),
}),
);
},
});
},
),
});
break;
////////////////////////////////
// ActionExecutionResult
////////////////////////////////
case RuntimeEventTypes.ActionExecutionResult:
logger.debug(
{ result: event.result },
"Action execution result event received",
);
pushMessage({
id: "result-" + event.actionExecutionId,
status: new SuccessMessageStatus(),
createdAt: new Date(),
actionExecutionId: event.actionExecutionId,
actionName: event.actionName,
result: event.result,
});
outputMessages.push(
plainToInstance(ResultMessage, {
id: "result-" + event.actionExecutionId,
createdAt: new Date(),
actionExecutionId: event.actionExecutionId,
actionName: event.actionName,
result: event.result,
}),
);
break;
////////////////////////////////
// AgentStateMessage
////////////////////////////////
case RuntimeEventTypes.AgentStateMessage:
logger.debug({ event }, "Agent message event received");
pushMessage({
id: randomId(),
status: new SuccessMessageStatus(),
threadId: event.threadId,
agentName: event.agentName,
nodeName: event.nodeName,
runId: event.runId,
active: event.active,
state: event.state,
running: event.running,
role: MessageRole.assistant,
createdAt: new Date(),
});
outputMessages.push(
plainToInstance(AgentStateMessage, {
id: randomId(),
threadId: event.threadId,
agentName: event.agentName,
nodeName: event.nodeName,
runId: event.runId,
active: event.active,
state: event.state,
running: event.running,
role: MessageRole.assistant,
createdAt: new Date(),
}),
);
break;
}
},
error: (err) => {
// For structured CopilotKit errors, set proper error response status
if (
err instanceof CopilotKitError ||
err instanceof CopilotKitLowLevelError ||
(err instanceof Error &&
err.name &&
err.name.includes("CopilotKit")) ||
err?.extensions?.visibility
) {
responseStatus$.next(
new UnknownErrorResponse({
description: err.message || "Agent error occurred",
// Include original error information for frontend to extract
originalError: {
code: err.code || err.extensions?.code,
statusCode: err.statusCode || err.extensions?.statusCode,
severity: err.severity || err.extensions?.severity,
visibility: err.visibility || err.extensions?.visibility,
originalErrorType:
err.originalErrorType ||
err.extensions?.originalErrorType,
extensions: err.extensions,
},
}),
);
eventStreamSubscription?.unsubscribe();
rejectOutputMessagesPromise(err);
stopStreamingMessages();
return;
}
responseStatus$.next(
new UnknownErrorResponse({
description: `An unknown error has occurred in the event stream`,
}),
);
eventStreamSubscription?.unsubscribe();
stopStreamingMessages();
rejectOutputMessagesPromise(err);
},
complete: async () => {
logger.debug("Event stream completed");
if (data.cloud?.guardrails) {
logger.debug(
"Guardrails is enabled, waiting for guardrails result",
);
await firstValueFrom(guardrailsResult$);
}
responseStatus$.next(new SuccessResponseStatus());
eventStreamSubscription?.unsubscribe();
stopStreamingMessages();
resolveOutputMessagesPromise(outputMessages);
},
});
}),
};
return response;
}
}
@@ -0,0 +1,14 @@
import { randomId } from "@copilotkit/shared";
/**
* Resolve the id to use for a streamed TextMessageOutput.
*
* Upstream events occasionally arrive with `messageId` missing, null, or an
* empty string. We fall back to a freshly generated id in those cases so the
* resulting output never surfaces a null id to the GraphQL client (#2118).
*/
export function resolveMessageId(
eventMessageId: string | null | undefined,
): string {
return eventMessageId || randomId();
}
@@ -0,0 +1,30 @@
import { Arg, Resolver } from "type-graphql";
import { Ctx } from "type-graphql";
import { Query } from "type-graphql";
import { LoadAgentStateResponse } from "../types/load-agent-state-response.type";
import type { GraphQLContext } from "../../lib/integrations";
import { LoadAgentStateInput } from "../inputs/load-agent-state.input";
import { CopilotKitAgentDiscoveryError } from "@copilotkit/shared";
import { CopilotRuntime } from "../../lib";
@Resolver(() => LoadAgentStateResponse)
export class StateResolver {
@Query(() => LoadAgentStateResponse)
async loadAgentState(
@Ctx() ctx: GraphQLContext,
@Arg("data") data: LoadAgentStateInput,
) {
const agents = [];
const hasAgent = agents.some((agent) => agent.name === data.agentName);
if (!hasAgent) {
throw new CopilotKitAgentDiscoveryError({
agentName: data.agentName,
availableAgents: agents.map((a) => ({ name: a.name, id: a.name })),
});
}
const state = {};
return state;
}
}
@@ -0,0 +1,19 @@
import { Field, ObjectType } from "type-graphql";
@ObjectType()
export class Agent {
@Field(() => String)
id: string;
@Field(() => String)
name: string;
@Field(() => String)
description?: string;
}
@ObjectType()
export class AgentsResponse {
@Field(() => [Agent])
agents: Agent[];
}
@@ -0,0 +1,10 @@
import { Field, InputType } from "type-graphql";
@InputType()
export class BaseMessageInput {
@Field(() => String)
id: string;
@Field(() => Date)
createdAt: Date;
}
@@ -0,0 +1,183 @@
import { randomId } from "@copilotkit/shared";
import {
ActionExecutionMessageInput,
ResultMessageInput,
TextMessageInput,
AgentStateMessageInput,
ImageMessageInput,
} from "../../inputs/message.input";
import { BaseMessageInput } from "../base";
import { BaseMessageOutput } from "../copilot-response.type";
import { MessageRole } from "../enums";
import { MessageStatus, MessageStatusCode } from "../message-status.type";
export type MessageType =
| "TextMessage"
| "ActionExecutionMessage"
| "ResultMessage"
| "AgentStateMessage"
| "ImageMessage";
export class Message {
type: MessageType;
id: BaseMessageOutput["id"];
createdAt: BaseMessageOutput["createdAt"];
status: MessageStatus;
constructor(props: any) {
props.id ??= randomId();
props.status ??= { code: MessageStatusCode.Success };
props.createdAt ??= new Date();
Object.assign(this, props);
}
isTextMessage(): this is TextMessage {
return this.type === "TextMessage";
}
isActionExecutionMessage(): this is ActionExecutionMessage {
return this.type === "ActionExecutionMessage";
}
isResultMessage(): this is ResultMessage {
return this.type === "ResultMessage";
}
isAgentStateMessage(): this is AgentStateMessage {
return this.type === "AgentStateMessage";
}
isImageMessage(): this is ImageMessage {
return this.type === "ImageMessage";
}
}
// alias Role to MessageRole
export const Role = MessageRole;
// when constructing any message, the base fields are optional
type MessageConstructorOptions = Partial<Message>;
type TextMessageConstructorOptions = MessageConstructorOptions &
TextMessageInput;
export class TextMessage
extends Message
implements TextMessageConstructorOptions
{
content: TextMessageInput["content"];
parentMessageId: TextMessageInput["parentMessageId"];
role: TextMessageInput["role"];
type = "TextMessage" as const;
constructor(props: TextMessageConstructorOptions) {
super(props);
this.type = "TextMessage";
}
}
export class ActionExecutionMessage
extends Message
implements Omit<ActionExecutionMessageInput, "arguments" | "scope">
{
type: MessageType = "ActionExecutionMessage";
name: string;
arguments: Record<string, any>;
parentMessageId?: string;
}
export class ResultMessage extends Message implements ResultMessageInput {
type: MessageType = "ResultMessage";
actionExecutionId: string;
actionName: string;
result: string;
static encodeResult(
result: any,
error?: { code: string; message: string } | string | Error,
): string {
const errorObj = error
? typeof error === "string"
? { code: "ERROR", message: error }
: error instanceof Error
? { code: "ERROR", message: error.message }
: error
: undefined;
if (errorObj) {
return JSON.stringify({
error: errorObj,
result: result || "",
});
}
if (result === undefined) {
return "";
}
return typeof result === "string" ? result : JSON.stringify(result);
}
static decodeResult(result: string): {
error?: { code: string; message: string };
result: string;
} {
if (!result) {
return { result: "" };
}
try {
const parsed = JSON.parse(result);
if (parsed && typeof parsed === "object") {
if ("error" in parsed) {
return {
error: parsed.error,
result: parsed.result || "",
};
}
return { result: JSON.stringify(parsed) };
}
return { result };
} catch (e) {
return { result };
}
}
hasError(): boolean {
try {
const { error } = ResultMessage.decodeResult(this.result);
return !!error;
} catch {
return false;
}
}
getError(): { code: string; message: string } | undefined {
try {
const { error } = ResultMessage.decodeResult(this.result);
return error;
} catch {
return undefined;
}
}
}
export class AgentStateMessage
extends Message
implements Omit<AgentStateMessageInput, "state">
{
type: MessageType = "AgentStateMessage";
threadId: string;
agentName: string;
nodeName: string;
runId: string;
active: boolean;
role: MessageRole;
state: any;
running: boolean;
}
export class ImageMessage extends Message implements ImageMessageInput {
type: MessageType = "ImageMessage";
format: string;
bytes: string;
role: MessageRole;
parentMessageId?: string;
}
@@ -0,0 +1,141 @@
import { Field, InterfaceType, ObjectType } from "type-graphql";
import { MessageRole } from "./enums";
import { MessageStatusUnion } from "./message-status.type";
import { ResponseStatusUnion } from "./response-status.type";
import { ExtensionsResponse } from "./extensions-response.type";
import { BaseMetaEvent } from "./meta-events.type";
@InterfaceType({
resolveType(value) {
if (value.hasOwnProperty("content")) {
return TextMessageOutput;
} else if (value.hasOwnProperty("name")) {
return ActionExecutionMessageOutput;
} else if (value.hasOwnProperty("result")) {
return ResultMessageOutput;
} else if (value.hasOwnProperty("state")) {
return AgentStateMessageOutput;
} else if (
value.hasOwnProperty("format") &&
value.hasOwnProperty("bytes")
) {
return ImageMessageOutput;
}
return undefined;
},
})
export abstract class BaseMessageOutput {
@Field(() => String)
id: string;
@Field(() => Date)
createdAt: Date;
@Field(() => MessageStatusUnion)
status: typeof MessageStatusUnion;
}
@ObjectType({ implements: BaseMessageOutput })
export class TextMessageOutput {
@Field(() => MessageRole)
role: MessageRole;
@Field(() => [String])
content: string[];
@Field(() => String, { nullable: true })
parentMessageId?: string;
}
@ObjectType({ implements: BaseMessageOutput })
export class ActionExecutionMessageOutput {
@Field(() => String)
name: string;
@Field(() => String, {
nullable: true,
deprecationReason: "This field will be removed in a future version",
})
scope?: string;
@Field(() => [String])
arguments: string[];
@Field(() => String, { nullable: true })
parentMessageId?: string;
}
@ObjectType({ implements: BaseMessageOutput })
export class ResultMessageOutput {
@Field(() => String)
actionExecutionId: string;
@Field(() => String)
actionName: string;
@Field(() => String)
result: string;
}
@ObjectType({ implements: BaseMessageOutput })
export class AgentStateMessageOutput {
@Field(() => String)
threadId: string;
@Field(() => String)
agentName: string;
@Field(() => String)
nodeName: string;
@Field(() => String)
runId: string;
@Field(() => Boolean)
active: boolean;
@Field(() => MessageRole)
role: MessageRole;
@Field(() => String)
state: string;
@Field(() => Boolean)
running: boolean;
}
@ObjectType({ implements: BaseMessageOutput })
export class ImageMessageOutput {
@Field(() => String)
format: string;
@Field(() => String)
bytes: string;
@Field(() => MessageRole)
role: MessageRole;
@Field(() => String, { nullable: true })
parentMessageId?: string;
}
@ObjectType()
export class CopilotResponse {
@Field(() => String)
threadId!: string;
@Field(() => ResponseStatusUnion)
status: typeof ResponseStatusUnion;
@Field({ nullable: true })
runId?: string;
@Field(() => [BaseMessageOutput])
messages: (typeof BaseMessageOutput)[];
@Field(() => ExtensionsResponse, { nullable: true })
extensions?: ExtensionsResponse;
@Field(() => [BaseMetaEvent], { nullable: true })
metaEvents?: (typeof BaseMetaEvent)[];
}
@@ -0,0 +1,38 @@
import { registerEnumType } from "type-graphql";
export enum MessageRole {
assistant = "assistant",
developer = "developer",
system = "system",
tool = "tool",
user = "user",
}
export enum CopilotRequestType {
Chat = "Chat",
Task = "Task",
TextareaCompletion = "TextareaCompletion",
TextareaPopover = "TextareaPopover",
Suggestion = "Suggestion",
}
export enum ActionInputAvailability {
disabled = "disabled",
enabled = "enabled",
remote = "remote",
}
registerEnumType(MessageRole, {
name: "MessageRole",
description: "The role of the message",
});
registerEnumType(CopilotRequestType, {
name: "CopilotRequestType",
description: "The type of Copilot request",
});
registerEnumType(ActionInputAvailability, {
name: "ActionInputAvailability",
description: "The availability of the frontend action",
});
@@ -0,0 +1,23 @@
import { Field, ObjectType } from "type-graphql";
/**
* The extensions response is used to receive additional information from the copilot runtime, specific to a
* service adapter or agent framework.
*
* Next time a request to the runtime is made, the extensions response will be included in the request as input.
*/
@ObjectType()
export class OpenAIApiAssistantAPIResponse {
@Field(() => String, { nullable: true })
runId?: string;
@Field(() => String, { nullable: true })
threadId?: string;
}
@ObjectType()
export class ExtensionsResponse {
@Field(() => OpenAIApiAssistantAPIResponse, { nullable: true })
openaiAssistantAPI?: OpenAIApiAssistantAPIResponse;
}
@@ -0,0 +1,20 @@
import { Field, ObjectType, registerEnumType } from "type-graphql";
export enum GuardrailsResultStatus {
ALLOWED = "allowed",
DENIED = "denied",
}
registerEnumType(GuardrailsResultStatus, {
name: "GuardrailsResultStatus",
description: "The status of the guardrails check",
});
@ObjectType()
export class GuardrailsResult {
@Field(() => GuardrailsResultStatus)
status: GuardrailsResultStatus;
@Field(() => String, { nullable: true })
reason?: string;
}
@@ -0,0 +1,17 @@
import { Field, ObjectType } from "type-graphql";
import { BaseMessageOutput } from "./copilot-response.type";
@ObjectType()
export class LoadAgentStateResponse {
@Field(() => String)
threadId: string;
@Field(() => Boolean)
threadExists: boolean;
@Field(() => String)
state: string;
@Field(() => String)
messages: string;
}
@@ -0,0 +1,48 @@
import {
Field,
ObjectType,
createUnionType,
registerEnumType,
} from "type-graphql";
export enum MessageStatusCode {
Pending = "pending",
Success = "success",
Failed = "failed",
}
registerEnumType(MessageStatusCode, {
name: "MessageStatusCode",
});
@ObjectType()
export class BaseMessageStatus {
@Field(() => MessageStatusCode)
code: MessageStatusCode;
}
@ObjectType()
export class PendingMessageStatus extends BaseMessageStatus {
code: MessageStatusCode = MessageStatusCode.Pending;
}
@ObjectType()
export class SuccessMessageStatus extends BaseMessageStatus {
code: MessageStatusCode = MessageStatusCode.Success;
}
@ObjectType()
export class FailedMessageStatus extends BaseMessageStatus {
code: MessageStatusCode = MessageStatusCode.Failed;
@Field(() => String)
reason: string;
}
export const MessageStatusUnion = createUnionType({
name: "MessageStatus",
types: () =>
[PendingMessageStatus, SuccessMessageStatus, FailedMessageStatus] as const,
});
export type MessageStatus = typeof MessageStatusUnion;
@@ -0,0 +1,78 @@
import {
createUnionType,
Field,
InterfaceType,
ObjectType,
registerEnumType,
} from "type-graphql";
import {
ActionExecutionMessageOutput,
AgentStateMessageOutput,
BaseMessageOutput,
ResultMessageOutput,
TextMessageOutput,
} from "./copilot-response.type";
export enum MetaEventName {
LangGraphInterruptEvent = "LangGraphInterruptEvent",
CopilotKitLangGraphInterruptEvent = "CopilotKitLangGraphInterruptEvent",
}
registerEnumType(MetaEventName, {
name: "MetaEventName",
description: "Meta event types",
});
@InterfaceType({
resolveType(value) {
if (value.name === MetaEventName.LangGraphInterruptEvent) {
return LangGraphInterruptEvent;
} else if (value.name === MetaEventName.CopilotKitLangGraphInterruptEvent) {
return CopilotKitLangGraphInterruptEvent;
}
return undefined;
},
})
@InterfaceType()
export abstract class BaseMetaEvent {
@Field(() => String)
type: "MetaEvent" = "MetaEvent";
@Field(() => MetaEventName)
name: MetaEventName;
}
@ObjectType()
export class CopilotKitLangGraphInterruptEventData {
@Field(() => String)
value: string;
@Field(() => [BaseMessageOutput])
messages: (typeof BaseMessageOutput)[];
}
@ObjectType({ implements: BaseMetaEvent })
export class LangGraphInterruptEvent {
@Field(() => MetaEventName)
name: MetaEventName.LangGraphInterruptEvent =
MetaEventName.LangGraphInterruptEvent;
@Field(() => String)
value: string;
@Field(() => String, { nullable: true })
response?: string;
}
@ObjectType({ implements: BaseMetaEvent })
export class CopilotKitLangGraphInterruptEvent {
@Field(() => MetaEventName)
name: MetaEventName.CopilotKitLangGraphInterruptEvent =
MetaEventName.CopilotKitLangGraphInterruptEvent;
@Field(() => CopilotKitLangGraphInterruptEventData)
data: CopilotKitLangGraphInterruptEventData;
@Field(() => String, { nullable: true })
response?: string;
}
@@ -0,0 +1,77 @@
import { GraphQLJSON } from "graphql-scalars";
import {
Field,
InterfaceType,
ObjectType,
createUnionType,
registerEnumType,
} from "type-graphql";
export enum ResponseStatusCode {
Pending = "pending",
Success = "success",
Failed = "failed",
}
registerEnumType(ResponseStatusCode, {
name: "ResponseStatusCode",
});
@InterfaceType({
resolveType(value) {
if (value.code === ResponseStatusCode.Success) {
return SuccessResponseStatus;
} else if (value.code === ResponseStatusCode.Failed) {
return FailedResponseStatus;
} else if (value.code === ResponseStatusCode.Pending) {
return PendingResponseStatus;
}
return undefined;
},
})
@ObjectType()
abstract class BaseResponseStatus {
@Field(() => ResponseStatusCode)
code: ResponseStatusCode;
}
@ObjectType({ implements: BaseResponseStatus })
export class PendingResponseStatus extends BaseResponseStatus {
code: ResponseStatusCode = ResponseStatusCode.Pending;
}
@ObjectType({ implements: BaseResponseStatus })
export class SuccessResponseStatus extends BaseResponseStatus {
code: ResponseStatusCode = ResponseStatusCode.Success;
}
export enum FailedResponseStatusReason {
GUARDRAILS_VALIDATION_FAILED = "GUARDRAILS_VALIDATION_FAILED",
MESSAGE_STREAM_INTERRUPTED = "MESSAGE_STREAM_INTERRUPTED",
UNKNOWN_ERROR = "UNKNOWN_ERROR",
}
registerEnumType(FailedResponseStatusReason, {
name: "FailedResponseStatusReason",
});
@ObjectType({ implements: BaseResponseStatus })
export class FailedResponseStatus extends BaseResponseStatus {
code: ResponseStatusCode = ResponseStatusCode.Failed;
@Field(() => FailedResponseStatusReason)
reason: FailedResponseStatusReason;
@Field(() => GraphQLJSON, { nullable: true })
details?: Record<string, any> = null;
}
export const ResponseStatusUnion = createUnionType({
name: "ResponseStatus",
types: () =>
[
PendingResponseStatus,
SuccessResponseStatus,
FailedResponseStatus,
] as const,
});
+3
View File
@@ -0,0 +1,3 @@
export * from "./lib";
export * from "./utils";
export * from "./service-adapters";
+1
View File
@@ -0,0 +1 @@
export * from "./lib/runtime/agent-integrations/langgraph";

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