diff --git a/.changeset/ag-ui-client-compliance.md b/.changeset/ag-ui-client-compliance.md new file mode 100644 index 000000000..679fac06c --- /dev/null +++ b/.changeset/ag-ui-client-compliance.md @@ -0,0 +1,24 @@ +--- +'@tanstack/ai': minor +'@tanstack/ai-client': minor +'@tanstack/ai-react': minor +'@tanstack/ai-solid': minor +'@tanstack/ai-vue': minor +'@tanstack/ai-svelte': minor +'@tanstack/ai-react-ui': minor +--- + +**Breaking:** AG-UI client-to-server compliance. + +`@tanstack/ai-client` now POSTs an AG-UI `RunAgentInput` request body and `@tanstack/ai` server endpoints must use the new `chatParamsFromRequestBody` + `mergeAgentTools` helpers. Upgrade both packages together. + +Highlights: + +- **Wire format**: `{threadId, runId, state, messages, tools, context, forwardedProps}` (per AG-UI 0.0.52 `RunAgentInputSchema`) instead of `{messages, data}`. +- **New server helpers** exported from `@tanstack/ai`: `chatParamsFromRequestBody`, `mergeAgentTools`. +- **`chat()` accepts `threadId`, `runId`, `parentRunId`** as optional fields for AG-UI run correlation. +- **`ChatClient` accepts `threadId`** option; auto-generates and persists per session if omitted; fresh `runId` per send. +- **Client tools auto-advertised** to the server via `RunAgentInput.tools`. +- **Foreign AG-UI clients** can hit a TanStack server: `developer` collapses to `system`, `reasoning`/`activity` drop. + +See `docs/migration/ag-ui-compliance.md` for full migration steps. diff --git a/codemods/README.md b/codemods/README.md new file mode 100644 index 000000000..087be4867 --- /dev/null +++ b/codemods/README.md @@ -0,0 +1,41 @@ +# TanStack AI codemods + +[jscodeshift](https://github.com/facebook/jscodeshift) transforms for migrating between API versions of `@tanstack/ai` and friends. + +Each codemod lives in its own subdirectory and is named after the migration it covers. Codemods are **opt-in modernizations** — the deprecated APIs they replace continue to work, so you can run them at your own pace. + +## Available codemods + +| Codemod | Migrates | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`ag-ui-compliance`](./ag-ui-compliance) | Client-side renames introduced by the AG-UI client/server compliance release: `body` → `forwardedProps` on `useChat` / `ChatClient` / `updateOptions`, Svelte's `updateBody` → `updateForwardedProps`, and `chat({ conversationId })` → `chat({ threadId })`. | + +## Running a codemod + +You can run any codemod via `npx jscodeshift` directly against a remote URL — no clone or install needed in your project: + +```bash +npx jscodeshift \ + --parser=tsx \ + -t https://raw.githubusercontent.com/TanStack/ai/main/codemods/ag-ui-compliance/transform.ts \ + src/**/*.{ts,tsx} +``` + +Add `--dry --print` to preview the rewrite without modifying files. + +## Running locally (this repo) + +If you've cloned this repo (e.g., to apply a codemod to the bundled examples), use the top-level pnpm script: + +```bash +pnpm codemod:ag-ui-compliance "examples/**/*.{ts,tsx}" +``` + +## Authoring a new codemod + +1. Add a sibling directory `codemods//`. +2. Implement the transform in `transform.ts` — follow `ag-ui-compliance/transform.ts` for structure (import-source gating, conflict-safe property renames). +3. Add fixtures under `__testfixtures__/.input.{ts,tsx}` and matching `.output.{ts,tsx}`. Cover at least one positive case per transformation, one negative case (file should be left alone), and one conflict case (legacy + canonical names both present). +4. Add tests in `transform.test.ts` using the `expectFixture` helper. +5. Run `pnpm --filter @tanstack/ai-codemods test`. +6. Document the codemod in its own `README.md` and add a row to the table above. diff --git a/codemods/ag-ui-compliance/README.md b/codemods/ag-ui-compliance/README.md new file mode 100644 index 000000000..dbfc27077 --- /dev/null +++ b/codemods/ag-ui-compliance/README.md @@ -0,0 +1,65 @@ +# `ag-ui-compliance` codemod + +Migrates client-side TanStack AI code from the legacy field names to the AG-UI–compliant ones introduced in the AG-UI client/server compliance release. + +> **Heads up — nothing breaks if you skip this.** The legacy names continue to work via deprecation bridges. Run this codemod when you want to clean up your codebase and remove deprecation warnings. See [`docs/migration/ag-ui-compliance.md`](../../docs/migration/ag-ui-compliance.md) for the full migration story. + +## What it does + +| Before | After | +| --------------------------------------- | ------------------------------------------------- | +| `useChat({ body: {...} })` | `useChat({ forwardedProps: {...} })` | +| `new ChatClient({ body: {...} })` | `new ChatClient({ forwardedProps: {...} })` | +| `client.updateOptions({ body: {...} })` | `client.updateOptions({ forwardedProps: {...} })` | +| `chat.updateBody(x)` _(Svelte)_ | `chat.updateForwardedProps(x)` | +| `chat({ conversationId: x })` | `chat({ threadId: x })` | + +Each rename is gated by an **import-source check** — the codemod only rewrites a call site if the relevant identifier (`useChat`, `ChatClient`, etc.) is imported from a known TanStack AI package in the same file. Files that just happen to use a `body` key on unrelated object literals are left untouched. + +## What it doesn't do + +- **Server-side `body.data.X` rewrites.** Detecting which `body.data.foo` reads belong to a TanStack AI route handler vs. unrelated code requires more context than a syntactic codemod can reliably provide. Migrate these by hand using the recipes in [`docs/migration/ag-ui-compliance.md`](../../docs/migration/ag-ui-compliance.md). +- **Re-exports and aliases.** If you re-export `useChat` from a barrel file (`export { useChat } from '@tanstack/ai-react'`), call sites that import the re-export won't be matched. Update the import to come directly from the framework package, or run the codemod against the barrel file too. +- **`chat({ conversationId: })` value-source rewrites.** The codemod renames the property _key_ `conversationId` → `threadId` but does NOT rewrite the value expression. If your server reads from a now-stale source (commonly `body.forwardedProps?.conversationId`, which the upgraded client no longer auto-emits), audit the value after the codemod runs and migrate to `body.threadId` (the AG-UI top-level wire field), `params.threadId` from `chatParamsFromRequest`, or omit `threadId` entirely so the runtime auto-generates one. + +## Running it + +### Against your app + +```bash +npx jscodeshift \ + --parser=tsx \ + -t https://raw.githubusercontent.com/TanStack/ai/main/codemods/ag-ui-compliance/transform.ts \ + "src/**/*.{ts,tsx}" +``` + +Preview changes without writing them: + +```bash +npx jscodeshift --dry --print \ + --parser=tsx \ + -t https://raw.githubusercontent.com/TanStack/ai/main/codemods/ag-ui-compliance/transform.ts \ + "src/**/*.{ts,tsx}" +``` + +### Against this repo's examples + +```bash +pnpm codemod:ag-ui-compliance "examples/**/*.{ts,tsx}" +``` + +## Conflict handling + +If an object literal already declares **both** the legacy and the canonical key — for example, `useChat({ body: {...}, forwardedProps: {...} })` — the codemod leaves it alone and prints a warning of the form: + +```text +[ag-ui-compliance] path/to/file.tsx:42 — useChat({ body }): both legacy + and canonical keys are already present; left alone. Merge by hand. +``` + +Renaming would produce a duplicate-key object literal and silently drop one of the two values; resolving the merge is a judgment call only the author can make. Search the codemod's output for `[ag-ui-compliance]` lines and merge each call site manually. + +## Limits and verification + +- Test fixtures cover the supported call-site shapes; out-of-shape uses (e.g., `useChat(...spread)`) are skipped. +- Run your test suite after the codemod completes — the rewrite is mechanical, not semantic, so unusual patterns may need manual review. diff --git a/codemods/ag-ui-compliance/__testfixtures__/chat-client-body.input.ts b/codemods/ag-ui-compliance/__testfixtures__/chat-client-body.input.ts new file mode 100644 index 000000000..dbe298fa3 --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/chat-client-body.input.ts @@ -0,0 +1,10 @@ +import { ChatClient, fetchServerSentEvents } from '@tanstack/ai-client' + +const client = new ChatClient({ + connection: fetchServerSentEvents('/api/chat'), + body: { userId: '123' }, +}) + +client.updateOptions({ + body: { sessionId: 'abc' }, +}) diff --git a/codemods/ag-ui-compliance/__testfixtures__/chat-client-body.output.ts b/codemods/ag-ui-compliance/__testfixtures__/chat-client-body.output.ts new file mode 100644 index 000000000..933ca2e55 --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/chat-client-body.output.ts @@ -0,0 +1,10 @@ +import { ChatClient, fetchServerSentEvents } from '@tanstack/ai-client' + +const client = new ChatClient({ + connection: fetchServerSentEvents('/api/chat'), + forwardedProps: { userId: '123' }, +}) + +client.updateOptions({ + forwardedProps: { sessionId: 'abc' }, +}) diff --git a/codemods/ag-ui-compliance/__testfixtures__/chat-conversation-id.input.ts b/codemods/ag-ui-compliance/__testfixtures__/chat-conversation-id.input.ts new file mode 100644 index 000000000..6e9194cf6 --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/chat-conversation-id.input.ts @@ -0,0 +1,12 @@ +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' + +export async function POST(req: Request) { + const body = await req.json() + const stream = chat({ + adapter: openaiText('gpt-4o'), + messages: body.messages, + conversationId: body.threadId, + }) + return toServerSentEventsResponse(stream) +} diff --git a/codemods/ag-ui-compliance/__testfixtures__/chat-conversation-id.output.ts b/codemods/ag-ui-compliance/__testfixtures__/chat-conversation-id.output.ts new file mode 100644 index 000000000..5fbf1df7c --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/chat-conversation-id.output.ts @@ -0,0 +1,12 @@ +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' + +export async function POST(req: Request) { + const body = await req.json() + const stream = chat({ + adapter: openaiText('gpt-4o'), + messages: body.messages, + threadId: body.threadId, + }) + return toServerSentEventsResponse(stream) +} diff --git a/codemods/ag-ui-compliance/__testfixtures__/conflict-leave-alone.input.ts b/codemods/ag-ui-compliance/__testfixtures__/conflict-leave-alone.input.ts new file mode 100644 index 000000000..b3621afbb --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/conflict-leave-alone.input.ts @@ -0,0 +1,14 @@ +// Conflict case: both `body` and `forwardedProps` are already set. +// The codemod must leave this alone — renaming would produce a +// duplicate-key object literal and silently drop one of the values. + +import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' + +export function Chat() { + const result = useChat({ + connection: fetchServerSentEvents('/api/chat'), + body: { legacy: true }, + forwardedProps: { newer: true }, + }) + return result +} diff --git a/codemods/ag-ui-compliance/__testfixtures__/conflict-leave-alone.output.ts b/codemods/ag-ui-compliance/__testfixtures__/conflict-leave-alone.output.ts new file mode 100644 index 000000000..b3621afbb --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/conflict-leave-alone.output.ts @@ -0,0 +1,14 @@ +// Conflict case: both `body` and `forwardedProps` are already set. +// The codemod must leave this alone — renaming would produce a +// duplicate-key object literal and silently drop one of the values. + +import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' + +export function Chat() { + const result = useChat({ + connection: fetchServerSentEvents('/api/chat'), + body: { legacy: true }, + forwardedProps: { newer: true }, + }) + return result +} diff --git a/codemods/ag-ui-compliance/__testfixtures__/no-imports.input.ts b/codemods/ag-ui-compliance/__testfixtures__/no-imports.input.ts new file mode 100644 index 000000000..b7affc522 --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/no-imports.input.ts @@ -0,0 +1,13 @@ +// Negative case: a file that has nothing to do with TanStack AI but +// happens to use a `body` key on object literals. Must remain +// untouched even though it pattern-matches. + +function buildRequest(opts: { body: Record }) { + return fetch('/api/something', { + method: 'POST', + body: JSON.stringify(opts.body), + }) +} + +const obj = { useChat: true, body: { hello: 'world' } } +const x = obj.useChat ? 1 : 0 diff --git a/codemods/ag-ui-compliance/__testfixtures__/no-imports.output.ts b/codemods/ag-ui-compliance/__testfixtures__/no-imports.output.ts new file mode 100644 index 000000000..b7affc522 --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/no-imports.output.ts @@ -0,0 +1,13 @@ +// Negative case: a file that has nothing to do with TanStack AI but +// happens to use a `body` key on object literals. Must remain +// untouched even though it pattern-matches. + +function buildRequest(opts: { body: Record }) { + return fetch('/api/something', { + method: 'POST', + body: JSON.stringify(opts.body), + }) +} + +const obj = { useChat: true, body: { hello: 'world' } } +const x = obj.useChat ? 1 : 0 diff --git a/codemods/ag-ui-compliance/__testfixtures__/shorthand-body.input.tsx b/codemods/ag-ui-compliance/__testfixtures__/shorthand-body.input.tsx new file mode 100644 index 000000000..f49d988bd --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/shorthand-body.input.tsx @@ -0,0 +1,15 @@ +// Shorthand-key edge case: `body` is a local variable, passed via +// shorthand. After the rename, the call must still reference the +// `body` identifier (i.e. `forwardedProps: body`), NOT the literal +// `forwardedProps` (which is undefined in this scope). + +import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' + +export function Chat() { + const body = { provider: 'openai' } + const result = useChat({ + connection: fetchServerSentEvents('/api/chat'), + body, + }) + return result +} diff --git a/codemods/ag-ui-compliance/__testfixtures__/shorthand-body.output.tsx b/codemods/ag-ui-compliance/__testfixtures__/shorthand-body.output.tsx new file mode 100644 index 000000000..c03cb1bf1 --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/shorthand-body.output.tsx @@ -0,0 +1,15 @@ +// Shorthand-key edge case: `body` is a local variable, passed via +// shorthand. After the rename, the call must still reference the +// `body` identifier (i.e. `forwardedProps: body`), NOT the literal +// `forwardedProps` (which is undefined in this scope). + +import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' + +export function Chat() { + const body = { provider: 'openai' } + const result = useChat({ + connection: fetchServerSentEvents('/api/chat'), + forwardedProps: body, + }) + return result +} diff --git a/codemods/ag-ui-compliance/__testfixtures__/svelte-update-body.input.ts b/codemods/ag-ui-compliance/__testfixtures__/svelte-update-body.input.ts new file mode 100644 index 000000000..3d00c0476 --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/svelte-update-body.input.ts @@ -0,0 +1,7 @@ +import { createChat, fetchServerSentEvents } from '@tanstack/ai-svelte' + +const chat = createChat({ + connection: fetchServerSentEvents('/api/chat'), +}) + +chat.updateBody({ provider: 'openai' }) diff --git a/codemods/ag-ui-compliance/__testfixtures__/svelte-update-body.output.ts b/codemods/ag-ui-compliance/__testfixtures__/svelte-update-body.output.ts new file mode 100644 index 000000000..dd5d88597 --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/svelte-update-body.output.ts @@ -0,0 +1,7 @@ +import { createChat, fetchServerSentEvents } from '@tanstack/ai-svelte' + +const chat = createChat({ + connection: fetchServerSentEvents('/api/chat'), +}) + +chat.updateForwardedProps({ provider: 'openai' }) diff --git a/codemods/ag-ui-compliance/__testfixtures__/use-chat-body.input.tsx b/codemods/ag-ui-compliance/__testfixtures__/use-chat-body.input.tsx new file mode 100644 index 000000000..4481728a1 --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/use-chat-body.input.tsx @@ -0,0 +1,12 @@ +import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' + +export function Chat() { + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents('/api/chat'), + body: { + provider: 'openai', + model: 'gpt-4o', + }, + }) + return null +} diff --git a/codemods/ag-ui-compliance/__testfixtures__/use-chat-body.output.tsx b/codemods/ag-ui-compliance/__testfixtures__/use-chat-body.output.tsx new file mode 100644 index 000000000..ed6cd2421 --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/use-chat-body.output.tsx @@ -0,0 +1,12 @@ +import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' + +export function Chat() { + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents('/api/chat'), + forwardedProps: { + provider: 'openai', + model: 'gpt-4o', + }, + }) + return null +} diff --git a/codemods/ag-ui-compliance/transform.test.ts b/codemods/ag-ui-compliance/transform.test.ts new file mode 100644 index 000000000..b19c0e451 --- /dev/null +++ b/codemods/ag-ui-compliance/transform.test.ts @@ -0,0 +1,102 @@ +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import jscodeshift from 'jscodeshift' +import { describe, expect, it } from 'vitest' +import transform from './transform' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) +const FIXTURES = resolve(__dirname, '__testfixtures__') + +function read(name: string): string { + return readFileSync(resolve(FIXTURES, name), 'utf-8') +} + +function runTransform( + fixtureBaseName: string, + ext: 'ts' | 'tsx', +): { output: string; reports: Array } { + const source = read(`${fixtureBaseName}.input.${ext}`) + const reports: Array = [] + const j = jscodeshift.withParser('tsx') + const result = transform( + { path: `${fixtureBaseName}.input.${ext}`, source }, + { + jscodeshift: j, + j, + stats: () => {}, + report: (msg: string) => { + reports.push(msg) + }, + }, + {}, + ) + if (typeof result !== 'string') { + throw new Error( + `transform returned ${typeof result} for ${fixtureBaseName}.input.${ext}; expected a string`, + ) + } + return { output: result, reports } +} + +// Normalize line endings — fixtures may be saved as CRLF on Windows +// while jscodeshift emits LF, which would make a string compare +// fail despite identical content. +function normalize(s: string): string { + return s.replace(/\r\n/g, '\n').trim() +} + +function expectFixture( + name: string, + ext: 'ts' | 'tsx' = 'ts', +): { reports: Array } { + const expected = read(`${name}.output.${ext}`) + const { output, reports } = runTransform(name, ext) + expect(normalize(output)).toBe(normalize(expected)) + return { reports } +} + +describe('ag-ui-compliance codemod', () => { + it('renames useChat({ body }) to useChat({ forwardedProps })', () => { + expectFixture('use-chat-body', 'tsx') + }) + + it('expands a shorthand `body` property to `forwardedProps: body` (preserves the original identifier reference)', () => { + expectFixture('shorthand-body', 'tsx') + }) + + it('renames body on ChatClient constructor and updateOptions calls', () => { + expectFixture('chat-client-body') + }) + + it('renames Svelte chat.updateBody() to chat.updateForwardedProps()', () => { + expectFixture('svelte-update-body') + }) + + it('renames chat({ conversationId }) to chat({ threadId })', () => { + expectFixture('chat-conversation-id') + }) + + it('leaves files without TanStack AI imports untouched', () => { + expectFixture('no-imports') + }) + + it('leaves objects that already declare both keys untouched, and reports the conflict for human resolution', () => { + const { reports } = expectFixture('conflict-leave-alone') + // The codemod must surface conflicts via api.report so users can + // find and merge them; silently leaving them alone hides intentional + // (or accidental) dual-key state from the migration audit. + expect(reports.length).toBeGreaterThan(0) + expect( + reports.some( + (r) => r.includes('useChat({ body })') && r.includes('left alone'), + ), + ).toBe(true) + }) + + it('emits no report messages for clean transforms', () => { + const { reports } = expectFixture('use-chat-body', 'tsx') + expect(reports).toEqual([]) + }) +}) diff --git a/codemods/ag-ui-compliance/transform.ts b/codemods/ag-ui-compliance/transform.ts new file mode 100644 index 000000000..37eb15462 --- /dev/null +++ b/codemods/ag-ui-compliance/transform.ts @@ -0,0 +1,343 @@ +/** + * jscodeshift transform: AG-UI client compliance migration + * + * Renames the deprecated client-side fields introduced by the AG-UI + * compliance release of `@tanstack/ai`. Each rename is gated by an + * import-source check so we don't touch unrelated code that happens + * to share a property name. + * + * Transforms (all opt-in — the deprecated names keep working): + * + * 1. `useChat({ body })` → `useChat({ forwardedProps })` + * 2. `new ChatClient({ body })` → `new ChatClient({ forwardedProps })` + * 3. `client.updateOptions({ body })` → `{ forwardedProps }` + * (when `ChatClient` is imported in the file) + * 4. `chat.updateBody(x)` → `chat.updateForwardedProps(x)` + * (when imported from `@tanstack/ai-svelte`) + * 5. `chat({ conversationId })` → `chat({ threadId })` + * (when `chat` is imported from `@tanstack/ai`) + * + * Conflict handling: if both the legacy and canonical key are already + * present in the same object literal we leave the property alone — the + * user has authored a deliberate mix and a blind rename would create + * a duplicate key. + */ + +import type { + API, + ASTPath, + Collection, + FileInfo, + ImportDeclaration, + JSCodeshift, + ObjectExpression, + Property, +} from 'jscodeshift' + +const FRAMEWORK_USE_CHAT_PACKAGES = new Set([ + '@tanstack/ai-react', + '@tanstack/ai-react-ui', + '@tanstack/ai-vue', + '@tanstack/ai-vue-ui', + '@tanstack/ai-solid', + '@tanstack/ai-solid-ui', + '@tanstack/ai-preact', +]) + +const SVELTE_PACKAGE = '@tanstack/ai-svelte' +const CLIENT_PACKAGE = '@tanstack/ai-client' +const CORE_PACKAGE = '@tanstack/ai' + +interface ImportFacts { + /** Whether `useChat` is imported from a framework package. */ + hasUseChat: boolean + /** Whether `ChatClient` is imported from `@tanstack/ai-client`. */ + hasChatClient: boolean + /** Whether `createChat` is imported from `@tanstack/ai-svelte`. */ + hasCreateChat: boolean + /** Whether `chat` is imported from `@tanstack/ai`. */ + hasChat: boolean +} + +function collectImportFacts(j: JSCodeshift, root: Collection): ImportFacts { + const facts: ImportFacts = { + hasUseChat: false, + hasChatClient: false, + hasCreateChat: false, + hasChat: false, + } + + root.find(j.ImportDeclaration).forEach((path: ASTPath) => { + const source = path.node.source.value + if (typeof source !== 'string') return + + const specifiers = path.node.specifiers ?? [] + const importedNames = new Set() + for (const spec of specifiers) { + if (spec.type === 'ImportSpecifier') { + importedNames.add(spec.imported.name) + } + } + + if ( + FRAMEWORK_USE_CHAT_PACKAGES.has(source) && + importedNames.has('useChat') + ) { + facts.hasUseChat = true + } + if (source === CLIENT_PACKAGE && importedNames.has('ChatClient')) { + facts.hasChatClient = true + } + // Gate the Svelte `updateBody` rename on the specific `createChat` + // import name, not "any import from the package" — otherwise an + // unrelated `.updateBody(...)` call elsewhere in the same file + // (a form helper, custom store, etc.) would be silently rewritten. + if (source === SVELTE_PACKAGE && importedNames.has('createChat')) { + facts.hasCreateChat = true + } + if (source === CORE_PACKAGE && importedNames.has('chat')) { + facts.hasChat = true + } + }) + + return facts +} + +/** + * Find a Property by its (Identifier) key name on an ObjectExpression. + * Skips spread elements, computed keys, and non-Identifier shorthand keys. + */ +function findKey(obj: ObjectExpression, name: string): Property | undefined { + for (const prop of obj.properties) { + if (prop.type !== 'Property' && prop.type !== 'ObjectProperty') continue + if (prop.computed) continue + const key = prop.key + if (key.type === 'Identifier' && key.name === name) { + return prop as Property + } + } + return undefined +} + +/** + * Rename a property `oldName` → `newName` on the given object expression + * iff `oldName` is present and `newName` is not. Returns + * - `'renamed'` when the rename was applied, + * - `'conflict'` when both keys were already present and the rename was + * skipped (caller should surface this to the user — silently leaving + * it alone hides intentional decisions that still need a human merge), + * - `'skipped'` otherwise (no `oldName` key found, or the key was not + * an Identifier we can safely rewrite). + */ +function renameProperty( + obj: ObjectExpression, + oldName: string, + newName: string, +): 'renamed' | 'conflict' | 'skipped' { + const oldProp = findKey(obj, oldName) + if (!oldProp) return 'skipped' + if (findKey(obj, newName)) { + return 'conflict' + } + if (oldProp.key.type === 'Identifier') { + // For shorthand `{ body }`, the AST stores `key === value === Identifier('body')` + // (or two equal-named Identifier nodes plus `shorthand: true`). Mutating + // only the key would leave the printer emitting `{ forwardedProps }`, + // which silently references an undefined identifier in the user's + // scope. Expand to long form so the original `body` reference survives: + // `{ body }` → `{ forwardedProps: body }`. + const propAsAny = oldProp as unknown as { + shorthand?: boolean + value?: { type?: string; name?: string } + } + if ( + propAsAny.shorthand && + propAsAny.value?.type === 'Identifier' && + propAsAny.value.name === oldName + ) { + // Leave value pointing at the original identifier; only flip + // `shorthand` off and rename the key. + propAsAny.shorthand = false + } + oldProp.key.name = newName + return 'renamed' + } + return 'skipped' +} + +interface RenameStats { + renamed: number + conflicts: Array<{ filePath: string; line?: number; site: string }> +} + +/** + * Rename `oldKey` → `newKey` on the first object-literal argument of every + * call site whose callee matches `predicate`. Conflicts (both keys already + * present) are recorded so the caller can surface them via `api.report`. + */ +function renameKeyOnCalls( + j: JSCodeshift, + root: Collection, + filePath: string, + predicate: (path: ASTPath) => boolean, + oldKey: string, + newKey: string, + siteLabel: string, + stats: RenameStats, +): void { + root + .find(j.CallExpression) + .filter(predicate) + .forEach((path) => { + const args = path.node.arguments + const objArg = args.find( + (a): a is ObjectExpression => a.type === 'ObjectExpression', + ) + if (!objArg) return + const outcome = renameProperty(objArg, oldKey, newKey) + if (outcome === 'renamed') { + stats.renamed++ + } else if (outcome === 'conflict') { + stats.conflicts.push({ + filePath, + line: path.node.loc?.start.line, + site: siteLabel, + }) + } + }) +} + +export default function transform( + file: FileInfo, + api: API, +): string | null | undefined { + const j = api.jscodeshift + const root = j(file.source) + const facts = collectImportFacts(j, root) + + // Bail out early if no relevant imports — keeps the codemod a no-op + // on files that just happen to use a `body` key in unrelated code. + if ( + !facts.hasUseChat && + !facts.hasChatClient && + !facts.hasCreateChat && + !facts.hasChat + ) { + return file.source + } + + const stats: RenameStats = { renamed: 0, conflicts: [] } + + // 1. useChat({ body }) → useChat({ forwardedProps }) + if (facts.hasUseChat) { + renameKeyOnCalls( + j, + root, + file.path, + (path) => { + const callee = path.node.callee + return callee.type === 'Identifier' && callee.name === 'useChat' + }, + 'body', + 'forwardedProps', + 'useChat({ body })', + stats, + ) + } + + // 2. new ChatClient({ body }) → new ChatClient({ forwardedProps }) + if (facts.hasChatClient) { + root.find(j.NewExpression).forEach((path) => { + const callee = path.node.callee + if (callee.type !== 'Identifier' || callee.name !== 'ChatClient') return + const objArg = path.node.arguments.find( + (a): a is ObjectExpression => a.type === 'ObjectExpression', + ) + if (!objArg) return + const outcome = renameProperty(objArg, 'body', 'forwardedProps') + if (outcome === 'renamed') { + stats.renamed++ + } else if (outcome === 'conflict') { + stats.conflicts.push({ + filePath: file.path, + line: path.node.loc?.start.line, + site: 'new ChatClient({ body })', + }) + } + }) + + // 3. .updateOptions({ body }) → { forwardedProps } + // + // We can't always tell statically that `` is a ChatClient + // instance, so we gate the whole transform on the file importing + // ChatClient (already checked) and pattern-match on the method + // name. `updateOptions` is distinctive enough that false matches + // are unlikely in a TanStack AI codebase. + renameKeyOnCalls( + j, + root, + file.path, + (path) => { + const callee = path.node.callee + return ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.property.type === 'Identifier' && + callee.property.name === 'updateOptions' + ) + }, + 'body', + 'forwardedProps', + 'updateOptions({ body })', + stats, + ) + } + + // 4. chat.updateBody(x) → chat.updateForwardedProps(x) + if (facts.hasCreateChat) { + root.find(j.MemberExpression).forEach((path) => { + if (path.node.computed) return + if (path.node.property.type !== 'Identifier') return + if (path.node.property.name !== 'updateBody') return + path.node.property.name = 'updateForwardedProps' + stats.renamed++ + }) + } + + // 5. chat({ conversationId }) → chat({ threadId }) + if (facts.hasChat) { + renameKeyOnCalls( + j, + root, + file.path, + (path) => { + const callee = path.node.callee + return callee.type === 'Identifier' && callee.name === 'chat' + }, + 'conversationId', + 'threadId', + 'chat({ conversationId })', + stats, + ) + } + + // Surface conflicts so users can resolve them by hand. The codemod + // intentionally leaves `{ body, forwardedProps }` (or other dual-key) + // objects alone — silently swallowing them would either drop one + // value or produce a duplicate-key object literal. + for (const conflict of stats.conflicts) { + const where = + conflict.line !== undefined + ? `${conflict.filePath}:${conflict.line}` + : conflict.filePath + api.report( + `[ag-ui-compliance] ${where} — ${conflict.site}: both legacy and canonical keys are already present; left alone. Merge by hand.`, + ) + } + + return stats.renamed > 0 ? root.toSource() : file.source +} + +// jscodeshift inspects `.parser` on the default export to choose its +// AST flavor. We support both .ts and .tsx out of the box. +;(transform as unknown as { parser: string }).parser = 'tsx' diff --git a/codemods/package.json b/codemods/package.json new file mode 100644 index 000000000..5bee64e8f --- /dev/null +++ b/codemods/package.json @@ -0,0 +1,19 @@ +{ + "name": "@tanstack/ai-codemods", + "version": "0.0.0", + "private": true, + "description": "jscodeshift codemods for migrating TanStack AI client APIs", + "type": "module", + "scripts": { + "test": "vitest run", + "test:dev": "vitest", + "ag-ui-compliance": "node ./run.mjs ag-ui-compliance" + }, + "devDependencies": { + "@types/jscodeshift": "^17.1.1", + "@types/node": "^24.10.1", + "jscodeshift": "^17.1.1", + "typescript": "5.9.3", + "vitest": "^4.0.14" + } +} diff --git a/codemods/run.mjs b/codemods/run.mjs new file mode 100644 index 000000000..a8be02e6d --- /dev/null +++ b/codemods/run.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +/** + * Codemod runner. + * + * Resolves user-supplied glob/file arguments against the original + * `INIT_CWD` (the directory the user ran `pnpm` from) instead of the + * package directory pnpm switches into when `--filter ... exec` is used. + * + * Usage (from the repo root): + * pnpm codemod:ag-ui-compliance "src/**\/*.{ts,tsx}" + * + * The first argument is the codemod folder name under `codemods/`. + * The remainder are jscodeshift's own arguments (paths and flags). + */ +import { spawnSync } from 'node:child_process' +import { dirname, isAbsolute, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import process from 'node:process' + +const here = dirname(fileURLToPath(import.meta.url)) +const [codemodName, ...rest] = process.argv.slice(2) + +if (!codemodName) { + console.error('Usage: codemod-runner [jscodeshift args...]') + process.exit(2) +} + +const transformPath = resolve(here, codemodName, 'transform.ts') +// `INIT_CWD` is set by pnpm to the directory the user ran `pnpm` from +// (preserved across `--filter ... exec`). Fall back to the parent shell's +// CWD if the runner is invoked outside pnpm. +const userCwd = process.env['INIT_CWD'] || process.cwd() + +// Resolve any positional path-like argument against `userCwd`. Flag +// arguments (start with `-`) are passed through untouched. +const args = ['--parser=tsx', '-t', transformPath] +for (const arg of rest) { + if (arg.startsWith('-') || isAbsolute(arg)) { + args.push(arg) + } else { + args.push(resolve(userCwd, arg)) + } +} + +const result = spawnSync('jscodeshift', args, { + cwd: here, + stdio: 'inherit', + shell: process.platform === 'win32', +}) +process.exit(result.status ?? 1) diff --git a/codemods/tsconfig.json b/codemods/tsconfig.json new file mode 100644 index 000000000..17d20f217 --- /dev/null +++ b/codemods/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "allowJs": true, + "noEmit": true, + "isolatedModules": true, + "verbatimModuleSyntax": false + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "**/__testfixtures__/**"] +} diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md index 91de53d95..620a97ec4 100644 --- a/docs/advanced/middleware.md +++ b/docs/advanced/middleware.md @@ -319,7 +319,8 @@ Every hook receives a `ChatMiddlewareContext` as its first argument. It provides |-------|------|-------------| | `requestId` | `string` | Unique ID for this chat request | | `streamId` | `string` | Unique ID for this stream | -| `conversationId` | `string \| undefined` | User-provided conversation ID | +| `threadId` | `string` | AG-UI thread identifier. Resolves to caller-provided `threadId` (or legacy `conversationId`), or an auto-generated value if neither is supplied. Use this for event correlation. | +| `conversationId` | `string \| undefined` | **Deprecated** alias of `threadId`. Always equals `ctx.threadId`; retained so middleware written before the AG-UI rename keeps working. New middleware should read `ctx.threadId`. | | `phase` | `ChatMiddlewarePhase` | Current lifecycle phase | | `iteration` | `number` | Agent loop iteration (0-indexed) | | `chunkIndex` | `number` | Running count of chunks yielded | diff --git a/docs/advanced/multimodal-content.md b/docs/advanced/multimodal-content.md index f30301e1b..25683d233 100644 --- a/docs/advanced/multimodal-content.md +++ b/docs/advanced/multimodal-content.md @@ -379,14 +379,14 @@ await client.sendMessage({ }) ``` -### Per-Message Body Parameters +### Per-Message Forwarded Props -The second parameter allows you to pass additional body parameters for that specific request. These are shallow-merged with the client's base body configuration, with per-message parameters taking priority: +The second parameter allows you to pass additional `forwardedProps` for that specific request. These are shallow-merged with the client's base `forwardedProps` configuration, with per-message values taking priority: ```typescript const client = new ChatClient({ connection: fetchServerSentEvents('/api/chat'), - body: { model: 'gpt-5' }, // Base body params + forwardedProps: { model: 'gpt-5' }, // Base forwarded props }) // Override model for this specific message @@ -394,10 +394,10 @@ await client.sendMessage('Analyze this complex problem', { model: 'gpt-5', temperature: 0.2, }) - - ``` +> **Note:** The legacy `body` constructor option is still supported but deprecated. New code should use `forwardedProps`. Both populate the same wire field. + ### React Example Here's how to use multimodal messages in a React component: diff --git a/docs/advanced/runtime-adapter-switching.md b/docs/advanced/runtime-adapter-switching.md index ba13debb3..96f4cafcf 100644 --- a/docs/advanced/runtime-adapter-switching.md +++ b/docs/advanced/runtime-adapter-switching.md @@ -34,11 +34,12 @@ const adapters = { } // In your request handler: -const provider: Provider = request.body.provider || 'openai' +const body = await request.json() +const provider: Provider = body.forwardedProps?.provider || 'openai' const stream = chat({ adapter: adapters[provider](), - messages, + messages: body.messages, }) ``` @@ -89,15 +90,16 @@ export const Route = createFileRoute('/api/chat')({ POST: async ({ request }) => { const abortController = new AbortController() const body = await request.json() - const { messages, data } = body - - const provider: Provider = data?.provider || 'openai' + // `forwardedProps` is the AG-UI field set by `useChat({ forwardedProps })`. + // The legacy `body.data.provider` access still works (mirrored on the + // wire for backward compatibility) but `forwardedProps` is preferred. + const provider: Provider = body.forwardedProps?.provider || 'openai' const stream = chat({ adapter: adapters[provider](), tools: [...], systemPrompts: [...], - messages, + messages: body.messages, abortController, }) diff --git a/docs/api/ai-client.md b/docs/api/ai-client.md index 379e58589..bf9f6eda6 100644 --- a/docs/api/ai-client.md +++ b/docs/api/ai-client.md @@ -46,7 +46,9 @@ const client = new ChatClient({ - `connection` - Connection adapter for streaming - `initialMessages?` - Initial messages array - `id?` - Unique identifier for this chat instance -- `body?` - Additional body parameters to send +- `threadId?` - Thread ID for AG-UI run correlation. Persists across sends; auto-generated if omitted +- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field +- `body?` - **Deprecated.** Use `forwardedProps` instead. Still works — values are merged into `forwardedProps` on the wire and mirrored under the legacy `data` field for backward compatibility - `onResponse?` - Callback when response is received - `onChunk?` - Callback when stream chunk is received - `onFinish?` - Callback when response finishes @@ -174,10 +176,12 @@ Creates a custom connection adapter. import { stream } from "@tanstack/ai-client"; const adapter = stream(async (messages, data, signal) => { - // Custom implementation + // `data` here carries the merged forwardedProps. The fetch-based + // adapters serialize it as the AG-UI `RunAgentInput.forwardedProps` + // field on the wire (with a backward-compat `data` mirror). const response = await fetch("/api/chat", { method: "POST", - body: JSON.stringify({ messages, ...data }), + body: JSON.stringify({ messages, forwardedProps: data }), signal, }); return processStream(response); diff --git a/docs/api/ai-preact.md b/docs/api/ai-preact.md index 91ae5b7f5..17dd706eb 100644 --- a/docs/api/ai-preact.md +++ b/docs/api/ai-preact.md @@ -65,7 +65,9 @@ Extends `ChatClientOptions` from `@tanstack/ai-client`: - `tools?` - Array of client tool implementations (with `.client()` method) - `initialMessages?` - Initial messages array - `id?` - Unique identifier for this chat instance -- `body?` - Additional body parameters to send +- `threadId?` - Thread ID for AG-UI run correlation. Persists across sends; auto-generated if omitted +- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-4o' }`) +- `body?` - **Deprecated.** Use `forwardedProps` instead. Still works for backward compatibility; values are merged into `forwardedProps` on the wire - `onResponse?` - Callback when response is received - `onChunk?` - Callback when stream chunk is received - `onFinish?` - Callback when response finishes diff --git a/docs/api/ai-react.md b/docs/api/ai-react.md index c736e207a..ac10e1667 100644 --- a/docs/api/ai-react.md +++ b/docs/api/ai-react.md @@ -65,7 +65,9 @@ Extends `ChatClientOptions` from `@tanstack/ai-client`: - `tools?` - Array of client tool implementations (with `.client()` method) - `initialMessages?` - Initial messages array - `id?` - Unique identifier for this chat instance -- `body?` - Additional body parameters to send +- `threadId?` - Thread ID for AG-UI run correlation. Persists across sends; auto-generated if omitted +- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-4o' }`) +- `body?` - **Deprecated.** Use `forwardedProps` instead. Still works for backward compatibility; values are merged into `forwardedProps` on the wire - `onResponse?` - Callback when response is received - `onChunk?` - Callback when stream chunk is received - `onFinish?` - Callback when response finishes diff --git a/docs/api/ai-solid.md b/docs/api/ai-solid.md index daaade764..8bf22bf7a 100644 --- a/docs/api/ai-solid.md +++ b/docs/api/ai-solid.md @@ -66,7 +66,9 @@ Extends `ChatClientOptions` from `@tanstack/ai-client`: - `tools?` - Array of client tool implementations (with `.client()` method) - `initialMessages?` - Initial messages array - `id?` - Unique identifier for this chat instance -- `body?` - Additional body parameters to send +- `threadId?` - Thread ID for AG-UI run correlation. Persists across sends; auto-generated if omitted +- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-4o' }`) +- `body?` - **Deprecated.** Use `forwardedProps` instead. Still works for backward compatibility; values are merged into `forwardedProps` on the wire - `onResponse?` - Callback when response is received - `onChunk?` - Callback when stream chunk is received - `onFinish?` - Callback when response finishes diff --git a/docs/api/ai-svelte.md b/docs/api/ai-svelte.md index 09e434032..770e3d90f 100644 --- a/docs/api/ai-svelte.md +++ b/docs/api/ai-svelte.md @@ -61,7 +61,9 @@ Extends `ChatClientOptions` from `@tanstack/ai-client` (minus internal state cal - `tools?` - Array of client tool implementations (with `.client()` method) - `initialMessages?` - Initial messages array - `id?` - Unique identifier for this chat instance -- `body?` - Additional body parameters to send +- `threadId?` - Thread ID for AG-UI run correlation. Persists across sends; auto-generated if omitted +- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-4o' }`) +- `body?` - **Deprecated.** Use `forwardedProps` instead. Still works for backward compatibility; values are merged into `forwardedProps` on the wire - `live?` - Enable live subscription mode (subscribes on creation) - `onResponse?` - Callback when response is received - `onChunk?` - Callback when stream chunk is received @@ -100,7 +102,9 @@ interface CreateChatReturn { readonly sessionGenerating: boolean; setMessages: (messages: UIMessage[]) => void; clear: () => void; + /** @deprecated Use `updateForwardedProps` instead. */ updateBody: (body: Record) => void; + updateForwardedProps: (forwardedProps: Record) => void; } ``` @@ -109,7 +113,7 @@ interface CreateChatReturn { - **`create*` naming** -- factory functions, not hooks. Call outside of any lifecycle. - **Reactive getters** -- state properties (`messages`, `isLoading`, `error`, `status`, `isSubscribed`, `connectionStatus`, `sessionGenerating`) are Svelte 5 `$state` via getters. Access directly (e.g., `chat.messages`, not `chat.messages.value`). - **No automatic cleanup** -- unlike React/Vue/Solid, `createChat` does not auto-dispose. Call `chat.stop()` manually when the component unmounts (e.g., in `onDestroy` or an `$effect` return). -- **`updateBody()`** -- update request body parameters dynamically (e.g., for model selection). In Vue, body changes are synced via `watch`; in Svelte, call this method explicitly. +- **`updateForwardedProps()`** -- update AG-UI `forwardedProps` dynamically (e.g., for model selection). In Vue, changes to the `forwardedProps` option are synced via `watch`; in Svelte, call this method explicitly. The legacy `updateBody()` is still available but deprecated. - **`.svelte.ts` files** -- source files use the `.svelte.ts` extension for Svelte 5 rune support. ## Connection Adapters diff --git a/docs/api/ai-vue.md b/docs/api/ai-vue.md index be3d6f681..d1831b6c1 100644 --- a/docs/api/ai-vue.md +++ b/docs/api/ai-vue.md @@ -61,7 +61,9 @@ Extends `ChatClientOptions` from `@tanstack/ai-client` (minus internal state cal - `tools?` - Array of client tool implementations (with `.client()` method) - `initialMessages?` - Initial messages array - `id?` - Unique identifier for this chat instance -- `body?` - Additional body parameters to send (reactive -- changes are synced automatically via `watch`) +- `threadId?` - Thread ID for AG-UI run correlation. Persists across sends; auto-generated if omitted +- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (reactive -- changes are synced automatically via `watch`) +- `body?` - **Deprecated.** Use `forwardedProps` instead. Still works for backward compatibility; values are merged into `forwardedProps` on the wire (reactive) - `live?` - Enable live subscription mode (auto-subscribes/unsubscribes) - `onResponse?` - Callback when response is received - `onChunk?` - Callback when stream chunk is received diff --git a/docs/api/ai.md b/docs/api/ai.md index da0970d14..0f50d74cd 100644 --- a/docs/api/ai.md +++ b/docs/api/ai.md @@ -41,12 +41,15 @@ const stream = chat({ ### Parameters - `adapter` - An AI adapter instance with model (e.g., `openaiText('gpt-5.2')`, `anthropicText('claude-sonnet-4-5')`) -- `messages` - Array of chat messages +- `messages` - Array of chat messages. Accepts mixed `UIMessage | ModelMessage` arrays — internal conversion handles AG-UI fan-out dedup, drops `reasoning`/`activity`, and collapses `developer` → `system` - `tools?` - Array of tools for function calling - `systemPrompts?` - System prompts to prepend to messages - `agentLoopStrategy?` - Strategy for agent loops (default: `maxIterations(5)`) - `abortController?` - AbortController for cancellation - `modelOptions?` - Model-specific options (renamed from `providerOptions`) +- `threadId?` - AG-UI thread identifier propagated into `RUN_STARTED` events for run correlation +- `runId?` - AG-UI run identifier (auto-generated if omitted) +- `parentRunId?` - AG-UI parent run identifier for nested runs ### Returns @@ -191,6 +194,73 @@ return toServerSentEventsResponse(stream); A `Response` object suitable for HTTP endpoints with SSE headers (`Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`). +## `chatParamsFromRequest(req)` + +Reads an HTTP `Request`, parses its JSON body, and validates it against AG-UI `RunAgentInputSchema`. Returns parsed chat parameters ready to spread into `chat()`. On a malformed body, **throws a 400 `Response`** that frameworks like TanStack Start, SolidStart, Remix, and React Router 7 return to the client automatically. + +```typescript +import { chat, chatParamsFromRequest, toServerSentEventsResponse } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; + +export async function POST(req: Request) { + const params = await chatParamsFromRequest(req); + const stream = chat({ + adapter: openaiText("gpt-4o"), + messages: params.messages, + tools: serverTools, + }); + return toServerSentEventsResponse(stream); +} +``` + +### Parameters + +- `req` - An incoming `Request` whose JSON body conforms to AG-UI `RunAgentInput` + +### Returns + +A promise resolving to `{ messages, threadId, runId, parentRunId?, tools, forwardedProps, state, context }`. + +> **Framework note.** Next.js Route Handlers, SvelteKit, Hono, and raw Node do not auto-handle thrown `Response` objects. In those, wrap with try/catch or use `chatParamsFromRequestBody(await req.json())` directly. + +## `chatParamsFromRequestBody(body)` + +Lower-level variant of `chatParamsFromRequest` that validates an already-parsed body. Rejects with an `AGUIError` on malformed input. Use this when you need explicit error handling control. + +```typescript +const body = await req.json(); +try { + const params = await chatParamsFromRequestBody(body); + // ... +} catch (error) { + return new Response(error.message, { status: 400 }); +} +``` + +## `mergeAgentTools(serverTools, clientTools)` + +Merges a server-side tool registry with the AG-UI client-declared tools received in the request payload. Server tools win on name collision; client-only tools become no-execute stubs that the runtime dispatches via `ClientToolRequest` events. + +```typescript +import { chat, chatParamsFromRequest, mergeAgentTools } from "@tanstack/ai"; + +const params = await chatParamsFromRequest(req); +const stream = chat({ + adapter: openaiText("gpt-4o"), + messages: params.messages, + tools: mergeAgentTools(serverTools, params.tools), +}); +``` + +### Parameters + +- `serverTools` - The server's `toolDefinition().server(...)` registry, keyed by tool name +- `clientTools` - The `tools` array from `chatParamsFromRequest`'s return value + +### Returns + +A merged tool record suitable for `chat({ tools })`. + ## `maxIterations(count)` Creates an agent loop strategy that limits iterations. diff --git a/docs/config.json b/docs/config.json index 89d4f5abc..b4aa34c80 100644 --- a/docs/config.json +++ b/docs/config.json @@ -215,6 +215,10 @@ { "label": "From Vercel AI SDK", "to": "migration/migration-from-vercel-ai" + }, + { + "label": "AG-UI Client Compliance", + "to": "migration/ag-ui-compliance" } ] }, diff --git a/docs/getting-started/quick-start-svelte.md b/docs/getting-started/quick-start-svelte.md index 0d3b6b14d..39c15189e 100644 --- a/docs/getting-started/quick-start-svelte.md +++ b/docs/getting-started/quick-start-svelte.md @@ -46,13 +46,14 @@ export const POST: RequestHandler = async ({ request }) => { ) } - const { messages, conversationId } = await request.json() + const body = await request.json() try { + // `chat()` uses the AG-UI `threadId` for devtools correlation + // when available — no need to plumb `conversationId` manually. const stream = chat({ adapter: openaiText('gpt-4o'), - messages, - conversationId, + messages: body.messages, }) return toServerSentEventsResponse(stream) diff --git a/docs/getting-started/quick-start-vue.md b/docs/getting-started/quick-start-vue.md index 23547fbcc..a993d31e1 100644 --- a/docs/getting-started/quick-start-vue.md +++ b/docs/getting-started/quick-start-vue.md @@ -41,7 +41,7 @@ const app = express() app.use(express.json()) app.post('/api/chat', async (req, res) => { - const { messages, conversationId } = req.body + const { messages } = req.body if (!process.env.OPENAI_API_KEY) { res.status(500).json({ error: 'OPENAI_API_KEY not configured' }) @@ -49,10 +49,11 @@ app.post('/api/chat', async (req, res) => { } try { + // `chat()` uses the AG-UI `threadId` for devtools correlation + // when available — no need to plumb `conversationId` manually. const stream = chat({ adapter: openaiText('gpt-4o'), messages, - conversationId, }) const response = toServerSentEventsResponse(stream) diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index fb5e92577..e8f21f7d4 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -58,14 +58,14 @@ export const Route = createFileRoute("/api/chat")({ ); } - const { messages, conversationId } = await request.json(); + const body = await request.json(); try { - // Create a streaming chat response + // Create a streaming chat response. `chat()` reads the AG-UI + // `threadId` for devtools correlation when available. const stream = chat({ adapter: openaiText("gpt-5.2"), - messages, - conversationId, + messages: body.messages, }); // Convert stream to HTTP response @@ -108,14 +108,14 @@ export async function POST(request: Request) { ); } - const { messages, conversationId } = await request.json(); + const body = await request.json(); try { - // Create a streaming chat response + // Create a streaming chat response. `chat()` reads the AG-UI + // `threadId` for devtools correlation when available. const stream = chat({ adapter: openaiText("gpt-5.2"), - messages, - conversationId + messages: body.messages, }); // Convert stream to HTTP response diff --git a/docs/migration/ag-ui-compliance.md b/docs/migration/ag-ui-compliance.md new file mode 100644 index 000000000..438b297b3 --- /dev/null +++ b/docs/migration/ag-ui-compliance.md @@ -0,0 +1,295 @@ +--- +title: Migrating to AG-UI Client-to-Server Compliance +--- + +# Migrating to AG-UI Client-to-Server Compliance + +> **TL;DR:** This release is fully backward compatible. Upgrade `@tanstack/ai` and `@tanstack/ai-client` together and existing code keeps working — both the legacy `body` client option and the legacy `data` server-side wire field continue to function unchanged. The HTTP wire format gained AG-UI `RunAgentInput` fields (`threadId`, `runId`, `tools`, `forwardedProps`, etc.) for full AG-UI compliance, and the legacy fields are emitted alongside them as a deprecation bridge. New helpers (`chatParamsFromRequest`, `mergeAgentTools`) are available for opt-in conveniences. Migrate to the new names when convenient — both `body` (client) and `data` (wire) will be removed in a future major release. + +## What changed + +`@tanstack/ai-client` now POSTs an AG-UI 0.0.52 `RunAgentInput` request body. The previous fields (`messages`, `data`) are emitted alongside the new AG-UI fields so existing servers and clients keep working without code changes. + +### Old wire shape + +```json +{ + "messages": [...], + "data": {...} +} +``` + +### New wire shape (with deprecation bridge) + +```json +{ + "threadId": "thread-...", + "runId": "run-...", + "state": {}, + "messages": [...], + "tools": [...], + "context": [], + "forwardedProps": {...}, + "data": {...} +} +``` + +`forwardedProps` and `data` carry the same content. New servers should read `forwardedProps`; legacy servers reading `data` keep working unchanged. The `data` field will be removed in a future major release. + +The `messages` array carries TanStack `UIMessage` anchors with `parts` intact, plus AG-UI mirror fields (`content`, `toolCalls`) so strict AG-UI servers can parse it. Tool results and thinking parts are additionally emitted as separate `{role:'tool',...}` and `{role:'reasoning',...}` fan-out messages alongside the anchors. + +## Backward compatibility & deprecation timeline + +This release introduces three compatibility bridges: + +| Surface | Before | After (deprecated, still works) | Recommended | +|---|---|---|---| +| Client option (`useChat`, `ChatClient`) | `body: { ... }` | `body: { ... }` | `forwardedProps: { ... }` | +| Server wire field | `body.data.X` | `body.data.X` (emitted as a mirror of `forwardedProps`) | `body.forwardedProps.X`, or `params.forwardedProps.X` via `chatParamsFromRequest` | +| Server `chat()` option | `conversationId` | `conversationId` (still accepted) | `threadId` (or rely on `chatParamsFromRequest`) | + +All three bridges will be removed in the next major release. Until then, you can mix old and new freely — if both `body` and `forwardedProps` are passed to `useChat`, they are merged with `forwardedProps` winning on key collision. + +### Automated codemod + +A jscodeshift codemod is available for the client-side renames. Run it against your codebase to flip every `useChat({ body })`, `new ChatClient({ body })`, `updateOptions({ body })`, Svelte `updateBody(...)`, and `chat({ conversationId })` to its canonical name in one pass: + +```bash +npx jscodeshift \ + --parser=tsx \ + -t https://raw.githubusercontent.com/TanStack/ai/main/codemods/ag-ui-compliance/transform.ts \ + "src/**/*.{ts,tsx}" +``` + +Add `--dry --print` to preview changes first. The codemod is import-source–gated, so files that don't import from `@tanstack/ai*` packages are left untouched. See [`codemods/ag-ui-compliance/README.md`](https://github.com/TanStack/ai/blob/main/codemods/ag-ui-compliance/README.md) for the full transform list, conflict-handling rules, and limitations. + +> **Server-side `body.data.X` rewrites are not automated.** Detecting whether a given `body.data.foo` read belongs to a TanStack AI route handler vs. unrelated code is unreliable in a syntactic codemod. Migrate those by hand using the Tier 2 / Tier 3 recipes below. + +### `conversationId` → `threadId` + +`conversationId` was the pre-AG-UI name for "a stable identifier for this conversation, used to correlate client and server devtools events." AG-UI's `threadId` is the same concept under the standard name. **`conversationId` is now a deprecated alias of `threadId` throughout the API** — passing either name resolves to the same internal value. + +**What changed on the wire:** the client no longer auto-emits `forwardedProps.conversationId`. It now sends only the AG-UI top-level `threadId` field. Anyone who explicitly sets `useChat({ forwardedProps: { conversationId } })` (or the legacy `body`) still has their value passed through unchanged. + +**What this means for server code:** + +- **Server code that doesn't reference `conversationId` is unaffected.** When `chat({ conversationId })` is omitted, the runtime auto-generates a stable `threadId` per request and uses it for devtools event correlation. +- **`chat({ conversationId: 'foo' })` still works** — `conversationId` is now a deprecated alias for `threadId`, resolved internally. No code change required. +- **`chat({ threadId: 'foo' })` is the canonical form** — prefer it in new code. If both are passed, `threadId` wins. +- **`TextOptions.conversationId` is `@deprecated`** in JSDoc and will be removed in a future major release. + +> **One real behavior change to verify.** If your server reads `body.forwardedProps?.conversationId` (or the legacy `body.data?.conversationId`) and threads it into `chat({ conversationId })`, the value will now be `undefined` for any client running the upgraded `@tanstack/ai-client`, because the client no longer auto-emits `conversationId`. The fall-back to an auto-generated `threadId` keeps devtools correlation working *within* a single request, but **threadId stability across requests now depends on the client sending its own `threadId`** (which `ChatClient` does — see the AG-UI top-level `threadId` field surfaced via `params.threadId`). To restore the prior cross-request stable identifier, switch the server to read `params.threadId` and pass it to `chat({ threadId: params.threadId })`, or rely on the auto-fallback if cross-request stability is not required. + +**Custom middleware:** `ChatMiddlewareContext` now exposes both `ctx.threadId` (canonical) and `ctx.conversationId` (deprecated alias, always equal to `ctx.threadId`). New middleware should read `ctx.threadId`; existing middleware reading `ctx.conversationId` keeps working. + +```ts +// Before — explicit conversationId plumbing +const params = await chatParamsFromRequest(req) +chat({ + messages: params.messages, + conversationId: params.forwardedProps.conversationId, // ← auto-emitted by old client +}) + +// After — drop the plumbing entirely +const params = await chatParamsFromRequest(req) +chat({ messages: params.messages }) +// devtools correlation auto-uses the resolved threadId +``` + +## Server endpoint upgrade — choose your tier + +The upgrade is **opt-in**: pick the tier that matches the features you use. Most servers fall into Tier 1 and need no code changes. + +### Tier 1 — Minimum (no changes for most servers) + +Keep reading `body.messages` and pass it through. `chat()` accepts mixed `UIMessage | ModelMessage` arrays and handles all AG-UI message-shape quirks internally — fan-out tool dedup, dropping `reasoning`/`activity`, collapsing `developer` → `system`. + +```ts +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' + +export async function POST(req: Request) { + const body = await req.json() + const provider = body.data?.provider // ← still works (legacy mirror) + // or, equivalently and recommended: + // const provider = body.forwardedProps?.provider + + const stream = chat({ + adapter: openaiText('gpt-4o'), + messages: body.messages, // AG-UI mixed shape — works directly + tools: serverTools, + }) + return toServerSentEventsResponse(stream) +} +``` + +If your existing endpoint reads `body.data.X`, **leave it as-is** — the wire emits a `data` field that mirrors `forwardedProps` exactly until the next major release. Migrate to `body.forwardedProps.X` (or Tier 2's `params.forwardedProps.X`) at your convenience. + +### Tier 2 — Recommended for production + +Adopt `chatParamsFromRequest` when you want any of: + +- **Clean 400 responses** for malformed bodies (Zod validation against `RunAgentInputSchema`). +- **Access to `forwardedProps`** for client-driven options (provider, model, temperature, etc.). +- **Access to AG-UI metadata** like `threadId`, `runId`, and `parentRunId` for observability, logging, or downstream forwarding (the runtime auto-generates these when not supplied; you only need to read them off `params` if you have a use for them). + +```ts +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' + +export async function POST(req: Request) { + const params = await chatParamsFromRequest(req) + const stream = chat({ + adapter: openaiText('gpt-4o'), + messages: params.messages, + tools: serverTools, + }) + return toServerSentEventsResponse(stream) +} +``` + +`chatParamsFromRequest` reads `req.json()`, validates against AG-UI `RunAgentInputSchema`, and on failure **throws a 400 `Response`** that frameworks like TanStack Start, SolidStart, Remix, and React Router 7 return to the client automatically. + +> **Framework note.** Next.js Route Handlers, SvelteKit, Hono, and raw Node do not auto-handle thrown `Response` objects. In those, either wrap the call with try/catch and return the caught Response, or use `chatParamsFromRequestBody(await req.json())` directly with your own error handling. + +### Tier 3 — Optional: let the client advertise its tools + +`mergeAgentTools` lets the client declare its tools in the request payload (`RunAgentInput.tools`) and have them registered server-side on a per-request basis. **This is purely a convenience over the existing pattern**, not a migration requirement. + +If you were already registering client-side tools in your server's `tools` array — even ones without a `.server()` implementation — that pattern still works exactly as before. The runtime treats tools without `execute` as client-side and emits `ClientToolRequest` events; whether the registration came from a static array or `mergeAgentTools` is irrelevant. + +Adopt this tier only if you want the client to drive tool advertisement (e.g., your client surfaces different tools per session and you'd rather not keep the server's static registry in sync). The only delta from Tier 2 is the `tools` line — wrap `serverTools` with `mergeAgentTools(serverTools, params.tools)`: + +```ts +import { + chat, + chatParamsFromRequest, + mergeAgentTools, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' + +export async function POST(req: Request) { + const params = await chatParamsFromRequest(req) + const stream = chat({ + adapter: openaiText('gpt-4o'), + messages: params.messages, + tools: mergeAgentTools(serverTools, params.tools), // ← merges client-declared tools + }) + return toServerSentEventsResponse(stream) +} +``` + +`mergeAgentTools` registers client-declared tools as no-execute stubs server-side. The runtime emits a `ClientToolRequest` event when the model calls one; the client executes via its registered handler and posts the result back. + +## `forwardedProps` security (Tier 2+ only) + +Skip this section if you're on Tier 1. `forwardedProps` is only surfaced when you opt into `chatParamsFromRequest` (or `chatParamsFromRequestBody`). + +`forwardedProps` is arbitrary client-controlled JSON. **Do not** spread it directly into `chat({...})`: + +```ts +// 🚫 UNSAFE — a client could override `adapter`, `model`, `tools`, system prompts, anything +chat({ + adapter: openaiText('gpt-4o'), + ...params, + ...params.forwardedProps, +}) +``` + +Always destructure the specific fields you intend to forward: + +```ts +// ✅ SAFE — explicit allowlist +chat({ + adapter: openaiText('gpt-4o'), + messages: params.messages, + tools: mergeAgentTools(serverTools, params.tools), + temperature: + typeof params.forwardedProps.temperature === 'number' + ? params.forwardedProps.temperature + : undefined, + maxTokens: + typeof params.forwardedProps.maxTokens === 'number' + ? params.forwardedProps.maxTokens + : undefined, +}) +``` + +## Client-side: nothing required, one rename recommended + +`useChat` and the connection adapters (`fetchServerSentEvents`, `fetchHttpStream`) handle the new wire format internally. Existing `UIMessage` state is unchanged. `clientTools(...)` declarations are now automatically advertised to the server in the request payload. + +### `body` → `forwardedProps` (recommended) + +The `body` option on `useChat` / `ChatClient` is now `@deprecated` in favor of `forwardedProps`. Both are accepted, both populate the same wire field. Migrate at your convenience: + +```ts +// Before — still works, but deprecated +useChat({ + connection: fetchServerSentEvents('/api/chat'), + body: { provider: 'openai', model: 'gpt-4o' }, +}) + +// After — recommended +useChat({ + connection: fetchServerSentEvents('/api/chat'), + forwardedProps: { provider: 'openai', model: 'gpt-4o' }, +}) +``` + +If both are passed during a partial migration, `forwardedProps` wins on key collision so stale `body` values don't shadow new ones. + +The Svelte equivalent renames `updateBody` → `updateForwardedProps`. The legacy `updateBody` is retained and marked `@deprecated`. + +### Optional: explicit thread control + +If you instantiated a `ChatClient` directly and want to control the thread identifier, pass `threadId` via the constructor options: + +```ts +const client = new ChatClient({ + threadId: 'persistent-thread-from-storage', + connection: fetchServerSentEvents('/api/chat'), + tools: [/* clientTools */], +}) +``` + +If you don't pass `threadId`, one is generated automatically and persists for the lifetime of the `ChatClient` instance. A fresh `runId` is generated for every send. + +## Tool-merge semantics + +- **Server tools win on name collision.** A tool registered server-side via `toolDefinition().server(...)` always executes server-side. +- **Client-only tools become no-execute stubs** in `chat()` (when registered via `mergeAgentTools`). The runtime emits a `ClientToolRequest` event back to the client; the client's registered handler (via `clientTools(...)`) executes locally and posts the result. +- **Dual-handler (both have it):** server executes, then `chat-client.ts`'s `onToolCall` fires the client's handler as a UI side-effect when the streamed tool result event arrives. The server's result is authoritative for the conversation. + +## Talking to a foreign AG-UI server + +A `@tanstack/ai-client` request hitting a foreign AG-UI server: + +- ✅ Single-turn user messages work — content is mirrored to AG-UI's `content` field. +- ✅ Server-emitted events stream and render correctly. +- ✅ Multi-turn history that includes tool results from prior turns: the foreign server reads them via the AG-UI fan-out duplicates we send (separate `{role:'tool',...}` messages). +- ⚠️ Client-only tools are sent in the AG-UI `tools` field; whether the foreign server actually invokes them depends on its tool-calling logic. + +## Talking to a TanStack server from a foreign AG-UI client + +Pure AG-UI `RunAgentInput` payloads (no TanStack `parts` field) work end-to-end: + +- Tool messages pass through as `ModelMessage` entries with `role: 'tool'`. +- `reasoning` messages are dropped (no LLM-replay equivalent today). +- `activity` messages are dropped (no TanStack equivalent). +- `developer` messages are collapsed to `system` role. + +## `@ag-ui/core` bump + +`@tanstack/ai` now depends on `@ag-ui/core@^0.0.52`. If your code imports types from `@tanstack/ai` that re-export AG-UI types, you may need minor type adjustments — see the changeset for specifics. + +## Out of scope (existing behavior preserved) + +- **Reasoning replay to LLM providers.** TanStack still drops `ThinkingPart` at the `UIMessage`→`ModelMessage` boundary (pre-existing behavior). Providers like Anthropic that require thinking blocks to be replayed for extended thinking continuation remain a separate concern, tracked outside this migration. +- **AG-UI `state` and `context` fields.** Surfaced on `chatParamsFromRequestBody`'s return value but not yet wired into `chat()`. They're available for your endpoint to inspect/forward, but the runtime ignores them. +- **PHP and Python server packages.** No `chatParamsFromRequestBody` parity yet. Their examples temporarily lag on the old shape until the matching helpers ship. diff --git a/examples/ts-react-chat/src/routes/api.tanchat.ts b/examples/ts-react-chat/src/routes/api.tanchat.ts index f571fd9c7..c5a677ef4 100644 --- a/examples/ts-react-chat/src/routes/api.tanchat.ts +++ b/examples/ts-react-chat/src/routes/api.tanchat.ts @@ -1,8 +1,10 @@ import { createFileRoute } from '@tanstack/react-router' import { chat, + chatParamsFromRequestBody, createChatOptions, maxIterations, + mergeAgentTools, toServerSentEventsResponse, } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' @@ -75,6 +77,18 @@ const addToCartToolServer = addToCartToolDef.server((args, context) => { } }) +const serverTools = [ + getGuitars, // Server tool + recommendGuitarToolDef, // No server execute - client will handle + addToCartToolServer, + addToWishListToolDef, + getPersonalGuitarPreferenceToolDef, + // Lazy tools - discovered on demand + compareGuitars, + calculateFinancing, + searchGuitars, +] + const loggingMiddleware: ChatMiddleware = { name: 'logging', onConfig(ctx, config) { @@ -122,13 +136,27 @@ export const Route = createFileRoute('/api/tanchat')({ const abortController = new AbortController() - const body = await request.json() - const { messages, data } = body + let params + try { + params = await chatParamsFromRequestBody(await request.json()) + } catch (error) { + return new Response( + error instanceof Error ? error.message : 'Bad request', + { status: 400 }, + ) + } - // Extract provider and model from data - const provider: Provider = data?.provider || 'openai' - const model: string = data?.model || 'gpt-4o' - const conversationId: string | undefined = data?.conversationId + // Extract provider and model from forwardedProps (sent by the client). + // Provider must be allowlisted against adapterConfig (validated below) + // to avoid SSRF/runtime crashes from arbitrary client-supplied strings. + const requestedProvider = + typeof params.forwardedProps.provider === 'string' + ? params.forwardedProps.provider + : 'openai' + const model: string = + typeof params.forwardedProps.model === 'string' + ? params.forwardedProps.model + : 'gpt-4o' // Pre-define typed adapter configurations with full type inference // Model is passed to the adapter factory function for type-safe autocomplete @@ -144,7 +172,9 @@ export const Route = createFileRoute('/api/tanchat')({ }), openrouter: () => createChatOptions({ - adapter: openRouterText('openai/gpt-5.1'), + adapter: openRouterText( + (model || 'openai/gpt-5.1') as 'openai/gpt-5.1', + ), modelOptions: { reasoning: { effort: 'medium', @@ -188,31 +218,26 @@ export const Route = createFileRoute('/api/tanchat')({ } try { + // Allowlist provider against adapterConfig keys; fall back to openai. + const provider: Provider = + requestedProvider in adapterConfig + ? (requestedProvider as Provider) + : 'openai' // Get typed adapter options using createChatOptions pattern const options = adapterConfig[provider]() - // Note: We cast to AsyncIterable because all chat adapters - // return streams, but TypeScript sees a union of all possible return types + const mergedTools = mergeAgentTools(serverTools, params.tools) + const stream = chat({ ...options, - - tools: [ - getGuitars, // Server tool - recommendGuitarToolDef, // No server execute - client will handle - addToCartToolServer, - addToWishListToolDef, - getPersonalGuitarPreferenceToolDef, - // Lazy tools - discovered on demand - compareGuitars, - calculateFinancing, - searchGuitars, - ], + tools: Object.values(mergedTools), middleware: [loggingMiddleware], systemPrompts: [SYSTEM_PROMPT], agentLoopStrategy: maxIterations(20), - messages, + messages: params.messages, + threadId: params.threadId, + runId: params.runId, abortController, - conversationId, }) return toServerSentEventsResponse(stream, { abortController }) } catch (error: any) { diff --git a/examples/ts-solid-chat/src/routes/api.chat.ts b/examples/ts-solid-chat/src/routes/api.chat.ts index 0b73e29be..fabdfb74a 100644 --- a/examples/ts-solid-chat/src/routes/api.chat.ts +++ b/examples/ts-solid-chat/src/routes/api.chat.ts @@ -1,8 +1,19 @@ import { createFileRoute } from '@tanstack/solid-router' -import { chat, maxIterations, toServerSentEventsResponse } from '@tanstack/ai' +import { + chat, + chatParamsFromRequestBody, + maxIterations, + mergeAgentTools, + toServerSentEventsResponse, +} from '@tanstack/ai' +import type { Tool } from '@tanstack/ai' import { anthropicText } from '@tanstack/ai-anthropic' import { serverTools } from '@/lib/guitar-tools' +const serverToolsRecord: Record = Object.fromEntries( + serverTools.map((t) => [t.name, t]), +) + const SYSTEM_PROMPT = `You are a helpful assistant for a guitar store. CRITICAL INSTRUCTIONS - YOU MUST FOLLOW THIS EXACT WORKFLOW: @@ -53,15 +64,27 @@ export const Route = createFileRoute('/api/chat')({ const abortController = new AbortController() - const { messages } = await request.json() + let params + try { + params = await chatParamsFromRequestBody(await request.json()) + } catch (error) { + return new Response( + error instanceof Error ? error.message : 'Bad request', + { status: 400 }, + ) + } + try { + const mergedTools = mergeAgentTools(serverToolsRecord, params.tools) // Use the stream abort signal for proper cancellation handling const stream = chat({ adapter: anthropicText('claude-sonnet-4-5'), - tools: serverTools, + tools: Object.values(mergedTools), systemPrompts: [SYSTEM_PROMPT], agentLoopStrategy: maxIterations(20), - messages, + messages: params.messages, + threadId: params.threadId, + runId: params.runId, modelOptions: { thinking: { type: 'enabled', diff --git a/examples/ts-svelte-chat/src/routes/api/chat/+server.ts b/examples/ts-svelte-chat/src/routes/api/chat/+server.ts index 6308af357..21a0de167 100644 --- a/examples/ts-svelte-chat/src/routes/api/chat/+server.ts +++ b/examples/ts-svelte-chat/src/routes/api/chat/+server.ts @@ -1,9 +1,12 @@ import { chat, + chatParamsFromRequestBody, createChatOptions, maxIterations, + mergeAgentTools, toServerSentEventsResponse, } from '@tanstack/ai' +import type { Tool } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { ollamaText } from '@tanstack/ai-ollama' import { anthropicText } from '@tanstack/ai-anthropic' @@ -68,7 +71,7 @@ IMPORTANT: Example workflow: User: "I want an acoustic guitar" Step 1: Call getGuitars() -Step 2: Call recommendGuitar(id: "6") +Step 2: Call recommendGuitar(id: "6") Step 3: Done - do NOT add any text after calling recommendGuitar ` @@ -80,6 +83,18 @@ const addToCartToolServer = addToCartToolDef.server((args) => ({ totalItems: args.quantity, })) +const serverToolsList = [ + getGuitars, // Server tool + recommendGuitarToolDef, // No server execute - client will handle + addToCartToolServer, + addToWishListToolDef, + getPersonalGuitarPreferenceToolDef, +] + +const serverTools: Record = Object.fromEntries( + serverToolsList.map((t) => [t.name, t]), +) + export const POST: RequestHandler = async ({ request }) => { // Capture request signal before reading body (it may be aborted after body is consumed) const requestSignal = request.signal @@ -91,28 +106,37 @@ export const POST: RequestHandler = async ({ request }) => { const abortController = new AbortController() + let params try { - const body = await request.json() - const { messages, data } = body + params = await chatParamsFromRequestBody(await request.json()) + } catch (error) { + return new Response( + error instanceof Error ? error.message : 'Bad request', + { status: 400 }, + ) + } - // Extract provider from data - const provider: Provider = data?.provider || 'openai' + // Extract provider from forwardedProps (sent by the client) + const provider: Provider = + typeof params.forwardedProps?.provider === 'string' && + params.forwardedProps.provider in adapterConfig + ? (params.forwardedProps.provider as Provider) + : 'openai' + try { // Get typed adapter options using createOptions pattern const options = adapterConfig[provider]() + const mergedTools = mergeAgentTools(serverTools, params.tools) + const stream = chat({ ...options, - tools: [ - getGuitars, // Server tool - recommendGuitarToolDef, // No server execute - client will handle - addToCartToolServer, - addToWishListToolDef, - getPersonalGuitarPreferenceToolDef, - ], + tools: Object.values(mergedTools), systemPrompts: [SYSTEM_PROMPT], agentLoopStrategy: maxIterations(20), - messages, + messages: params.messages, + threadId: params.threadId, + runId: params.runId, abortController, }) diff --git a/examples/ts-vue-chat/vite.config.ts b/examples/ts-vue-chat/vite.config.ts index 74c3563f1..42140d0a2 100644 --- a/examples/ts-vue-chat/vite.config.ts +++ b/examples/ts-vue-chat/vite.config.ts @@ -2,7 +2,13 @@ import { fileURLToPath, URL } from 'node:url' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import tailwindcss from '@tailwindcss/vite' -import { chat, maxIterations, toServerSentEventsStream } from '@tanstack/ai' +import { + chat, + chatParamsFromRequestBody, + maxIterations, + mergeAgentTools, + toServerSentEventsStream, +} from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { anthropicText } from '@tanstack/ai-anthropic' import { geminiText } from '@tanstack/ai-gemini' @@ -196,10 +202,23 @@ export default defineConfig({ body += chunk } + let params try { - const { messages, data } = JSON.parse(body) - const provider: Provider = data?.provider || 'openai' - const model: string | undefined = data?.model + params = await chatParamsFromRequestBody(JSON.parse(body)) + } catch (error) { + res.statusCode = 400 + res.end(error instanceof Error ? error.message : 'Bad request') + return + } + + try { + const fp = params.forwardedProps as Record + const provider: Provider = + typeof fp.provider === 'string' + ? (fp.provider as Provider) + : 'openai' + const model: string | undefined = + typeof fp.model === 'string' ? fp.model : undefined let adapter @@ -231,18 +250,18 @@ export default defineConfig({ const abortController = new AbortController() + const serverTools = Object.fromEntries( + [getGuitars, addToCartToolServer].map((t) => [t.name, t]), + ) + const stream = chat({ adapter, - tools: [ - getGuitars, - recommendGuitarToolDef, - addToCartToolServer, - addToWishListToolDef, - getPersonalGuitarPreferenceToolDef, - ], + tools: Object.values(mergeAgentTools(serverTools, params.tools)), systemPrompts: [SYSTEM_PROMPT], agentLoopStrategy: maxIterations(20), - messages, + messages: params.messages, + threadId: params.threadId, + runId: params.runId, abortController, }) diff --git a/knip.json b/knip.json index 7ece05b5b..f0de2fd92 100644 --- a/knip.json +++ b/knip.json @@ -15,7 +15,8 @@ "packages/typescript/ai-openai/src/audio/audio-provider-options.ts", "packages/typescript/ai-openai/src/audio/transcribe-provider-options.ts", "packages/typescript/ai-openai/src/image/image-provider-options.ts", - "packages/typescript/ai-devtools/src/production.ts" + "packages/typescript/ai-devtools/src/production.ts", + "codemods/**/__testfixtures__/**" ], "ignoreExportsUsedInFile": true, "workspaces": { diff --git a/package.json b/package.json index 549ac3dcd..ec2f0b9cf 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "test:docs": "node scripts/verify-links.ts", "test:e2e": "pnpm --filter @tanstack/ai-e2e test:e2e", "test:e2e:ui": "pnpm --filter @tanstack/ai-e2e test:e2e:ui", + "codemod:ag-ui-compliance": "pnpm --filter @tanstack/ai-codemods exec node ./run.mjs ag-ui-compliance", "build": "nx affected --skip-nx-cache --targets=build --exclude=examples/**", "build:all": "nx run-many --targets=build --exclude=examples/**", "watch": "pnpm run build:all && env NX_DAEMON=true nx watch --all -- pnpm run build:all", diff --git a/packages/typescript/ai-anthropic/src/adapters/text.ts b/packages/typescript/ai-anthropic/src/adapters/text.ts index f057a18a4..3547fe570 100644 --- a/packages/typescript/ai-anthropic/src/adapters/text.ts +++ b/packages/typescript/ai-anthropic/src/adapters/text.ts @@ -663,6 +663,7 @@ export class AnthropicTextAdapter< threadId, model, timestamp, + parentRunId: options.parentRunId, }) } diff --git a/packages/typescript/ai-client/src/chat-client.ts b/packages/typescript/ai-client/src/chat-client.ts index 060547d08..755a8e2a9 100644 --- a/packages/typescript/ai-client/src/chat-client.ts +++ b/packages/typescript/ai-client/src/chat-client.ts @@ -1,5 +1,6 @@ import { StreamProcessor, + convertSchemaToJsonSchema, generateMessageId, normalizeToUIMessage, } from '@tanstack/ai' @@ -30,7 +31,13 @@ export class ChatClient { private processor: StreamProcessor private connection: SubscribeConnectionAdapter private uniqueId: string - private body: Record = {} + private threadId: string + // Track the legacy `body` option and the canonical `forwardedProps` + // option as separate slots so that `updateOptions({ forwardedProps })` + // doesn't wipe a previously-set `body` (and vice versa). They are + // merged on every send, with `forwardedProps` winning on key collision. + private bodyOption: Record = {} + private forwardedPropsOption: Record = {} private pendingMessageBody: Record | undefined = undefined private isLoading = false private isSubscribed = false @@ -81,7 +88,14 @@ export class ChatClient { constructor(options: ChatClientOptions) { this.uniqueId = options.id || this.generateUniqueId('chat') - this.body = options.body || {} + this.threadId = options.threadId || this.generateUniqueId('thread') + // Both `body` (deprecated) and `forwardedProps` populate the AG-UI + // `RunAgentInput.forwardedProps` wire field. They are stored + // separately so `updateOptions` can replace one without touching the + // other; the merge happens at send time, with `forwardedProps` + // winning on key collision. + this.bodyOption = options.body || {} + this.forwardedPropsOption = options.forwardedProps || {} this.connection = normalizeConnectionAdapter(options.connection) this.events = new DefaultChatClientEventEmitter(this.uniqueId) @@ -154,11 +168,7 @@ export class ChatClient { this.events.textUpdated(this.currentStreamId, messageId, content) } }, - onThinkingUpdate: ( - messageId: string, - _stepId: string, - content: string, - ) => { + onThinkingUpdate: (messageId: string, content: string) => { // Emit thinking update to devtools if (this.currentStreamId) { this.events.thinkingUpdated( @@ -401,7 +411,14 @@ export class ChatClient { // RUN_FINISHED / RUN_ERROR signal run completion — resolve processing // (redundant if onStreamEnd already resolved it, harmless) if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') { - const runId = chunk.type === 'RUN_FINISHED' ? chunk.runId : undefined + // RUN_FINISHED has runId in its schema; RUN_ERROR carries it via the + // AG-UI passthrough so adapters can correlate per-run errors. Extract + // both so a RUN_ERROR with a runId only clears that run, not every + // active run in the session. + const runId = + chunk.type === 'RUN_FINISHED' + ? chunk.runId + : (chunk as { runId?: string }).runId if (runId) { this.activeRunIds.delete(runId) } else if (chunk.type === 'RUN_ERROR') { @@ -596,12 +613,19 @@ export class ChatClient { return false } - // Merge body: base body + per-message body (per-message takes priority) - // Include conversationId for server-side event correlation + // Merge sources for the wire `forwardedProps` field, in priority + // order (later spreads win): + // 1. Legacy `body` option (deprecated). + // 2. Canonical `forwardedProps` option (wins over `body`). + // 3. Per-message `body` arg passed to `sendMessage` (highest). + // The AG-UI standard `threadId` is sent at the wire's top level for + // run/conversation correlation, so we no longer auto-emit a separate + // `conversationId` here — `chat({ threadId })` server-side covers the + // same role for devtools/observability. const mergedBody = { - ...this.body, + ...this.bodyOption, + ...this.forwardedPropsOption, ...this.pendingMessageBody, - conversationId: this.uniqueId, } // Clear the pending message body after use @@ -622,8 +646,31 @@ export class ChatClient { // Set up promise that resolves when onStreamEnd fires const processingComplete = this.waitForProcessing() + // Build per-send run context for AG-UI compliance + // Note: mergedBody already contains the merged this.body + pendingMessageBody + // (pendingMessageBody was cleared above, so we use mergedBody as forwardedProps) + // Convert each client tool's `inputSchema` (a Standard Schema: + // Zod, ArkType, Valibot, etc.) to JSON Schema for the wire. Foreign + // AG-UI servers consuming `RunAgentInput.tools[].parameters` expect + // JSON Schema; sending a Standard Schema instance directly would + // serialize to an unusable shape. + const runContext = { + threadId: this.threadId, + runId: `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + clientTools: Array.from(this.clientToolsRef.current.values()).map( + (t) => ({ + name: t.name, + description: t.description, + parameters: t.inputSchema + ? convertSchemaToJsonSchema(t.inputSchema) + : { type: 'object' }, + }), + ), + forwardedProps: { ...mergedBody }, + } + // Send through normalized connection (pushes chunks to subscription queue) - await this.connection.send(messages, mergedBody, signal) + await this.connection.send(messages, mergedBody, signal, runContext) // Wait for subscription loop to finish processing all chunks await processingComplete @@ -982,7 +1029,9 @@ export class ChatClient { */ updateOptions(options: { connection?: ConnectionAdapter + /** @deprecated Use `forwardedProps` instead. */ body?: Record + forwardedProps?: Record tools?: ReadonlyArray onResponse?: (response?: Response) => void | Promise onChunk?: (chunk: StreamChunk) => void @@ -1018,8 +1067,14 @@ export class ChatClient { this.subscribe() } } + // Replace each slot independently so callers can update one without + // wiping the other. (Passing `undefined` for either field is a "leave + // unchanged" signal — to clear a slot, pass an empty object `{}`.) if (options.body !== undefined) { - this.body = options.body + this.bodyOption = options.body + } + if (options.forwardedProps !== undefined) { + this.forwardedPropsOption = options.forwardedProps } if (options.tools !== undefined) { this.clientToolsRef.current = new Map() diff --git a/packages/typescript/ai-client/src/connection-adapters.ts b/packages/typescript/ai-client/src/connection-adapters.ts index 91d63a146..583ec2db2 100644 --- a/packages/typescript/ai-client/src/connection-adapters.ts +++ b/packages/typescript/ai-client/src/connection-adapters.ts @@ -1,5 +1,10 @@ +import { uiMessagesToWire } from '@tanstack/ai' import type { ModelMessage, StreamChunk, UIMessage } from '@tanstack/ai' +function generateRunId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +} + /** * Merge custom headers into request headers */ @@ -62,6 +67,25 @@ async function* readStreamLines( } } +/** + * Per-send context provided by the chat client to the connection adapter. + * The adapter combines this with serialized messages to build a full + * AG-UI `RunAgentInput` payload. + */ +export interface RunAgentInputContext { + threadId: string + runId: string + parentRunId?: string + /** Client-declared tools to advertise in the request payload. */ + clientTools?: Array<{ + name: string + description: string + parameters: unknown + }> + /** Arbitrary user-controlled passthrough data. */ + forwardedProps?: Record +} + export interface ConnectConnectionAdapter { /** * Connect and return an async iterable of StreamChunks. @@ -70,6 +94,7 @@ export interface ConnectConnectionAdapter { messages: Array | Array, data?: Record, abortSignal?: AbortSignal, + runContext?: RunAgentInputContext, ) => AsyncIterable } @@ -85,6 +110,7 @@ export interface SubscribeConnectionAdapter { messages: Array | Array, data?: Record, abortSignal?: AbortSignal, + runContext?: RunAgentInputContext, ) => Promise } @@ -173,10 +199,15 @@ export function normalizeConnectionAdapter( } })() }, - async send(messages, data, abortSignal) { + async send(messages, data, abortSignal, runContext) { let hasTerminalEvent = false try { - const stream = connection.connect(messages, data, abortSignal) + const stream = connection.connect( + messages, + data, + abortSignal, + runContext, + ) for await (const chunk of stream) { if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') { hasTerminalEvent = true @@ -186,10 +217,12 @@ export function normalizeConnectionAdapter( // If the connect stream ended cleanly without a terminal event, // synthesize RUN_FINISHED so request-scoped consumers can complete. + // Reuse the caller's threadId/runId so client-side activeRunIds tracking matches. if (!abortSignal?.aborted && !hasTerminalEvent) { push({ type: 'RUN_FINISHED', - runId: `run-${Date.now()}`, + threadId: runContext?.threadId, + runId: runContext?.runId ?? `run-${Date.now()}`, model: 'connect-wrapper', timestamp: Date.now(), finishReason: 'stop', @@ -199,6 +232,8 @@ export function normalizeConnectionAdapter( if (!abortSignal?.aborted && !hasTerminalEvent) { push({ type: 'RUN_ERROR', + threadId: runContext?.threadId, + runId: runContext?.runId, timestamp: Date.now(), message: err instanceof Error ? err.message : 'Unknown error in connect()', @@ -268,7 +303,7 @@ export function fetchServerSentEvents( | (() => FetchConnectionOptions | Promise) = {}, ): ConnectConnectionAdapter { return { - async *connect(messages, data, abortSignal) { + async *connect(messages, data, abortSignal, runContext) { // Resolve URL and options if they are functions const resolvedUrl = typeof url === 'function' ? url() : url const resolvedOptions = @@ -279,12 +314,40 @@ export function fetchServerSentEvents( ...mergeHeaders(resolvedOptions.headers), } - // Send messages as-is (UIMessages with parts preserved) - // Server-side TextEngine handles conversion to ModelMessages - const requestBody = { - messages, - data, + // Build AG-UI RunAgentInput payload. + // + // Precedence (later spreads win): static adapter `body` is the base, + // overridden by `runContext.forwardedProps` (constructor body / + // forwardedProps options), overridden by per-message `data` passed + // to `connection.send`. Runtime values win over static config — + // this matches the documented "forwardedProps wins" semantic. + const wireMessages = uiMessagesToWire(messages as Array) + const forwardedProps = { ...resolvedOptions.body, + ...(runContext?.forwardedProps ?? {}), + ...data, + } + const requestBody = { + threadId: runContext?.threadId ?? generateRunId('thread'), + runId: runContext?.runId ?? generateRunId('run'), + ...(runContext?.parentRunId !== undefined && { + parentRunId: runContext.parentRunId, + }), + state: {}, + messages: wireMessages, + tools: runContext?.clientTools ?? [], + context: [], + forwardedProps, + // Backward-compat mirror of `forwardedProps` under the legacy + // field name `data`. Server endpoints that have not migrated + // off the pre-AG-UI shape (`{ messages, data }`) keep working. + // AG-UI strict consumers strip this via `RunAgentInputSchema` + // (see `chatParamsFromRequestBody`). Will be removed when the + // legacy `body` client option is dropped. + // Shallow-cloned so that downstream mutation of `data` (e.g. + // by a logging interceptor or fetch wrapper) cannot corrupt + // `forwardedProps` and vice versa. + data: { ...forwardedProps }, } const fetchClient = resolvedOptions.fetchClient ?? fetch @@ -372,7 +435,7 @@ export function fetchHttpStream( | (() => FetchConnectionOptions | Promise) = {}, ): ConnectConnectionAdapter { return { - async *connect(messages, data, abortSignal) { + async *connect(messages, data, abortSignal, runContext) { // Resolve URL and options if they are functions const resolvedUrl = typeof url === 'function' ? url() : url const resolvedOptions = @@ -383,12 +446,40 @@ export function fetchHttpStream( ...mergeHeaders(resolvedOptions.headers), } - // Send messages as-is (UIMessages with parts preserved) - // Server-side TextEngine handles conversion to ModelMessages - const requestBody = { - messages, - data, + // Build AG-UI RunAgentInput payload. + // + // Precedence (later spreads win): static adapter `body` is the base, + // overridden by `runContext.forwardedProps` (constructor body / + // forwardedProps options), overridden by per-message `data` passed + // to `connection.send`. Runtime values win over static config — + // this matches the documented "forwardedProps wins" semantic. + const wireMessages = uiMessagesToWire(messages as Array) + const forwardedProps = { ...resolvedOptions.body, + ...(runContext?.forwardedProps ?? {}), + ...data, + } + const requestBody = { + threadId: runContext?.threadId ?? generateRunId('thread'), + runId: runContext?.runId ?? generateRunId('run'), + ...(runContext?.parentRunId !== undefined && { + parentRunId: runContext.parentRunId, + }), + state: {}, + messages: wireMessages, + tools: runContext?.clientTools ?? [], + context: [], + forwardedProps, + // Backward-compat mirror of `forwardedProps` under the legacy + // field name `data`. Server endpoints that have not migrated + // off the pre-AG-UI shape (`{ messages, data }`) keep working. + // AG-UI strict consumers strip this via `RunAgentInputSchema` + // (see `chatParamsFromRequestBody`). Will be removed when the + // legacy `body` client option is dropped. + // Shallow-cloned so that downstream mutation of `data` (e.g. + // by a logging interceptor or fetch wrapper) cannot corrupt + // `forwardedProps` and vice versa. + data: { ...forwardedProps }, } const fetchClient = resolvedOptions.fetchClient ?? fetch @@ -445,7 +536,7 @@ export function stream( ) => AsyncIterable, ): ConnectConnectionAdapter { return { - async *connect(messages, data) { + async *connect(messages, data, _abortSignal, _runContext) { // Pass messages as-is (UIMessages with parts preserved) // Server-side chat() handles conversion to ModelMessages yield* streamFactory(messages, data) @@ -476,7 +567,7 @@ export function rpcStream( ) => AsyncIterable, ): ConnectConnectionAdapter { return { - async *connect(messages, data) { + async *connect(messages, data, _abortSignal, _runContext) { // Pass messages as-is (UIMessages with parts preserved) // Server-side chat() handles conversion to ModelMessages yield* rpcCall(messages, data) diff --git a/packages/typescript/ai-client/src/types.ts b/packages/typescript/ai-client/src/types.ts index b705ebbdf..eb2966d30 100644 --- a/packages/typescript/ai-client/src/types.ts +++ b/packages/typescript/ai-client/src/types.ts @@ -205,7 +205,28 @@ export interface ChatClientOptions< id?: string /** - * Additional body parameters to send + * Thread ID to use for this chat session. Persists across sends within + * the session. If omitted, a unique thread ID is generated. + */ + threadId?: string + + /** + * Arbitrary client-controlled JSON forwarded to the server in the + * AG-UI `RunAgentInput.forwardedProps` field. Use this for per-session + * options like provider/model selection or feature flags that the + * server endpoint should read. + * + * Replaces the legacy `body` option. If both are provided, + * `forwardedProps` wins on key collision. + */ + forwardedProps?: Record + + /** + * @deprecated Use `forwardedProps` instead. `body` continues to work + * unchanged — its values are merged into the AG-UI + * `RunAgentInput.forwardedProps` field on the wire and are also + * mirrored under the legacy `data` field for servers that have not + * migrated yet. Will be removed in a future major release. */ body?: Record diff --git a/packages/typescript/ai-client/tests/chat-client.test.ts b/packages/typescript/ai-client/tests/chat-client.test.ts index ec997c868..ca8b1e7c5 100644 --- a/packages/typescript/ai-client/tests/chat-client.test.ts +++ b/packages/typescript/ai-client/tests/chat-client.test.ts @@ -1691,7 +1691,101 @@ describe('ChatClient', () => { expect(capturedData?.maxTokens).toBe(100) // From per-message body }) - it('should include conversationId in merged body', async () => { + it('should accept forwardedProps option and merge into request body', async () => { + const chunks = createTextChunks('Response') + let capturedData: Record | undefined + const adapter = createMockConnectionAdapter({ + chunks, + onConnect: (_messages, data) => { + capturedData = data + }, + }) + + const client = new ChatClient({ + connection: adapter, + forwardedProps: { provider: 'openai', model: 'gpt-4o' }, + }) + + await client.sendMessage('Hello') + + expect(capturedData?.provider).toBe('openai') + expect(capturedData?.model).toBe('gpt-4o') + }) + + it('updateOptions({ forwardedProps }) leaves a previously-set body intact', async () => { + const chunks = createTextChunks('Response') + const captures: Array | undefined> = [] + const adapter = createMockConnectionAdapter({ + chunks, + onConnect: (_messages, data) => { + captures.push(data) + }, + }) + + const client = new ChatClient({ + connection: adapter, + body: { provider: 'openai' }, + forwardedProps: { model: 'gpt-4' }, + }) + + // Replace only `forwardedProps` — `body` must survive. + client.updateOptions({ forwardedProps: { model: 'gpt-4o' } }) + + await client.sendMessage('Hi') + expect(captures[0]?.provider).toBe('openai') + expect(captures[0]?.model).toBe('gpt-4o') + }) + + it('updateOptions({ body }) leaves a previously-set forwardedProps intact', async () => { + const chunks = createTextChunks('Response') + const captures: Array | undefined> = [] + const adapter = createMockConnectionAdapter({ + chunks, + onConnect: (_messages, data) => { + captures.push(data) + }, + }) + + const client = new ChatClient({ + connection: adapter, + body: { provider: 'openai' }, + forwardedProps: { model: 'gpt-4' }, + }) + + client.updateOptions({ body: { provider: 'anthropic' } }) + + await client.sendMessage('Hi') + expect(captures[0]?.provider).toBe('anthropic') + expect(captures[0]?.model).toBe('gpt-4') + }) + + it('should merge body and forwardedProps with forwardedProps winning', async () => { + const chunks = createTextChunks('Response') + let capturedData: Record | undefined + const adapter = createMockConnectionAdapter({ + chunks, + onConnect: (_messages, data) => { + capturedData = data + }, + }) + + const client = new ChatClient({ + connection: adapter, + // Legacy `body` and new `forwardedProps` declared together — + // simulates a mid-migration codebase. + body: { model: 'gpt-4', temperature: 0.7 }, + forwardedProps: { model: 'gpt-4o' }, + }) + + await client.sendMessage('Hello') + + // forwardedProps wins on key collision so partial migrations are sane. + expect(capturedData?.model).toBe('gpt-4o') + // Non-conflicting keys from `body` are still forwarded. + expect(capturedData?.temperature).toBe(0.7) + }) + + it('should not auto-emit `conversationId` in merged body (replaced by AG-UI threadId)', async () => { const chunks = createTextChunks('Response') let capturedData: Record | undefined const adapter = createMockConnectionAdapter({ @@ -1708,7 +1802,33 @@ describe('ChatClient', () => { await client.sendMessage('Hello') - expect(capturedData?.conversationId).toBe('my-conversation') + // `conversationId` was the pre-AG-UI auto-emitted field. The client + // now emits `threadId` at the wire's top level instead; the legacy + // auto-emit was dropped to avoid duplicating the same identifier. + // User-set `forwardedProps.conversationId` would still pass through. + expect(capturedData?.conversationId).toBeUndefined() + }) + + it('should pass through user-set conversationId via forwardedProps', async () => { + const chunks = createTextChunks('Response') + let capturedData: Record | undefined + const adapter = createMockConnectionAdapter({ + chunks, + onConnect: (_messages, data) => { + capturedData = data + }, + }) + + const client = new ChatClient({ + connection: adapter, + forwardedProps: { conversationId: 'user-supplied' }, + }) + + await client.sendMessage('Hello') + + // Backward compat: a user explicitly setting `conversationId` (e.g. + // because their server still reads it) still works unchanged. + expect(capturedData?.conversationId).toBe('user-supplied') }) it('should clear per-message body after request', async () => { diff --git a/packages/typescript/ai-client/tests/connection-adapters.test.ts b/packages/typescript/ai-client/tests/connection-adapters.test.ts index 60c36763a..5584f3f8e 100644 --- a/packages/typescript/ai-client/tests/connection-adapters.test.ts +++ b/packages/typescript/ai-client/tests/connection-adapters.test.ts @@ -302,7 +302,7 @@ describe('connection-adapters', () => { expect(authValue).toBe('Bearer token') }) - it('should pass data to request body', async () => { + it('should pass data to request body forwardedProps', async () => { const mockReader = { read: vi.fn().mockResolvedValue({ done: true, value: undefined }), releaseLock: vi.fn(), @@ -329,7 +329,36 @@ describe('connection-adapters', () => { expect(fetchMock).toHaveBeenCalled() const call = fetchMock.mock.calls[0] const body = JSON.parse(call?.[1]?.body as string) - expect(body.data).toEqual({ key: 'value' }) + expect(body.forwardedProps).toMatchObject({ key: 'value' }) + }) + + it('should mirror forwardedProps under legacy `data` field for backward-compat', async () => { + const mockReader = { + read: vi.fn().mockResolvedValue({ done: true, value: undefined }), + releaseLock: vi.fn(), + } + const mockResponse = { + ok: true, + body: { getReader: () => mockReader }, + } + fetchMock.mockResolvedValue(mockResponse as any) + + const adapter = fetchServerSentEvents('/api/chat') + + for await (const _ of adapter.connect( + [{ role: 'user', content: 'Hello' }], + { provider: 'openai', model: 'gpt-4o' }, + )) { + // Consume + } + + const call = fetchMock.mock.calls[0] + const body = JSON.parse(call?.[1]?.body as string) + // Legacy server code reads `body.data.X`; new server code reads + // `body.forwardedProps.X`. Both must contain the same content + // until the legacy `body` client option is removed. + expect(body.data).toEqual(body.forwardedProps) + expect(body.data).toMatchObject({ provider: 'openai', model: 'gpt-4o' }) }) it('should use custom fetchClient when provided', async () => { @@ -436,7 +465,7 @@ describe('connection-adapters', () => { expect(call?.[1]?.headers).toMatchObject({ 'X-Async': 'token' }) }) - it('should merge options.body into request body', async () => { + it('should merge options.body into request body forwardedProps', async () => { const mockReader = { read: vi.fn().mockResolvedValue({ done: true, value: undefined }), releaseLock: vi.fn(), @@ -462,9 +491,11 @@ describe('connection-adapters', () => { const call = fetchMock.mock.calls[0] const body = JSON.parse(call?.[1]?.body as string) - expect(body.model).toBe('gpt-4o') - expect(body.provider).toBe('openai') - expect(body.data).toEqual({ key: 'value' }) + expect(body.forwardedProps).toMatchObject({ + model: 'gpt-4o', + provider: 'openai', + key: 'value', + }) }) it('should handle multiple chunks across multiple reads', async () => { @@ -688,7 +719,7 @@ describe('connection-adapters', () => { }) }) - it('should pass data to request body', async () => { + it('should pass data to request body forwardedProps', async () => { const mockReader = { read: vi.fn().mockResolvedValue({ done: true, value: undefined }), releaseLock: vi.fn(), @@ -712,7 +743,7 @@ describe('connection-adapters', () => { const call = fetchMock.mock.calls[0] const body = JSON.parse(call?.[1]?.body as string) - expect(body.data).toEqual({ key: 'value' }) + expect(body.forwardedProps).toMatchObject({ key: 'value' }) }) it('should resolve dynamic URL from function', async () => { diff --git a/packages/typescript/ai-gemini/src/adapters/text.ts b/packages/typescript/ai-gemini/src/adapters/text.ts index f4efcc466..93cb10c93 100644 --- a/packages/typescript/ai-gemini/src/adapters/text.ts +++ b/packages/typescript/ai-gemini/src/adapters/text.ts @@ -273,6 +273,7 @@ export class GeminiTextAdapter< threadId, model, timestamp, + parentRunId: options.parentRunId, }) } diff --git a/packages/typescript/ai-grok/src/adapters/text.ts b/packages/typescript/ai-grok/src/adapters/text.ts index e185c5ecf..e5cd97127 100644 --- a/packages/typescript/ai-grok/src/adapters/text.ts +++ b/packages/typescript/ai-grok/src/adapters/text.ts @@ -126,6 +126,7 @@ export class GrokTextAdapter< threadId: aguiState.threadId, model: options.model, timestamp, + parentRunId: options.parentRunId, }) } @@ -266,6 +267,7 @@ export class GrokTextAdapter< threadId: aguiState.threadId, model: chunk.model || options.model, timestamp, + parentRunId: options.parentRunId, }) } diff --git a/packages/typescript/ai-groq/src/adapters/text.ts b/packages/typescript/ai-groq/src/adapters/text.ts index 34f44ba81..61c51bf6f 100644 --- a/packages/typescript/ai-groq/src/adapters/text.ts +++ b/packages/typescript/ai-groq/src/adapters/text.ts @@ -129,6 +129,7 @@ export class GroqTextAdapter< threadId: aguiState.threadId, model: options.model, timestamp, + parentRunId: options.parentRunId, }) } @@ -265,6 +266,7 @@ export class GroqTextAdapter< threadId: aguiState.threadId, model: chunk.model || options.model, timestamp, + parentRunId: options.parentRunId, }) } diff --git a/packages/typescript/ai-ollama/src/adapters/text.ts b/packages/typescript/ai-ollama/src/adapters/text.ts index 209951569..f60220ba6 100644 --- a/packages/typescript/ai-ollama/src/adapters/text.ts +++ b/packages/typescript/ai-ollama/src/adapters/text.ts @@ -253,6 +253,7 @@ export class OllamaTextAdapter extends BaseTextAdapter< threadId, model: chunk.model, timestamp, + parentRunId: options.parentRunId, }) } diff --git a/packages/typescript/ai-openai/src/adapters/text.ts b/packages/typescript/ai-openai/src/adapters/text.ts index 139629869..fed485cd2 100644 --- a/packages/typescript/ai-openai/src/adapters/text.ts +++ b/packages/typescript/ai-openai/src/adapters/text.ts @@ -321,6 +321,7 @@ export class OpenAITextAdapter< threadId, model: model || options.model, timestamp, + parentRunId: options.parentRunId, }) } diff --git a/packages/typescript/ai-openrouter/src/adapters/text.ts b/packages/typescript/ai-openrouter/src/adapters/text.ts index 29427171c..d0881bb05 100644 --- a/packages/typescript/ai-openrouter/src/adapters/text.ts +++ b/packages/typescript/ai-openrouter/src/adapters/text.ts @@ -162,6 +162,7 @@ export class OpenRouterTextAdapter< threadId: aguiState.threadId, model: currentModel || options.model, timestamp, + parentRunId: options.parentRunId, }) } @@ -229,6 +230,7 @@ export class OpenRouterTextAdapter< threadId: aguiState.threadId, model: options.model, timestamp, + parentRunId: options.parentRunId, }) } diff --git a/packages/typescript/ai-preact/src/use-chat.ts b/packages/typescript/ai-preact/src/use-chat.ts index dacd2be88..f9185de9c 100644 --- a/packages/typescript/ai-preact/src/use-chat.ts +++ b/packages/typescript/ai-preact/src/use-chat.ts @@ -60,6 +60,7 @@ export function useChat = any>( id: clientId, initialMessages: messagesToUse, body: optionsRef.current.body, + forwardedProps: optionsRef.current.forwardedProps, // Wrap every callback so the latest options are read at call time. // Capturing the function reference directly would freeze it to whatever // the parent passed on the first render. @@ -99,11 +100,15 @@ export function useChat = any>( }) }, [clientId]) - // Sync body changes to the client - // This allows dynamic body values (like model selection) to be updated without recreating the client + // Sync body / forwardedProps changes to the client. + // Both populate the same wire payload; `forwardedProps` is preferred + // and `body` is deprecated but still supported. useEffect(() => { - client.updateOptions({ body: options.body }) - }, [client, options.body]) + client.updateOptions({ + body: options.body, + forwardedProps: options.forwardedProps, + }) + }, [client, options.body, options.forwardedProps]) // Sync initial messages on mount only // Note: initialMessages are passed to ChatClient constructor, but we also diff --git a/packages/typescript/ai-react/src/use-chat.ts b/packages/typescript/ai-react/src/use-chat.ts index c95589874..3805cc2e8 100644 --- a/packages/typescript/ai-react/src/use-chat.ts +++ b/packages/typescript/ai-react/src/use-chat.ts @@ -58,6 +58,7 @@ export function useChat = any>( id: clientId, initialMessages: messagesToUse, body: optionsRef.current.body, + forwardedProps: optionsRef.current.forwardedProps, // Wrap every callback so the latest options are read at call time. // Capturing the function reference directly would freeze it to whatever // the parent passed on the first render. @@ -97,11 +98,17 @@ export function useChat = any>( }) }, [clientId]) - // Sync body changes to the client - // This allows dynamic body values (like model selection) to be updated without recreating the client + // Sync body / forwardedProps changes to the client. + // This allows dynamic values (like model selection) to be updated + // without recreating the client. Both fields populate the same + // wire payload; `forwardedProps` is preferred and `body` is + // deprecated but still supported. useEffect(() => { - client.updateOptions({ body: options.body }) - }, [client, options.body]) + client.updateOptions({ + body: options.body, + forwardedProps: options.forwardedProps, + }) + }, [client, options.body, options.forwardedProps]) // Sync initial messages on mount only // Note: initialMessages are passed to ChatClient constructor, but we also diff --git a/packages/typescript/ai-solid/README.md b/packages/typescript/ai-solid/README.md index 1cbdcf4e1..72803cea7 100644 --- a/packages/typescript/ai-solid/README.md +++ b/packages/typescript/ai-solid/README.md @@ -71,7 +71,10 @@ interface UseChatOptions { // Configuration initialMessages?: UIMessage[] // Starting messages id?: string // Unique chat ID - body?: Record // Extra data to send + threadId?: string // AG-UI thread ID (auto-generated if omitted) + forwardedProps?: Record // Forwarded to AG-UI RunAgentInput.forwardedProps + /** @deprecated Use `forwardedProps` instead. */ + body?: Record // Callbacks onResponse?: (response?: Response) => void @@ -256,7 +259,7 @@ const chat = useChat({ 'X-Custom-Header': 'value', }, }), - body: { + forwardedProps: { userId: '123', sessionId: 'abc', }, diff --git a/packages/typescript/ai-solid/src/use-chat.ts b/packages/typescript/ai-solid/src/use-chat.ts index 0aff8603a..86614fba8 100644 --- a/packages/typescript/ai-solid/src/use-chat.ts +++ b/packages/typescript/ai-solid/src/use-chat.ts @@ -45,6 +45,7 @@ export function useChat = any>( id: clientId, initialMessages: options.initialMessages, body: options.body, + forwardedProps: options.forwardedProps, onResponse: (response) => options.onResponse?.(response), onChunk: (chunk) => options.onChunk?.(chunk), onFinish: (message) => { @@ -83,11 +84,14 @@ export function useChat = any>( // Connection and other options are captured at creation time }, [clientId]) - // Sync body changes to the client - // This allows dynamic body values (like model selection) to be updated without recreating the client + // Sync body / forwardedProps changes to the client. + // Both populate the same wire payload; `forwardedProps` is preferred + // and `body` is deprecated but still supported. createEffect(() => { - const currentBody = options.body - client().updateOptions({ body: currentBody }) + client().updateOptions({ + body: options.body, + forwardedProps: options.forwardedProps, + }) }) // Sync initial messages on mount only diff --git a/packages/typescript/ai-svelte/src/create-chat.svelte.ts b/packages/typescript/ai-svelte/src/create-chat.svelte.ts index 3a9eeb232..f46356f5a 100644 --- a/packages/typescript/ai-svelte/src/create-chat.svelte.ts +++ b/packages/typescript/ai-svelte/src/create-chat.svelte.ts @@ -65,6 +65,7 @@ export function createChat = any>( id: clientId, initialMessages: options.initialMessages, body: options.body, + forwardedProps: options.forwardedProps, onResponse: options.onResponse, onChunk: options.onChunk, onFinish: (message) => { @@ -150,10 +151,18 @@ export function createChat = any>( await client.addToolApprovalResponse(response) } + /** + * @deprecated Use `updateForwardedProps` instead. + * Both populate the same wire payload. + */ const updateBody = (newBody: Record) => { client.updateOptions({ body: newBody }) } + const updateForwardedProps = (newForwardedProps: Record) => { + client.updateOptions({ forwardedProps: newForwardedProps }) + } + // Return the chat interface with reactive getters // Using getters allows Svelte to track reactivity without needing $ prefix return { @@ -187,5 +196,6 @@ export function createChat = any>( addToolResult, addToolApprovalResponse, updateBody, + updateForwardedProps, } } diff --git a/packages/typescript/ai-svelte/src/types.ts b/packages/typescript/ai-svelte/src/types.ts index 88e6bb32f..2c11320f3 100644 --- a/packages/typescript/ai-svelte/src/types.ts +++ b/packages/typescript/ai-svelte/src/types.ts @@ -130,9 +130,15 @@ export interface CreateChatReturn< */ readonly sessionGenerating: boolean /** - * Update the body sent with requests (e.g., for changing model selection) + * @deprecated Use `updateForwardedProps` instead. Both populate the + * same wire payload; `updateBody` is retained for backward compatibility. */ updateBody: (body: Record) => void + /** + * Update the AG-UI `forwardedProps` sent with requests (e.g., for + * changing model selection or other client-driven options). + */ + updateForwardedProps: (forwardedProps: Record) => void } // Note: createChatClientOptions and InferChatMessages are now in @tanstack/ai-client diff --git a/packages/typescript/ai-vue/src/use-chat.ts b/packages/typescript/ai-vue/src/use-chat.ts index 3f10b4dcb..10beb524b 100644 --- a/packages/typescript/ai-vue/src/use-chat.ts +++ b/packages/typescript/ai-vue/src/use-chat.ts @@ -37,6 +37,7 @@ export function useChat = any>( id: clientId, initialMessages: options.initialMessages, body: options.body, + forwardedProps: options.forwardedProps, onResponse: (response) => options.onResponse?.(response), onChunk: (chunk) => options.onChunk?.(chunk), onFinish: (message) => { @@ -72,12 +73,16 @@ export function useChat = any>( }, }) - // Sync body changes to the client - // This allows dynamic body values (like model selection) to be updated without recreating the client + // Sync body / forwardedProps changes to the client. + // Both populate the same wire payload; `forwardedProps` is preferred + // and `body` is deprecated but still supported. watch( - () => options.body, - (newBody) => { - client.updateOptions({ body: newBody }) + () => [options.body, options.forwardedProps] as const, + ([newBody, newForwardedProps]) => { + client.updateOptions({ + body: newBody, + forwardedProps: newForwardedProps, + }) }, ) diff --git a/packages/typescript/ai/docs/chat-architecture.md b/packages/typescript/ai/docs/chat-architecture.md index 5625a6ab5..687a0b50b 100644 --- a/packages/typescript/ai/docs/chat-architecture.md +++ b/packages/typescript/ai/docs/chat-architecture.md @@ -9,18 +9,19 @@ ## Table of Contents 1. [System Overview](#system-overview) -2. [Single-Shot Text Response](#single-shot-text-response) -3. [Single-Shot Tool Call Response](#single-shot-tool-call-response) -4. [Parallel Tool Calls (Single Shot)](#parallel-tool-calls-single-shot) -5. [Text-Then-Tool Interleaving (Single Shot)](#text-then-tool-interleaving-single-shot) -6. [Thinking/Reasoning Content](#thinkingreasoning-content) -7. [Tool Results and the TOOL_CALL_END Dual Role](#tool-results-and-the-tool_call_end-dual-role) -8. [Client Tools and Approval Flows](#client-tools-and-approval-flows) -9. [Multi-Iteration Agent Loop](#multi-iteration-agent-loop) -10. [Adapter Contract](#adapter-contract) -11. [StreamProcessor Internal State](#streamprocessor-internal-state) -12. [UIMessage Part Ordering Invariants](#uimessage-part-ordering-invariants) -13. [Testing Strategy](#testing-strategy) +2. [Wire format (HTTP body)](#wire-format-http-body) +3. [Single-Shot Text Response](#single-shot-text-response) +4. [Single-Shot Tool Call Response](#single-shot-tool-call-response) +5. [Parallel Tool Calls (Single Shot)](#parallel-tool-calls-single-shot) +6. [Text-Then-Tool Interleaving (Single Shot)](#text-then-tool-interleaving-single-shot) +7. [Thinking/Reasoning Content](#thinkingreasoning-content) +8. [Tool Results and the TOOL_CALL_END Dual Role](#tool-results-and-the-tool_call_end-dual-role) +9. [Client Tools and Approval Flows](#client-tools-and-approval-flows) +10. [Multi-Iteration Agent Loop](#multi-iteration-agent-loop) +11. [Adapter Contract](#adapter-contract) +12. [StreamProcessor Internal State](#streamprocessor-internal-state) +13. [UIMessage Part Ordering Invariants](#uimessage-part-ordering-invariants) +14. [Testing Strategy](#testing-strategy) --- @@ -59,6 +60,33 @@ Both trust the adapter to emit events in the correct order. The processor does * --- +## Wire format (HTTP body) + +The HTTP body posted by `@tanstack/ai-client` to a `chat()` endpoint is the AG-UI `RunAgentInput` shape from `@ag-ui/core`: + +```json +{ + "threadId": "thread-...", + "runId": "run-...", + "state": {}, + "messages": [...], + "tools": [...], + "context": [], + "forwardedProps": {} +} +``` + +Each entry in `messages` is either: + +- A **TanStack anchor** — a `UIMessage` with its canonical `parts` array, augmented with AG-UI mirror fields (`content` for system/user/assistant; `toolCalls` for assistant) so AG-UI Zod parsing succeeds. +- An **AG-UI fan-out duplicate** — a `{role:'tool',...}` or `{role:'reasoning',...}` entry generated from each `ToolResultPart`/`ThinkingPart` on the assistant anchor. Strict AG-UI server consumers walk these role-based messages directly. + +On the server, `chatParamsFromRequestBody` validates the body and returns the parsed fields. `convertMessagesToModelMessages` (called inside `chat()`) handles dedup: when an anchor's `parts` already contain a `tool-result`, the matching fan-out tool message is dropped from the `ModelMessage[]` fed to the LLM. `reasoning` and `activity` messages are dropped (no `ModelMessage` equivalent today); `developer` messages collapse to `system`. + +For the migration story when upgrading, see [`docs/migration/ag-ui-compliance.md`](../../../../docs/migration/ag-ui-compliance.md). + +--- + ## Single-Shot Text Response The simplest possible flow. The model returns text with no tool calls. diff --git a/packages/typescript/ai/package.json b/packages/typescript/ai/package.json index 91c0843b1..c9219f92e 100644 --- a/packages/typescript/ai/package.json +++ b/packages/typescript/ai/package.json @@ -65,7 +65,7 @@ "tanstack-intent" ], "dependencies": { - "@ag-ui/core": "0.0.49", + "@ag-ui/core": "^0.0.52", "@tanstack/ai-event-client": "workspace:*", "partial-json": "^0.1.7" }, diff --git a/packages/typescript/ai/skills/ai-core/ag-ui-protocol/SKILL.md b/packages/typescript/ai/skills/ai-core/ag-ui-protocol/SKILL.md index 561843174..3b798bcb5 100644 --- a/packages/typescript/ai/skills/ai-core/ag-ui-protocol/SKILL.md +++ b/packages/typescript/ai/skills/ai-core/ag-ui-protocol/SKILL.md @@ -39,6 +39,47 @@ export async function POST(request: Request) { typed AG-UI event (discriminated union on `type`). The `toServerSentEventsResponse()` helper encodes that iterable into an SSE-formatted `Response` with correct headers. +## Setup — Receiving AG-UI RunAgentInput on the Server + +```typescript +import { + chat, + chatParamsFromRequestBody, + mergeAgentTools, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { serverTools } from './tools' + +export async function POST(req: Request) { + let params + try { + params = await chatParamsFromRequestBody(await req.json()) + } catch (error) { + return new Response( + error instanceof Error ? error.message : 'Bad request', + { status: 400 }, + ) + } + + const stream = chat({ + adapter: openaiText('gpt-4o'), + messages: params.messages, + tools: mergeAgentTools(serverTools, params.tools), + }) + + return toServerSentEventsResponse(stream) +} +``` + +`chatParamsFromRequestBody` validates the body against `RunAgentInputSchema` from `@ag-ui/core`. `mergeAgentTools` merges the server's tool registry with client-declared tools (server wins on collision; client-only tools become no-execute stubs that flow through the runtime's `ClientToolRequest` path). + +`params.messages` is a mixed array of TanStack `UIMessage` anchors (with `parts`) and AG-UI fan-out duplicates (`{role:'tool',...}`, `{role:'reasoning',...}`). The existing `convertMessagesToModelMessages` (called inside `chat()`) handles dedup automatically. + +**Wire shape (POST body):** AG-UI `RunAgentInput` — `{threadId, runId, parentRunId?, state, messages, tools, context, forwardedProps}`. The `messages` array carries TanStack `UIMessage` anchors with their canonical `parts` plus AG-UI mirror fields (`content`, `toolCalls`) inline; tool results and thinking parts are additionally emitted as fan-out `{role:'tool',...}` and `{role:'reasoning',...}` entries. + +**`forwardedProps` security:** Don't spread it directly into `chat()` — clients could override `adapter`, `model`, `tools`, etc. Always allowlist specific fields. + ## Core Patterns ### 1. SSE Format — toServerSentEventsStream / toServerSentEventsResponse @@ -223,9 +264,11 @@ Source: docs/protocol/chunk-definitions.md ## Tension -HIGH Tension: AG-UI protocol compliance vs. internal message format -- TanStack -AI's `UIMessage` format (parts-based) diverges from AG-UI spec (content-based). -Full compliance would require a different message structure. +RESOLVED: TanStack AI is fully AG-UI compliant on both axes (server→client events +AND client→server `RunAgentInput`). The wire format carries TanStack `UIMessage` +anchors with their parts intact alongside AG-UI fan-out messages, so strict AG-UI +servers see role-based messages while TanStack-aware servers read parts directly +without transformation. See `docs/migration/ag-ui-compliance.md` for details. ## Cross-References diff --git a/packages/typescript/ai/src/activities/chat/index.ts b/packages/typescript/ai/src/activities/chat/index.ts index 8ce192a4f..3f131e6b8 100644 --- a/packages/typescript/ai/src/activities/chat/index.ts +++ b/packages/typescript/ai/src/activities/chat/index.ts @@ -45,6 +45,7 @@ import type { ToolCallArgsEvent, ToolCallEndEvent, ToolCallStartEvent, + UIMessage, } from '../../types' import type { ChatMiddleware, @@ -82,12 +83,21 @@ export interface TextActivityOptions< > { /** The text adapter to use (created by a provider function like openaiText('gpt-4o')) */ adapter: TAdapter - /** Conversation messages - content types are constrained by the adapter's input modalities and metadata */ + /** + * Conversation messages. Accepts: + * - `ConstrainedModelMessage` — content types constrained by the adapter's input modalities. + * - `ModelMessage` — unconstrained model message (e.g., forwarded from an AG-UI wire payload). + * - `UIMessage` — parts-based UI representation; converted internally via `convertMessagesToModelMessages`. + * + * The three shapes can be mixed in a single array (e.g., when forwarding a wire payload that includes both anchor UIMessages and AG-UI fan-out ModelMessages). + */ messages?: Array< - ConstrainedModelMessage<{ - inputModalities: TAdapter['~types']['inputModalities'] - messageMetadataByModality: TAdapter['~types']['messageMetadataByModality'] - }> + | UIMessage + | ModelMessage + | ConstrainedModelMessage<{ + inputModalities: TAdapter['~types']['inputModalities'] + messageMetadataByModality: TAdapter['~types']['messageMetadataByModality'] + }> > /** System prompts to prepend to the conversation */ systemPrompts?: TextOptions['systemPrompts'] @@ -125,6 +135,8 @@ export interface TextActivityOptions< threadId?: TextOptions['threadId'] /** Run ID override for AG-UI protocol. Auto-generated by adapter if not provided. */ runId?: TextOptions['runId'] + /** Parent run ID for AG-UI protocol nested run correlation. */ + parentRunId?: TextOptions['parentRunId'] /** * Optional Standard Schema for structured output. * When provided, the activity will: @@ -298,6 +310,7 @@ class TextEngine< // AG-UI protocol IDs private threadId: string private runIdOverride?: string + private parentRunIdOverride?: string // Middleware support private readonly middlewareRunner: MiddlewareRunner @@ -349,8 +362,15 @@ class TextEngine< ? { signal: config.params.abortController.signal } : undefined this.effectiveSignal = config.params.abortController?.signal - this.threadId = config.params.threadId || this.createId('thread') + // `conversationId` is the legacy alias of `threadId` — accept it + // as a fallback so `chat({ conversationId })` keeps working, with + // explicit `threadId` winning when both are set. + this.threadId = + config.params.threadId || + config.params.conversationId || + this.createId('thread') this.runIdOverride = config.params.runId + this.parentRunIdOverride = config.params.parentRunId // Initialize middleware — devtools first, strip-to-spec always last. // handleStreamChunk processes raw chunks BEFORE middleware, so internal @@ -366,7 +386,10 @@ class TextEngine< this.middlewareCtx = { requestId: this.requestId, streamId: this.streamId, - conversationId: config.params.conversationId, + threadId: this.threadId, + // Legacy alias kept on the ctx so middleware that reads + // `ctx.conversationId` keeps working. Always equals `threadId`. + conversationId: this.threadId, phase: 'init' as ChatMiddlewarePhase, iteration: 0, chunkIndex: 0, @@ -414,7 +437,7 @@ class TextEngine< async *run(): AsyncGenerator { this.beforeRun() this.logger.agentLoop('run started', { - conversationId: this.middlewareCtx.conversationId, + threadId: this.middlewareCtx.threadId, }) try { @@ -493,7 +516,7 @@ class TextEngine< // Genuine error — call onError this.logger.errors('chat run failed', { error, - conversationId: this.middlewareCtx.conversationId, + threadId: this.middlewareCtx.threadId, }) await this.middlewareRunner.runOnError(this.middlewareCtx, { error, @@ -619,6 +642,7 @@ class TextEngine< logger: this.logger, threadId: this.threadId, runId: this.runIdOverride, + parentRunId: this.parentRunIdOverride, })) { if (this.isCancelled()) { break diff --git a/packages/typescript/ai/src/activities/chat/messages.ts b/packages/typescript/ai/src/activities/chat/messages.ts index 893b51943..5b0db6ffd 100644 --- a/packages/typescript/ai/src/activities/chat/messages.ts +++ b/packages/typescript/ai/src/activities/chat/messages.ts @@ -63,15 +63,55 @@ function getTextContent(content: string | null | Array): string { export function convertMessagesToModelMessages( messages: Array, ): Array { + // Pre-pass: collect toolCallIds already represented in anchor UIMessage parts. + // Fan-out tool messages whose toolCallId matches an anchored ToolResultPart + // are AG-UI duplicates and must be dropped to avoid double-feeding the LLM. + const anchoredToolCallIds = new Set() + for (const msg of messages) { + if ('parts' in msg) { + for (const part of msg.parts) { + if (part.type === 'tool-result') { + anchoredToolCallIds.add(part.toolCallId) + } + } + } + } + const modelMessages: Array = [] for (const msg of messages) { if ('parts' in msg) { - // UIMessage - convert to ModelMessages + // UIMessage anchor — existing fan-out path modelMessages.push(...uiMessageToModelMessages(msg)) - } else { - // Already ModelMessage - modelMessages.push(msg) + continue + } + + const role = (msg as { role: string }).role + + // AG-UI tool fan-out duplicate — drop if anchor already covers it + if ( + role === 'tool' && + msg.toolCallId && + anchoredToolCallIds.has(msg.toolCallId) + ) { + continue } + + // AG-UI reasoning and activity — no ModelMessage equivalent today + if (role === 'reasoning' || role === 'activity') { + continue + } + + // AG-UI developer — collapse to system + if (role === 'developer') { + modelMessages.push({ + role: 'system' as ModelMessage['role'], + content: (msg as { content: string }).content, + } as ModelMessage) + continue + } + + // Already a ModelMessage (user, assistant, system, tool with no anchor) — pass through + modelMessages.push(msg) } return modelMessages } diff --git a/packages/typescript/ai/src/activities/chat/middleware/compose.ts b/packages/typescript/ai/src/activities/chat/middleware/compose.ts index b3e4cf6cc..912ea768d 100644 --- a/packages/typescript/ai/src/activities/chat/middleware/compose.ts +++ b/packages/typescript/ai/src/activities/chat/middleware/compose.ts @@ -26,7 +26,7 @@ function instrumentCtx(ctx: ChatMiddlewareContext) { return { requestId: ctx.requestId, streamId: ctx.streamId, - clientId: ctx.conversationId, + clientId: ctx.threadId, timestamp: Date.now(), } } diff --git a/packages/typescript/ai/src/activities/chat/middleware/types.ts b/packages/typescript/ai/src/activities/chat/middleware/types.ts index 19ce04586..c825a696b 100644 --- a/packages/typescript/ai/src/activities/chat/middleware/types.ts +++ b/packages/typescript/ai/src/activities/chat/middleware/types.ts @@ -28,7 +28,18 @@ export interface ChatMiddlewareContext { requestId: string /** Unique identifier for this stream */ streamId: string - /** Conversation identifier, if provided by the caller */ + /** + * AG-UI thread identifier — a stable per-conversation ID used to + * correlate client and server devtools events. Resolves to the + * caller-provided `threadId` (or legacy `conversationId`), or an + * auto-generated value when neither is supplied. + */ + threadId: string + /** + * @deprecated Use `threadId` instead. Retained as an alias of + * `threadId` so middleware written before the AG-UI rename keeps + * working unchanged. Will be removed in a future major release. + */ conversationId?: string /** Current lifecycle phase */ phase: ChatMiddlewarePhase diff --git a/packages/typescript/ai/src/index.ts b/packages/typescript/ai/src/index.ts index ef45543be..8f7c677e4 100644 --- a/packages/typescript/ai/src/index.ts +++ b/packages/typescript/ai/src/index.ts @@ -168,6 +168,17 @@ export type { JSONParser, } from './activities/chat/stream/index' +// Chat utilities +export { + chatParamsFromRequest, + chatParamsFromRequestBody, + mergeAgentTools, +} from './utilities/chat-params' + +// AG-UI wire serialization (used internally by @tanstack/ai-client) +export { uiMessagesToWire } from './utilities/ag-ui-wire' +export type { WireMessage } from './utilities/ag-ui-wire' + // Adapter extension utilities export { createModel, extendAdapter } from './extend-adapter' export type { ExtendedModelDef } from './extend-adapter' diff --git a/packages/typescript/ai/src/types.ts b/packages/typescript/ai/src/types.ts index 29672a540..7fff174e0 100644 --- a/packages/typescript/ai/src/types.ts +++ b/packages/typescript/ai/src/types.ts @@ -724,8 +724,14 @@ export interface TextOptions< */ outputSchema?: SchemaInput /** - * Conversation ID for correlating client and server-side devtools events. - * When provided, server-side events will be linked to the client conversation in devtools. + * @deprecated Use `threadId` instead. `conversationId` is the legacy + * pre-AG-UI name for the same concept (a stable per-conversation + * identifier used to correlate client/server devtools events). When + * `conversationId` is omitted, the runtime falls back to `threadId` + * automatically, so most callers can simply pass `threadId` (or rely + * on `chatParamsFromRequest`, which surfaces it on `params`). + * + * Will be removed in a future major release. */ conversationId?: string /** @@ -761,6 +767,11 @@ export interface TextOptions< * If not provided, a unique ID will be generated. */ runId?: string + /** + * Parent run ID for AG-UI protocol nested run correlation. + * Surfaced for observability/middleware; not consumed by the LLM call. + */ + parentRunId?: string } // ============================================================================ diff --git a/packages/typescript/ai/src/utilities/ag-ui-wire.ts b/packages/typescript/ai/src/utilities/ag-ui-wire.ts new file mode 100644 index 000000000..f7abd3606 --- /dev/null +++ b/packages/typescript/ai/src/utilities/ag-ui-wire.ts @@ -0,0 +1,182 @@ +import type { ContentPart, MessagePart, TextPart, UIMessage } from '../types' + +type AGUITextInputContent = { type: 'text'; text: string } +type AGUIInputContent = + | AGUITextInputContent + | (ContentPart & { type: 'image' | 'audio' | 'video' | 'document' }) + +type AGUIToolCallMirror = { + id: string + type: 'function' + function: { name: string; arguments: string } +} + +type AGUIToolMessage = { + role: 'tool' + id: string + toolCallId: string + content: string + error?: string +} + +type AGUIReasoningMessage = { + role: 'reasoning' + id: string + content: string +} + +type WireAnchorMessage = UIMessage & { + content?: string | Array + toolCalls?: Array +} + +export type WireMessage = + | WireAnchorMessage + | AGUIToolMessage + | AGUIReasoningMessage + +/** + * Serialize TanStack `UIMessage`s into the AG-UI `RunAgentInput.messages` + * wire shape. Each anchor (system/user/assistant) carries the canonical + * `parts` array verbatim plus AG-UI mirror fields (`content`, `toolCalls`) + * so AG-UI Zod parsing succeeds. Tool results and thinking parts on + * assistant messages are additionally emitted as fan-out + * `{role:'tool',...}` and `{role:'reasoning',...}` entries for strict + * AG-UI server consumers. + */ +export function uiMessagesToWire( + messages: Array, +): Array { + const wire: Array = [] + + for (const msg of messages) { + // Defensive: if parts is missing (ModelMessage-shaped input), pass through as-is. + // UIMessage always has parts; ModelMessage uses content directly. + const parts: ReadonlyArray = + (msg.parts as ReadonlyArray | undefined) ?? [] + + if (msg.role === 'system') { + wire.push({ + ...msg, + content: + parts.length > 0 + ? collectText(parts) + : ((msg as unknown as { content?: string }).content ?? ''), + }) + continue + } + + if (msg.role === 'user') { + wire.push({ + ...msg, + content: + parts.length > 0 + ? collectUserContent(parts) + : ((msg as unknown as { content?: string }).content ?? ''), + }) + continue + } + + // assistant: emit reasoning fan-outs first, then anchor, then tool fan-outs + for (const part of parts) { + if (part.type === 'thinking') { + wire.push({ + role: 'reasoning', + id: deriveReasoningId(msg.id, part), + content: part.content, + }) + } + } + + const text = collectText(parts) + const toolCalls = collectToolCalls(parts) + wire.push({ + ...msg, + ...(text !== '' && { content: text }), + ...(toolCalls && { toolCalls }), + }) + + for (const part of parts) { + if (part.type === 'tool-result') { + wire.push({ + role: 'tool', + id: deriveToolMessageId(part.toolCallId), + toolCallId: part.toolCallId, + content: part.content, + ...(part.error !== undefined && { error: part.error }), + }) + } + } + } + + return wire +} + +function collectText(parts: ReadonlyArray): string { + return parts + .filter((p): p is TextPart => p.type === 'text') + .map((p) => p.content) + .join('') +} + +function collectUserContent( + parts: ReadonlyArray, +): string | Array { + const hasMultimodal = parts.some( + (p) => + p.type === 'image' || + p.type === 'audio' || + p.type === 'video' || + p.type === 'document', + ) + if (!hasMultimodal) { + return collectText(parts) + } + const out: Array = [] + for (const p of parts) { + if (p.type === 'text') { + out.push({ type: 'text', text: p.content }) + } else if ( + p.type === 'image' || + p.type === 'audio' || + p.type === 'video' || + p.type === 'document' + ) { + out.push(p as AGUIInputContent) + } + } + return out +} + +function collectToolCalls( + parts: ReadonlyArray, +): Array | undefined { + const calls: Array = [] + for (const p of parts) { + if (p.type === 'tool-call') { + calls.push({ + id: p.id, + type: 'function', + function: { name: p.name, arguments: p.arguments }, + }) + } + } + return calls.length > 0 ? calls : undefined +} + +function deriveReasoningId(messageId: string, part: MessagePart): string { + return `${messageId}-reasoning-${(part as { id?: string }).id ?? hashContent((part as { content: string }).content)}` +} + +function deriveToolMessageId(toolCallId: string): string { + return `tool-${toolCallId}` +} + +function hashContent(s: string): string { + // Cheap deterministic id suffix; collisions are tolerable since + // reasoning ids only matter for AG-UI server consumers, not for our + // own server's dedup logic (which keys on toolCallId, not reasoning id). + let h = 0 + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0 + return Math.abs(h).toString(36) +} diff --git a/packages/typescript/ai/src/utilities/chat-params.ts b/packages/typescript/ai/src/utilities/chat-params.ts new file mode 100644 index 000000000..3d45d7914 --- /dev/null +++ b/packages/typescript/ai/src/utilities/chat-params.ts @@ -0,0 +1,199 @@ +import { AGUIError, RunAgentInputSchema } from '@ag-ui/core' +import type { Context as AGUIContext } from '@ag-ui/core' +import type { JSONSchema, ModelMessage, Tool, UIMessage } from '../types' + +const KNOWN_PART_TYPES = new Set([ + 'text', + 'image', + 'audio', + 'video', + 'document', + 'tool-call', + 'tool-result', + 'thinking', +]) + +function isValidParts(value: unknown): value is Array<{ type: string }> { + if (!Array.isArray(value)) return false + for (const p of value) { + if (!p || typeof p !== 'object') return false + const type = (p as { type?: unknown }).type + if (typeof type !== 'string' || !KNOWN_PART_TYPES.has(type)) return false + } + return true +} + +/** + * Parse and validate an HTTP request body as an AG-UI `RunAgentInput`. + * + * Returns a spread-friendly object whose `messages` field is suitable for + * passing directly to `chat({ messages })`. The existing + * `convertMessagesToModelMessages` handles AG-UI fan-out dedup and + * reasoning/activity/developer-role normalization internally. + * + * @throws An error with a migration-pointing message when the body does + * not conform to AG-UI 0.0.52 `RunAgentInputSchema`. Surface this as a + * 400 Bad Request to the client. + */ +export function chatParamsFromRequestBody(body: unknown): Promise<{ + messages: Array + threadId: string + runId: string + parentRunId?: string + tools: Array<{ name: string; description: string; parameters: JSONSchema }> + forwardedProps: Record + state: unknown + context: Array +}> { + const parseResult = RunAgentInputSchema.safeParse(body) + if (!parseResult.success) { + return Promise.reject( + new AGUIError( + `Request body is not a valid AG-UI RunAgentInput. ` + + `If you're upgrading from a previous @tanstack/ai-client release, ` + + `see docs/migration/ag-ui-compliance.md. ` + + `Validation errors: ${parseResult.error.message}`, + ), + ) + } + + const parsed = parseResult.data + + // AG-UI Zod uses `.strip()` so extra fields like `parts` on messages are + // dropped during parse. We re-attach them from the original body so the + // existing UIMessage path inside `chat()` can use them directly. + const rawMessages = + (body as { messages?: Array> }).messages ?? [] + const messages = parsed.messages.map((m, i) => { + const raw = rawMessages[i] + if ( + raw && + typeof raw === 'object' && + 'parts' in raw && + isValidParts(raw.parts) + ) { + return { ...m, parts: raw.parts } as UIMessage | ModelMessage + } + return m as ModelMessage + }) + + return Promise.resolve({ + messages, + threadId: parsed.threadId, + runId: parsed.runId, + parentRunId: parsed.parentRunId, + tools: parsed.tools as Array<{ + name: string + description: string + parameters: JSONSchema + }>, + forwardedProps: (parsed.forwardedProps ?? {}) as Record, + state: parsed.state, + context: parsed.context, + }) +} + +/** + * Read an HTTP `Request`, parse its JSON body, and validate it as an + * AG-UI `RunAgentInput` — collapsing the standard `req.json()` + + * `chatParamsFromRequestBody(...)` pair into a single call. + * + * On a malformed body or invalid AG-UI shape, this **throws a + * `Response`** with status 400 and a migration-pointing message in the + * body. Frameworks that natively handle thrown `Response` objects + * (TanStack Start, SolidStart, Remix, React Router 7) will return the + * 400 to the client automatically, so the handler reduces to: + * + * ```ts + * export async function POST(req: Request) { + * const params = await chatParamsFromRequest(req) + * // ...use params + * } + * ``` + * + * In frameworks that do not auto-handle thrown `Response` objects + * (Next.js Route Handlers, SvelteKit, Hono, raw Node), wrap the call + * with try/catch and return the caught Response yourself, or use + * `chatParamsFromRequestBody` directly with your own JSON-parsing. + * + * @throws {Response} 400 on malformed JSON or invalid AG-UI shape. + */ +export async function chatParamsFromRequest( + req: Request, +): Promise>> { + let body: unknown + try { + body = await req.json() + } catch (cause) { + // Preserve the underlying error on the thrown Response for + // server-side observability without leaking it to the client. + const res = new Response( + 'Invalid AG-UI request body. See docs/migration/ag-ui-compliance.md.', + { status: 400 }, + ) + ;(res as unknown as { cause?: unknown }).cause = cause + throw res + } + try { + return await chatParamsFromRequestBody(body) + } catch (cause) { + // Generic public message — avoid echoing Zod paths (which can contain + // user payload fragments) or internal validator strings to the client. + // The original AGUIError is attached as `cause` so server logs can + // surface it without exposing it to remote callers. + const res = new Response( + 'Invalid AG-UI request body. See docs/migration/ag-ui-compliance.md.', + { status: 400 }, + ) + ;(res as unknown as { cause?: unknown }).cause = cause + throw res + } +} + +/** + * Merge a server-side tool array with the AG-UI client-declared tools + * received in the request body. + * + * Rules: + * - Server tools win on name collision. The client's declaration is + * ignored if the server already has a tool with that name. The client's + * UI-side handler still fires when the streamed tool-result event comes + * through (see `chat-client.ts` `onToolCall`), giving the + * "after server execution the client also handles" semantic for free. + * - Client-only tools (name not in `serverTools`) become no-execute + * entries: the runtime's existing `ClientToolRequest` path handles + * them — server emits a tool-call request, client executes via its + * registered handler, client posts back the result. + * + * @param serverTools - The server's tool array (e.g. from + * `[myToolDef.server(...)]`). Pass directly to `chat({ tools })`. + * @param clientTools - The `tools` array received from + * `chatParamsFromRequest(...)` / `chatParamsFromRequestBody(...)`. + * @returns A merged array suitable for `chat({ tools })`. + */ +export function mergeAgentTools( + serverTools: ReadonlyArray, + clientTools: ReadonlyArray<{ + name: string + description: string + parameters: JSONSchema + }>, +): Array { + const seen = new Set(serverTools.map((t) => t.name)) + const merged: Array = [...serverTools] + for (const ct of clientTools) { + if (seen.has(ct.name)) { + // Server wins on name collision. + continue + } + seen.add(ct.name) + merged.push({ + name: ct.name, + description: ct.description, + inputSchema: ct.parameters, + // No `execute` — runtime treats this as a client-side tool and + // emits ClientToolRequest events. + } as Tool) + } + return merged +} diff --git a/packages/typescript/ai/tests/ag-ui-wire.test.ts b/packages/typescript/ai/tests/ag-ui-wire.test.ts new file mode 100644 index 000000000..7de0fc0a0 --- /dev/null +++ b/packages/typescript/ai/tests/ag-ui-wire.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from 'vitest' +import { uiMessagesToWire } from '../src/utilities/ag-ui-wire' +import type { UIMessage } from '../src/types' + +describe('uiMessagesToWire', () => { + it('mirrors a system UIMessage to a string content field', () => { + const messages: Array = [ + { + id: 's1', + role: 'system', + parts: [{ type: 'text', content: 'You are helpful' }], + }, + ] + const wire = uiMessagesToWire(messages) + expect(wire).toHaveLength(1) + expect(wire[0]!).toMatchObject({ + id: 's1', + role: 'system', + content: 'You are helpful', + }) + expect((wire[0]! as any).parts).toBeDefined() + }) + + it('mirrors a user UIMessage with a text-only parts list to a string content', () => { + const messages: Array = [ + { id: 'u1', role: 'user', parts: [{ type: 'text', content: 'hi' }] }, + ] + const wire = uiMessagesToWire(messages) + expect(wire).toHaveLength(1) + expect(wire[0]!).toMatchObject({ id: 'u1', role: 'user', content: 'hi' }) + }) + + it('mirrors a user UIMessage with mixed multimodal parts to an InputContent[] content', () => { + const messages: Array = [ + { + id: 'u1', + role: 'user', + parts: [ + { type: 'text', content: 'look at this' }, + { + type: 'image', + source: { + type: 'url', + value: 'https://example.com/cat.png', + mimeType: 'image/png', + }, + }, + ], + }, + ] + const wire = uiMessagesToWire(messages) + expect(wire).toHaveLength(1) + expect(Array.isArray((wire[0]! as any).content)).toBe(true) + expect((wire[0]! as any).content).toHaveLength(2) + expect((wire[0]! as any).content[0]).toEqual({ + type: 'text', + text: 'look at this', + }) + expect((wire[0]! as any).content[1]).toMatchObject({ + type: 'image', + source: { + type: 'url', + value: 'https://example.com/cat.png', + mimeType: 'image/png', + }, + }) + }) + + it('emits assistant anchor with toolCalls mirror and a separate tool fan-out per ToolResultPart', () => { + const messages: Array = [ + { + id: 'a1', + role: 'assistant', + parts: [ + { type: 'text', content: 'ok' }, + { + type: 'tool-call', + id: 'tc1', + name: 'getTodos', + arguments: '{}', + state: 'input-complete', + }, + { + type: 'tool-result', + toolCallId: 'tc1', + content: '[]', + state: 'complete', + }, + ], + }, + ] + const wire = uiMessagesToWire(messages) + expect(wire).toHaveLength(2) + // Anchor + expect(wire[0]!).toMatchObject({ + id: 'a1', + role: 'assistant', + content: 'ok', + toolCalls: [ + { + id: 'tc1', + type: 'function', + function: { name: 'getTodos', arguments: '{}' }, + }, + ], + }) + // Fan-out tool message + expect(wire[1]!).toMatchObject({ + role: 'tool', + toolCallId: 'tc1', + content: '[]', + }) + }) + + it('emits a separate reasoning fan-out before the assistant anchor for each ThinkingPart', () => { + const messages: Array = [ + { + id: 'a1', + role: 'assistant', + parts: [ + { type: 'thinking', content: 'pondering' }, + { type: 'text', content: 'answer' }, + ], + }, + ] + const wire = uiMessagesToWire(messages) + expect(wire).toHaveLength(2) + expect(wire[0]!).toMatchObject({ role: 'reasoning', content: 'pondering' }) + expect(wire[1]!).toMatchObject({ + id: 'a1', + role: 'assistant', + content: 'answer', + }) + }) + + it('preserves the original `parts` array on every anchor message', () => { + const messages: Array = [ + { id: 'u1', role: 'user', parts: [{ type: 'text', content: 'hi' }] }, + ] + const wire = uiMessagesToWire(messages) + expect((wire[0]! as any).parts).toEqual([{ type: 'text', content: 'hi' }]) + }) + + it('preserves per-part metadata on multimodal parts (round-trip via parts field)', () => { + const messages: Array = [ + { + id: 'u1', + role: 'user', + parts: [ + { + type: 'image', + source: { type: 'data', value: 'base64...', mimeType: 'image/png' }, + metadata: { detail: 'high' }, + }, + ], + }, + ] + const wire = uiMessagesToWire(messages) + const partOnAnchor = (wire[0]! as any).parts[0] + expect(partOnAnchor.metadata).toEqual({ detail: 'high' }) + }) +}) diff --git a/packages/typescript/ai/tests/chat-params.test.ts b/packages/typescript/ai/tests/chat-params.test.ts new file mode 100644 index 000000000..711e8ca7a --- /dev/null +++ b/packages/typescript/ai/tests/chat-params.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from 'vitest' +import { + chatParamsFromRequest, + chatParamsFromRequestBody, + mergeAgentTools, +} from '../src/utilities/chat-params' + +describe('chatParamsFromRequestBody', () => { + const validBody = { + threadId: 'thread-1', + runId: 'run-1', + state: {}, + messages: [ + { + id: 'm1', + role: 'user', + content: 'hello', + // TanStack canonical (extra) — should pass through untouched + parts: [{ type: 'text', content: 'hello' }], + }, + ], + tools: [], + context: [], + forwardedProps: { temperature: 0.7 }, + } + + it('returns parsed fields verbatim on a valid body', async () => { + const result = await chatParamsFromRequestBody(validBody) + expect(result.threadId).toBe('thread-1') + expect(result.runId).toBe('run-1') + expect(result.messages).toHaveLength(1) + expect(result.tools).toEqual([]) + expect(result.forwardedProps).toEqual({ temperature: 0.7 }) + }) + + it('preserves the `parts` field on messages (AG-UI strip mode tolerates extras in raw JSON)', async () => { + const result = await chatParamsFromRequestBody(validBody) + const m = result.messages[0] as { parts?: unknown } + expect(m.parts).toEqual([{ type: 'text', content: 'hello' }]) + }) + + it('throws on missing threadId', async () => { + const { threadId, ...rest } = validBody + await expect(chatParamsFromRequestBody(rest)).rejects.toThrow() + }) + + it('throws on missing runId', async () => { + const { runId, ...rest } = validBody + await expect(chatParamsFromRequestBody(rest)).rejects.toThrow() + }) + + it('throws on missing messages', async () => { + const { messages, ...rest } = validBody + await expect(chatParamsFromRequestBody(rest)).rejects.toThrow() + }) + + it('rejects the legacy {messages, data} shape with a migration-pointing error', async () => { + const oldBody = { + messages: [ + { id: 'm1', role: 'user', parts: [{ type: 'text', content: 'hi' }] }, + ], + data: {}, + } + await expect(chatParamsFromRequestBody(oldBody)).rejects.toThrow( + /AG-UI|RunAgentInput|migration/i, + ) + }) +}) + +describe('chatParamsFromRequest', () => { + const validBody = { + threadId: 'thread-1', + runId: 'run-1', + state: {}, + messages: [ + { + id: 'm1', + role: 'user', + content: 'hello', + parts: [{ type: 'text', content: 'hello' }], + }, + ], + tools: [], + context: [], + forwardedProps: {}, + } + + const makeRequest = (body: unknown): Request => + new Request('https://example.test/api/chat', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }) + + it('returns parsed params on a valid body', async () => { + const params = await chatParamsFromRequest(makeRequest(validBody)) + expect(params.threadId).toBe('thread-1') + expect(params.runId).toBe('run-1') + expect(params.messages).toHaveLength(1) + }) + + it('throws a 400 Response when JSON is malformed', async () => { + // `Request.json()` consumes the body — every call needs a fresh + // Request so the second invocation actually exercises the parse-failure + // path rather than the "body already read" branch. + await expect( + chatParamsFromRequest(makeRequest('{not-json')), + ).rejects.toBeInstanceOf(Response) + + try { + await chatParamsFromRequest(makeRequest('{not-json')) + } catch (thrown) { + expect(thrown).toBeInstanceOf(Response) + const res = thrown as Response + expect(res.status).toBe(400) + const body = await res.text() + // Public message must NOT echo Zod / parser internals. + expect(body).toMatch(/AG-UI|migration/i) + // Underlying error is preserved as `cause` for server-side logs. + expect((res as unknown as { cause?: unknown }).cause).toBeDefined() + } + }) + + it('throws a 400 Response with a migration-pointing message on invalid AG-UI shape', async () => { + const req = makeRequest({ messages: [], data: {} }) + try { + await chatParamsFromRequest(req) + throw new Error('should have thrown') + } catch (thrown) { + expect(thrown).toBeInstanceOf(Response) + const res = thrown as Response + expect(res.status).toBe(400) + const body = await res.text() + expect(body).toMatch(/AG-UI|migration/i) + // Original AGUIError is attached as `cause`. + expect((res as unknown as { cause?: unknown }).cause).toBeDefined() + } + }) +}) + +describe('mergeAgentTools', () => { + const fakeServerTool = (name: string) => ({ + name, + description: `server ${name}`, + inputSchema: { type: 'object', properties: {} }, + execute: async () => ({ ok: true }), + }) + + it('returns server tools unchanged when client list is empty', () => { + const server = [fakeServerTool('greet')] + const result = mergeAgentTools(server, []) + expect(result).toHaveLength(1) + expect(result[0]!.name).toBe('greet') + expect(result[0]!.execute).toBeDefined() + }) + + it('adds client-only tools as no-execute stubs', () => { + const server: Array> = [] + const client = [ + { + name: 'showToast', + description: 'render a toast', + parameters: { type: 'object', properties: {} }, + }, + ] + const result = mergeAgentTools(server, client) + expect(result).toHaveLength(1) + expect(result[0]!.name).toBe('showToast') + expect(result[0]!.execute).toBeUndefined() + expect(result[0]!.inputSchema).toEqual({ type: 'object', properties: {} }) + expect(result[0]!.description).toBe('render a toast') + }) + + it('server wins on name collision (client declaration ignored)', () => { + const server = [fakeServerTool('greet')] + const client = [ + { + name: 'greet', + description: 'overridden', + parameters: { type: 'object', properties: { foo: { type: 'string' } } }, + }, + ] + const result = mergeAgentTools(server, client) + expect(result).toHaveLength(1) + expect(result[0]!.description).toBe('server greet') + expect(result[0]!.execute).toBeDefined() + }) + + it('preserves the order: server tools first, then unique client tools', () => { + const server = [fakeServerTool('alpha'), fakeServerTool('beta')] + const client = [ + { + name: 'beta', // collides — should NOT be added again + description: 'overridden', + parameters: { type: 'object', properties: {} }, + }, + { + name: 'gamma', + description: 'a client-only tool', + parameters: { type: 'object', properties: {} }, + }, + ] + const result = mergeAgentTools(server, client) + expect(result.map((t) => t.name)).toEqual(['alpha', 'beta', 'gamma']) + }) + + it('handles empty server and empty client', () => { + expect(mergeAgentTools([], [])).toEqual([]) + }) +}) diff --git a/packages/typescript/ai/tests/messages.test.ts b/packages/typescript/ai/tests/messages.test.ts new file mode 100644 index 000000000..025e33b8a --- /dev/null +++ b/packages/typescript/ai/tests/messages.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { convertMessagesToModelMessages } from '../src/activities/chat/messages' +import type { ModelMessage, UIMessage } from '../src/types' + +describe('convertMessagesToModelMessages — AG-UI dedup pre-pass', () => { + it('drops fan-out tool message when an anchor UIMessage already represents the tool result', () => { + const messages = [ + { + id: 'a1', + role: 'assistant', + parts: [ + { type: 'text', content: 'calling' }, + { + type: 'tool-call', + id: 'tc1', + name: 'getTodos', + arguments: '{}', + state: 'input-complete', + }, + { + type: 'tool-result', + toolCallId: 'tc1', + content: '[]', + state: 'complete', + }, + ], + } as UIMessage, + // AG-UI fan-out duplicate — should be dropped + { + role: 'tool', + toolCallId: 'tc1', + content: '[]', + } as ModelMessage, + ] + + const result = convertMessagesToModelMessages(messages) + const toolMessages = result.filter((m) => m.role === 'tool') + expect(toolMessages).toHaveLength(1) + expect(toolMessages[0]?.toolCallId).toBe('tc1') + }) + + it('keeps tool messages from a foreign AG-UI client (no anchor parts)', () => { + const messages = [ + // No UIMessage with parts; this is what a foreign AG-UI client sends. + { + role: 'assistant', + content: 'calling', + toolCalls: [ + { + id: 'tc1', + type: 'function', + function: { name: 'getTodos', arguments: '{}' }, + }, + ], + } as ModelMessage, + { role: 'tool', toolCallId: 'tc1', content: '[]' } as ModelMessage, + ] + + const result = convertMessagesToModelMessages(messages) + const toolMessages = result.filter((m) => m.role === 'tool') + expect(toolMessages).toHaveLength(1) + expect(toolMessages[0]?.toolCallId).toBe('tc1') + }) + + it('drops AG-UI reasoning messages (no ModelMessage equivalent today)', () => { + const messages = [ + { role: 'reasoning', content: 'thinking...' } as unknown as ModelMessage, + { role: 'user', content: 'hi' } as ModelMessage, + ] + + const result = convertMessagesToModelMessages(messages) + expect(result.find((m) => (m as any).role === 'reasoning')).toBeUndefined() + expect(result).toHaveLength(1) + expect(result[0]?.role).toBe('user') + }) + + it('drops AG-UI activity messages', () => { + const messages = [ + { role: 'activity', content: 'event' } as unknown as ModelMessage, + { role: 'user', content: 'hi' } as ModelMessage, + ] + + const result = convertMessagesToModelMessages(messages) + expect(result).toHaveLength(1) + expect(result[0]?.role).toBe('user') + }) + + it('collapses AG-UI developer messages to system role', () => { + const messages = [ + { + role: 'developer', + content: 'You are helpful', + } as unknown as ModelMessage, + { role: 'user', content: 'hi' } as ModelMessage, + ] + + const result = convertMessagesToModelMessages(messages) + expect(result).toHaveLength(2) + expect(result[0]?.role).toBe('system') + expect(result[0]?.content).toBe('You are helpful') + }) +}) diff --git a/packages/typescript/ai/tests/middleware.test.ts b/packages/typescript/ai/tests/middleware.test.ts index a3d98ad8f..d0c2e8297 100644 --- a/packages/typescript/ai/tests/middleware.test.ts +++ b/packages/typescript/ai/tests/middleware.test.ts @@ -1236,34 +1236,73 @@ describe('chat() middleware', () => { }) // ========================================================================== - // conversationId propagation - // ========================================================================== - describe('conversationId', () => { - it('should propagate conversationId to middleware context', async () => { - let capturedConvId: string | undefined - + // threadId / conversationId propagation + // ========================================================================== + describe('threadId / conversationId', () => { + const runChatWithMiddleware = async (params: { + threadId?: string + conversationId?: string + }): Promise<{ + ctxThreadId: string | undefined + ctxConvId: string | undefined + }> => { + let ctxThreadId: string | undefined + let ctxConvId: string | undefined const { adapter } = createMockAdapter({ iterations: [ [ev.runStarted(), ev.textContent('hi'), ev.runFinished('stop')], ], }) - const middleware: ChatMiddleware = { name: 'test', onStart: (ctx) => { - capturedConvId = ctx.conversationId + ctxThreadId = ctx.threadId + ctxConvId = ctx.conversationId }, } - const stream = chat({ adapter, messages: [{ role: 'user', content: 'Hi' }], middleware: [middleware], - conversationId: 'conv-42', + ...params, }) await collectChunks(stream as AsyncIterable) + return { ctxThreadId, ctxConvId } + } + + it('uses caller-provided threadId for both ctx.threadId and ctx.conversationId (legacy alias)', async () => { + const { ctxThreadId, ctxConvId } = await runChatWithMiddleware({ + threadId: 'thread-7', + }) + expect(ctxThreadId).toBe('thread-7') + expect(ctxConvId).toBe('thread-7') + }) + + it('routes legacy `conversationId` option into threadId resolution', async () => { + const { ctxThreadId, ctxConvId } = await runChatWithMiddleware({ + conversationId: 'conv-42', + }) + // Legacy `conversationId` is an alias for `threadId` — both fields + // on the middleware ctx resolve to the same value. + expect(ctxThreadId).toBe('conv-42') + expect(ctxConvId).toBe('conv-42') + }) + + it('explicit threadId wins when both are passed', async () => { + const { ctxThreadId, ctxConvId } = await runChatWithMiddleware({ + threadId: 'thread-canonical', + conversationId: 'conv-legacy', + }) + expect(ctxThreadId).toBe('thread-canonical') + expect(ctxConvId).toBe('thread-canonical') + }) - expect(capturedConvId).toBe('conv-42') + it('auto-generates a threadId when neither is provided', async () => { + const { ctxThreadId, ctxConvId } = await runChatWithMiddleware({}) + expect(ctxThreadId).toMatch(/^thread/) + // ctx.conversationId mirrors threadId for backward compat with + // middleware that hasn't been migrated yet. + expect(ctxConvId).toBe(ctxThreadId) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f05b781d9..5521a3d87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,24 @@ importers: specifier: ^4.0.14 version: 4.0.15(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + codemods: + devDependencies: + '@types/jscodeshift': + specifier: ^17.1.1 + version: 17.3.0 + '@types/node': + specifier: ^24.10.1 + version: 24.10.3 + jscodeshift: + specifier: ^17.1.1 + version: 17.3.0 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.14 + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + examples/php-slim: devDependencies: concurrently: @@ -935,8 +953,8 @@ importers: packages/typescript/ai: dependencies: '@ag-ui/core': - specifier: 0.0.49 - version: 0.0.49 + specifier: ^0.0.52 + version: 0.0.52 '@tanstack/ai-event-client': specifier: workspace:* version: link:../ai-event-client @@ -1896,8 +1914,8 @@ packages: '@acemir/cssom@0.9.29': resolution: {integrity: sha512-G90x0VW+9nW4dFajtjCoT+NM0scAfH9Mb08IcjgFHYbfiL/lU04dTF9JuVOi3/OH+DJCQdcIseSXkdCB9Ky6JA==} - '@ag-ui/core@0.0.49': - resolution: {integrity: sha512-9ywypwjUGtIvTxJ2eKQjhPZgLnSFAfNK7vZUcT7Bz4ur4yAIB+lAFtzvu7VDYe6jsUx/6N/71Dh4R0zX5woNVw==} + '@ag-ui/core@0.0.52': + resolution: {integrity: sha512-Xo0bUaNV56EqylzcrAuhUkQX7et7+SZIrqZZtEByGwEq/I1EHny6ZMkWHLkKR7UNi0FJZwJyhKYmKJS3B2SEgA==} '@anthropic-ai/sdk@0.71.2': resolution: {integrity: sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==} @@ -1925,6 +1943,10 @@ packages: resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.28.5': resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} engines: {node: '>=6.9.0'} @@ -1937,6 +1959,10 @@ packages: resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@7.27.3': resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} engines: {node: '>=6.9.0'} @@ -1951,6 +1977,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-create-class-features-plugin@7.29.3': + resolution: {integrity: sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} @@ -1981,12 +2013,22 @@ packages: resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + '@babel/helper-replace-supers@7.27.1': resolution: {integrity: sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} engines: {node: '>=6.9.0'} @@ -2017,6 +2059,12 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/plugin-syntax-flow@7.28.6': + resolution: {integrity: sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-jsx@7.27.1': resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} engines: {node: '>=6.9.0'} @@ -2029,12 +2077,42 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-class-properties@7.28.6': + resolution: {integrity: sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-flow-strip-types@7.27.1': + resolution: {integrity: sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-modules-commonjs@7.27.1': resolution: {integrity: sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6': + resolution: {integrity: sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.28.6': + resolution: {integrity: sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-methods@7.28.6': + resolution: {integrity: sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} @@ -2053,12 +2131,24 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/preset-flow@7.27.1': + resolution: {integrity: sha512-ez3a2it5Fn6P54W8QkbfIyyIbxlXvcxyWHHvno1Wg0Ej5eiJY5hBb8ExttoIOJJk7V2dZE6prP7iby5q2aQ0Lg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/preset-typescript@7.28.5': resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/register@7.29.3': + resolution: {integrity: sha512-F6C1KpIdoImKQfsD6HSxZ+mS4YY/2Q+JsqrmTC5ApVkTR2rG+nnbpjhWwzA5bDNu8mJjB3AryqDaWFLd4gCbJQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.4': resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} @@ -2067,10 +2157,18 @@ packages: resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.28.5': resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + '@babel/types@7.28.5': resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} @@ -6342,6 +6440,9 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/jscodeshift@17.3.0': + resolution: {integrity: sha512-ogvGG8VQQqAQQ096uRh+d6tBHrYuZjsumHirKtvBa5qEyTMN3IQJ7apo+sw9lxaB/iKWIhbbLlF3zmAWk9XQIg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -7235,6 +7336,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + clone-deep@4.0.1: + resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} + engines: {node: '>=6'} + clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} @@ -8100,6 +8205,14 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-cache-dir@2.1.0: + resolution: {integrity: sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==} + engines: {node: '>=6'} + + find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -8122,6 +8235,10 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flow-parser@0.313.0: + resolution: {integrity: sha512-JQaYSzcm2oEG4bCMMYxMBmJ3Uc4zQUQHwsB7Xvjy9+RmVLedUy3bnnDAwV2wZSyxk00vIKlNy+/FxFsoNYSDWQ==} + engines: {node: '>=0.4.0'} + follow-redirects@1.15.11: resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} engines: {node: '>=4.0'} @@ -8702,6 +8819,10 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-plain-object@2.0.4: + resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} + engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -8795,6 +8916,10 @@ packages: resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} engines: {node: '>=16'} + isobject@3.0.1: + resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} + engines: {node: '>=0.10.0'} + isolated-vm@6.1.2: resolution: {integrity: sha512-GGfsHqtlZiiurZaxB/3kY7LLAXR3sgzDul0fom4cSyBjx6ZbjpTrFWiH3z/nUfLJGJ8PIq9LQmQFiAxu24+I7A==} engines: {node: '>=22.0.0'} @@ -8870,6 +8995,16 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jscodeshift@17.3.0: + resolution: {integrity: sha512-LjFrGOIORqXBU+jwfC9nbkjmQfFldtMIoS6d9z2LG/lkmyNXsJAySPT+2SWXJEoE68/bCWcxKpXH37npftgmow==} + engines: {node: '>=16'} + hasBin: true + peerDependencies: + '@babel/preset-env': ^7.1.6 + peerDependenciesMeta: + '@babel/preset-env': + optional: true + jsdom@27.3.0: resolution: {integrity: sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -8929,6 +9064,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -9073,6 +9212,10 @@ packages: locate-character@3.0.0: resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + locate-path@3.0.0: + resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} + engines: {node: '>=6'} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -9163,6 +9306,10 @@ packages: magicast@0.5.2: resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + make-dir@2.1.0: + resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} + engines: {node: '>=6'} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -9487,6 +9634,9 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + netmask@2.0.2: resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} engines: {node: '>= 0.4.0'} @@ -9760,6 +9910,10 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@3.0.0: + resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} + engines: {node: '>=6'} + p-locate@4.1.0: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} @@ -9830,6 +9984,10 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -9902,6 +10060,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkg-dir@3.0.0: + resolution: {integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==} + engines: {node: '>=6'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -10426,6 +10588,10 @@ packages: sdp@3.2.1: resolution: {integrity: sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==} + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -10515,6 +10681,10 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shallow-clone@3.0.1: + resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} + engines: {node: '>=8'} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -11916,6 +12086,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ws@8.18.0: resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} engines: {node: '>=10.0.0'} @@ -12033,7 +12207,7 @@ snapshots: '@acemir/cssom@0.9.29': {} - '@ag-ui/core@0.0.49': + '@ag-ui/core@0.0.52': dependencies: zod: 3.25.76 @@ -12073,6 +12247,12 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.28.5': {} '@babel/core@7.28.5': @@ -12103,6 +12283,14 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.27.3': dependencies: '@babel/types': 7.29.0 @@ -12128,6 +12316,19 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.28.5) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/helper-globals@7.28.0': {} '@babel/helper-member-expression-to-functions@7.28.5': @@ -12163,6 +12364,8 @@ snapshots: '@babel/helper-plugin-utils@7.27.1': {} + '@babel/helper-plugin-utils@7.28.6': {} + '@babel/helper-replace-supers@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12172,6 +12375,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-replace-supers@7.28.6(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: '@babel/traverse': 7.28.5 @@ -12198,6 +12410,11 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/plugin-syntax-flow@7.28.6(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12208,6 +12425,20 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-class-properties@7.28.6(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.28.5) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.28.5) + '@babel/plugin-transform-modules-commonjs@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12216,6 +12447,27 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-optional-chaining@7.28.6(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-private-methods@7.28.6(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.28.5) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12237,6 +12489,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/preset-flow@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.28.5) + '@babel/preset-typescript@7.28.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12248,6 +12507,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/register@7.29.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + clone-deep: 4.0.1 + find-cache-dir: 2.1.0 + make-dir: 2.1.0 + pirates: 4.0.7 + source-map-support: 0.5.21 + '@babel/runtime@7.28.4': {} '@babel/template@7.27.2': @@ -12256,6 +12524,12 @@ snapshots: '@babel/parser': 7.28.5 '@babel/types': 7.28.5 + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@babel/traverse@7.28.5': dependencies: '@babel/code-frame': 7.27.1 @@ -12268,6 +12542,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.28.5': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -16899,6 +17185,11 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/jscodeshift@17.3.0': + dependencies: + ast-types: 0.16.1 + recast: 0.23.11 + '@types/json-schema@7.0.15': {} '@types/mdast@4.0.4': @@ -18033,6 +18324,12 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + clone-deep@4.0.1: + dependencies: + is-plain-object: 2.0.4 + kind-of: 6.0.3 + shallow-clone: 3.0.1 + clone@1.0.4: {} clsx@2.1.1: {} @@ -19022,6 +19319,16 @@ snapshots: transitivePeerDependencies: - supports-color + find-cache-dir@2.1.0: + dependencies: + commondir: 1.0.1 + make-dir: 2.1.0 + pkg-dir: 3.0.0 + + find-up@3.0.0: + dependencies: + locate-path: 3.0.0 + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -19047,6 +19354,8 @@ snapshots: flatted@3.3.3: {} + flow-parser@0.313.0: {} + follow-redirects@1.15.11: {} for-each@0.3.5: @@ -19717,6 +20026,10 @@ snapshots: is-plain-obj@4.1.0: {} + is-plain-object@2.0.4: + dependencies: + isobject: 3.0.1 + is-potential-custom-element-name@1.0.1: {} is-promise@4.0.0: {} @@ -19796,6 +20109,8 @@ snapshots: isexe@3.1.1: {} + isobject@3.0.1: {} + isolated-vm@6.1.2: dependencies: node-gyp-build: 4.8.4 @@ -19874,6 +20189,29 @@ snapshots: dependencies: argparse: 2.0.1 + jscodeshift@17.3.0: + dependencies: + '@babel/core': 7.28.5 + '@babel/parser': 7.29.0 + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.28.5) + '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.28.5) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.28.5) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.28.5) + '@babel/preset-flow': 7.27.1(@babel/core@7.28.5) + '@babel/preset-typescript': 7.28.5(@babel/core@7.28.5) + '@babel/register': 7.29.3(@babel/core@7.28.5) + flow-parser: 0.313.0 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + neo-async: 2.6.2 + picocolors: 1.1.1 + recast: 0.23.11 + tmp: 0.2.5 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + jsdom@27.3.0(postcss@8.5.9): dependencies: '@acemir/cssom': 0.9.29 @@ -19948,6 +20286,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kind-of@6.0.3: {} + kleur@4.1.5: {} klona@2.0.6: {} @@ -20098,6 +20438,11 @@ snapshots: locate-character@3.0.0: {} + locate-path@3.0.0: + dependencies: + p-locate: 3.0.0 + path-exists: 3.0.0 + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -20177,6 +20522,11 @@ snapshots: '@babel/types': 7.29.0 source-map-js: 1.2.1 + make-dir@2.1.0: + dependencies: + pify: 4.0.1 + semver: 5.7.2 + make-dir@4.0.0: dependencies: semver: 7.7.4 @@ -20685,6 +21035,8 @@ snapshots: negotiator@1.0.0: {} + neo-async@2.6.2: {} + netmask@2.0.2: {} nf3@0.3.10: {} @@ -21283,6 +21635,10 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-locate@3.0.0: + dependencies: + p-limit: 2.3.0 + p-locate@4.1.0: dependencies: p-limit: 2.3.0 @@ -21370,6 +21726,8 @@ snapshots: path-browserify@1.0.1: {} + path-exists@3.0.0: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -21416,6 +21774,10 @@ snapshots: pirates@4.0.7: {} + pkg-dir@3.0.0: + dependencies: + find-up: 3.0.0 + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -22171,6 +22533,8 @@ snapshots: sdp@3.2.1: {} + semver@5.7.2: {} + semver@6.3.1: {} semver@7.5.4: @@ -22288,6 +22652,10 @@ snapshots: setprototypeof@1.2.0: {} + shallow-clone@3.0.1: + dependencies: + kind-of: 6.0.3 + sharp@0.34.5: dependencies: '@img/colour': 1.1.0 @@ -23790,6 +24158,11 @@ snapshots: wrappy@1.0.2: {} + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + ws@8.18.0: {} ws@8.18.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 738adc729..3cfc9cbfc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,3 +7,4 @@ packages: - 'packages/typescript/ai-code-mode/models-eval' - 'examples/*' - 'testing/*' + - 'codemods' diff --git a/testing/e2e/src/routes/api.chat.ts b/testing/e2e/src/routes/api.chat.ts index 30a00f8cc..8d42a21fd 100644 --- a/testing/e2e/src/routes/api.chat.ts +++ b/testing/e2e/src/routes/api.chat.ts @@ -1,5 +1,10 @@ import { createFileRoute } from '@tanstack/react-router' -import { chat, maxIterations, toServerSentEventsResponse } from '@tanstack/ai' +import { + chat, + chatParamsFromRequestBody, + maxIterations, + toServerSentEventsResponse, +} from '@tanstack/ai' import type { Feature, Provider } from '@/lib/types' import { createTextAdapter } from '@/lib/providers' import { featureConfigs } from '@/lib/features' @@ -14,14 +19,27 @@ export const Route = createFileRoute('/api/chat')({ } const abortController = new AbortController() - const body = await request.json() - const { messages, data } = body - const provider: Provider = data?.provider || 'openai' - const feature: Feature = data?.feature || 'chat' - const testId: string | undefined = - typeof data?.testId === 'string' ? data.testId : undefined - const aimockPort: number | undefined = - data?.aimockPort != null ? Number(data.aimockPort) : undefined + + let params + try { + params = await chatParamsFromRequestBody(await request.json()) + } catch (error) { + return new Response( + error instanceof Error ? error.message : 'Bad request', + { status: 400 }, + ) + } + + const fp = params.forwardedProps as Record + const provider: Provider = ( + typeof fp.provider === 'string' ? fp.provider : 'openai' + ) as Provider + const feature: Feature = ( + typeof fp.feature === 'string' ? fp.feature : 'chat' + ) as Feature + const testId = typeof fp.testId === 'string' ? fp.testId : undefined + const aimockPort = + fp.aimockPort != null ? Number(fp.aimockPort) : undefined const config = featureConfigs[feature] const modelOverride = config.modelOverrides?.[provider] @@ -39,7 +57,9 @@ export const Route = createFileRoute('/api/chat')({ modelOptions: config.modelOptions, systemPrompts: ['You are a helpful assistant for a guitar store.'], agentLoopStrategy: maxIterations(5), - messages, + messages: params.messages, + threadId: params.threadId, + runId: params.runId, abortController, }) diff --git a/testing/e2e/src/routes/api.image.stream.ts b/testing/e2e/src/routes/api.image.stream.ts index 3dc986d9c..abcf2c280 100644 --- a/testing/e2e/src/routes/api.image.stream.ts +++ b/testing/e2e/src/routes/api.image.stream.ts @@ -10,7 +10,7 @@ export const Route = createFileRoute('/api/image/stream')({ await import('@/lib/llmock-server').then((m) => m.ensureLLMock()) const abortController = new AbortController() const body = await request.json() - const data = body.data ?? body + const data = body.forwardedProps ?? body.data ?? body const { prompt, provider, numberOfImages, testId, aimockPort } = data as { prompt: string diff --git a/testing/e2e/src/routes/api.image.ts b/testing/e2e/src/routes/api.image.ts index 7356a9260..8fb9829ac 100644 --- a/testing/e2e/src/routes/api.image.ts +++ b/testing/e2e/src/routes/api.image.ts @@ -10,7 +10,7 @@ export const Route = createFileRoute('/api/image')({ await import('@/lib/llmock-server').then((m) => m.ensureLLMock()) const abortController = new AbortController() const body = await request.json() - const data = body.data ?? body + const data = body.forwardedProps ?? body.data ?? body const { prompt, provider, numberOfImages, testId, aimockPort } = data as { prompt: string diff --git a/testing/e2e/src/routes/api.middleware-test.ts b/testing/e2e/src/routes/api.middleware-test.ts index 6a817c265..c31e01ff0 100644 --- a/testing/e2e/src/routes/api.middleware-test.ts +++ b/testing/e2e/src/routes/api.middleware-test.ts @@ -1,6 +1,7 @@ import { createFileRoute } from '@tanstack/react-router' import { chat, + chatParamsFromRequestBody, maxIterations, toServerSentEventsResponse, toolDefinition, @@ -197,18 +198,27 @@ export const Route = createFileRoute('/api/middleware-test')({ if (request.signal?.aborted) return new Response(null, { status: 499 }) const abortController = new AbortController() + let params try { - const body = await request.json() - const messages = body.messages - const scenario = body.data?.scenario || 'basic-text' - const middlewareMode = body.data?.middlewareMode || 'none' - const testId: string | undefined = - typeof body.data?.testId === 'string' ? body.data.testId : undefined - const aimockPort: number | undefined = - body.data?.aimockPort != null - ? Number(body.data.aimockPort) - : undefined + params = await chatParamsFromRequestBody(await request.json()) + } catch (error) { + return new Response( + error instanceof Error ? error.message : 'Bad request', + { status: 400 }, + ) + } + const fp = params.forwardedProps as Record + const scenario = + typeof fp.scenario === 'string' ? fp.scenario : 'basic-text' + const middlewareMode = + typeof fp.middlewareMode === 'string' ? fp.middlewareMode : 'none' + const testId: string | undefined = + typeof fp.testId === 'string' ? fp.testId : undefined + const aimockPort: number | undefined = + fp.aimockPort != null ? Number(fp.aimockPort) : undefined + + try { const adapterOptions = createTextAdapter( 'openai', undefined, @@ -249,9 +259,11 @@ export const Route = createFileRoute('/api/middleware-test')({ const stream = chat({ ...adapterOptions, - messages, + messages: params.messages, tools, middleware, + threadId: params.threadId, + runId: params.runId, agentLoopStrategy: maxIterations(10), abortController, }) diff --git a/testing/e2e/src/routes/api.tools-test.ts b/testing/e2e/src/routes/api.tools-test.ts index 5dbd8da8a..403ad12c7 100644 --- a/testing/e2e/src/routes/api.tools-test.ts +++ b/testing/e2e/src/routes/api.tools-test.ts @@ -1,5 +1,10 @@ import { createFileRoute } from '@tanstack/react-router' -import { chat, maxIterations, toServerSentEventsResponse } from '@tanstack/ai' +import { + chat, + chatParamsFromRequestBody, + maxIterations, + toServerSentEventsResponse, +} from '@tanstack/ai' import { createTextAdapter } from '@/lib/providers' import { getToolsForScenario } from '@/lib/tools-test-tools' @@ -15,17 +20,25 @@ export const Route = createFileRoute('/api/tools-test')({ const abortController = new AbortController() + let params try { - const body = await request.json() - const messages = body.messages - const scenario = body.data?.scenario || body.scenario || 'text-only' - const testId: string | undefined = - typeof body.data?.testId === 'string' ? body.data.testId : undefined - const aimockPort: number | undefined = - body.data?.aimockPort != null - ? Number(body.data.aimockPort) - : undefined + params = await chatParamsFromRequestBody(await request.json()) + } catch (error) { + return new Response( + error instanceof Error ? error.message : 'Bad request', + { status: 400 }, + ) + } + const fp = params.forwardedProps as Record + const scenario = + typeof fp.scenario === 'string' ? fp.scenario : 'text-only' + const testId: string | undefined = + typeof fp.testId === 'string' ? fp.testId : undefined + const aimockPort: number | undefined = + fp.aimockPort != null ? Number(fp.aimockPort) : undefined + + try { // Special error scenario: return a stream that immediately errors if (scenario === 'error') { const errorStream = (async function* () { @@ -57,8 +70,10 @@ export const Route = createFileRoute('/api/tools-test')({ const stream = chat({ ...adapterOptions, - messages, + messages: params.messages, tools, + threadId: params.threadId, + runId: params.runId, agentLoopStrategy: maxIterations(20), abortController, }) diff --git a/testing/e2e/src/routes/api.transcription.stream.ts b/testing/e2e/src/routes/api.transcription.stream.ts index e43f154a1..34979ea17 100644 --- a/testing/e2e/src/routes/api.transcription.stream.ts +++ b/testing/e2e/src/routes/api.transcription.stream.ts @@ -10,7 +10,7 @@ export const Route = createFileRoute('/api/transcription/stream')({ await import('@/lib/llmock-server').then((m) => m.ensureLLMock()) const abortController = new AbortController() const body = await request.json() - const data = body.data ?? body + const data = body.forwardedProps ?? body.data ?? body const { audio, language, provider, testId, aimockPort } = data as { audio: string language?: string diff --git a/testing/e2e/src/routes/api.transcription.ts b/testing/e2e/src/routes/api.transcription.ts index 904776a4b..070b29db7 100644 --- a/testing/e2e/src/routes/api.transcription.ts +++ b/testing/e2e/src/routes/api.transcription.ts @@ -10,7 +10,7 @@ export const Route = createFileRoute('/api/transcription')({ await import('@/lib/llmock-server').then((m) => m.ensureLLMock()) const abortController = new AbortController() const body = await request.json() - const data = body.data ?? body + const data = body.forwardedProps ?? body.data ?? body const { audio, language, provider, testId, aimockPort } = data as { audio: string language?: string diff --git a/testing/e2e/src/routes/api.tts.stream.ts b/testing/e2e/src/routes/api.tts.stream.ts index 69144cb31..1917ed5ad 100644 --- a/testing/e2e/src/routes/api.tts.stream.ts +++ b/testing/e2e/src/routes/api.tts.stream.ts @@ -10,7 +10,7 @@ export const Route = createFileRoute('/api/tts/stream')({ await import('@/lib/llmock-server').then((m) => m.ensureLLMock()) const abortController = new AbortController() const body = await request.json() - const data = body.data ?? body + const data = body.forwardedProps ?? body.data ?? body const { text, voice, provider, testId, aimockPort } = data as { text: string voice?: string diff --git a/testing/e2e/src/routes/api.tts.ts b/testing/e2e/src/routes/api.tts.ts index 7ae80f7f7..b981baab1 100644 --- a/testing/e2e/src/routes/api.tts.ts +++ b/testing/e2e/src/routes/api.tts.ts @@ -10,7 +10,7 @@ export const Route = createFileRoute('/api/tts')({ await import('@/lib/llmock-server').then((m) => m.ensureLLMock()) const abortController = new AbortController() const body = await request.json() - const data = body.data ?? body + const data = body.forwardedProps ?? body.data ?? body const { text, voice, provider, testId, aimockPort } = data as { text: string voice?: string diff --git a/testing/e2e/src/routes/api.video.stream.ts b/testing/e2e/src/routes/api.video.stream.ts index 2d3760058..33643bd02 100644 --- a/testing/e2e/src/routes/api.video.stream.ts +++ b/testing/e2e/src/routes/api.video.stream.ts @@ -10,7 +10,7 @@ export const Route = createFileRoute('/api/video/stream')({ await import('@/lib/llmock-server').then((m) => m.ensureLLMock()) const abortController = new AbortController() const body = await request.json() - const data = body.data ?? body + const data = body.forwardedProps ?? body.data ?? body const { prompt, provider, testId, aimockPort } = data as { prompt: string provider: Provider diff --git a/testing/e2e/src/routes/api.video.ts b/testing/e2e/src/routes/api.video.ts index d9269c035..e50d9cb87 100644 --- a/testing/e2e/src/routes/api.video.ts +++ b/testing/e2e/src/routes/api.video.ts @@ -10,7 +10,7 @@ export const Route = createFileRoute('/api/video')({ await import('@/lib/llmock-server').then((m) => m.ensureLLMock()) const abortController = new AbortController() const body = await request.json() - const data = body.data ?? body + const data = body.forwardedProps ?? body.data ?? body const { prompt, provider, testId, aimockPort } = data as { prompt: string provider: Provider diff --git a/testing/e2e/tests/ag-ui-compliance.spec.ts b/testing/e2e/tests/ag-ui-compliance.spec.ts new file mode 100644 index 000000000..31ded5dae --- /dev/null +++ b/testing/e2e/tests/ag-ui-compliance.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from './fixtures' +import { sendMessage, waitForResponse, featureUrl } from './helpers' + +test.describe('AG-UI client-to-server compliance', () => { + test('POST body has RunAgentInput shape and persists threadId across sends', async ({ + page, + testId, + aimockPort, + }) => { + const requestBodies: Array = [] + page.on('request', (request) => { + if (request.url().includes('/api/chat') && request.method() === 'POST') { + const body = request.postDataJSON() + if (body) requestBodies.push(body) + } + }) + + await page.goto(featureUrl('openai', 'chat', testId, aimockPort)) + + // Send first message + await sendMessage(page, '[chat] hello') + await waitForResponse(page) + + // Send second message in the same session + await sendMessage(page, '[chat] another message') + await waitForResponse(page) + + expect(requestBodies.length).toBeGreaterThanOrEqual(2) + + const first = requestBodies[0]! + const second = requestBodies[1]! + + // Wire shape: every field required by RunAgentInput must be present + for (const body of [first, second]) { + expect(body).toHaveProperty('threadId') + expect(body).toHaveProperty('runId') + expect(body).toHaveProperty('state') + expect(body).toHaveProperty('messages') + expect(body).toHaveProperty('tools') + expect(body).toHaveProperty('context') + expect(body).toHaveProperty('forwardedProps') + expect(Array.isArray(body.messages)).toBe(true) + expect(Array.isArray(body.tools)).toBe(true) + } + + // threadId continuity: same session → same threadId + expect(first.threadId).toBe(second.threadId) + + // runId freshness: each send generates a new runId + expect(first.runId).not.toBe(second.runId) + + // Anchor messages carry `parts` (re-attached by chatParamsFromRequestBody) + const anchors = second.messages.filter( + (m: any) => + m.role === 'user' || m.role === 'system' || m.role === 'assistant', + ) + expect(anchors.length).toBeGreaterThan(0) + for (const a of anchors) { + expect(a).toHaveProperty('parts') + expect(Array.isArray(a.parts)).toBe(true) + } + }) +}) diff --git a/testing/e2e/tests/ag-ui-foreign-client.spec.ts b/testing/e2e/tests/ag-ui-foreign-client.spec.ts new file mode 100644 index 000000000..b7a19b252 --- /dev/null +++ b/testing/e2e/tests/ag-ui-foreign-client.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from './fixtures' + +test.describe('AG-UI foreign client compatibility', () => { + test('TanStack server accepts pure RunAgentInput with fan-out tool messages', async ({ + request, + testId, + aimockPort, + }) => { + const body = { + threadId: 'thread-foreign-1', + runId: 'run-foreign-1', + state: {}, + messages: [ + { id: 'u1', role: 'user', content: '[chat] recommend a guitar' }, + ], + tools: [], + context: [], + forwardedProps: { + provider: 'openai', + feature: 'chat', + testId, + aimockPort, + }, + } + const response = await request.post('/api/chat', { + data: body, + headers: { 'Content-Type': 'application/json' }, + }) + expect( + response.ok(), + `expected 200, got ${response.status()}: ${await response.text()}`, + ).toBe(true) + const text = await response.text() + expect(text).toContain('RUN_FINISHED') + }) + + test('developer role is collapsed to system without breaking the run', async ({ + request, + testId, + aimockPort, + }) => { + const body = { + threadId: 'thread-foreign-2', + runId: 'run-foreign-2', + state: {}, + messages: [ + { id: 'd1', role: 'developer', content: 'You only speak in haiku.' }, + { id: 'u1', role: 'user', content: '[chat] recommend a guitar' }, + ], + tools: [], + context: [], + forwardedProps: { + provider: 'openai', + feature: 'chat', + testId, + aimockPort, + }, + } + const response = await request.post('/api/chat', { + data: body, + headers: { 'Content-Type': 'application/json' }, + }) + expect(response.ok()).toBe(true) + }) +}) diff --git a/testing/e2e/tests/ag-ui-old-client-rejection.spec.ts b/testing/e2e/tests/ag-ui-old-client-rejection.spec.ts new file mode 100644 index 000000000..694a373fc --- /dev/null +++ b/testing/e2e/tests/ag-ui-old-client-rejection.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from './fixtures' + +test('legacy {messages, data} wire shape is rejected with a migration-pointing error', async ({ + request, +}) => { + const oldBody = { + messages: [ + { id: 'u1', role: 'user', parts: [{ type: 'text', content: 'hi' }] }, + ], + data: {}, + } + const response = await request.post('/api/chat', { + data: oldBody, + headers: { 'Content-Type': 'application/json' }, + }) + expect(response.status()).toBe(400) + const body = await response.text() + expect(body).toMatch(/AG-UI|RunAgentInput|migration/i) +})