diff --git a/.changeset/ai-devtools-hook-dashboard.md b/.changeset/ai-devtools-hook-dashboard.md
new file mode 100644
index 000000000..6c2623365
--- /dev/null
+++ b/.changeset/ai-devtools-hook-dashboard.md
@@ -0,0 +1,12 @@
+---
+'@tanstack/ai-client': minor
+'@tanstack/ai-devtools-core': minor
+'@tanstack/ai-event-client': minor
+'@tanstack/ai-preact': patch
+'@tanstack/ai-react': patch
+'@tanstack/ai-solid': patch
+'@tanstack/ai-svelte': patch
+'@tanstack/ai-vue': patch
+---
+
+Add hook-aware AI devtools registration, run tracking, state snapshots, and tool fixture replay.
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 000000000..102612c7c
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,42 @@
+# AGENTS.md
+
+Cross-agent guidance for this repository. See `CLAUDE.md` for the full project
+overview, architecture, and conventions — this file mirrors the rules that
+apply to every coding agent regardless of tool.
+
+## Pre-PR Quality Gate (MANDATORY)
+
+**Before opening a PR or pushing changes intended for review, you MUST run the
+same checks CI runs and confirm they pass locally.** Pushing without running
+these is not acceptable — CI will fail and waste review cycles.
+
+The single canonical command is:
+
+```bash
+pnpm test:pr
+```
+
+This runs the exact target set the `PR` workflow runs in CI
+(`nx affected --targets=test:sherif,test:knip,test:docs,test:eslint,test:lib,test:types,test:build,build --exclude=examples/**,testing/**`).
+
+If you can't run `test:pr` (e.g. it's too slow on your machine), at minimum run
+each of these and confirm they're green before pushing:
+
+- `pnpm test:sherif` — workspace consistency
+- `pnpm test:knip` — unused dependencies
+- `pnpm test:docs` — doc link verification
+- `pnpm test:eslint` — lint
+- `pnpm test:types` — typecheck
+- `pnpm test:lib` — unit tests
+- `pnpm test:build` — build artifact verification
+- `pnpm build` — build all affected packages
+- `pnpm --filter @tanstack/ai-e2e test:e2e` — E2E suite (mandatory for any
+ behavior change; see `testing/e2e/README.md`)
+
+Do **not** rely on CI as your first signal. Run locally, fix, then push.
+
+## Everything Else
+
+For package manager (`pnpm@10.17.0`), monorepo layout, adapter architecture,
+tool system, framework integrations, E2E requirements, and all other
+conventions, read `CLAUDE.md` in this directory.
diff --git a/CLAUDE.md b/CLAUDE.md
index 93e5a73ef..903c991ff 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -215,6 +215,32 @@ Each framework integration uses the headless `ai-client` under the hood.
8. Format code: `pnpm format`
9. Verify build: `pnpm test:build` or `pnpm build`
+### Pre-PR Quality Gate (MANDATORY)
+
+**Before opening a PR or pushing changes intended for review, you MUST run the same checks CI runs and confirm they pass locally.** Pushing without running these is not acceptable — CI will fail and waste review cycles.
+
+The single canonical command is:
+
+```bash
+pnpm test:pr
+```
+
+This runs the exact target set the `PR` workflow runs in CI (`nx affected --targets=test:sherif,test:knip,test:docs,test:eslint,test:lib,test:types,test:build,build --exclude=examples/**,testing/**`).
+
+If you can't run `test:pr` (e.g. it's too slow on your machine), at minimum run each of these and confirm they're green before pushing:
+
+- `pnpm test:sherif` — workspace consistency
+- `pnpm test:knip` — unused dependencies
+- `pnpm test:docs` — doc link verification
+- `pnpm test:eslint` — lint
+- `pnpm test:types` — typecheck
+- `pnpm test:lib` — unit tests
+- `pnpm test:build` — build artifact verification
+- `pnpm build` — build all affected packages
+- `pnpm --filter @tanstack/ai-e2e test:e2e` — E2E suite (mandatory for any behavior change; see E2E Testing)
+
+Do **not** rely on CI as your first signal. Run locally, fix, then push.
+
### Working with Examples
Examples are not built by Nx. To run an example:
diff --git a/docs/getting-started/devtools.md b/docs/getting-started/devtools.md
index 5178dbef0..d81625a1d 100644
--- a/docs/getting-started/devtools.md
+++ b/docs/getting-started/devtools.md
@@ -16,11 +16,68 @@ keywords:
TanStack Devtools is a unified devtools panel for inspecting and debugging TanStack libraries, including TanStack AI. It provides real-time insights into AI interactions, tool calls, and state changes, making it easier to develop and troubleshoot AI-powered applications.
## Features
+- **Hook dashboard** - Discover every active TanStack AI hook on the page, including chat, structured output, image, video, audio, speech, transcription, and summarize hooks.
+- **Run timeline** - Inspect user turns, linked runs, stream events, client snapshots, and server-only events by `threadId` and `runId`.
- **Real-time Monitoring** - View live chat messages, tool invocations, and AI responses.
- **Tool Call Inspection** - Inspect input and output of tool calls.
+- **Tool Fixture Replay** - Build tool payloads from a tool's standard-schema input, append the result into chat messages, and save fixtures in localStorage for repeated UI iteration.
- **State Visualization** - Visualize chat state and message history.
- **Error Tracking** - Monitor errors and exceptions in AI interactions.
+## Hook Dashboard
+
+The AI devtools panel listens for active TanStack AI clients and shows them in the left sidebar. Hooks register when they are created, emit a snapshot immediately, and respond again whenever the devtools panel opens or requests state. This keeps hooks discoverable even when the panel is opened after the app has already rendered.
+
+Each hook entry includes its type, lifecycle, message count, run count, and the latest linked `threadId`. Selecting a hook opens the full timeline for that hook. Chat hooks keep the current turn-based view: a user message wraps every run and event that happened while answering that turn. The details view also includes lightweight client/server state snapshots between runs so you can see exactly what changed.
+
+### Naming Hooks
+
+When a page has more than one AI hook, pass `devtools.name` to give each hook a user-facing label in the dashboard. The configured name is display-only; hook type, framework, thread id, and run correlation still come from the TanStack AI client.
+
+```tsx
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
+
+export function SupportChat() {
+ const chat = useChat({
+ id: 'support-chat',
+ connection: fetchServerSentEvents('/api/chat'),
+ devtools: {
+ name: 'Support Chat',
+ },
+ })
+
+ // render your chat UI with `chat.messages`, `chat.sendMessage`, etc.
+}
+```
+
+The same display option works for specialized generation hooks:
+
+```tsx
+import { fetchServerSentEvents, useGenerateImage } from '@tanstack/ai-react'
+
+export function ImageStudio() {
+ const image = useGenerateImage({
+ id: 'generation-hooks:useGenerateImage',
+ connection: fetchServerSentEvents('/api/image'),
+ devtools: {
+ name: 'Image Studio',
+ },
+ })
+
+ // render your image generation UI with `image.generate` and `image.result`
+}
+```
+
+## Tool Fixtures
+
+When a `useChat` hook receives tools, the devtools panel lists those tools and their schemas. For standard-schema-compatible inputs, the panel renders a small form from the input schema so you can create a tool call payload without hand-writing JSON.
+
+Applying a tool fixture appends the tool call and result into the real chat messages for that hook. Saved fixtures are stored in browser localStorage under the AI devtools namespace so they are available the next time you open the panel.
+
+## Event Sources
+
+Client-visible state is emitted by the headless client. Server-only details, such as middleware and provider stream events that never exist on the client, are emitted from the server counterpart. Events include a source descriptor and stable envelope id so the panel can link related events and avoid displaying duplicates.
+
## Installation
To use TanStack Devtools with TanStack AI, install the `@tanstack/react-ai-devtools` package:
diff --git a/examples/ts-react-chat/src/components/Header.tsx b/examples/ts-react-chat/src/components/Header.tsx
index 3598224a9..bd4a1822d 100644
--- a/examples/ts-react-chat/src/components/Header.tsx
+++ b/examples/ts-react-chat/src/components/Header.tsx
@@ -2,6 +2,7 @@ import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import {
+ Activity,
Braces,
FileAudio,
FileText,
@@ -76,6 +77,19 @@ export default function Header() {
Generations
+ setIsOpen(false)}
+ className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-1"
+ activeProps={{
+ className:
+ 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-1',
+ }}
+ >
+
+ Generation Hooks
+
+
setIsOpen(false)}
diff --git a/examples/ts-react-chat/src/routeTree.gen.ts b/examples/ts-react-chat/src/routeTree.gen.ts
index 1c3995291..6bbf55373 100644
--- a/examples/ts-react-chat/src/routeTree.gen.ts
+++ b/examples/ts-react-chat/src/routeTree.gen.ts
@@ -13,6 +13,7 @@ import { Route as ServerFnChatRouteImport } from './routes/server-fn-chat'
import { Route as RealtimeRouteImport } from './routes/realtime'
import { Route as Issue176ToolResultRouteImport } from './routes/issue-176-tool-result'
import { Route as ImageGenRouteImport } from './routes/image-gen'
+import { Route as GenerationHooksRouteImport } from './routes/generation-hooks'
import { Route as IndexRouteImport } from './routes/index'
import { Route as GenerationsVideoRouteImport } from './routes/generations.video'
import { Route as GenerationsTranscriptionRouteImport } from './routes/generations.transcription'
@@ -55,6 +56,11 @@ const ImageGenRoute = ImageGenRouteImport.update({
path: '/image-gen',
getParentRoute: () => rootRouteImport,
} as any)
+const GenerationHooksRoute = GenerationHooksRouteImport.update({
+ id: '/generation-hooks',
+ path: '/generation-hooks',
+ getParentRoute: () => rootRouteImport,
+} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
@@ -166,6 +172,7 @@ const ApiGenerateAudioRoute = ApiGenerateAudioRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
+ '/generation-hooks': typeof GenerationHooksRoute
'/image-gen': typeof ImageGenRoute
'/issue-176-tool-result': typeof Issue176ToolResultRoute
'/realtime': typeof RealtimeRoute
@@ -193,6 +200,7 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
+ '/generation-hooks': typeof GenerationHooksRoute
'/image-gen': typeof ImageGenRoute
'/issue-176-tool-result': typeof Issue176ToolResultRoute
'/realtime': typeof RealtimeRoute
@@ -221,6 +229,7 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
+ '/generation-hooks': typeof GenerationHooksRoute
'/image-gen': typeof ImageGenRoute
'/issue-176-tool-result': typeof Issue176ToolResultRoute
'/realtime': typeof RealtimeRoute
@@ -250,6 +259,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
+ | '/generation-hooks'
| '/image-gen'
| '/issue-176-tool-result'
| '/realtime'
@@ -277,6 +287,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
+ | '/generation-hooks'
| '/image-gen'
| '/issue-176-tool-result'
| '/realtime'
@@ -304,6 +315,7 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/'
+ | '/generation-hooks'
| '/image-gen'
| '/issue-176-tool-result'
| '/realtime'
@@ -332,6 +344,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
+ GenerationHooksRoute: typeof GenerationHooksRoute
ImageGenRoute: typeof ImageGenRoute
Issue176ToolResultRoute: typeof Issue176ToolResultRoute
RealtimeRoute: typeof RealtimeRoute
@@ -388,6 +401,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ImageGenRouteImport
parentRoute: typeof rootRouteImport
}
+ '/generation-hooks': {
+ id: '/generation-hooks'
+ path: '/generation-hooks'
+ fullPath: '/generation-hooks'
+ preLoaderRoute: typeof GenerationHooksRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/': {
id: '/'
path: '/'
@@ -540,6 +560,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
+ GenerationHooksRoute: GenerationHooksRoute,
ImageGenRoute: ImageGenRoute,
Issue176ToolResultRoute: Issue176ToolResultRoute,
RealtimeRoute: RealtimeRoute,
diff --git a/examples/ts-react-chat/src/routes/api.structured-output.ts b/examples/ts-react-chat/src/routes/api.structured-output.ts
index a4be14d23..b02654a1f 100644
--- a/examples/ts-react-chat/src/routes/api.structured-output.ts
+++ b/examples/ts-react-chat/src/routes/api.structured-output.ts
@@ -1,5 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
-import { chat, toServerSentEventsResponse } from '@tanstack/ai'
+import {
+ chat,
+ chatParamsFromRequestBody,
+ toServerSentEventsResponse,
+} from '@tanstack/ai'
import { openaiChatCompletions, openaiText } from '@tanstack/ai-openai'
import {
ANTHROPIC_COMBINED_TOOLS_AND_SCHEMA_MODELS,
@@ -53,19 +57,35 @@ async function* withTrailingPhaseCounts(
snapshot: () => Record,
model: string,
): AsyncIterable {
+ let yieldedCounts = false
for await (const chunk of stream) {
+ if (
+ chunk.type === EventType.RUN_FINISHED ||
+ chunk.type === EventType.RUN_ERROR
+ ) {
+ yieldedCounts = true
+ yield {
+ type: EventType.CUSTOM,
+ name: 'phase-counts',
+ value: snapshot(),
+ model,
+ timestamp: Date.now(),
+ }
+ }
yield chunk
}
- yield {
- type: EventType.CUSTOM,
- name: 'phase-counts',
- value: snapshot(),
- model,
- timestamp: Date.now(),
+ if (!yieldedCounts) {
+ yield {
+ type: EventType.CUSTOM,
+ name: 'phase-counts',
+ value: snapshot(),
+ model,
+ timestamp: Date.now(),
+ }
}
}
-const GuitarRecommendationSchema = z.object({
+export const GuitarRecommendationSchema = z.object({
title: z.string().describe('Short headline for the recommendation'),
summary: z.string().describe('One paragraph summary'),
recommendations: z
@@ -83,33 +103,24 @@ const GuitarRecommendationSchema = z.object({
nextSteps: z.array(z.string()).describe('Practical follow-up actions'),
})
-type Provider =
- | 'openai'
- | 'openai-chat'
- | 'anthropic'
- | 'gemini'
- | 'grok'
- | 'groq'
- | 'openrouter'
- | 'openrouter-responses'
+export type GuitarRecommendation = z.infer
-const StructuredOutputRequestSchema = z.object({
- prompt: z.string().min(1),
- provider: z
- .enum([
- 'openai',
- 'openai-chat',
- 'anthropic',
- 'gemini',
- 'grok',
- 'groq',
- 'openrouter',
- 'openrouter-responses',
- ])
- .optional(),
- model: z.string().optional(),
- stream: z.boolean().optional(),
-})
+const PROVIDERS = [
+ 'openai',
+ 'openai-chat',
+ 'anthropic',
+ 'gemini',
+ 'grok',
+ 'groq',
+ 'openrouter',
+ 'openrouter-responses',
+] as const
+
+type Provider = (typeof PROVIDERS)[number]
+
+function isProvider(value: unknown): value is Provider {
+ return typeof value === 'string' && PROVIDERS.includes(value as Provider)
+}
/**
* Synthetic suffixes the dropdown uses to opt the route into reasoning
@@ -285,27 +296,92 @@ function reasoningOptionsFor(
}
}
+async function* structuredOutputResultStream(args: {
+ result: GuitarRecommendation
+ phaseCounts: Record
+ threadId: string
+ runId: string
+ model: string
+}): AsyncIterable {
+ const messageId = `structured-output-${args.runId}`
+ const raw = JSON.stringify(args.result)
+ const timestamp = Date.now()
+
+ yield {
+ type: EventType.RUN_STARTED,
+ threadId: args.threadId,
+ runId: args.runId,
+ model: args.model,
+ timestamp,
+ }
+ yield {
+ type: EventType.CUSTOM,
+ name: 'structured-output.start',
+ value: { messageId },
+ model: args.model,
+ timestamp: Date.now(),
+ }
+ yield {
+ type: EventType.CUSTOM,
+ name: 'structured-output.complete',
+ value: { object: args.result, raw },
+ model: args.model,
+ timestamp: Date.now(),
+ }
+ yield {
+ type: EventType.CUSTOM,
+ name: 'phase-counts',
+ value: args.phaseCounts,
+ model: args.model,
+ timestamp: Date.now(),
+ }
+ yield {
+ type: EventType.RUN_FINISHED,
+ threadId: args.threadId,
+ runId: args.runId,
+ model: args.model,
+ timestamp: Date.now(),
+ finishReason: 'stop',
+ }
+}
+
export const Route = createFileRoute('/api/structured-output')({
server: {
handlers: {
POST: async ({ request }) => {
+ if (request.signal.aborted) {
+ return new Response(null, { status: 499 })
+ }
+
+ const abortController = new AbortController()
+ const onAbort = () => abortController.abort()
+ request.signal.addEventListener('abort', onAbort, { once: true })
+ if (request.signal.aborted) {
+ onAbort()
+ }
+
+ let params: Awaited>
try {
- const parsed = StructuredOutputRequestSchema.safeParse(
- await request.json(),
+ params = await chatParamsFromRequestBody(await request.json())
+ } catch (error) {
+ return new Response(
+ error instanceof Error ? error.message : 'Bad request',
+ { status: 400 },
)
- if (!parsed.success) {
- return new Response(
- JSON.stringify({ error: 'Invalid request body' }),
- {
- status: 400,
- headers: { 'Content-Type': 'application/json' },
- },
- )
- }
- const { prompt, provider, model, stream } = parsed.data
- const resolvedProvider: Provider = provider || 'openrouter'
- const modelOptions = reasoningOptionsFor(resolvedProvider, model)
+ }
+ try {
+ const providerValue = params.forwardedProps.provider
+ const resolvedProvider: Provider = isProvider(providerValue)
+ ? providerValue
+ : 'openrouter'
+ const model =
+ typeof params.forwardedProps.model === 'string'
+ ? params.forwardedProps.model
+ : undefined
+ const stream = params.forwardedProps.stream !== false
+ const adapter = adapterFor(resolvedProvider, model)
+ const modelOptions = reasoningOptionsFor(resolvedProvider, model)
// Adaptive thinking on Claude 4.7 can chew through a few thousand
// tokens before the schema-constrained JSON even starts. The
// adapter's default `max_tokens` (1024) was producing truncated
@@ -317,22 +393,18 @@ export const Route = createFileRoute('/api/structured-output')({
resolvedProvider === 'anthropic' &&
model?.endsWith(':thinking-max') === true
const maxTokens = wantsAnthropicMaxThinking ? 16_000 : undefined
-
const counter = phaseCounterMiddleware()
if (stream) {
- const abortController = new AbortController()
- request.signal.addEventListener('abort', () =>
- abortController.abort(),
- )
- const adapter = adapterFor(resolvedProvider, model)
const streamIterable = chat({
adapter,
modelOptions: modelOptions as never,
- messages: [{ role: 'user', content: prompt }],
+ messages: params.messages,
outputSchema: GuitarRecommendationSchema,
stream: true,
middleware: [counter.middleware],
+ threadId: params.threadId,
+ runId: params.runId,
abortController,
...(maxTokens !== undefined && { maxTokens }),
}) as AsyncIterable
@@ -346,29 +418,27 @@ export const Route = createFileRoute('/api/structured-output')({
})
}
- const abortController = new AbortController()
- request.signal.addEventListener('abort', () =>
- abortController.abort(),
- )
const result = await chat({
- adapter: adapterFor(resolvedProvider, model),
+ adapter,
modelOptions: modelOptions as never,
- messages: [{ role: 'user', content: prompt }],
+ messages: params.messages,
outputSchema: GuitarRecommendationSchema,
middleware: [counter.middleware],
+ threadId: params.threadId,
+ runId: params.runId,
abortController,
...(maxTokens !== undefined && { maxTokens }),
})
- return new Response(
- JSON.stringify({
- data: result,
- _diagnostics: { phaseCounts: counter.snapshot() },
- }),
- {
- headers: { 'Content-Type': 'application/json' },
- },
- )
+ const responseStream = structuredOutputResultStream({
+ result,
+ phaseCounts: counter.snapshot(),
+ threadId: params.threadId,
+ runId: params.runId,
+ model: adapter.model,
+ })
+
+ return toServerSentEventsResponse(responseStream, { abortController })
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : 'An error occurred'
@@ -377,6 +447,8 @@ export const Route = createFileRoute('/api/structured-output')({
status: 500,
headers: { 'Content-Type': 'application/json' },
})
+ } finally {
+ request.signal.removeEventListener('abort', onAbort)
}
},
},
diff --git a/examples/ts-react-chat/src/routes/generation-hooks.tsx b/examples/ts-react-chat/src/routes/generation-hooks.tsx
new file mode 100644
index 000000000..a4913116b
--- /dev/null
+++ b/examples/ts-react-chat/src/routes/generation-hooks.tsx
@@ -0,0 +1,804 @@
+import { useState } from 'react'
+import type { ReactNode } from 'react'
+import { createFileRoute } from '@tanstack/react-router'
+import {
+ FileAudio,
+ FileText,
+ Image,
+ Mic,
+ Music,
+ Play,
+ RotateCcw,
+ Video,
+} from 'lucide-react'
+import {
+ useGenerateAudio,
+ useGenerateImage,
+ useGenerateSpeech,
+ useGenerateVideo,
+ useSummarize,
+ useTranscription,
+} from '@tanstack/ai-react'
+import { EventType } from '@tanstack/ai'
+import {
+ GENERATION_EVENTS,
+ type ConnectConnectionAdapter,
+ type VideoGenerateResult,
+} from '@tanstack/ai-client'
+import type {
+ AudioGenerationResult,
+ ImageGenerationResult,
+ StreamChunk,
+ SummarizationResult,
+ TranscriptionResult,
+ TTSResult,
+} from '@tanstack/ai'
+import type { LucideIcon } from 'lucide-react'
+
+const SAMPLE_WAV_BASE64 = createToneWavBase64()
+const SAMPLE_AUDIO_DATA_URL = `data:audio/wav;base64,${SAMPLE_WAV_BASE64}`
+const SAMPLE_VIDEO_URL =
+ 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4'
+const SAMPLE_TRANSCRIPTION_AUDIO = SAMPLE_AUDIO_DATA_URL
+
+const imageConnection = createGenerationConnection(
+ 'image',
+ (data) => {
+ const prompt = stringField(data, 'prompt', 'Generated devtools test image')
+ const count = Math.max(
+ 1,
+ Math.min(4, numberField(data, 'numberOfImages', 1)),
+ )
+ return {
+ id: `local-image-${Date.now()}`,
+ images: Array.from({ length: count }, (_, index) => ({
+ url: svgDataUrl(
+ `Image ${index + 1}`,
+ prompt,
+ index % 2 === 0 ? '#0ea5e9' : '#f97316',
+ ),
+ revisedPrompt: `${prompt} (${index + 1})`,
+ })),
+ model: 'local-devtools-image-fixture',
+ }
+ },
+)
+
+const audioConnection = createGenerationConnection(
+ 'audio',
+ (data) => ({
+ id: `local-audio-${Date.now()}`,
+ audio: {
+ url: SAMPLE_AUDIO_DATA_URL,
+ contentType: 'audio/wav',
+ duration: numberField(data, 'duration', 3),
+ },
+ model: 'local-devtools-audio-fixture',
+ }),
+)
+
+const speechConnection = createGenerationConnection(
+ 'speech',
+ () => ({
+ id: `local-speech-${Date.now()}`,
+ model: 'local-devtools-speech-fixture',
+ audio: SAMPLE_WAV_BASE64,
+ contentType: 'audio/wav',
+ format: 'wav',
+ duration: 0.8,
+ }),
+)
+
+const transcriptionConnection = createGenerationConnection(
+ 'transcription',
+ (data) => ({
+ id: `local-transcription-${Date.now()}`,
+ model: 'local-devtools-transcription-fixture',
+ text: `Transcribed local fixture in ${stringField(data, 'language', 'en')}.`,
+ language: stringField(data, 'language', 'en'),
+ duration: 3,
+ segments: [
+ {
+ id: 0,
+ start: 0,
+ end: 1.5,
+ text: 'Transcribed local fixture',
+ },
+ {
+ id: 1,
+ start: 1.5,
+ end: 3,
+ text: 'ready for devtools inspection',
+ },
+ ],
+ }),
+)
+
+const summarizeConnection = createGenerationConnection(
+ 'summarize',
+ (data) => {
+ const text = stringField(data, 'text', SAMPLE_SUMMARY_TEXT)
+ const style = stringField(data, 'style', 'concise')
+ return {
+ id: `local-summary-${Date.now()}`,
+ summary: `${style}: ${text.split(/\s+/).slice(0, 24).join(' ')}.`,
+ model: 'local-devtools-summary-fixture',
+ usage: {
+ promptTokens: text.split(/\s+/).length,
+ completionTokens: 24,
+ totalTokens: text.split(/\s+/).length + 24,
+ },
+ }
+ },
+)
+
+const videoConnection = createVideoConnection()
+
+const SAMPLE_SUMMARY_TEXT =
+ 'Generation hooks emit core devtools snapshots with input, progress, result, and renderable previews for media and text outputs.'
+
+export const Route = createFileRoute('/generation-hooks')({
+ component: GenerationHooksPage,
+})
+
+function GenerationHooksPage() {
+ const [prompt, setPrompt] = useState(
+ 'A compact diagnostics console showing every TanStack AI generation hook',
+ )
+ const [speechText, setSpeechText] = useState(
+ 'This local fixture exercises the speech generation hook.',
+ )
+ const [summaryText, setSummaryText] = useState(SAMPLE_SUMMARY_TEXT)
+ const [imageCount, setImageCount] = useState(2)
+ const [audioDuration, setAudioDuration] = useState(3)
+
+ const image = useGenerateImage({
+ id: 'generation-hooks:useGenerateImage',
+ connection: imageConnection,
+ })
+
+ const audio = useGenerateAudio({
+ id: 'generation-hooks:useGenerateAudio',
+ connection: audioConnection,
+ })
+
+ const speech = useGenerateSpeech({
+ id: 'generation-hooks:useGenerateSpeech',
+ connection: speechConnection,
+ })
+
+ const transcription = useTranscription({
+ id: 'generation-hooks:useTranscription',
+ connection: transcriptionConnection,
+ })
+
+ const summarize = useSummarize({
+ id: 'generation-hooks:useSummarize',
+ connection: summarizeConnection,
+ })
+
+ const video = useGenerateVideo({
+ id: 'generation-hooks:useGenerateVideo',
+ connection: videoConnection,
+ })
+
+ const loadingCount = [
+ image.isLoading,
+ audio.isLoading,
+ speech.isLoading,
+ transcription.isLoading,
+ summarize.isLoading,
+ video.isLoading,
+ ].filter(Boolean).length
+
+ const runImage = () => image.generate({ prompt, numberOfImages: imageCount })
+ const runAudio = () => audio.generate({ prompt, duration: audioDuration })
+ const runSpeech = () => speech.generate({ text: speechText, voice: 'local' })
+ const runTranscription = () =>
+ transcription.generate({
+ audio: SAMPLE_TRANSCRIPTION_AUDIO,
+ language: 'en',
+ })
+ const runSummarize = () =>
+ summarize.generate({
+ text: summaryText,
+ style: 'bullet-points',
+ })
+ const runVideo = () => video.generate({ prompt })
+
+ const runAll = async () => {
+ await Promise.all([
+ runImage(),
+ runAudio(),
+ runSpeech(),
+ runTranscription(),
+ runSummarize(),
+ runVideo(),
+ ])
+ }
+
+ const resetAll = () => {
+ image.reset()
+ audio.reset()
+ speech.reset()
+ transcription.reset()
+ summarize.reset()
+ video.reset()
+ }
+
+ const stopAll = () => {
+ image.stop()
+ audio.stop()
+ speech.stop()
+ transcription.stop()
+ summarize.stop()
+ video.stop()
+ }
+
+ return (
+
+
+
+
+
+ Devtools fixture route
+
+
+ Generation Hooks
+
+
+ Six mounted hooks, stable IDs, local streaming fixtures, and
+ media-shaped results for the devtools panel.
+
+
+
+
void runAll()}
+ disabled={loadingCount > 0}
+ className="inline-flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-cyan-500 disabled:bg-gray-800 disabled:text-gray-500"
+ >
+
+ Run All
+
+
+ Stop
+
+
+
+ Reset
+
+
+
+
+
+
+
+ Shared prompt
+
+
+
+
+
+
+
+
+
+
+
+ void runImage()}
+ onReset={image.reset}
+ >
+
+ {[1, 2, 3, 4].map((count) => (
+ setImageCount(count)}
+ className={`h-8 w-8 rounded-md text-xs font-semibold transition-colors ${
+ imageCount === count
+ ? 'bg-cyan-600 text-white'
+ : 'bg-gray-800 text-gray-300 hover:bg-gray-700'
+ }`}
+ >
+ {count}
+
+ ))}
+
+
+ {image.result?.images.map((item, index) => (
+
+ ))}
+
+
+
+ void runAudio()}
+ onReset={audio.reset}
+ >
+
+
+ Duration {audioDuration}s
+
+
+ setAudioDuration(Number(event.target.value))
+ }
+ className="accent-cyan-500"
+ />
+
+ {audio.result?.audio.url && (
+
+ )}
+
+
+ void runSpeech()}
+ onReset={speech.reset}
+ >
+
+
+ void runTranscription()}
+ onReset={transcription.reset}
+ >
+
+ {transcription.result?.text ?? 'No transcript yet.'}
+
+
+
+ void runSummarize()}
+ onReset={summarize.reset}
+ >
+
+
+ void runVideo()}
+ onReset={video.reset}
+ >
+
+ job {video.jobId ?? 'none'}
+ status {video.videoStatus?.status ?? 'idle'}
+
+ progress{' '}
+ {video.videoStatus?.progress == null
+ ? '0%'
+ : `${video.videoStatus.progress}%`}
+
+
+ {video.result?.url && (
+
+ )}
+
+
+
+
+ )
+}
+
+function HookCard({
+ title,
+ hookId,
+ icon: Icon,
+ status,
+ isLoading,
+ error,
+ onGenerate,
+ onReset,
+ children,
+}: {
+ title: string
+ hookId: string
+ icon: LucideIcon
+ status: string
+ isLoading: boolean
+ error?: Error
+ onGenerate: () => void
+ onReset: () => void
+ children: ReactNode
+}) {
+ return (
+
+
+
+
+
+
+
+
+ {title}
+
+
{hookId}
+
+
+
+ {status}
+
+
+
+
+
+
+ Run
+
+
+
+ Reset
+
+
+
+ {error && (
+
+ {error.message}
+
+ )}
+
+ {children}
+
+ )
+}
+
+function Counter({ label, value }: { label: string; value: number }) {
+ return (
+
+
{value}
+
+ {label}
+
+
+ )
+}
+
+function createGenerationConnection(
+ label: string,
+ createResult: (data: Record) => TResult,
+): ConnectConnectionAdapter {
+ return {
+ async *connect(_messages, data, abortSignal, runContext) {
+ const runId = runContext?.runId ?? `${label}-run-${Date.now()}`
+ const threadId = runContext?.threadId ?? `${label}-thread`
+ yield runStarted(runId, threadId)
+ await waitForFixtureStep(abortSignal)
+ if (abortSignal?.aborted) return
+
+ yield progress(25, `${label} queued`)
+ await waitForFixtureStep(abortSignal)
+ if (abortSignal?.aborted) return
+
+ yield progress(70, `${label} rendering`)
+ await waitForFixtureStep(abortSignal)
+ if (abortSignal?.aborted) return
+
+ yield result(createResult(toRecord(data)))
+ await waitForFixtureStep(abortSignal)
+ if (abortSignal?.aborted) return
+
+ yield runFinished(runId, threadId)
+ },
+ }
+}
+
+function createVideoConnection(): ConnectConnectionAdapter {
+ return {
+ async *connect(_messages, data, abortSignal, runContext) {
+ const runId = runContext?.runId ?? `video-run-${Date.now()}`
+ const threadId = runContext?.threadId ?? 'video-thread'
+ const jobId = `local-video-${Date.now()}`
+ const prompt = stringField(
+ toRecord(data),
+ 'prompt',
+ 'Local video fixture',
+ )
+
+ yield runStarted(runId, threadId)
+ await waitForFixtureStep(abortSignal)
+ if (abortSignal?.aborted) return
+
+ yield custom(GENERATION_EVENTS.VIDEO_JOB_CREATED, { jobId })
+ yield custom(GENERATION_EVENTS.VIDEO_STATUS, {
+ jobId,
+ status: 'pending',
+ progress: 10,
+ })
+ await waitForFixtureStep(abortSignal)
+ if (abortSignal?.aborted) return
+
+ yield custom(GENERATION_EVENTS.VIDEO_STATUS, {
+ jobId,
+ status: 'processing',
+ progress: 60,
+ })
+ await waitForFixtureStep(abortSignal)
+ if (abortSignal?.aborted) return
+
+ const finalResult: VideoGenerateResult = {
+ jobId,
+ status: 'completed',
+ url: SAMPLE_VIDEO_URL,
+ }
+ yield custom(GENERATION_EVENTS.VIDEO_STATUS, {
+ jobId,
+ status: 'completed',
+ progress: 100,
+ url: SAMPLE_VIDEO_URL,
+ prompt,
+ })
+ yield result(finalResult)
+ yield runFinished(runId, threadId)
+ },
+ }
+}
+
+async function waitForFixtureStep(abortSignal: AbortSignal | undefined) {
+ await new Promise((resolve) => {
+ const timeout = window.setTimeout(resolve, 180)
+ abortSignal?.addEventListener(
+ 'abort',
+ () => {
+ window.clearTimeout(timeout)
+ resolve()
+ },
+ { once: true },
+ )
+ })
+}
+
+function runStarted(runId: string, threadId: string): StreamChunk {
+ return {
+ type: EventType.RUN_STARTED,
+ runId,
+ threadId,
+ timestamp: Date.now(),
+ }
+}
+
+function runFinished(runId: string, threadId: string): StreamChunk {
+ return {
+ type: EventType.RUN_FINISHED,
+ runId,
+ threadId,
+ finishReason: 'stop',
+ timestamp: Date.now(),
+ }
+}
+
+function progress(value: number, message: string): StreamChunk {
+ return custom(GENERATION_EVENTS.PROGRESS, {
+ progress: value,
+ message,
+ })
+}
+
+function result(value: unknown): StreamChunk {
+ return custom(GENERATION_EVENTS.RESULT, value)
+}
+
+function custom(name: string, value: unknown): StreamChunk {
+ return {
+ type: EventType.CUSTOM,
+ name,
+ value,
+ timestamp: Date.now(),
+ }
+}
+
+function toRecord(value: unknown): Record {
+ return value && typeof value === 'object' && !Array.isArray(value)
+ ? { ...value }
+ : {}
+}
+
+function stringField(
+ data: Record,
+ field: string,
+ fallback: string,
+): string {
+ const value = data[field]
+ return typeof value === 'string' && value.trim() ? value.trim() : fallback
+}
+
+function numberField(
+ data: Record,
+ field: string,
+ fallback: number,
+): number {
+ const value = data[field]
+ return typeof value === 'number' ? value : fallback
+}
+
+function createToneWavBase64({
+ frequency = 440,
+ durationSeconds = 0.8,
+ sampleRate = 8000,
+} = {}): string {
+ const sampleCount = Math.floor(sampleRate * durationSeconds)
+ const dataSize = sampleCount * 2
+ const bytes = new Uint8Array(44 + dataSize)
+ const view = new DataView(bytes.buffer)
+
+ writeAscii(bytes, 0, 'RIFF')
+ view.setUint32(4, 36 + dataSize, true)
+ writeAscii(bytes, 8, 'WAVE')
+ writeAscii(bytes, 12, 'fmt ')
+ view.setUint32(16, 16, true)
+ view.setUint16(20, 1, true)
+ view.setUint16(22, 1, true)
+ view.setUint32(24, sampleRate, true)
+ view.setUint32(28, sampleRate * 2, true)
+ view.setUint16(32, 2, true)
+ view.setUint16(34, 16, true)
+ writeAscii(bytes, 36, 'data')
+ view.setUint32(40, dataSize, true)
+
+ for (let index = 0; index < sampleCount; index++) {
+ const envelope = Math.sin((Math.PI * index) / sampleCount)
+ const wave = Math.sin((2 * Math.PI * frequency * index) / sampleRate)
+ const sample = Math.round(wave * envelope * 0.25 * 32767)
+ view.setInt16(44 + index * 2, sample, true)
+ }
+
+ return bytesToBase64(bytes)
+}
+
+function writeAscii(bytes: Uint8Array, offset: number, value: string): void {
+ for (let index = 0; index < value.length; index++) {
+ bytes[offset + index] = value.charCodeAt(index)
+ }
+}
+
+function bytesToBase64(bytes: Uint8Array): string {
+ const alphabet =
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
+ let output = ''
+
+ for (let index = 0; index < bytes.length; index += 3) {
+ const first = bytes[index] ?? 0
+ const second = index + 1 < bytes.length ? bytes[index + 1] : undefined
+ const third = index + 2 < bytes.length ? bytes[index + 2] : undefined
+ const combined = (first << 16) | ((second ?? 0) << 8) | (third ?? 0)
+
+ output += alphabet[(combined >> 18) & 63]
+ output += alphabet[(combined >> 12) & 63]
+ output += second === undefined ? '=' : alphabet[(combined >> 6) & 63]
+ output += third === undefined ? '=' : alphabet[combined & 63]
+ }
+
+ return output
+}
+
+function svgDataUrl(title: string, prompt: string, color: string): string {
+ const safeTitle = escapeXml(title)
+ const safePrompt = escapeXml(prompt)
+ const svg = `${safeTitle} ${safePrompt.slice(0, 56)} local devtools fixture `
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
+}
+
+function escapeXml(value: string): string {
+ return value
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''')
+}
diff --git a/examples/ts-react-chat/src/routes/generations.structured-output.tsx b/examples/ts-react-chat/src/routes/generations.structured-output.tsx
index 18a83eb56..7a1d89245 100644
--- a/examples/ts-react-chat/src/routes/generations.structured-output.tsx
+++ b/examples/ts-react-chat/src/routes/generations.structured-output.tsx
@@ -1,6 +1,9 @@
import { useRef, useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { parsePartialJSON } from '@tanstack/ai'
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
+import { GuitarRecommendationSchema } from './api.structured-output'
+import type { StreamChunk } from '@tanstack/ai'
const SAMPLE_PROMPT =
'I play indie rock and have a $1500 budget. Recommend two electric guitars and one acoustic to round out my rig.'
@@ -197,18 +200,17 @@ function StructuredOutputPage() {
const [reasoningLine, setReasoningLine] = useState('')
const [reasoningFull, setReasoningFull] = useState('')
const [error, setError] = useState(null)
- const [isLoading, setIsLoading] = useState(false)
const [phaseCounts, setPhaseCounts] = useState | null>(
null,
)
- const abortRef = useRef(null)
+ const sawCompleteRef = useRef(false)
const onProviderChange = (next: Provider) => {
setProvider(next)
setModel(PROVIDER_MODELS[next][0].value)
}
- const reset = () => {
+ const resetLocal = () => {
setResult(null)
setRawJson('')
setDeltaCount(0)
@@ -219,160 +221,90 @@ function StructuredOutputPage() {
setPhaseCounts(null)
}
- const handleGenerate = async () => {
- if (!prompt.trim()) return
- setIsLoading(true)
- reset()
- setIsStreaming(stream)
+ const handleChunk = (chunk: StreamChunk) => {
+ const payload = chunk as StreamChunkPayload
- const controller = new AbortController()
- abortRef.current = controller
-
- try {
- const response = await fetch('/api/structured-output', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- prompt: prompt.trim(),
- provider,
- model,
- stream,
- }),
- signal: controller.signal,
- })
-
- if (!response.ok) {
- const errPayload = await response.json().catch(() => ({}))
- throw new Error(
- errPayload.error || `Request failed (${response.status})`,
- )
- }
-
- if (!stream) {
- const payload = await response.json()
- setResult(payload.data as PartialResult)
- setHasFinalResult(true)
- const diag = (
- payload as {
- _diagnostics?: { phaseCounts?: Record }
- }
- )._diagnostics
- if (diag?.phaseCounts) {
- setPhaseCounts(diag.phaseCounts)
+ if (payload.type === 'TEXT_MESSAGE_CONTENT' && payload.delta) {
+ setRawJson((current) => {
+ const next = current + payload.delta
+ const partial = parsePartialJSON(next) as PartialResult | undefined
+ if (partial && typeof partial === 'object') {
+ setResult(partial)
}
- return
+ return next
+ })
+ setDeltaCount((current) => current + 1)
+ } else if (payload.type === 'REASONING_MESSAGE_CONTENT' && payload.delta) {
+ setReasoningFull((current) => {
+ const next = current + payload.delta
+ setReasoningLine(latestThought(next))
+ return next
+ })
+ } else if (
+ payload.type === 'CUSTOM' &&
+ payload.name === 'phase-counts' &&
+ payload.value
+ ) {
+ setPhaseCounts(payload.value as unknown as Record)
+ } else if (
+ payload.type === 'CUSTOM' &&
+ payload.name === 'structured-output.complete' &&
+ payload.value?.object
+ ) {
+ sawCompleteRef.current = true
+ setResult(payload.value.object as PartialResult)
+ setHasFinalResult(true)
+ if (
+ typeof (payload.value as { reasoning?: string }).reasoning === 'string'
+ ) {
+ const finalReasoning = (payload.value as { reasoning: string })
+ .reasoning
+ setReasoningFull(finalReasoning)
+ setReasoningLine(latestThought(finalReasoning))
}
+ }
+ }
- // Streaming path — parse SSE, accumulate raw JSON, render the partially
- // parsed object live, snap to the validated terminal payload.
- const reader = response.body!.getReader()
- const decoder = new TextDecoder()
- let buffer = ''
- let accumulated = ''
- let reasoning = ''
- let deltas = 0
- let sawComplete = false
-
- const processBuffer = () => {
- let sepIdx = buffer.indexOf('\n\n')
- while (sepIdx !== -1) {
- const frame = buffer.slice(0, sepIdx)
- buffer = buffer.slice(sepIdx + 2)
- sepIdx = buffer.indexOf('\n\n')
-
- for (const line of frame.split('\n')) {
- if (!line.startsWith('data: ')) continue
- const json = line.slice(6).trim()
- if (!json) continue
- let chunk: StreamChunkPayload
- try {
- chunk = JSON.parse(json) as StreamChunkPayload
- } catch {
- continue
- }
-
- if (chunk.type === 'TEXT_MESSAGE_CONTENT' && chunk.delta) {
- accumulated += chunk.delta
- deltas += 1
- setRawJson(accumulated)
- setDeltaCount(deltas)
- // partial-json tolerates incomplete JSON — it returns whatever
- // structure can be inferred. Render it directly so the UI fills
- // in field by field as the model produces them.
- const partial = parsePartialJSON(accumulated) as
- | PartialResult
- | undefined
- if (partial && typeof partial === 'object') {
- setResult(partial)
- }
- } else if (
- chunk.type === 'REASONING_MESSAGE_CONTENT' &&
- chunk.delta
- ) {
- reasoning += chunk.delta
- setReasoningFull(reasoning)
- // One-liner: take the last non-empty line/sentence so consumers
- // see "what it's thinking right now" without a wall of text.
- setReasoningLine(latestThought(reasoning))
- } else if (
- chunk.type === 'CUSTOM' &&
- chunk.name === 'phase-counts' &&
- chunk.value
- ) {
- setPhaseCounts(chunk.value as unknown as Record)
- } else if (
- chunk.type === 'CUSTOM' &&
- chunk.name === 'structured-output.complete' &&
- chunk.value?.object
- ) {
- sawComplete = true
- setResult(chunk.value.object as PartialResult)
- setHasFinalResult(true)
- if (
- typeof (chunk.value as { reasoning?: string }).reasoning ===
- 'string'
- ) {
- const finalReasoning = (chunk.value as { reasoning: string })
- .reasoning
- setReasoningFull(finalReasoning)
- setReasoningLine(latestThought(finalReasoning))
- }
- } else if (chunk.type === 'RUN_ERROR') {
- throw new Error(chunk.message || 'Stream failed')
- }
- }
- }
- }
+ const chat = useChat({
+ id: 'structured-output:useChat',
+ outputSchema: GuitarRecommendationSchema,
+ connection: fetchServerSentEvents('/api/structured-output'),
+ forwardedProps: { provider, model, stream },
+ devtools: {
+ outputKind: 'structured',
+ },
+ onChunk: handleChunk,
+ onError: (err) => {
+ setError(err.message)
+ },
+ })
- while (true) {
- const { done, value } = await reader.read()
- if (done) break
- buffer += decoder.decode(value, { stream: true })
- processBuffer()
- }
+ const isLoading = chat.isLoading
- // Flush any buffered bytes from incomplete multi-byte UTF-8 sequences
- // so the final SSE frame isn't dropped.
- buffer += decoder.decode()
- processBuffer()
+ const reset = () => {
+ resetLocal()
+ chat.clear()
+ }
- if (!sawComplete) {
- throw new Error('Stream ended before structured-output.complete')
- }
- } catch (err) {
- if (err instanceof Error && err.name === 'AbortError') {
- setError('Aborted')
- } else {
- setError(err instanceof Error ? err.message : 'Unknown error')
- }
- } finally {
- setIsLoading(false)
- setIsStreaming(false)
- abortRef.current = null
+ const handleGenerate = async () => {
+ if (!prompt.trim()) return
+ sawCompleteRef.current = false
+ resetLocal()
+ chat.clear()
+ setIsStreaming(stream)
+ await chat.sendMessage(prompt.trim())
+ setIsStreaming(false)
+ if (stream && !sawCompleteRef.current && chat.status === 'ready') {
+ setError('Stream ended before structured-output.complete')
}
}
- const handleAbort = () => abortRef.current?.abort()
+ const handleAbort = () => {
+ sawCompleteRef.current = true
+ chat.stop()
+ setIsStreaming(false)
+ setError('Aborted')
+ }
const renderingPartial = isStreaming && !hasFinalResult
const recommendations = result?.recommendations ?? []
diff --git a/packages/ai-client/package.json b/packages/ai-client/package.json
index 4bbf6d334..31511b0ed 100644
--- a/packages/ai-client/package.json
+++ b/packages/ai-client/package.json
@@ -30,6 +30,10 @@
".": {
"types": "./dist/esm/index.d.ts",
"import": "./dist/esm/index.js"
+ },
+ "./devtools": {
+ "types": "./dist/esm/devtools.d.ts",
+ "import": "./dist/esm/devtools.js"
}
},
"files": [
diff --git a/packages/ai-client/src/chat-client.ts b/packages/ai-client/src/chat-client.ts
index 106ce86e0..b2b1ae770 100644
--- a/packages/ai-client/src/chat-client.ts
+++ b/packages/ai-client/src/chat-client.ts
@@ -4,7 +4,7 @@ import {
generateMessageId,
normalizeToUIMessage,
} from '@tanstack/ai'
-import { DefaultChatClientEventEmitter } from './events'
+import { createNoOpChatDevtoolsBridge } from './devtools-noop'
import {
fetcherToConnectionAdapter,
normalizeConnectionAdapter,
@@ -19,7 +19,15 @@ import type {
ConnectionAdapter,
SubscribeConnectionAdapter,
} from './connection-adapters'
-import type { ChatClientEventEmitter } from './events'
+import type {
+ ChatClientEventEmitter,
+ ChatClientRunEventContext,
+} from './events'
+import type {
+ AIDevtoolsChatSnapshot,
+ ChatDevtoolsBridge,
+ ChatDevtoolsBridgeOptions,
+} from './devtools'
import type {
ChatClientOptions,
ChatClientState,
@@ -64,8 +72,15 @@ export class ChatClient {
private status: ChatClientState = 'ready'
private connectionStatus: ConnectionStatus = 'disconnected'
private abortController: AbortController | null = null
- private readonly events: ChatClientEventEmitter
private readonly clientToolsRef: { current: Map }
+ private readonly devtoolsBridge: ChatDevtoolsBridge
+ /**
+ * Alias for `this.events`. The bridge installs an
+ * emitter that auto-attaches run/thread context and auto-emits a
+ * snapshot after every event, so chat-client only ever calls
+ * `this.events.X(...)` exactly like it did before devtools landed.
+ */
+ private readonly events: ChatClientEventEmitter
private currentStreamId: string | null = null
private currentMessageId: string | null = null
private readonly postStreamActions: Array<() => Promise> = []
@@ -83,6 +98,7 @@ export class ChatClient {
private draining = false
private sessionGenerating = false
private readonly activeRunIds = new Set()
+ private devtoolsMounted = false
private readonly callbacksRef: {
current: {
@@ -116,7 +132,6 @@ export class ChatClient {
this.bodyOption = options.body || {}
this.forwardedPropsOption = options.forwardedProps || {}
this.connection = normalizeConnectionAdapter(resolveTransport(options))
- this.events = new DefaultChatClientEventEmitter(this.uniqueId)
// Build client tools map
this.clientToolsRef = { current: new Map() }
@@ -126,6 +141,11 @@ export class ChatClient {
}
}
+ this.devtoolsBridge = (
+ options.devtoolsBridgeFactory ?? createNoOpChatDevtoolsBridge
+ )(this.buildDevtoolsBridgeOptions(options.devtools))
+ this.events = this.devtoolsBridge.events
+
this.callbacksRef = {
current: {
onResponse: options.onResponse || (() => {}),
@@ -200,9 +220,41 @@ export class ChatClient {
this.currentStreamId,
messageId,
content,
+ undefined,
)
}
},
+ onStructuredOutputChange: (args) => {
+ const streamId = this.devtoolsBridge.resolveStreamId()
+ const eventName =
+ args.phase === 'start'
+ ? 'structured-output:started'
+ : args.phase === 'complete'
+ ? 'structured-output:completed'
+ : args.phase === 'error'
+ ? 'structured-output:errored'
+ : 'structured-output:updated'
+
+ this.currentMessageId = args.messageId
+ this.events.structuredOutputChanged(
+ eventName,
+ streamId,
+ args.messageId,
+ {
+ status: args.status,
+ raw: args.raw,
+ ...(args.partial !== undefined ? { partial: args.partial } : {}),
+ ...(args.data !== undefined ? { data: args.data } : {}),
+ ...(args.reasoning !== undefined
+ ? { reasoning: args.reasoning }
+ : {}),
+ ...(args.errorMessage !== undefined
+ ? { errorMessage: args.errorMessage }
+ : {}),
+ ...(args.delta !== undefined ? { delta: args.delta } : {}),
+ },
+ )
+ },
onToolCallStateChange: (
messageId: string,
toolCallId: string,
@@ -239,24 +291,36 @@ export class ChatClient {
const clientTool = this.clientToolsRef.current.get(args.toolName)
const executeFunc = clientTool?.execute
if (executeFunc) {
+ // Capture the run context at execution-start so a tool whose
+ // result lands AFTER the originating run finishes still reports
+ // back against the originating run, not whatever run is
+ // current when the result emits.
+ const runEventContext =
+ this.devtoolsBridge.getCurrentRunEventContext()
// Create and track the execution promise
const executionPromise = (async () => {
try {
const output = await executeFunc(args.input)
- await this.addToolResult({
- toolCallId: args.toolCallId,
- tool: args.toolName,
- output,
- state: 'output-available',
- })
+ await this.addToolResultInternal(
+ {
+ toolCallId: args.toolCallId,
+ tool: args.toolName,
+ output,
+ state: 'output-available',
+ },
+ runEventContext,
+ )
} catch (error: any) {
- await this.addToolResult({
- toolCallId: args.toolCallId,
- tool: args.toolName,
- output: null,
- state: 'output-error',
- errorText: error.message,
- })
+ await this.addToolResultInternal(
+ {
+ toolCallId: args.toolCallId,
+ tool: args.toolName,
+ output: null,
+ state: 'output-error',
+ errorText: error.message,
+ },
+ runEventContext,
+ )
} finally {
// Remove from pending when complete
this.pendingToolExecutions.delete(args.toolCallId)
@@ -273,16 +337,20 @@ export class ChatClient {
input: any
approvalId: string
}) => {
- if (this.currentStreamId) {
- this.events.approvalRequested(
- this.currentStreamId,
- this.currentMessageId || '',
- args.toolCallId,
- args.toolName,
- args.input,
- args.approvalId,
- )
- }
+ const streamId = this.devtoolsBridge.resolveStreamId()
+ const messageIdForApproval =
+ this.findMessageIdForToolCall(args.toolCallId) ??
+ this.currentMessageId ??
+ ''
+
+ this.events.approvalRequested(
+ streamId,
+ messageIdForApproval,
+ args.toolCallId,
+ args.toolName,
+ args.input,
+ args.approvalId,
+ )
},
onCustomEvent: (
eventType: string,
@@ -293,8 +361,15 @@ export class ChatClient {
},
},
})
+ }
- this.events.clientCreated(this.processor.getMessages().length)
+ mountDevtools(): void {
+ if (this.devtoolsMounted) {
+ return
+ }
+
+ this.devtoolsMounted = true
+ this.devtoolsBridge.mountWithTools(this.processor.getMessages().length)
}
private generateUniqueId(prefix: string): string {
@@ -310,22 +385,26 @@ export class ChatClient {
private setStatus(status: ChatClientState): void {
this.status = status
this.callbacksRef.current.onStatusChange(status)
+ this.devtoolsBridge.emitSnapshot()
}
private setIsSubscribed(isSubscribed: boolean): void {
this.isSubscribed = isSubscribed
this.callbacksRef.current.onSubscriptionChange(isSubscribed)
+ this.devtoolsBridge.emitSnapshot()
}
private setConnectionStatus(status: ConnectionStatus): void {
this.connectionStatus = status
this.callbacksRef.current.onConnectionStatusChange(status)
+ this.devtoolsBridge.emitSnapshot()
}
private setSessionGenerating(isGenerating: boolean): void {
if (this.sessionGenerating === isGenerating) return
this.sessionGenerating = isGenerating
this.callbacksRef.current.onSessionGeneratingChange(isGenerating)
+ this.devtoolsBridge.emitSnapshot()
}
private resetSessionGenerating(): void {
@@ -339,6 +418,57 @@ export class ChatClient {
this.events.errorChanged(error?.message || null)
}
+ private buildDevtoolsBridgeOptions(
+ devtools: ChatClientOptions['devtools'],
+ ): ChatDevtoolsBridgeOptions {
+ return {
+ hookId: this.uniqueId,
+ clientId: this.uniqueId,
+ threadId: this.threadId,
+ metadata: {
+ hookName: devtools?.hookName ?? 'useChat',
+ outputKind: devtools?.outputKind ?? 'chat',
+ ...(devtools?.framework ? { framework: devtools.framework } : {}),
+ ...(devtools?.name ? { name: devtools.name } : {}),
+ },
+ getSnapshot: () => this.getDevtoolsSnapshot(),
+ getTools: () => this.clientToolsRef.current.values(),
+ getMessages: () => this.processor.getMessages(),
+ setMessages: (messages: Array) => {
+ this.processor.setMessages(messages)
+ },
+ addToolResult: (toolCallId, output, errorText) => {
+ this.processor.addToolResult(toolCallId, output, errorText)
+ },
+ generateId: (prefix) => this.generateUniqueId(prefix),
+ }
+ }
+
+ private getDevtoolsSnapshot(): AIDevtoolsChatSnapshot {
+ return {
+ messages: this.processor.getMessages(),
+ status: this.status,
+ isLoading: this.isLoading,
+ isSubscribed: this.isSubscribed,
+ connectionStatus: this.connectionStatus,
+ sessionGenerating: this.sessionGenerating,
+ activeRunIds: Array.from(this.activeRunIds),
+ ...(this.error ? { error: this.error.message } : {}),
+ }
+ }
+
+ private findMessageIdForToolCall(toolCallId: string): string | undefined {
+ const messages = this.processor.getMessages()
+ for (const message of messages) {
+ const match = message.parts.find(
+ (part: MessagePart): part is ToolCallPart =>
+ part.type === 'tool-call' && part.id === toolCallId,
+ )
+ if (match) return message.id
+ }
+ return undefined
+ }
+
private abortSubscriptionLoop(): void {
this.subscriptionAbortController?.abort()
this.subscriptionAbortController = null
@@ -428,11 +558,12 @@ export class ChatClient {
this.setConnectionStatus('connected')
}
this.callbacksRef.current.onChunk(chunk)
- this.processor.processChunk(chunk)
if (chunk.type === 'RUN_STARTED') {
this.activeRunIds.add(chunk.runId)
this.setSessionGenerating(true)
}
+ this.devtoolsBridge.observeChunk(chunk)
+ this.processor.processChunk(chunk)
// 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') {
@@ -529,6 +660,7 @@ export class ChatClient {
content: string | MultimodalContent,
body?: Record,
): Promise {
+ this.mountDevtools()
const emptyMessage = typeof content === 'string' && !content.trim()
if (emptyMessage || this.isLoading) {
return
@@ -567,6 +699,7 @@ export class ChatClient {
* Append a message and stream the response
*/
async append(message: UIMessage | ModelMessage): Promise {
+ this.mountDevtools()
// Normalize the message to ensure it has id and createdAt
const normalizedMessage = normalizeToUIMessage(message, generateMessageId)
@@ -584,6 +717,7 @@ export class ChatClient {
// Add to messages
const messages = this.processor.getMessages()
this.processor.setMessages([...messages, uiMessage])
+ this.devtoolsBridge.emitSnapshot()
// If stream is in progress, queue the response for after it ends
if (this.isLoading) {
@@ -621,6 +755,8 @@ export class ChatClient {
// Reset pending tool executions for the new stream
this.pendingToolExecutions.clear()
let streamCompletedSuccessfully = false
+ let activeDevtoolsRunId: string | null = null
+ let runTerminalEventEmitted = false
try {
// Get UIMessages with parts (preserves approval state and client tool results)
@@ -658,6 +794,7 @@ export class ChatClient {
// Generate stream ID — assistant message will be created by stream events
this.currentStreamId = this.generateUniqueId('stream')
+ this.devtoolsBridge.setCurrentStreamId(this.currentStreamId)
this.currentMessageId = null
// Reset processor stream state for new response — prevents stale
@@ -693,6 +830,19 @@ export class ChatClient {
),
forwardedProps: { ...mergedBody },
}
+ this.devtoolsBridge.beginRun(runContext.runId, this.threadId)
+ activeDevtoolsRunId = runContext.runId
+ this.devtoolsBridge.emitRunLifecycle(
+ 'run:created',
+ runContext.runId,
+ 'created',
+ )
+ this.devtoolsBridge.emitRunLifecycle(
+ 'run:started',
+ runContext.runId,
+ 'started',
+ )
+ this.devtoolsBridge.emitSnapshot()
// Send through normalized connection (pushes chunks to subscription queue)
await this.connection.send(messages, mergedBody, signal, runContext)
@@ -709,6 +859,15 @@ export class ChatClient {
// A RUN_ERROR from the stream transitions status to error.
// Do not treat this stream as a successful completion.
if (this.status === 'error') {
+ if (activeDevtoolsRunId) {
+ this.devtoolsBridge.emitRunLifecycle(
+ 'run:errored',
+ activeDevtoolsRunId,
+ 'errored',
+ this.error ? { error: this.error.message } : {},
+ )
+ runTerminalEventEmitted = true
+ }
return false
}
@@ -723,10 +882,27 @@ export class ChatClient {
} catch (err) {
if (err instanceof Error) {
if (err.name === 'AbortError') {
+ if (activeDevtoolsRunId) {
+ this.devtoolsBridge.emitRunLifecycle(
+ 'run:cancelled',
+ activeDevtoolsRunId,
+ 'cancelled',
+ )
+ runTerminalEventEmitted = true
+ }
return false
}
if (generation === this.streamGeneration) {
this.reportStreamError(err)
+ if (activeDevtoolsRunId) {
+ this.devtoolsBridge.emitRunLifecycle(
+ 'run:errored',
+ activeDevtoolsRunId,
+ 'errored',
+ { error: err.message },
+ )
+ runTerminalEventEmitted = true
+ }
}
}
} finally {
@@ -735,11 +911,28 @@ export class ChatClient {
// clobber the new stream's abortController or isLoading state.
if (generation === this.streamGeneration) {
this.currentStreamId = null
+ this.devtoolsBridge.setCurrentStreamId(null)
this.currentMessageId = null
this.abortController = null
this.setIsLoading(false)
this.pendingMessageBody = undefined // Ensure it's cleared even on error
+ if (activeDevtoolsRunId && !runTerminalEventEmitted) {
+ if (streamCompletedSuccessfully) {
+ this.devtoolsBridge.emitRunLifecycle(
+ 'run:completed',
+ activeDevtoolsRunId,
+ 'completed',
+ )
+ } else if (signal.aborted) {
+ this.devtoolsBridge.emitRunLifecycle(
+ 'run:cancelled',
+ activeDevtoolsRunId,
+ 'cancelled',
+ )
+ }
+ }
+
// Drain any actions that were queued while the stream was in progress
await this.drainPostStreamActions()
@@ -811,7 +1004,7 @@ export class ChatClient {
// Find the last user message
const lastUserMessageIndex = messages.findLastIndex(
- (m: UIMessage) => m.role === 'user',
+ (m) => m.role === 'user',
)
if (lastUserMessageIndex === -1) return
@@ -825,6 +1018,7 @@ export class ChatClient {
// Remove all messages after the last user message
this.processor.removeMessagesAfter(lastUserMessageIndex)
+ this.devtoolsBridge.emitSnapshot()
// Resend
await this.streamResponse()
@@ -857,11 +1051,25 @@ export class ChatClient {
state?: 'output-available' | 'output-error'
errorText?: string
}): Promise {
+ await this.addToolResultInternal(result)
+ }
+
+ private async addToolResultInternal(
+ result: {
+ toolCallId: string
+ tool: string
+ output: any
+ state?: 'output-available' | 'output-error'
+ errorText?: string
+ },
+ context?: ChatClientRunEventContext,
+ ): Promise {
this.events.toolResultAdded(
result.toolCallId,
result.tool,
result.output,
result.state || 'output-available',
+ context,
)
// Add result via processor
@@ -912,6 +1120,7 @@ export class ChatClient {
// Add response via processor
this.processor.addToolApprovalResponse(response.id, response.approved)
+ this.devtoolsBridge.emitSnapshot()
// If stream is in progress, queue continuation check for after it ends
if (this.isLoading) {
@@ -1051,6 +1260,7 @@ export class ChatClient {
*/
setMessagesManually(messages: Array): void {
this.processor.setMessages(messages)
+ this.devtoolsBridge.emitSnapshot()
}
/**
@@ -1104,7 +1314,7 @@ export class ChatClient {
}
// 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 `{}`.)
+ // unchanged" signal - to clear a slot, pass an empty object `{}`.)
if (options.body !== undefined) {
this.bodyOption = options.body
}
@@ -1116,6 +1326,7 @@ export class ChatClient {
for (const tool of options.tools) {
this.clientToolsRef.current.set(tool.name, tool)
}
+ this.devtoolsBridge.notifyToolsChanged()
}
if (options.onResponse !== undefined) {
this.callbacksRef.current.onResponse = options.onResponse
@@ -1145,4 +1356,10 @@ export class ChatClient {
this.callbacksRef.current.onCustomEvent = options.onCustomEvent
}
}
+
+ dispose(): void {
+ this.unsubscribe()
+ this.devtoolsBridge.dispose()
+ this.devtoolsMounted = false
+ }
}
diff --git a/packages/ai-client/src/devtools-noop.ts b/packages/ai-client/src/devtools-noop.ts
new file mode 100644
index 000000000..7fc28c828
--- /dev/null
+++ b/packages/ai-client/src/devtools-noop.ts
@@ -0,0 +1,205 @@
+// No-op devtools bridge implementations + factories. The chat / generation /
+// video clients import the real bridge classes as types only and accept a
+// factory in options; when no factory is supplied they fall back to the
+// no-op factories here, which never touch `aiEventClient` or any of the
+// heavy preview/fixture machinery in `./devtools`. This keeps `./devtools`
+// outside the main-entry import graph — consumers opt into functional
+// devtools via `@tanstack/ai-client/devtools` (see `package.json#exports`).
+import { ChatClientEventEmitter } from './events'
+import type {
+ AIDevtoolsToolFixture,
+ ChatDevtoolsBridge,
+ ChatDevtoolsBridgeOptions,
+ GenerationDevtoolsBridge,
+ GenerationDevtoolsBridgeOptions,
+ VideoDevtoolsBridge,
+ VideoDevtoolsBridgeOptions,
+} from './devtools'
+import type { StreamChunk } from '@tanstack/ai'
+import type {
+ ChatClientEventContext,
+ ChatClientRunEventContext,
+} from './events'
+
+export type ChatDevtoolsBridgeFactory = (
+ options: ChatDevtoolsBridgeOptions,
+) => ChatDevtoolsBridge
+
+export type GenerationDevtoolsBridgeFactory = (
+ options: GenerationDevtoolsBridgeOptions,
+) => GenerationDevtoolsBridge
+
+export type VideoDevtoolsBridgeFactory = (
+ options: VideoDevtoolsBridgeOptions,
+) => VideoDevtoolsBridge
+
+// ===========================================================================
+// No-op event emitter — extends the abstract base so it satisfies the type
+// without dragging in any of the event-bus runtime cost.
+// ===========================================================================
+
+class NoOpChatClientEventEmitter extends ChatClientEventEmitter {
+ protected emitEvent(): void {
+ // intentionally empty
+ }
+}
+
+// ===========================================================================
+// No-op bridges. Methods exist to satisfy the structural shape of the real
+// classes; every emit/record call short-circuits.
+// ===========================================================================
+
+export class NoOpChatDevtoolsBridge {
+ readonly events: ChatClientEventEmitter
+
+ constructor(options: ChatDevtoolsBridgeOptions) {
+ this.events = new NoOpChatClientEventEmitter(options.clientId)
+ }
+
+ // base bridge surface
+ emitRegistered(): void {}
+ emitUpdated(): void {}
+ emitSnapshot(): void {}
+ emitToolsRegistered(): void {}
+ emitRunLifecycle(
+ _eventType: unknown,
+ _runId: string,
+ _status: unknown,
+ _options?: { error?: string },
+ ): void {}
+ deactivate(): void {}
+ supersede(): void {}
+ dispose(): void {}
+
+ // chat-specific surface
+ setCurrentStreamId(_streamId: string | null): void {}
+ getCurrentStreamId(): string | null {
+ return null
+ }
+ getLastStreamId(): string | null {
+ return null
+ }
+ resolveStreamId(): string {
+ return ''
+ }
+ observeChunk(_chunk: StreamChunk): void {}
+ beginRun(_runId: string, _threadId: string): void {}
+ getCurrentRunEventContext(): ChatClientRunEventContext | undefined {
+ return undefined
+ }
+ getCurrentOrLastRunEventContext(): ChatClientRunEventContext | undefined {
+ return undefined
+ }
+ findToolCallContext(toolCallId: string): ChatClientEventContext {
+ return { toolCallId }
+ }
+ async applyFixture(_fixture: AIDevtoolsToolFixture): Promise {
+ // intentionally empty
+ }
+}
+
+export class NoOpGenerationDevtoolsBridge {
+ constructor(_options: GenerationDevtoolsBridgeOptions) {}
+
+ // base bridge surface
+ emitRegistered(): void {}
+ emitUpdated(): void {}
+ emitSnapshot(): void {}
+ emitToolsRegistered(): void {}
+ emitRunLifecycle(): void {}
+ deactivate(): void {}
+ supersede(): void {}
+ dispose(): void {}
+
+ // generation-specific surface
+ beginRun(_input: unknown): string {
+ // Real factories supply a stable id; the no-op still returns a
+ // unique value because the generation client passes this run id to
+ // the adapter's RunAgentInputContext.
+ return `noop-run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
+ }
+ ensureRunStarted(_runId: string): void {}
+ finishRun(
+ _runId: string,
+ _eventType: 'run:completed' | 'run:errored' | 'run:cancelled',
+ _status: 'completed' | 'errored' | 'cancelled',
+ _error?: string,
+ ): void {}
+ getActiveRunId(): string | null {
+ return null
+ }
+ resetRuns(): void {}
+ recordResultChange(): void {}
+ recordLoadingChange(): void {}
+ recordErrorChange(_error: Error | undefined): void {}
+ recordStatusChange(): void {}
+ recordProgressChange(): void {}
+ emitState(): void {}
+}
+
+export class NoOpVideoDevtoolsBridge<
+ TOutput,
+> extends NoOpGenerationDevtoolsBridge {
+ constructor(options: VideoDevtoolsBridgeOptions) {
+ super(options)
+ }
+
+ recordJobIdChange(): void {}
+ recordVideoStatusChange(): void {}
+}
+
+// Compile-time parity checks. If a public method is added to the real
+// bridge class without a matching stub on the no-op, the corresponding
+// `Exclude<...>` will resolve to a non-`never` union and the `as never`
+// assignment below will fail to typecheck — surfacing the drift at build
+// time instead of as a runtime TypeError later.
+type _ChatBridgeMissing = Exclude<
+ keyof ChatDevtoolsBridge,
+ keyof NoOpChatDevtoolsBridge
+>
+type _GenerationBridgeMissing = Exclude<
+ keyof GenerationDevtoolsBridge,
+ keyof NoOpGenerationDevtoolsBridge
+>
+type _VideoBridgeMissing = Exclude<
+ keyof VideoDevtoolsBridge,
+ keyof NoOpVideoDevtoolsBridge
+>
+const _chatBridgeParity: _ChatBridgeMissing = undefined as never
+const _generationBridgeParity: _GenerationBridgeMissing = undefined as never
+const _videoBridgeParity: _VideoBridgeMissing = undefined as never
+void _chatBridgeParity
+void _generationBridgeParity
+void _videoBridgeParity
+
+// ===========================================================================
+// Factories — these are what the clients call when no real factory was
+// supplied in options.
+// ===========================================================================
+
+// Casts use `unknown` because the no-op classes don't `extend` the real bridge
+// (that would pull the real implementation into the main-entry import graph).
+// Structural parity is enforced by the `_*BridgeMissing` checks above.
+
+export const createNoOpChatDevtoolsBridge: ChatDevtoolsBridgeFactory = (
+ options,
+) =>
+ // eslint-disable-next-line no-restricted-syntax -- see comment above
+ new NoOpChatDevtoolsBridge(options) as unknown as ChatDevtoolsBridge
+
+export const createNoOpGenerationDevtoolsBridge: GenerationDevtoolsBridgeFactory =
+ (options: GenerationDevtoolsBridgeOptions) =>
+ // eslint-disable-next-line no-restricted-syntax -- see comment above
+ new NoOpGenerationDevtoolsBridge(
+ options,
+ ) as unknown as GenerationDevtoolsBridge
+
+export const createNoOpVideoDevtoolsBridge: VideoDevtoolsBridgeFactory = <
+ TOutput,
+>(
+ options: VideoDevtoolsBridgeOptions,
+) =>
+ // eslint-disable-next-line no-restricted-syntax -- see comment above
+ new NoOpVideoDevtoolsBridge(
+ options,
+ ) as unknown as VideoDevtoolsBridge
diff --git a/packages/ai-client/src/devtools.ts b/packages/ai-client/src/devtools.ts
new file mode 100644
index 000000000..0d57e876e
--- /dev/null
+++ b/packages/ai-client/src/devtools.ts
@@ -0,0 +1,1866 @@
+import {
+ aiEventClient,
+ createAIDevtoolsEventEnvelope,
+ emitAIDevtoolsEvent,
+} from '@tanstack/ai-event-client'
+import { convertSchemaToJsonSchema } from '@tanstack/ai'
+import { DefaultChatClientEventEmitter } from './events'
+import type { AnyClientTool, StreamChunk } from '@tanstack/ai'
+import type { AIDevtoolsEventVisibility } from '@tanstack/ai-event-client'
+import type {
+ ChatClientEventContext,
+ ChatClientEventEmitter,
+ ChatClientRunEventContext,
+} from './events'
+import type {
+ ChatClientState,
+ ConnectionStatus,
+ MessagePart,
+ ToolCallPart,
+ UIMessage,
+} from './types'
+
+export interface AIDevtoolsDisplayOptions {
+ name?: string
+}
+
+export interface AIDevtoolsClientMetadata extends AIDevtoolsDisplayOptions {
+ framework?: string
+ hookName: string
+ outputKind?: 'chat' | 'text' | 'structured' | 'image' | 'video' | 'audio'
+}
+
+export interface AIDevtoolsGenerationProgress {
+ value: number
+ message?: string
+}
+
+export interface AIDevtoolsGenerationMediaItem {
+ src: string
+ sourceType: 'url' | 'base64'
+ mimeType?: string
+ format?: string
+ duration?: number
+}
+
+export interface AIDevtoolsGenerationVideoJob {
+ jobId: string
+ status?: string
+ progress?: number
+ error?: string
+}
+
+export type AIDevtoolsGenerationPreview =
+ | {
+ kind: 'image'
+ items: Array
+ }
+ | {
+ kind: 'audio'
+ items: Array
+ }
+ | {
+ kind: 'video'
+ items: Array
+ job?: AIDevtoolsGenerationVideoJob
+ }
+ | {
+ kind: 'text'
+ text: string
+ }
+ | {
+ kind: 'structured'
+ value: unknown
+ }
+ | {
+ kind: 'empty'
+ }
+
+export type AIDevtoolsGenerationRunStatus =
+ | 'idle'
+ | 'generating'
+ | 'success'
+ | 'error'
+ | 'cancelled'
+
+export interface AIDevtoolsGenerationRunSnapshot {
+ id: string
+ input: unknown
+ result: TOutput | null
+ preview: AIDevtoolsGenerationPreview
+ progress: AIDevtoolsGenerationProgress | null
+ status: AIDevtoolsGenerationRunStatus
+ isLoading: boolean
+ startedAt: number
+ updatedAt: number
+ completedAt?: number
+ error?: string
+ jobId?: string | null
+ videoStatus?: unknown
+}
+
+export interface AIDevtoolsGenerationPreviewInput {
+ outputKind?: AIDevtoolsClientMetadata['outputKind']
+ result: unknown
+ videoStatus?: unknown
+}
+
+export interface AIDevtoolsChatSnapshot {
+ [key: string]: unknown
+ messages: Array
+ status: ChatClientState
+ isLoading: boolean
+ isSubscribed: boolean
+ connectionStatus: ConnectionStatus
+ sessionGenerating: boolean
+ activeRunIds: Array
+ error?: string
+}
+
+export function createAIDevtoolsGenerationPreview(
+ input: AIDevtoolsGenerationPreviewInput,
+): AIDevtoolsGenerationPreview {
+ if (input.outputKind === 'image') {
+ return imagePreviewFromResult(input.result)
+ }
+
+ if (input.outputKind === 'audio') {
+ return audioPreviewFromResult(input.result)
+ }
+
+ if (input.outputKind === 'video') {
+ return videoPreviewFromResult(input.result, input.videoStatus)
+ }
+
+ if (input.outputKind === 'text') {
+ return textPreviewFromResult(input.result)
+ }
+
+ if (input.result === null || input.result === undefined) {
+ return { kind: 'empty' }
+ }
+
+ return {
+ kind: 'structured',
+ value: input.result,
+ }
+}
+
+type UnknownRecord = { [key: string]: unknown }
+
+function imagePreviewFromResult(result: unknown): AIDevtoolsGenerationPreview {
+ const record = asRecord(result)
+ const images = Array.isArray(record?.images) ? record.images : []
+ const items = images
+ .map((image) => mediaItemFromSource(image, 'image/png'))
+ .filter(isGenerationMediaItem)
+
+ if (items.length === 0 && result !== null && result !== undefined) {
+ const directItem = mediaItemFromSource(result, 'image/png')
+ if (directItem) {
+ items.push(directItem)
+ }
+ }
+
+ return { kind: 'image', items }
+}
+
+function audioPreviewFromResult(result: unknown): AIDevtoolsGenerationPreview {
+ const record = asRecord(result)
+ const audio = record?.audio
+ const resultContentType = stringField(record, 'contentType')
+ const format = stringField(record, 'format')
+ const mimeType = resultContentType ?? mimeTypeFromAudioFormat(format)
+
+ const items: Array = []
+ const directItem =
+ typeof audio === 'string'
+ ? base64MediaItem(audio, mimeType, {
+ format,
+ duration: numberField(record, 'duration'),
+ })
+ : mediaItemFromSource(audio, mimeType, {
+ format,
+ })
+
+ if (directItem) {
+ items.push(directItem)
+ }
+
+ return { kind: 'audio', items }
+}
+
+function videoPreviewFromResult(
+ result: unknown,
+ videoStatus: unknown,
+): AIDevtoolsGenerationPreview {
+ const resultRecord = asRecord(result)
+ const statusRecord = asRecord(videoStatus)
+ const item =
+ mediaItemFromSource(result, 'video/mp4') ??
+ mediaItemFromSource(videoStatus, 'video/mp4')
+ const items = item ? [item] : []
+ const job = videoJobFromStatus(statusRecord ?? resultRecord)
+
+ return {
+ kind: 'video',
+ items,
+ ...(job ? { job } : {}),
+ }
+}
+
+function textPreviewFromResult(result: unknown): AIDevtoolsGenerationPreview {
+ const record = asRecord(result)
+ const text =
+ stringField(record, 'text') ??
+ stringField(record, 'summary') ??
+ stringField(record, 'content') ??
+ (typeof result === 'string' ? result : undefined)
+
+ if (text !== undefined) {
+ return { kind: 'text', text }
+ }
+
+ if (result === null || result === undefined) {
+ return { kind: 'empty' }
+ }
+
+ return {
+ kind: 'structured',
+ value: result,
+ }
+}
+
+function videoJobFromStatus(
+ record: UnknownRecord | undefined,
+): AIDevtoolsGenerationVideoJob | undefined {
+ const jobId = stringField(record, 'jobId')
+ if (!jobId) return undefined
+
+ return {
+ jobId,
+ ...(stringField(record, 'status')
+ ? { status: stringField(record, 'status') }
+ : {}),
+ ...(numberField(record, 'progress') !== undefined
+ ? { progress: numberField(record, 'progress') }
+ : {}),
+ ...(stringField(record, 'error')
+ ? { error: stringField(record, 'error') }
+ : {}),
+ }
+}
+
+function mediaItemFromSource(
+ value: unknown,
+ defaultMimeType: string,
+ extras: {
+ format?: string
+ duration?: number
+ } = {},
+): AIDevtoolsGenerationMediaItem | undefined {
+ const record = asRecord(value)
+ if (!record) return undefined
+
+ const explicitContentType =
+ stringField(record, 'contentType') ?? stringField(record, 'mimeType')
+ const duration = numberField(record, 'duration') ?? extras.duration
+ const format = stringField(record, 'format') ?? extras.format
+ const url = stringField(record, 'url')
+ if (url) {
+ return {
+ src: url,
+ sourceType: 'url',
+ ...(explicitContentType ? { mimeType: explicitContentType } : {}),
+ ...(format ? { format } : {}),
+ ...(duration !== undefined ? { duration } : {}),
+ }
+ }
+
+ const b64Json = stringField(record, 'b64Json')
+ if (!b64Json) return undefined
+
+ return base64MediaItem(b64Json, explicitContentType ?? defaultMimeType, {
+ format,
+ duration,
+ })
+}
+
+function base64MediaItem(
+ value: string,
+ mimeType: string | undefined,
+ extras: {
+ format?: string
+ duration?: number
+ } = {},
+): AIDevtoolsGenerationMediaItem {
+ const src = value.startsWith('data:')
+ ? value
+ : `data:${mimeType ?? 'application/octet-stream'};base64,${value}`
+
+ return {
+ src,
+ sourceType: 'base64',
+ ...(mimeType ? { mimeType } : {}),
+ ...(extras.format ? { format: extras.format } : {}),
+ ...(extras.duration !== undefined ? { duration: extras.duration } : {}),
+ }
+}
+
+function mimeTypeFromAudioFormat(format: string | undefined): string {
+ if (!format) return 'audio/mpeg'
+ if (format === 'mp3') return 'audio/mpeg'
+ return `audio/${format}`
+}
+
+function asRecord(value: unknown): UnknownRecord | undefined {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return undefined
+ }
+ return value as UnknownRecord
+}
+
+function stringField(
+ record: UnknownRecord | undefined,
+ field: string,
+): string | undefined {
+ const value = record?.[field]
+ return typeof value === 'string' && value.length > 0 ? value : undefined
+}
+
+function numberField(
+ record: UnknownRecord | undefined,
+ field: string,
+): number | undefined {
+ const value = record?.[field]
+ return typeof value === 'number' ? value : undefined
+}
+
+function isGenerationMediaItem(
+ value: AIDevtoolsGenerationMediaItem | undefined,
+): value is AIDevtoolsGenerationMediaItem {
+ return Boolean(value)
+}
+
+export interface AIDevtoolsToolFixture {
+ fixtureId?: string
+ hookId?: string
+ threadId?: string
+ runId?: string
+ toolName: string
+ input: unknown
+ output: unknown
+ execute?: boolean
+ message?: {
+ id: string
+ role: UIMessage['role']
+ parts: Array
+ createdAt?: number | string
+ }
+ toolCallId?: string
+ messageId?: string
+ errorText?: string
+}
+
+type AIDevtoolsRunEventType =
+ | 'run:created'
+ | 'run:started'
+ | 'run:updated'
+ | 'run:completed'
+ | 'run:errored'
+ | 'run:cancelled'
+
+type AIDevtoolsRunStatus =
+ | 'created'
+ | 'started'
+ | 'updated'
+ | 'completed'
+ | 'errored'
+ | 'cancelled'
+
+export interface AIDevtoolsBridgeOptions {
+ hookId: string
+ threadId?: string
+ clientId: string
+ metadata: AIDevtoolsClientMetadata
+ getSnapshot: () => TSnapshot
+ getTools?: () => Iterable
+ applyToolFixture?: (fixture: AIDevtoolsToolFixture) => void | Promise
+}
+
+type Unsubscribe = () => void
+
+interface AIDevtoolsEvent {
+ payload: TPayload
+}
+
+interface ActiveDevtoolsBridge {
+ deactivate: () => void
+ dispose: () => void
+ supersede?: () => void
+}
+
+const activeBridgeRegistryKey = Symbol.for(
+ 'tanstack.ai.devtools.activeBridgeByHookId',
+)
+
+function getActiveBridgeRegistry(): Map {
+ const global = globalThis as typeof globalThis & {
+ [activeBridgeRegistryKey]?: Map
+ }
+ const existing = global[activeBridgeRegistryKey]
+ if (existing) return existing
+
+ const registry = new Map()
+ global[activeBridgeRegistryKey] = registry
+ return registry
+}
+
+export class ClientDevtoolsBridge {
+ protected readonly options: AIDevtoolsBridgeOptions
+ private readonly bridgeId: string
+ private readonly unsubscribers: Array = []
+ private disposed = false
+ private superseded = false
+ private registered = false
+
+ constructor(options: AIDevtoolsBridgeOptions) {
+ this.options = options
+ this.bridgeId = createBridgeId(options.hookId)
+ }
+
+ emitRegistered(): void {
+ if (!this.prepareForMountEmit()) {
+ return
+ }
+ this.registered = true
+ emitAIDevtoolsEvent('hook:registered', {
+ ...this.createEnvelope('hook:registered'),
+ ...this.createMetadataPayload(),
+ lifecycle: 'mounted',
+ })
+ }
+
+ emitUpdated(): void {
+ if (!this.prepareForEmit()) {
+ return
+ }
+ emitAIDevtoolsEvent('hook:updated', {
+ ...this.createEnvelope('hook:updated'),
+ ...this.createMetadataPayload(),
+ lifecycle: 'active',
+ })
+ }
+
+ emitSnapshot(): void {
+ if (!this.prepareForEmit()) {
+ return
+ }
+ emitAIDevtoolsEvent('hook:state-snapshot', {
+ ...this.createEnvelope('hook:state-snapshot'),
+ ...this.createMetadataPayload(),
+ // Wire envelope uses Record; widen the typed snapshot
+ // here so the typed-snapshot constraint above can stay narrow.
+ // eslint-disable-next-line no-restricted-syntax -- TSnapshot extends object is structurally compatible but TS can't see the missing index signature
+ state: this.options.getSnapshot() as unknown as Record,
+ })
+ }
+
+ emitToolsRegistered(): void {
+ if (!this.prepareForEmit()) {
+ return
+ }
+ const tools = this.options.getTools
+ ? Array.from(this.options.getTools()).map((tool) => ({
+ name: tool.name,
+ description: tool.description,
+ inputSchema: tool.inputSchema
+ ? convertSchemaToJsonSchema(tool.inputSchema)
+ : { type: 'object' },
+ outputSchema: tool.outputSchema
+ ? convertSchemaToJsonSchema(tool.outputSchema)
+ : undefined,
+ needsApproval: tool.needsApproval,
+ metadata: tool.metadata,
+ }))
+ : []
+
+ emitAIDevtoolsEvent('tools:registered', {
+ ...this.createEnvelope('tools:registered'),
+ ...this.createMetadataPayload(),
+ tools,
+ })
+ }
+
+ emitRunLifecycle(
+ eventType: AIDevtoolsRunEventType,
+ runId: string,
+ status: AIDevtoolsRunStatus,
+ options: { error?: string } = {},
+ ): void {
+ if (!this.prepareForEmit()) {
+ return
+ }
+ emitAIDevtoolsEvent(eventType, {
+ ...this.createEnvelope(eventType, 'client-state', { runId }),
+ runId,
+ status,
+ ...(options.error ? { error: options.error } : {}),
+ })
+ }
+
+ deactivate(): void {
+ const activeBridgeByHookId = getActiveBridgeRegistry()
+ if (activeBridgeByHookId.get(this.options.hookId) === this) {
+ activeBridgeByHookId.delete(this.options.hookId)
+ }
+
+ for (const unsubscribe of this.unsubscribers.splice(0)) {
+ unsubscribe()
+ }
+ }
+
+ supersede(): void {
+ if (this.disposed) {
+ return
+ }
+
+ this.superseded = true
+ this.disposed = true
+ this.deactivate()
+ }
+
+ dispose(): void {
+ if (this.disposed) {
+ return
+ }
+
+ this.disposed = true
+ if (!this.registered) {
+ this.deactivate()
+ return
+ }
+
+ const payload = {
+ ...this.createEnvelope('hook:unregistered'),
+ ...this.createMetadataPayload(),
+ reason: 'disposed',
+ } as const
+
+ emitAIDevtoolsEvent('hook:unregistered', payload)
+
+ this.deactivate()
+ }
+
+ private prepareForEmit(): boolean {
+ if (this.disposed || this.superseded) {
+ return false
+ }
+ this.activate()
+ return true
+ }
+
+ private prepareForMountEmit(): boolean {
+ if (this.superseded) {
+ return false
+ }
+
+ if (this.disposed) {
+ this.disposed = false
+ this.registered = false
+ }
+
+ this.activate()
+ return true
+ }
+
+ private activate(): void {
+ if (this.disposed) {
+ return
+ }
+
+ const activeBridgeByHookId = getActiveBridgeRegistry()
+ const activeBridge = activeBridgeByHookId.get(this.options.hookId)
+ if (activeBridge && activeBridge !== this) {
+ if (typeof activeBridge.supersede === 'function') {
+ activeBridge.supersede()
+ } else {
+ activeBridge.deactivate()
+ }
+ }
+ activeBridgeByHookId.set(this.options.hookId, this)
+
+ if (this.unsubscribers.length > 0) {
+ return
+ }
+
+ this.unsubscribers.push(
+ aiEventClient.on('devtools:request-state', (event) => {
+ this.handleRequestState(event)
+ }),
+ )
+
+ if (this.options.applyToolFixture) {
+ this.unsubscribers.push(
+ aiEventClient.on('devtools:tool-fixture:apply', (event) => {
+ void this.handleToolFixtureApply(event)
+ }),
+ )
+ }
+ }
+
+ private handleRequestState(
+ event: AIDevtoolsEvent<{ targetHookId?: string }>,
+ ): void {
+ if (this.disposed || this.superseded) {
+ return
+ }
+
+ const targetHookId = event.payload.targetHookId
+ if (targetHookId && targetHookId !== this.options.hookId) {
+ return
+ }
+
+ this.emitRegistered()
+ this.emitToolsRegistered()
+ this.emitSnapshot()
+ }
+
+ private async handleToolFixtureApply(
+ event: AIDevtoolsEvent,
+ ): Promise {
+ const fixture = event.payload
+ if (!this.matchesFixtureTarget(fixture)) {
+ return
+ }
+
+ await this.options.applyToolFixture?.(fixture)
+ }
+
+ private matchesFixtureTarget(fixture: AIDevtoolsToolFixture): boolean {
+ if (!fixture.hookId && !fixture.threadId) {
+ return false
+ }
+
+ if (fixture.hookId) {
+ return fixture.hookId === this.options.hookId
+ }
+
+ if (
+ fixture.threadId &&
+ (!this.options.threadId || fixture.threadId !== this.options.threadId)
+ ) {
+ return false
+ }
+ return true
+ }
+
+ private createEnvelope(
+ eventType:
+ | 'hook:registered'
+ | 'hook:updated'
+ | 'hook:unregistered'
+ | 'hook:state-snapshot'
+ | 'tools:registered'
+ | AIDevtoolsRunEventType,
+ visibility: AIDevtoolsEventVisibility = 'client-state',
+ context: { runId?: string } = {},
+ ) {
+ return createAIDevtoolsEventEnvelope({
+ eventType,
+ source: 'client',
+ visibility,
+ clientId: this.options.clientId,
+ hookId: this.options.hookId,
+ correlationId: this.bridgeId,
+ ...(this.options.threadId ? { threadId: this.options.threadId } : {}),
+ ...(context.runId ? { runId: context.runId } : {}),
+ timestamp: Date.now(),
+ })
+ }
+
+ private createMetadataPayload() {
+ return {
+ hookId: this.options.hookId,
+ hookName: this.options.metadata.hookName,
+ ...(this.options.metadata.name
+ ? { displayName: this.options.metadata.name }
+ : {}),
+ ...(this.options.metadata.outputKind
+ ? { outputKind: this.options.metadata.outputKind }
+ : {}),
+ ...(this.options.metadata.framework
+ ? { framework: this.options.metadata.framework }
+ : {}),
+ }
+ }
+}
+
+let bridgeIdSequence = 0
+
+function createBridgeId(hookId: string): string {
+ const cryptoLike = (
+ globalThis as {
+ crypto?: {
+ randomUUID?: () => string
+ }
+ }
+ ).crypto
+
+ if (cryptoLike?.randomUUID) {
+ return `bridge:${hookId}:${cryptoLike.randomUUID()}`
+ }
+
+ bridgeIdSequence += 1
+ return `bridge:${hookId}:${bridgeIdSequence}`
+}
+
+// Owns the chat-client devtools surface so the chat client itself stays a
+// pure transport. Fixture replay, per-run / per-stream event context, and
+// snapshot emission all live here; a no-op bridge can drop in for prod.
+
+export interface ChatDevtoolsBridgeOptions extends AIDevtoolsBridgeOptions {
+ getMessages: () => Array
+ setMessages: (messages: Array) => void
+ addToolResult: (
+ toolCallId: string,
+ output: unknown,
+ errorText?: string,
+ ) => void
+ generateId: (prefix: string) => string
+}
+
+export class ChatDevtoolsBridge extends ClientDevtoolsBridge {
+ readonly events: ChatClientEventEmitter
+ private readonly chatOptions: ChatDevtoolsBridgeOptions
+ private currentRunId: string | null = null
+ private currentRunThreadId: string | null = null
+ private currentStreamId: string | null = null
+ private lastStreamId: string | null = null
+ private lastRunEventContext: ChatClientRunEventContext | undefined
+
+ constructor(options: ChatDevtoolsBridgeOptions) {
+ super({
+ ...options,
+ // Thunk defers `this.applyFixture` lookup until after `super` returns.
+ applyToolFixture: (fixture) => this.applyFixture(fixture),
+ })
+ this.chatOptions = options
+ // Auto-attaches run/thread context and auto-emits a snapshot after each
+ // event so callers can keep using `this.events.X(...)` with no context arg.
+ this.events = new ChatDevtoolsAwareEventEmitter(options.clientId, this)
+ }
+
+ // --- Stream / run context API -------------------------------------------
+
+ setCurrentStreamId(streamId: string | null): void {
+ this.currentStreamId = streamId
+ if (streamId) {
+ this.lastStreamId = streamId
+ }
+ }
+
+ /**
+ * Called by the auto-attaching emitter every time it sees a non-empty
+ * streamId pass through. Lets devtools track the latest stream id
+ * without the chat client wiring it up explicitly.
+ */
+ recordStreamId(streamId: string): void {
+ if (streamId) this.lastStreamId = streamId
+ }
+
+ mountWithTools(initialMessageCount: number): void {
+ this.events.clientCreated(initialMessageCount)
+ this.emitRegistered()
+ this.emitToolsRegistered()
+ this.emitSnapshot()
+ }
+
+ notifyToolsChanged(): void {
+ this.emitToolsRegistered()
+ this.emitSnapshot()
+ }
+
+ getCurrentStreamId(): string | null {
+ return this.currentStreamId
+ }
+
+ getLastStreamId(): string | null {
+ return this.lastStreamId
+ }
+
+ resolveStreamId(): string {
+ return (
+ this.currentStreamId ??
+ this.lastStreamId ??
+ this.chatOptions.generateId('stream')
+ )
+ }
+
+ // Called when the chat client has just generated a runId for outbound emits;
+ // the matching RUN_STARTED chunk from the adapter lands later and
+ // observeChunk keeps the same context.
+ beginRun(runId: string, threadId: string): void {
+ this.currentRunId = runId
+ this.currentRunThreadId = threadId
+ this.lastRunEventContext = { runId, threadId }
+ }
+
+ observeChunk(chunk: StreamChunk): void {
+ if (chunk.type === 'RUN_STARTED') {
+ this.beginRun(chunk.runId, chunk.threadId)
+ return
+ }
+
+ if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') {
+ const runId =
+ chunk.type === 'RUN_FINISHED'
+ ? chunk.runId
+ : (chunk as { runId?: string }).runId
+ if (!runId || runId === this.currentRunId) {
+ const context = this.getCurrentRunEventContext()
+ if (context) {
+ this.lastRunEventContext = context
+ }
+ this.currentRunId = null
+ this.currentRunThreadId = null
+ }
+ }
+ }
+
+ getCurrentRunEventContext(): ChatClientRunEventContext | undefined {
+ if (!this.currentRunId) return undefined
+ return {
+ threadId: this.currentRunThreadId ?? this.chatOptions.threadId ?? '',
+ runId: this.currentRunId,
+ }
+ }
+
+ getCurrentOrLastRunEventContext(): ChatClientRunEventContext | undefined {
+ return this.getCurrentRunEventContext() ?? this.lastRunEventContext
+ }
+
+ findToolCallContext(toolCallId: string): ChatClientEventContext {
+ const base: ChatClientEventContext = { toolCallId }
+ const runContext = this.getCurrentRunEventContext()
+ if (runContext) {
+ return {
+ threadId: runContext.threadId,
+ runId: runContext.runId,
+ toolCallId,
+ }
+ }
+ if (this.chatOptions.threadId) {
+ return { threadId: this.chatOptions.threadId, toolCallId }
+ }
+ return base
+ }
+
+ // --- Fixture replay ------------------------------------------------------
+
+ /**
+ * Entry point invoked when the devtools panel emits
+ * `devtools:tool-fixture:apply`. The chat client never calls this
+ * directly; it is wired through the base bridge's fixture subscription.
+ */
+ async applyFixture(fixture: AIDevtoolsToolFixture): Promise {
+ const messages = this.chatOptions.getMessages()
+ const threadId = fixture.threadId ?? this.chatOptions.threadId ?? ''
+ if (fixture.execute) {
+ await this.executeFixture(fixture, messages, threadId)
+ return
+ }
+
+ const replay = this.createReplayMessageFromFixture(fixture, messages)
+ const { message, toolCallId } = replay
+ const messageId = message.id
+
+ this.events.messageAppended(message, undefined, {
+ threadId,
+ toolCallId,
+ ...(fixture.runId ? { runId: fixture.runId } : {}),
+ })
+ this.chatOptions.setMessages([...messages, message])
+ this.events.toolFixtureApplied({
+ hookId: this.chatOptions.hookId,
+ threadId,
+ ...(fixture.runId ? { runId: fixture.runId } : {}),
+ toolName: fixture.toolName,
+ input: fixture.input,
+ output: fixture.output,
+ messageId,
+ toolCallId,
+ ...(fixture.execute !== undefined ? { execute: fixture.execute } : {}),
+ ...(fixture.message ? { message: fixture.message } : {}),
+ ...(fixture.errorText ? { errorText: fixture.errorText } : {}),
+ })
+ this.emitSnapshot()
+ }
+
+ private async executeFixture(
+ fixture: AIDevtoolsToolFixture,
+ messages: Array,
+ threadId: string,
+ ): Promise {
+ const toolCallId = this.resolveFixtureToolCallId(
+ fixture.toolCallId,
+ messages,
+ )
+ const messageId = this.resolveFixtureMessageId(fixture.messageId, messages)
+ const message: UIMessage = {
+ id: messageId,
+ role: 'assistant',
+ parts: [
+ {
+ type: 'tool-call',
+ id: toolCallId,
+ name: fixture.toolName,
+ arguments: stringifyFixtureValue(fixture.input),
+ input: fixture.input,
+ state: 'input-complete',
+ },
+ ],
+ createdAt: new Date(),
+ }
+
+ this.events.messageAppended(message, undefined, {
+ threadId,
+ toolCallId,
+ ...(fixture.runId ? { runId: fixture.runId } : {}),
+ })
+ this.chatOptions.setMessages([...messages, message])
+ this.emitSnapshot()
+
+ const clientTool = this.findClientTool(fixture.toolName)
+ const executeFunc = clientTool?.execute
+ if (!executeFunc) {
+ console.warn(
+ `[ai-devtools] tool fixture "${fixture.toolName}" requested execute=true but no client tool implementation is registered; replaying saved output instead.`,
+ )
+ this.addToolResultForFixture({
+ fixture,
+ messageId,
+ toolCallId,
+ threadId,
+ output: fixture.output,
+ errorText: fixture.errorText,
+ })
+ return
+ }
+
+ let output: unknown
+ try {
+ output = await executeFunc(fixture.input)
+ } catch (error) {
+ console.error(
+ `[ai-devtools] tool fixture "${fixture.toolName}" execute threw`,
+ error,
+ )
+ this.addToolResultForFixture({
+ fixture,
+ messageId,
+ toolCallId,
+ threadId,
+ output: null,
+ errorText:
+ error instanceof Error
+ ? `${error.name}: ${error.message}`
+ : `Tool execution failed: ${String(error)}`,
+ })
+ return
+ }
+ this.addToolResultForFixture({
+ fixture,
+ messageId,
+ toolCallId,
+ threadId,
+ output,
+ })
+ }
+
+ private addToolResultForFixture(input: {
+ fixture: AIDevtoolsToolFixture
+ messageId: string
+ toolCallId: string
+ threadId: string
+ output: unknown
+ errorText?: string
+ }): void {
+ const state = input.errorText ? 'output-error' : 'output-available'
+ this.events.toolResultAdded(
+ input.toolCallId,
+ input.fixture.toolName,
+ input.output,
+ state,
+ {
+ threadId: input.threadId,
+ ...(input.fixture.runId ? { runId: input.fixture.runId } : {}),
+ toolCallId: input.toolCallId,
+ },
+ )
+ this.chatOptions.addToolResult(
+ input.toolCallId,
+ input.output,
+ input.errorText,
+ )
+ this.events.toolFixtureApplied({
+ hookId: this.chatOptions.hookId,
+ threadId: input.threadId,
+ ...(input.fixture.runId ? { runId: input.fixture.runId } : {}),
+ toolName: input.fixture.toolName,
+ input: input.fixture.input,
+ output: input.output,
+ execute: true,
+ messageId: input.messageId,
+ toolCallId: input.toolCallId,
+ ...(input.errorText ? { errorText: input.errorText } : {}),
+ })
+ this.emitSnapshot()
+ }
+
+ private createReplayMessageFromFixture(
+ fixture: AIDevtoolsToolFixture,
+ messages: Array,
+ ): { message: UIMessage; toolCallId: string } {
+ const cloned = this.cloneFixtureSourceMessage(fixture, messages)
+ if (cloned) return cloned
+
+ const toolCallId = this.resolveFixtureToolCallId(
+ fixture.toolCallId,
+ messages,
+ )
+ const messageId = this.resolveFixtureMessageId(fixture.messageId, messages)
+ const state = fixture.errorText ? 'error' : 'complete'
+
+ return {
+ toolCallId,
+ message: {
+ id: messageId,
+ role: 'assistant',
+ parts: [
+ {
+ type: 'tool-call',
+ id: toolCallId,
+ name: fixture.toolName,
+ arguments: stringifyFixtureValue(fixture.input),
+ input: fixture.input,
+ state: 'input-complete',
+ output: fixture.output,
+ },
+ {
+ type: 'tool-result',
+ toolCallId,
+ content: stringifyFixtureValue(fixture.output),
+ state,
+ ...(fixture.errorText ? { error: fixture.errorText } : {}),
+ },
+ ],
+ createdAt: new Date(),
+ },
+ }
+ }
+
+ private cloneFixtureSourceMessage(
+ fixture: AIDevtoolsToolFixture,
+ messages: Array,
+ ): { message: UIMessage; toolCallId: string } | undefined {
+ const sourceMessage = fixture.message
+ if (!sourceMessage || !Array.isArray(sourceMessage.parts)) {
+ return undefined
+ }
+
+ const toolCallIds = this.createFixtureToolCallIdMap(
+ sourceMessage.parts,
+ messages,
+ )
+ const parts = sourceMessage.parts
+ .map((part) => cloneFixtureMessagePart(part, toolCallIds))
+ .filter((part): part is MessagePart => Boolean(part))
+ const mappedFixtureToolCallId = fixture.toolCallId
+ ? toolCallIds.get(fixture.toolCallId)
+ : undefined
+ hydrateToolCallOutputs(parts, {
+ ...(mappedFixtureToolCallId
+ ? { mappedToolCallId: mappedFixtureToolCallId }
+ : {}),
+ output: fixture.output,
+ })
+
+ if (parts.length === 0) return undefined
+
+ const toolCallId =
+ (fixture.toolCallId ? toolCallIds.get(fixture.toolCallId) : undefined) ??
+ firstToolCallId(parts)
+ if (!toolCallId) return undefined
+
+ return {
+ toolCallId,
+ message: {
+ id: this.resolveFixtureMessageId(sourceMessage.id, messages),
+ role: sourceMessage.role,
+ parts,
+ createdAt: new Date(),
+ },
+ }
+ }
+
+ private createFixtureToolCallIdMap(
+ parts: Array,
+ messages: Array,
+ ): Map {
+ const ids = new Map()
+ for (const part of parts) {
+ if (!isRecord(part) || part.type !== 'tool-call') continue
+ if (typeof part.id !== 'string') continue
+ ids.set(part.id, this.resolveFixtureToolCallId(part.id, messages))
+ }
+ return ids
+ }
+
+ private resolveFixtureMessageId(
+ messageId: string | undefined,
+ messages: Array,
+ ): string {
+ if (messageId && !messages.some((message) => message.id === messageId)) {
+ return messageId
+ }
+ return this.chatOptions.generateId('fixture-msg')
+ }
+
+ private resolveFixtureToolCallId(
+ toolCallId: string | undefined,
+ messages: Array,
+ ): string {
+ if (toolCallId && !hasToolCallId(messages, toolCallId)) {
+ return toolCallId
+ }
+ return this.chatOptions.generateId('fixture-tool-call')
+ }
+
+ private findClientTool(name: string): AnyClientTool | undefined {
+ const tools = this.chatOptions.getTools?.()
+ if (!tools) return undefined
+ for (const tool of tools) {
+ if (tool.name === name) return tool
+ }
+ return undefined
+ }
+}
+
+// ---- Module-level fixture helpers (pure; share no state) -------------------
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null
+}
+
+function stringifyFixtureValue(value: unknown): string {
+ if (typeof value === 'string') return value
+ if (
+ value === undefined ||
+ typeof value === 'function' ||
+ typeof value === 'symbol'
+ ) {
+ return String(value)
+ }
+ try {
+ return JSON.stringify(value)
+ } catch (error) {
+ console.error(
+ '[ai-devtools] failed to JSON.stringify fixture value; falling back to String(). Tool call arguments may be malformed.',
+ { error, value },
+ )
+ return String(value)
+ }
+}
+
+function parseFixtureResultContent(content: string): unknown {
+ try {
+ return JSON.parse(content)
+ } catch (error) {
+ console.error(
+ '[ai-devtools] failed to JSON.parse fixture result content; replaying as raw string. Fixture payload may be corrupted.',
+ { error, content },
+ )
+ return content
+ }
+}
+
+function cloneFixtureMessagePart(
+ part: unknown,
+ toolCallIds: Map,
+): MessagePart | undefined {
+ if (!isRecord(part) || typeof part.type !== 'string') return undefined
+ const cloned: Record = { ...part }
+
+ if (part.type === 'tool-call' && typeof part.id === 'string') {
+ cloned.id = toolCallIds.get(part.id) ?? part.id
+ }
+ if (part.type === 'tool-result' && typeof part.toolCallId === 'string') {
+ cloned.toolCallId = toolCallIds.get(part.toolCallId) ?? part.toolCallId
+ }
+ return cloned as MessagePart
+}
+
+function firstToolCallId(parts: Array): string | undefined {
+ const toolCall = parts.find((part) => part.type === 'tool-call')
+ return toolCall?.type === 'tool-call' ? toolCall.id : undefined
+}
+
+function hydrateToolCallOutputs(
+ parts: Array,
+ fixtureOutput: { mappedToolCallId?: string; output: unknown },
+): void {
+ for (const part of parts) {
+ if (part.type !== 'tool-result') continue
+ const toolCall = parts.find(
+ (candidate): candidate is ToolCallPart =>
+ candidate.type === 'tool-call' &&
+ candidate.id === part.toolCallId &&
+ candidate.output === undefined,
+ )
+ if (toolCall) {
+ toolCall.output = parseFixtureResultContent(part.content)
+ }
+ }
+
+ if (fixtureOutput.mappedToolCallId && fixtureOutput.output !== undefined) {
+ const toolCall = parts.find(
+ (candidate): candidate is ToolCallPart =>
+ candidate.type === 'tool-call' &&
+ candidate.id === fixtureOutput.mappedToolCallId &&
+ candidate.output === undefined,
+ )
+ if (toolCall) {
+ toolCall.output = fixtureOutput.output
+ }
+ }
+}
+
+function hasToolCallId(
+ messages: Array,
+ toolCallId: string,
+): boolean {
+ return messages.some((message) =>
+ message.parts.some((part) => {
+ if (part.type === 'tool-call') return part.id === toolCallId
+ if (part.type === 'tool-result') return part.toolCallId === toolCallId
+ return false
+ }),
+ )
+}
+
+// Devtools surface for GenerationClient / VideoGenerationClient. Owns per-run
+// history, active-run lifecycle, and snapshot emission; the generation client
+// pushes its core state in via the record* methods.
+
+export interface AIDevtoolsGenerationSnapshotBase {
+ input: unknown
+ result: TOutput | null
+ preview: AIDevtoolsGenerationPreview
+ progress: AIDevtoolsGenerationProgress | null
+ status: AIDevtoolsGenerationRunStatus
+ isLoading: boolean
+ activeRunId: string | null
+ runs: Array>
+ error?: string
+}
+
+export interface GenerationDevtoolsBridgeOptions extends Omit<
+ AIDevtoolsBridgeOptions>,
+ 'getSnapshot'
+> {
+ getCoreState: () => GenerationDevtoolsCoreState
+ maxRuns?: number
+}
+
+export interface GenerationDevtoolsCoreState {
+ input: unknown
+ result: TOutput | null
+ progress: AIDevtoolsGenerationProgress | null
+ status: AIDevtoolsGenerationRunStatus
+ isLoading: boolean
+ error?: string
+}
+
+export interface GenerationRunPatch {
+ input?: unknown
+ result?: TOutput | null
+ preview?: AIDevtoolsGenerationPreview
+ progress?: AIDevtoolsGenerationProgress | null
+ status?: AIDevtoolsGenerationRunStatus
+ isLoading?: boolean
+ completedAt?: number
+ error?: string
+ clearError?: boolean
+}
+
+export class GenerationDevtoolsBridge extends ClientDevtoolsBridge<
+ AIDevtoolsGenerationSnapshotBase
+> {
+ protected activeRunId: string | null = null
+ protected activeRunStarted = false
+ protected devtoolsRuns: Array> = []
+ protected readonly maxRuns: number
+ protected readonly getCoreState: () => GenerationDevtoolsCoreState
+
+ constructor(options: GenerationDevtoolsBridgeOptions) {
+ super({
+ ...options,
+ getSnapshot: () => this.buildSnapshot(),
+ })
+ this.maxRuns = options.maxRuns ?? 20
+ this.getCoreState = options.getCoreState
+ }
+
+ // --- Run lifecycle (called by GenerationClient) -----------------------
+
+ beginRun(input: unknown): string {
+ const runId = this.generateRunId()
+ this.activeRunId = runId
+ this.activeRunStarted = false
+ this.upsertRun(runId, {
+ input,
+ result: null,
+ preview: this.createPreview(null),
+ progress: null,
+ status: 'generating',
+ isLoading: true,
+ clearError: true,
+ })
+ return runId
+ }
+
+ ensureRunStarted(runId: string): void {
+ if (this.activeRunStarted && this.activeRunId === runId) return
+
+ if (
+ !this.activeRunStarted &&
+ this.activeRunId &&
+ this.activeRunId !== runId
+ ) {
+ this.renameRun(this.activeRunId, runId)
+ }
+
+ this.activeRunId = runId
+ this.activeRunStarted = true
+ this.upsertRun(runId, {
+ status: 'generating',
+ isLoading: true,
+ clearError: true,
+ })
+ this.emitRunLifecycle('run:started', runId, 'started')
+ this.emitState()
+ }
+
+ finishRun(
+ runId: string,
+ eventType: 'run:completed' | 'run:errored' | 'run:cancelled',
+ status: 'completed' | 'errored' | 'cancelled',
+ error?: string,
+ ): void {
+ this.ensureRunStarted(runId)
+ const completedAt = Date.now()
+ const completedProgress =
+ status === 'completed' ? this.completeProgress() : this.getProgress()
+ const runStatus =
+ status === 'completed'
+ ? 'success'
+ : status === 'errored'
+ ? 'error'
+ : 'cancelled'
+
+ this.upsertRun(runId, {
+ status: runStatus,
+ isLoading: false,
+ progress: completedProgress,
+ completedAt,
+ ...(error ? { error } : { clearError: true }),
+ })
+
+ if (this.activeRunId === runId) {
+ this.activeRunId = null
+ }
+ this.activeRunStarted = false
+ this.emitRunLifecycle(eventType, runId, status, {
+ ...(error ? { error } : {}),
+ })
+ this.emitState()
+ }
+
+ getActiveRunId(): string | null {
+ return this.activeRunId
+ }
+
+ /** Clear all per-run history. Called when the client `reset()`s. */
+ resetRuns(): void {
+ this.activeRunId = null
+ this.activeRunStarted = false
+ this.devtoolsRuns = []
+ }
+
+ /** Record state changes from the client and emit the matching snapshot. */
+ recordResultChange(): void {
+ this.updateActiveRun({
+ result: this.getCoreState().result,
+ preview: this.createPreview(this.getCoreState().result),
+ clearError: true,
+ })
+ this.emitState()
+ }
+
+ recordLoadingChange(): void {
+ this.updateActiveRun({ isLoading: this.getCoreState().isLoading })
+ this.emitState()
+ }
+
+ recordErrorChange(error: Error | undefined): void {
+ this.updateActiveRun(
+ error ? { error: error.message } : { clearError: true },
+ )
+ this.emitState()
+ }
+
+ recordStatusChange(status: AIDevtoolsGenerationRunStatus): void {
+ this.updateActiveRun({ status })
+ this.emitState()
+ }
+
+ recordProgressChange(): void {
+ this.updateActiveRun({ progress: this.getCoreState().progress })
+ this.emitState()
+ }
+
+ /** Emit the latest snapshot without touching run state. */
+ emitState(): void {
+ this.emitUpdated()
+ this.emitSnapshot()
+ }
+
+ // --- Internal ---------------------------------------------------------
+
+ protected buildSnapshot(): AIDevtoolsGenerationSnapshotBase {
+ const core = this.getCoreState()
+ return {
+ input: core.input,
+ result: core.result,
+ preview: this.createPreview(core.result),
+ progress: core.progress,
+ status: core.status,
+ isLoading: core.isLoading,
+ activeRunId: this.activeRunId,
+ runs: this.devtoolsRuns,
+ ...(core.error ? { error: core.error } : {}),
+ }
+ }
+
+ protected updateActiveRun(patch: GenerationRunPatch): void {
+ if (!this.activeRunId) return
+ this.upsertRun(this.activeRunId, patch)
+ }
+
+ protected upsertRun(runId: string, patch: GenerationRunPatch): void {
+ const now = Date.now()
+ const index = this.devtoolsRuns.findIndex((run) => run.id === runId)
+ const existing = index >= 0 ? this.devtoolsRuns[index] : undefined
+ const next: AIDevtoolsGenerationRunSnapshot = existing
+ ? { ...existing }
+ : {
+ id: runId,
+ input: this.getCoreState().input,
+ result: null,
+ preview: this.createPreview(null),
+ progress: null,
+ status: 'idle',
+ isLoading: false,
+ startedAt: now,
+ updatedAt: now,
+ }
+
+ if ('input' in patch) next.input = patch.input ?? null
+ if ('result' in patch) next.result = patch.result ?? null
+ if (patch.preview) next.preview = patch.preview
+ if ('progress' in patch) next.progress = patch.progress ?? null
+ if (patch.status) next.status = patch.status
+ if ('isLoading' in patch) next.isLoading = patch.isLoading === true
+ if (patch.completedAt !== undefined) next.completedAt = patch.completedAt
+ if (patch.clearError) delete next.error
+ if (patch.error !== undefined) next.error = patch.error
+ next.updatedAt = now
+
+ if (index >= 0) {
+ this.devtoolsRuns = this.devtoolsRuns.map((run) =>
+ run.id === runId ? next : run,
+ )
+ } else {
+ this.devtoolsRuns = [...this.devtoolsRuns, next]
+ }
+
+ if (this.devtoolsRuns.length > this.maxRuns) {
+ this.devtoolsRuns = this.devtoolsRuns.slice(-this.maxRuns)
+ }
+ }
+
+ protected renameRun(previousRunId: string, nextRunId: string): void {
+ if (previousRunId === nextRunId) return
+
+ const existing = this.devtoolsRuns.find((run) => run.id === previousRunId)
+ if (!existing) return
+
+ const renamed = { ...existing, id: nextRunId, updatedAt: Date.now() }
+ this.devtoolsRuns = this.devtoolsRuns
+ .filter((run) => run.id !== nextRunId)
+ .map((run) => (run.id === previousRunId ? renamed : run))
+ }
+
+ protected getProgress(): AIDevtoolsGenerationProgress | null {
+ return this.getCoreState().progress
+ }
+
+ protected completeProgress(): AIDevtoolsGenerationProgress | null {
+ const progress = this.getCoreState().progress
+ if (!progress) return null
+ return {
+ value: 100,
+ ...(progress.message ? { message: progress.message } : {}),
+ }
+ }
+
+ protected createPreview(result: TOutput | null): AIDevtoolsGenerationPreview {
+ return createAIDevtoolsGenerationPreview({
+ outputKind: this.options.metadata.outputKind,
+ result,
+ })
+ }
+
+ protected generateRunId(): string {
+ return `run-${Date.now()}-${Math.random().toString(36).substring(7)}`
+ }
+}
+
+// Video-job specialization: snapshots also carry the job id and the latest
+// provider-reported video status so the panel can show streaming progress
+// before the final URL lands.
+
+export interface AIDevtoolsVideoSnapshotBase<
+ TOutput,
+> extends AIDevtoolsGenerationSnapshotBase {
+ jobId: string | null
+ videoStatus: unknown
+}
+
+export interface VideoDevtoolsCoreState<
+ TOutput,
+> extends GenerationDevtoolsCoreState {
+ jobId: string | null
+ videoStatus: unknown
+}
+
+export interface VideoDevtoolsBridgeOptions extends Omit<
+ GenerationDevtoolsBridgeOptions,
+ 'getCoreState'
+> {
+ getCoreState: () => VideoDevtoolsCoreState
+}
+
+export interface VideoRunPatch extends GenerationRunPatch {
+ jobId?: string | null
+ videoStatus?: unknown
+}
+
+export class VideoDevtoolsBridge<
+ TOutput,
+> extends GenerationDevtoolsBridge {
+ constructor(options: VideoDevtoolsBridgeOptions) {
+ super(options)
+ }
+
+ recordJobIdChange(): void {
+ this.updateActiveRun({
+ jobId: (this.getCoreState() as VideoDevtoolsCoreState).jobId,
+ } as VideoRunPatch)
+ this.emitState()
+ }
+
+ recordVideoStatusChange(): void {
+ const core = this.getCoreState() as VideoDevtoolsCoreState
+ this.updateActiveRun({
+ videoStatus: core.videoStatus,
+ preview: this.createVideoPreview(core.result, core.videoStatus),
+ } as VideoRunPatch)
+ this.emitState()
+ }
+
+ protected override buildSnapshot(): AIDevtoolsVideoSnapshotBase {
+ const core = this.getCoreState() as VideoDevtoolsCoreState
+ return {
+ input: core.input,
+ result: core.result,
+ preview: this.createVideoPreview(core.result, core.videoStatus),
+ progress: core.progress,
+ status: core.status,
+ isLoading: core.isLoading,
+ activeRunId: this.activeRunId,
+ runs: this.devtoolsRuns,
+ jobId: core.jobId,
+ videoStatus: core.videoStatus,
+ ...(core.error ? { error: core.error } : {}),
+ }
+ }
+
+ protected override upsertRun(
+ runId: string,
+ patch: VideoRunPatch,
+ ): void {
+ super.upsertRun(runId, patch)
+ if (!('jobId' in patch || 'videoStatus' in patch)) return
+
+ const index = this.devtoolsRuns.findIndex((run) => run.id === runId)
+ if (index < 0) return
+ const target = this.devtoolsRuns[index]
+ if (!target) return
+ const merged: AIDevtoolsGenerationRunSnapshot = { ...target }
+ if ('jobId' in patch) merged.jobId = patch.jobId ?? null
+ if ('videoStatus' in patch) merged.videoStatus = patch.videoStatus ?? null
+ this.devtoolsRuns = this.devtoolsRuns.map((run) =>
+ run.id === runId ? merged : run,
+ )
+ }
+
+ // Override so record* methods inherited from GenerationDevtoolsBridge
+ // (e.g. recordResultChange) thread the latest videoStatus into the preview.
+ protected override createPreview(
+ result: TOutput | null,
+ ): AIDevtoolsGenerationPreview {
+ const core = this.getCoreState() as VideoDevtoolsCoreState
+ return this.createVideoPreview(result, core.videoStatus)
+ }
+
+ private createVideoPreview(
+ result: TOutput | null,
+ videoStatus: unknown,
+ ): AIDevtoolsGenerationPreview {
+ return createAIDevtoolsGenerationPreview({
+ outputKind: this.options.metadata.outputKind,
+ result,
+ videoStatus,
+ })
+ }
+}
+
+// Wraps the plain emitter so callers can do `this.events.X(...)` and get:
+// auto-attached run/thread context on every event that accepts one,
+// an auto-emitted snapshot after each event, and passive streamId tracking
+// so resolveStreamId() works without the chat client telling it.
+class ChatDevtoolsAwareEventEmitter extends DefaultChatClientEventEmitter {
+ constructor(
+ clientId: string,
+ private readonly helper: ChatDevtoolsBridge,
+ ) {
+ super(clientId)
+ }
+
+ private afterEmit(streamId?: string): void {
+ if (streamId) this.helper.recordStreamId(streamId)
+ this.helper.emitSnapshot()
+ }
+
+ // -- methods with run context --------------------------------------------
+
+ override textUpdated(
+ streamId: string,
+ messageId: string,
+ content: string,
+ context?: ChatClientRunEventContext,
+ ): void {
+ super.textUpdated(
+ streamId,
+ messageId,
+ content,
+ context ?? this.helper.getCurrentRunEventContext(),
+ )
+ this.afterEmit(streamId)
+ }
+
+ override thinkingUpdated(
+ streamId: string,
+ messageId: string,
+ content: string,
+ delta?: string,
+ context?: ChatClientRunEventContext,
+ ): void {
+ super.thinkingUpdated(
+ streamId,
+ messageId,
+ content,
+ delta,
+ context ?? this.helper.getCurrentRunEventContext(),
+ )
+ this.afterEmit(streamId)
+ }
+
+ override messageAppended(
+ uiMessage: Parameters[0],
+ streamId?: string,
+ context?: ChatClientEventContext,
+ ): void {
+ super.messageAppended(
+ uiMessage,
+ streamId,
+ context ?? this.helper.getCurrentRunEventContext(),
+ )
+ this.afterEmit(streamId)
+ }
+
+ override toolCallStateChanged(
+ streamId: string,
+ messageId: string,
+ toolCallId: string,
+ toolName: string,
+ state: string,
+ args: string,
+ context?: ChatClientRunEventContext,
+ ): void {
+ super.toolCallStateChanged(
+ streamId,
+ messageId,
+ toolCallId,
+ toolName,
+ state,
+ args,
+ context ?? this.helper.getCurrentRunEventContext(),
+ )
+ this.afterEmit(streamId)
+ }
+
+ override structuredOutputChanged(
+ eventName: Parameters<
+ DefaultChatClientEventEmitter['structuredOutputChanged']
+ >[0],
+ streamId: string,
+ messageId: string,
+ output: Parameters<
+ DefaultChatClientEventEmitter['structuredOutputChanged']
+ >[3],
+ context?: ChatClientRunEventContext,
+ ): void {
+ super.structuredOutputChanged(
+ eventName,
+ streamId,
+ messageId,
+ output,
+ context ?? this.helper.getCurrentOrLastRunEventContext(),
+ )
+ this.afterEmit(streamId)
+ }
+
+ override approvalRequested(
+ streamId: string,
+ messageId: string,
+ toolCallId: string,
+ toolName: string,
+ input: unknown,
+ approvalId: string,
+ context?: ChatClientRunEventContext,
+ ): void {
+ super.approvalRequested(
+ streamId,
+ messageId,
+ toolCallId,
+ toolName,
+ input,
+ approvalId,
+ context ?? this.helper.getCurrentOrLastRunEventContext(),
+ )
+ this.afterEmit(streamId)
+ }
+
+ override toolResultAdded(
+ toolCallId: string,
+ toolName: string,
+ output: unknown,
+ state: string,
+ context?: ChatClientEventContext,
+ ): void {
+ super.toolResultAdded(
+ toolCallId,
+ toolName,
+ output,
+ state,
+ context ?? this.helper.getCurrentRunEventContext(),
+ )
+ this.afterEmit()
+ }
+
+ override toolApprovalResponded(
+ approvalId: string,
+ toolCallId: string,
+ approved: boolean,
+ context?: ChatClientRunEventContext,
+ ): void {
+ super.toolApprovalResponded(
+ approvalId,
+ toolCallId,
+ approved,
+ context ?? this.helper.getCurrentRunEventContext(),
+ )
+ this.afterEmit()
+ }
+
+ // -- methods without context (just auto-emit snapshot) -------------------
+
+ override clientCreated(initialMessageCount: number): void {
+ super.clientCreated(initialMessageCount)
+ this.afterEmit()
+ }
+ override loadingChanged(isLoading: boolean): void {
+ super.loadingChanged(isLoading)
+ this.afterEmit()
+ }
+ override errorChanged(error: string | null): void {
+ super.errorChanged(error)
+ this.afterEmit()
+ }
+ override reloaded(fromMessageIndex: number): void {
+ super.reloaded(fromMessageIndex)
+ this.afterEmit()
+ }
+ override stopped(): void {
+ super.stopped()
+ this.afterEmit()
+ }
+ override messagesCleared(): void {
+ super.messagesCleared()
+ this.afterEmit()
+ }
+ override messageSent(
+ messageId: string,
+ content: Parameters[1],
+ ): void {
+ super.messageSent(messageId, content)
+ this.afterEmit()
+ }
+ override toolFixtureApplied(
+ fixture: Parameters[0],
+ ): void {
+ super.toolFixtureApplied(fixture)
+ this.afterEmit()
+ }
+}
+
+export function createChatDevtoolsBridge(
+ options: ChatDevtoolsBridgeOptions,
+): ChatDevtoolsBridge {
+ return new ChatDevtoolsBridge(options)
+}
+
+export function createGenerationDevtoolsBridge(
+ options: GenerationDevtoolsBridgeOptions,
+): GenerationDevtoolsBridge {
+ return new GenerationDevtoolsBridge(options)
+}
+
+export function createVideoDevtoolsBridge(
+ options: VideoDevtoolsBridgeOptions,
+): VideoDevtoolsBridge {
+ return new VideoDevtoolsBridge(options)
+}
diff --git a/packages/ai-client/src/events.ts b/packages/ai-client/src/events.ts
index f6cee6757..e06a072e1 100644
--- a/packages/ai-client/src/events.ts
+++ b/packages/ai-client/src/events.ts
@@ -1,7 +1,57 @@
-import { aiEventClient } from '@tanstack/ai-event-client'
+import {
+ aiEventClient,
+ createAIDevtoolsEventEnvelope,
+} from '@tanstack/ai-event-client'
import type { ContentPart } from '@tanstack/ai'
import type { UIMessage } from './types'
+export interface ChatClientRunEventContext {
+ threadId: string
+ runId: string
+ toolCallId?: string
+}
+
+export interface ChatClientEventContext {
+ threadId?: string
+ runId?: string
+ toolCallId?: string
+}
+
+export interface ChatClientToolFixtureAppliedEvent {
+ hookId: string
+ threadId: string
+ toolName: string
+ input: unknown
+ output: unknown
+ execute?: boolean
+ message?: {
+ id: string
+ role: 'system' | 'user' | 'assistant'
+ parts: Array
+ createdAt?: number | string
+ }
+ messageId: string
+ toolCallId: string
+ runId?: string
+ errorText?: string
+}
+
+export interface ChatClientStructuredOutputEvent {
+ status: 'streaming' | 'complete' | 'error'
+ raw?: string
+ partial?: unknown
+ data?: unknown
+ reasoning?: string
+ errorMessage?: string
+ delta?: string
+}
+
+export type ChatClientStructuredOutputEventName =
+ | 'structured-output:started'
+ | 'structured-output:updated'
+ | 'structured-output:completed'
+ | 'structured-output:errored'
+
/**
* Abstract base class for ChatClient event emission
*/
@@ -49,11 +99,17 @@ export abstract class ChatClientEventEmitter {
/**
* Emit text update events (combines processor and client events)
*/
- textUpdated(streamId: string, messageId: string, content: string): void {
+ textUpdated(
+ streamId: string,
+ messageId: string,
+ content: string,
+ context?: ChatClientRunEventContext,
+ ): void {
this.emitEvent('text:chunk:content', {
streamId,
messageId,
content,
+ ...context,
})
}
@@ -67,6 +123,7 @@ export abstract class ChatClientEventEmitter {
toolName: string,
state: string,
args: string,
+ context?: ChatClientRunEventContext,
): void {
this.emitEvent('tools:call:updated', {
streamId,
@@ -75,6 +132,7 @@ export abstract class ChatClientEventEmitter {
toolName,
state,
arguments: args,
+ ...context,
})
}
@@ -89,12 +147,29 @@ export abstract class ChatClientEventEmitter {
messageId: string,
content: string,
delta?: string,
+ context?: ChatClientRunEventContext,
): void {
this.emitEvent('text:chunk:thinking', {
streamId,
messageId,
content,
delta,
+ ...context,
+ })
+ }
+
+ structuredOutputChanged(
+ eventName: ChatClientStructuredOutputEventName,
+ streamId: string,
+ messageId: string,
+ output: ChatClientStructuredOutputEvent,
+ context?: ChatClientRunEventContext,
+ ): void {
+ this.emitEvent(eventName, {
+ streamId,
+ messageId,
+ ...output,
+ ...context,
})
}
@@ -108,6 +183,7 @@ export abstract class ChatClientEventEmitter {
toolName: string,
input: unknown,
approvalId: string,
+ context?: ChatClientRunEventContext,
): void {
this.emitEvent('tools:approval:requested', {
streamId,
@@ -116,13 +192,18 @@ export abstract class ChatClientEventEmitter {
toolName,
input,
approvalId,
+ ...context,
})
}
/**
* Emit message appended event
*/
- messageAppended(uiMessage: UIMessage, streamId?: string): void {
+ messageAppended(
+ uiMessage: UIMessage,
+ streamId?: string,
+ context?: ChatClientEventContext,
+ ): void {
const content = uiMessage.parts
.filter((part) => part.type === 'text')
.map((part) => part.content)
@@ -134,6 +215,7 @@ export abstract class ChatClientEventEmitter {
role: uiMessage.role,
content,
parts: uiMessage.parts,
+ ...context,
})
}
@@ -201,12 +283,14 @@ export abstract class ChatClientEventEmitter {
toolName: string,
output: unknown,
state: string,
+ context?: ChatClientEventContext,
): void {
this.emitEvent('tools:result:added', {
toolCallId,
toolName,
output,
state,
+ ...context,
})
}
@@ -217,13 +301,22 @@ export abstract class ChatClientEventEmitter {
approvalId: string,
toolCallId: string,
approved: boolean,
+ context?: ChatClientRunEventContext,
): void {
this.emitEvent('tools:approval:responded', {
approvalId,
toolCallId,
approved,
+ ...context,
})
}
+
+ /**
+ * Emit tool fixture applied event.
+ */
+ toolFixtureApplied(fixture: ChatClientToolFixtureAppliedEvent): void {
+ this.emitEvent('devtools:tool-fixture:applied', { ...fixture })
+ }
}
/**
@@ -234,23 +327,61 @@ export class DefaultChatClientEventEmitter extends ChatClientEventEmitter {
* Emit an event with automatic clientId and timestamp for client/tool events
*/
protected emitEvent(eventName: string, data?: Record): void {
- // For client:* and tool:* events, automatically add clientId and timestamp
- if (
+ const timestamp = Date.now()
+ const isUserVisibleEvent =
+ eventName.startsWith('text:') ||
+ eventName.startsWith('tools:') ||
+ eventName.startsWith('structured-output:') ||
+ eventName === 'devtools:tool-fixture:applied'
+ const includesClientContext =
eventName.startsWith('client:') ||
eventName.startsWith('tools:') ||
- eventName.startsWith('text:')
- ) {
- aiEventClient.emit(eventName as any, {
- ...data,
+ eventName.startsWith('text:') ||
+ eventName.startsWith('structured-output:') ||
+ eventName === 'devtools:tool-fixture:applied'
+ const visibility = isUserVisibleEvent ? 'user-visible' : 'client-state'
+ const envelopeContext = {
+ hookId: this.clientId,
+ ...(typeof data?.threadId === 'string'
+ ? { threadId: data.threadId }
+ : {}),
+ ...(typeof data?.runId === 'string' ? { runId: data.runId } : {}),
+ ...(typeof data?.streamId === 'string'
+ ? { streamId: data.streamId }
+ : {}),
+ ...(typeof data?.messageId === 'string'
+ ? { messageId: data.messageId }
+ : {}),
+ ...(typeof data?.toolCallId === 'string'
+ ? { toolCallId: data.toolCallId }
+ : {}),
+ }
+
+ // For client:* and tool:* events, automatically add clientId and timestamp
+ if (includesClientContext) {
+ const envelope = createAIDevtoolsEventEnvelope({
+ eventType: eventName,
clientId: this.clientId,
+ ...envelopeContext,
source: 'client',
- timestamp: Date.now(),
+ visibility,
+ timestamp,
+ })
+ aiEventClient.emit(eventName as any, {
+ ...data,
+ ...envelope,
})
} else {
+ const envelope = createAIDevtoolsEventEnvelope({
+ eventType: eventName,
+ source: 'client',
+ visibility: 'client-state',
+ timestamp,
+ })
// For other events, just add timestamp
aiEventClient.emit(eventName as any, {
...data,
- timestamp: Date.now(),
+ ...envelope,
})
}
}
diff --git a/packages/ai-client/src/generation-client.ts b/packages/ai-client/src/generation-client.ts
index c5395770c..0dd991e31 100644
--- a/packages/ai-client/src/generation-client.ts
+++ b/packages/ai-client/src/generation-client.ts
@@ -1,7 +1,17 @@
import { GENERATION_EVENTS } from './generation-types'
+import { createNoOpGenerationDevtoolsBridge } from './devtools-noop'
import { parseSSEResponse } from './sse-parser'
import type { StreamChunk } from '@tanstack/ai'
-import type { ConnectConnectionAdapter } from './connection-adapters'
+import type {
+ ConnectConnectionAdapter,
+ RunAgentInputContext,
+} from './connection-adapters'
+import type {
+ AIDevtoolsClientMetadata,
+ AIDevtoolsGenerationProgress,
+ GenerationDevtoolsBridge,
+ GenerationDevtoolsBridgeOptions,
+} from './devtools'
import type {
GenerationClientOptions,
GenerationClientState,
@@ -67,13 +77,20 @@ export class GenerationClient<
> {
private readonly connection: ConnectConnectionAdapter | undefined
private readonly fetcher: GenerationFetcher | undefined
+ private readonly uniqueId: string
+ private readonly devtoolsMetadata: AIDevtoolsClientMetadata
+ private readonly devtoolsBridge: GenerationDevtoolsBridge
+ private readonly threadId: string
private body: Record
private result: TOutput | null = null
+ private input: TInput | null = null
+ private progress: AIDevtoolsGenerationProgress | null = null
private isLoading = false
private error: Error | undefined = undefined
private status: GenerationClientState = 'idle'
private abortController: AbortController | null = null
private readonly callbacksRef: GenerationCallbacks
+ private devtoolsMounted = false
constructor(
options: GenerationClientOptions &
@@ -85,6 +102,8 @@ export class GenerationClient<
}
),
) {
+ this.uniqueId = options.id ?? this.generateUniqueId('generation')
+ this.threadId = this.uniqueId
this.connection = options.connection
this.fetcher = options.fetcher
this.body = options.body ?? {}
@@ -99,6 +118,38 @@ export class GenerationClient<
onErrorChange: options.onErrorChange,
onStatusChange: options.onStatusChange,
}
+
+ this.devtoolsMetadata = this.createDevtoolsMetadata(options.devtools)
+ this.devtoolsBridge = (
+ options.devtoolsBridgeFactory ?? createNoOpGenerationDevtoolsBridge
+ )(this.buildDevtoolsBridgeOptions())
+ }
+
+ private buildDevtoolsBridgeOptions(): GenerationDevtoolsBridgeOptions {
+ return {
+ hookId: this.uniqueId,
+ clientId: this.uniqueId,
+ threadId: this.threadId,
+ metadata: this.devtoolsMetadata,
+ getCoreState: () => ({
+ input: this.input,
+ result: this.result,
+ progress: this.progress,
+ status: this.status,
+ isLoading: this.isLoading,
+ ...(this.error ? { error: this.error.message } : {}),
+ }),
+ }
+ }
+
+ mountDevtools(): void {
+ if (this.devtoolsMounted) {
+ return
+ }
+
+ this.devtoolsMounted = true
+ this.devtoolsBridge.emitRegistered()
+ this.devtoolsBridge.emitSnapshot()
}
/**
@@ -107,8 +158,12 @@ export class GenerationClient<
* while already generating will be a no-op.
*/
async generate(input: TInput): Promise {
+ this.mountDevtools()
if (this.isLoading) return
+ this.input = input
+ this.progress = null
+ const runId = this.devtoolsBridge.beginRun(input)
this.setIsLoading(true)
this.setStatus('generating')
this.setError(undefined)
@@ -124,26 +179,50 @@ export class GenerationClient<
if (signal.aborted) return
if (result instanceof Response) {
// Server function returned SSE Response — parse stream
- await this.processStream(parseSSEResponse(result, signal))
+ await this.processStream(parseSSEResponse(result, signal), runId)
} else {
+ this.devtoolsBridge.ensureRunStarted(runId)
this.setResult(result)
this.setStatus('success')
}
} else if (this.connection) {
// Streaming adapter path
const mergedData = { ...this.body, ...input }
- const stream = this.connection.connect([], mergedData, signal)
- await this.processStream(stream)
+ const stream = this.connection.connect(
+ [],
+ mergedData,
+ signal,
+ this.createRunContext(runId),
+ )
+ await this.processStream(stream, runId)
} else {
throw new Error(
'GenerationClient requires either a connection or fetcher option',
)
}
- } catch (err: any) {
+ if (!signal.aborted && this.status === 'success') {
+ // Bump progress to 100 on successful completion so devtools
+ // snapshots reflect the final state. The bridge mirrors this in
+ // the run's recorded progress, but the snapshot reads `progress`
+ // from the client's core state.
+ this.progress = completeProgressValue(this.progress)
+ this.devtoolsBridge.finishRun(
+ this.devtoolsBridge.getActiveRunId() ?? runId,
+ 'run:completed',
+ 'completed',
+ )
+ }
+ } catch (err: unknown) {
if (signal.aborted) return
const error = err instanceof Error ? err : new Error(String(err))
this.setError(error)
this.setStatus('error')
+ this.devtoolsBridge.finishRun(
+ this.devtoolsBridge.getActiveRunId() ?? runId,
+ 'run:errored',
+ 'errored',
+ error.message,
+ )
this.callbacksRef.onError?.(error)
} finally {
this.abortController = null
@@ -156,15 +235,28 @@ export class GenerationClient<
*/
private async processStream(
source: AsyncIterable,
+ fallbackRunId: string,
): Promise {
+ let streamRunId: string | undefined
+
for await (const chunk of source) {
if (this.abortController?.signal.aborted) break
this.callbacksRef.onChunk?.(chunk)
+ const chunkRunId =
+ 'runId' in chunk && typeof chunk.runId === 'string'
+ ? chunk.runId
+ : undefined
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- AG-UI EventType has ~22 variants; this consumer only handles the subset relevant to generation lifecycle.
switch (chunk.type) {
+ case 'RUN_STARTED': {
+ streamRunId = chunk.runId
+ this.devtoolsBridge.ensureRunStarted(chunk.runId)
+ break
+ }
case 'CUSTOM': {
+ this.devtoolsBridge.ensureRunStarted(streamRunId ?? fallbackRunId)
if (chunk.name === GENERATION_EVENTS.RESULT) {
this.setResult(chunk.value as TResult)
} else if (chunk.name === GENERATION_EVENTS.PROGRESS) {
@@ -172,15 +264,20 @@ export class GenerationClient<
progress: number
message?: string
}
- this.callbacksRef.onProgress?.(progress, message)
+ this.setProgress(progress, message)
}
break
}
case 'RUN_FINISHED': {
+ streamRunId = chunk.runId
+ this.devtoolsBridge.ensureRunStarted(chunk.runId)
this.setStatus('success')
break
}
case 'RUN_ERROR': {
+ this.devtoolsBridge.ensureRunStarted(
+ chunkRunId ?? streamRunId ?? fallbackRunId,
+ )
// Prefer spec `message`; fall back to deprecated `error.message`
const msg =
(chunk.message as string | undefined) ||
@@ -198,6 +295,7 @@ export class GenerationClient<
* Abort any in-flight generation request.
*/
stop(): void {
+ const runId = this.devtoolsBridge.getActiveRunId()
if (this.abortController) {
this.abortController.abort()
this.abortController = null
@@ -205,6 +303,9 @@ export class GenerationClient<
this.setIsLoading(false)
if (this.status === 'generating') {
this.setStatus('idle')
+ if (runId) {
+ this.devtoolsBridge.finishRun(runId, 'run:cancelled', 'cancelled')
+ }
}
}
@@ -214,8 +315,12 @@ export class GenerationClient<
reset(): void {
this.stop()
this.setResult(null)
+ this.input = null
+ this.progress = null
+ this.devtoolsBridge.resetRuns()
this.setError(undefined)
this.setStatus('idle')
+ this.devtoolsBridge.emitState()
}
/**
@@ -246,6 +351,12 @@ export class GenerationClient<
}
}
+ dispose(): void {
+ this.stop()
+ this.devtoolsBridge.dispose()
+ this.devtoolsMounted = false
+ }
+
// ===========================
// Getters
// ===========================
@@ -274,40 +385,96 @@ export class GenerationClient<
if (rawResult === null) {
this.result = null
this.callbacksRef.onResultChange?.(null)
+ this.devtoolsBridge.recordResultChange()
return
}
if (this.callbacksRef.onResult) {
const transformed = this.callbacksRef.onResult(rawResult)
if (transformed === null) {
- // null return → keep previous result unchanged
+ // null return → keep previous result unchanged, just re-emit
+ this.devtoolsBridge.emitState()
return
}
if (transformed !== undefined) {
// Non-null, non-undefined → use transformed value
this.result = transformed
this.callbacksRef.onResultChange?.(this.result)
+ this.devtoolsBridge.recordResultChange()
return
}
}
- // No onResult callback, or callback returned void → use raw value
- this.result = rawResult as TOutput
+ // No onResult callback, or callback returned void → use raw value as
+ // TOutput. When the caller did not supply an onResult transform,
+ // `TOutput` defaults to `TResult`, so the runtime cast is sound.
+ // eslint-disable-next-line no-restricted-syntax -- TOutput defaults to TResult when no onResult transform is supplied
+ this.result = rawResult as unknown as TOutput
this.callbacksRef.onResultChange?.(this.result)
+ this.devtoolsBridge.recordResultChange()
}
private setIsLoading(isLoading: boolean): void {
this.isLoading = isLoading
this.callbacksRef.onLoadingChange?.(isLoading)
+ this.devtoolsBridge.recordLoadingChange()
}
private setError(error: Error | undefined): void {
this.error = error
this.callbacksRef.onErrorChange?.(error)
+ this.devtoolsBridge.recordErrorChange(error)
}
private setStatus(status: GenerationClientState): void {
this.status = status
this.callbacksRef.onStatusChange?.(status)
+ this.devtoolsBridge.recordStatusChange(status)
+ }
+
+ private setProgress(value: number, message?: string): void {
+ this.progress = {
+ value,
+ ...(message ? { message } : {}),
+ }
+ if (message === undefined) {
+ this.callbacksRef.onProgress?.(value)
+ } else {
+ this.callbacksRef.onProgress?.(value, message)
+ }
+ this.devtoolsBridge.recordProgressChange()
+ }
+
+ private createDevtoolsMetadata(
+ metadata?: Partial,
+ ): AIDevtoolsClientMetadata {
+ return {
+ hookName: metadata?.hookName ?? 'useGeneration',
+ ...(metadata?.framework ? { framework: metadata.framework } : {}),
+ ...(metadata?.outputKind ? { outputKind: metadata.outputKind } : {}),
+ ...(metadata?.name ? { name: metadata.name } : {}),
+ }
+ }
+
+ private generateUniqueId(prefix: string): string {
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}`
+ }
+
+ private createRunContext(runId: string): RunAgentInputContext {
+ return {
+ threadId: this.threadId,
+ runId,
+ }
+ }
+}
+
+function completeProgressValue(
+ progress: AIDevtoolsGenerationProgress | null,
+): AIDevtoolsGenerationProgress | null {
+ if (!progress) return null
+ const message = progress.message
+ return {
+ value: 100,
+ ...(message ? { message } : {}),
}
}
diff --git a/packages/ai-client/src/generation-types.ts b/packages/ai-client/src/generation-types.ts
index 88573bd59..347be9d1b 100644
--- a/packages/ai-client/src/generation-types.ts
+++ b/packages/ai-client/src/generation-types.ts
@@ -1,5 +1,10 @@
import type { StreamChunk } from '@tanstack/ai'
import type { ConnectConnectionAdapter } from './connection-adapters'
+import type { AIDevtoolsClientMetadata } from './devtools'
+import type {
+ GenerationDevtoolsBridgeFactory,
+ VideoDevtoolsBridgeFactory,
+} from './devtools-noop'
// ===========================
// Inference Utilities
@@ -106,6 +111,15 @@ export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> {
/** Additional body parameters to send with connect-based adapter requests */
body?: Record
+ /** Metadata used to register this generation hook with TanStack AI Devtools */
+ devtools?: Partial
+
+ /**
+ * Factory that constructs the devtools bridge. Default is a no-op
+ * factory; the real implementation lives in `@tanstack/ai-client/devtools`.
+ */
+ devtoolsBridgeFactory?: GenerationDevtoolsBridgeFactory
+
/**
* Callback when a result is received. Can optionally return a transformed value
* that replaces the stored result.
@@ -172,11 +186,16 @@ export interface VideoGenerateResult {
*/
export interface VideoGenerationClientOptions<
TOutput = VideoGenerateResult,
-> extends GenerationClientOptions<
- VideoGenerateInput,
- VideoGenerateResult,
- TOutput
+> extends Omit<
+ GenerationClientOptions,
+ 'devtoolsBridgeFactory'
> {
+ /**
+ * Factory that constructs the video devtools bridge. Default is a no-op
+ * factory; the real implementation lives in `@tanstack/ai-client/devtools`.
+ */
+ devtoolsBridgeFactory?: VideoDevtoolsBridgeFactory
+
/** Callback when a video job is created */
onJobCreated?: (jobId: string) => void
/** Callback on each status update */
diff --git a/packages/ai-client/src/index.ts b/packages/ai-client/src/index.ts
index b06424071..1a673704f 100644
--- a/packages/ai-client/src/index.ts
+++ b/packages/ai-client/src/index.ts
@@ -44,6 +44,15 @@ export type {
} from './generation-types'
export { GENERATION_EVENTS } from './generation-types'
export { clientTools, createChatClientOptions } from './types'
+export {
+ createAIDevtoolsGenerationPreview,
+ type AIDevtoolsClientMetadata,
+ type AIDevtoolsDisplayOptions,
+ type AIDevtoolsGenerationMediaItem,
+ type AIDevtoolsGenerationPreview,
+ type AIDevtoolsGenerationProgress,
+ type AIDevtoolsGenerationVideoJob,
+} from './devtools'
export type {
ExtractToolNames,
ExtractToolInput,
diff --git a/packages/ai-client/src/types.ts b/packages/ai-client/src/types.ts
index e05062c07..4175d2243 100644
--- a/packages/ai-client/src/types.ts
+++ b/packages/ai-client/src/types.ts
@@ -13,6 +13,8 @@ import type {
VideoPart,
} from '@tanstack/ai'
import type { ConnectionAdapter } from './connection-adapters'
+import type { AIDevtoolsClientMetadata } from './devtools'
+import type { ChatDevtoolsBridgeFactory } from './devtools-noop'
export type { StructuredOutputPart } from '@tanstack/ai'
@@ -270,6 +272,7 @@ export interface UIMessage<
*/
export type ChatClientOptions<
TTools extends ReadonlyArray = any,
+ TContext = unknown,
> = {
/**
* Initial messages to populate the chat
@@ -387,6 +390,25 @@ export type ChatClientOptions<
*/
tools?: TTools
+ /**
+ * Client-local context passed to client-side tool execution.
+ */
+ context?: TContext
+
+ /**
+ * Devtools hook metadata for this client instance.
+ */
+ devtools?: Partial
+
+ /**
+ * Factory that constructs the devtools bridge. Default is a no-op
+ * factory, which keeps `@tanstack/ai-client/devtools` (the heavy
+ * bridge implementation) out of the main entry's bundle. Frameworks
+ * that need live devtools should pass the real factory from
+ * `@tanstack/ai-client/devtools`.
+ */
+ devtoolsBridgeFactory?: ChatDevtoolsBridgeFactory
+
/**
* Stream processing options (optional)
* Configure chunking strategy
@@ -444,7 +466,10 @@ export function clientTools>(
*/
export function createChatClientOptions<
const TTools extends ReadonlyArray,
->(options: ChatClientOptions): ChatClientOptions {
+ TContext = unknown,
+>(
+ options: ChatClientOptions,
+): ChatClientOptions {
return options
}
@@ -463,4 +488,6 @@ export function createChatClientOptions<
* ```
*/
export type InferChatMessages =
- T extends ChatClientOptions ? Array> : never
+ T extends ChatClientOptions
+ ? Array>
+ : never
diff --git a/packages/ai-client/src/video-generation-client.ts b/packages/ai-client/src/video-generation-client.ts
index ae5765894..d61e4b0b3 100644
--- a/packages/ai-client/src/video-generation-client.ts
+++ b/packages/ai-client/src/video-generation-client.ts
@@ -1,7 +1,17 @@
import { GENERATION_EVENTS } from './generation-types'
+import { createNoOpVideoDevtoolsBridge } from './devtools-noop'
import { parseSSEResponse } from './sse-parser'
import type { StreamChunk } from '@tanstack/ai'
-import type { ConnectConnectionAdapter } from './connection-adapters'
+import type {
+ ConnectConnectionAdapter,
+ RunAgentInputContext,
+} from './connection-adapters'
+import type {
+ AIDevtoolsClientMetadata,
+ AIDevtoolsGenerationProgress,
+ VideoDevtoolsBridge,
+ VideoDevtoolsBridgeOptions,
+} from './devtools'
import type {
GenerationClientState,
GenerationFetcher,
@@ -74,9 +84,15 @@ export class VideoGenerationClient {
private readonly fetcher:
| GenerationFetcher
| undefined
+ private readonly uniqueId: string
+ private readonly devtoolsMetadata: AIDevtoolsClientMetadata
+ private readonly devtoolsBridge: VideoDevtoolsBridge
+ private readonly threadId: string
private body: Record
private result: TOutput | null = null
+ private input: VideoGenerateInput | null = null
+ private progress: AIDevtoolsGenerationProgress | null = null
private jobId: string | null = null
private videoStatus: VideoStatusInfo | null = null
private isLoading = false
@@ -84,6 +100,7 @@ export class VideoGenerationClient {
private status: GenerationClientState = 'idle'
private abortController: AbortController | null = null
private readonly callbacksRef: VideoCallbacks
+ private devtoolsMounted = false
constructor(
options: VideoGenerationClientOptions &
@@ -95,6 +112,8 @@ export class VideoGenerationClient {
}
),
) {
+ this.uniqueId = options.id ?? this.generateUniqueId('video')
+ this.threadId = this.uniqueId
this.connection = options.connection
this.fetcher = options.fetcher
this.body = options.body ?? {}
@@ -113,6 +132,40 @@ export class VideoGenerationClient {
onJobIdChange: options.onJobIdChange,
onVideoStatusChange: options.onVideoStatusChange,
}
+
+ this.devtoolsMetadata = this.createDevtoolsMetadata(options.devtools)
+ this.devtoolsBridge = (
+ options.devtoolsBridgeFactory ?? createNoOpVideoDevtoolsBridge
+ )(this.buildDevtoolsBridgeOptions())
+ }
+
+ private buildDevtoolsBridgeOptions(): VideoDevtoolsBridgeOptions {
+ return {
+ hookId: this.uniqueId,
+ clientId: this.uniqueId,
+ threadId: this.threadId,
+ metadata: this.devtoolsMetadata,
+ getCoreState: () => ({
+ input: this.input,
+ result: this.result,
+ progress: this.progress,
+ status: this.status,
+ isLoading: this.isLoading,
+ jobId: this.jobId,
+ videoStatus: this.videoStatus,
+ ...(this.error ? { error: this.error.message } : {}),
+ }),
+ }
+ }
+
+ mountDevtools(): void {
+ if (this.devtoolsMounted) {
+ return
+ }
+
+ this.devtoolsMounted = true
+ this.devtoolsBridge.emitRegistered()
+ this.devtoolsBridge.emitSnapshot()
}
/**
@@ -120,8 +173,12 @@ export class VideoGenerationClient {
* Only one generation can be in-flight at a time.
*/
async generate(input: VideoGenerateInput): Promise {
+ this.mountDevtools()
if (this.isLoading) return
+ this.input = input
+ this.progress = null
+ const runId = this.devtoolsBridge.beginRun(input)
this.setIsLoading(true)
this.setStatus('generating')
this.setError(undefined)
@@ -134,21 +191,39 @@ export class VideoGenerationClient {
try {
if (this.fetcher) {
- await this.generateWithFetcher(input, signal)
+ await this.generateWithFetcher(input, signal, runId)
} else if (this.connection) {
const mergedData = { ...this.body, ...input }
- const stream = this.connection.connect([], mergedData, signal)
- await this.processStream(stream)
+ const stream = this.connection.connect(
+ [],
+ mergedData,
+ signal,
+ this.createRunContext(runId),
+ )
+ await this.processStream(stream, runId)
} else {
throw new Error(
'VideoGenerationClient requires either a connection or fetcher option',
)
}
- } catch (err: any) {
+ if (!signal.aborted && this.status === 'success') {
+ this.devtoolsBridge.finishRun(
+ this.devtoolsBridge.getActiveRunId() ?? runId,
+ 'run:completed',
+ 'completed',
+ )
+ }
+ } catch (err: unknown) {
if (signal.aborted) return
const error = err instanceof Error ? err : new Error(String(err))
this.setError(error)
this.setStatus('error')
+ this.devtoolsBridge.finishRun(
+ this.devtoolsBridge.getActiveRunId() ?? runId,
+ 'run:errored',
+ 'errored',
+ error.message,
+ )
this.callbacksRef.onError?.(error)
} finally {
this.abortController = null
@@ -162,6 +237,7 @@ export class VideoGenerationClient {
private async generateWithFetcher(
input: VideoGenerateInput,
signal: AbortSignal,
+ runId: string,
): Promise {
if (!this.fetcher) return
@@ -171,8 +247,9 @@ export class VideoGenerationClient {
if (result instanceof Response) {
// Server function returned SSE Response — parse stream
- await this.processStream(parseSSEResponse(result, signal))
+ await this.processStream(parseSSEResponse(result, signal), runId)
} else {
+ this.devtoolsBridge.ensureRunStarted(runId)
this.setResult(result)
this.setStatus('success')
}
@@ -184,15 +261,28 @@ export class VideoGenerationClient {
*/
private async processStream(
source: AsyncIterable,
+ fallbackRunId: string,
): Promise {
+ let streamRunId: string | undefined
+
for await (const chunk of source) {
if (this.abortController?.signal.aborted) break
this.callbacksRef.onChunk?.(chunk)
+ const chunkRunId =
+ 'runId' in chunk && typeof chunk.runId === 'string'
+ ? chunk.runId
+ : undefined
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- AG-UI EventType has ~22 variants; this consumer only handles the subset relevant to video generation lifecycle.
switch (chunk.type) {
+ case 'RUN_STARTED': {
+ streamRunId = chunk.runId
+ this.devtoolsBridge.ensureRunStarted(chunk.runId)
+ break
+ }
case 'CUSTOM': {
+ this.devtoolsBridge.ensureRunStarted(streamRunId ?? fallbackRunId)
if (chunk.name === GENERATION_EVENTS.VIDEO_JOB_CREATED) {
const { jobId } = chunk.value as { jobId: string }
this.setJobId(jobId)
@@ -202,7 +292,7 @@ export class VideoGenerationClient {
this.setVideoStatus(statusInfo)
this.callbacksRef.onStatusUpdate?.(statusInfo)
if (statusInfo.progress !== undefined) {
- this.callbacksRef.onProgress?.(statusInfo.progress)
+ this.setProgress(statusInfo.progress)
}
} else if (chunk.name === GENERATION_EVENTS.RESULT) {
this.setResult(chunk.value as VideoGenerateResult)
@@ -211,15 +301,20 @@ export class VideoGenerationClient {
progress: number
message?: string
}
- this.callbacksRef.onProgress?.(progress, message)
+ this.setProgress(progress, message)
}
break
}
case 'RUN_FINISHED': {
+ streamRunId = chunk.runId
+ this.devtoolsBridge.ensureRunStarted(chunk.runId)
this.setStatus('success')
break
}
case 'RUN_ERROR': {
+ this.devtoolsBridge.ensureRunStarted(
+ chunkRunId ?? streamRunId ?? fallbackRunId,
+ )
// Prefer spec `message`; fall back to deprecated `error.message`
const msg =
(chunk.message as string | undefined) ||
@@ -237,6 +332,7 @@ export class VideoGenerationClient {
* Abort any in-flight generation or polling.
*/
stop(): void {
+ const runId = this.devtoolsBridge.getActiveRunId()
if (this.abortController) {
this.abortController.abort()
this.abortController = null
@@ -244,6 +340,9 @@ export class VideoGenerationClient {
this.setIsLoading(false)
if (this.status === 'generating') {
this.setStatus('idle')
+ if (runId) {
+ this.devtoolsBridge.finishRun(runId, 'run:cancelled', 'cancelled')
+ }
}
}
@@ -253,10 +352,14 @@ export class VideoGenerationClient {
reset(): void {
this.stop()
this.setResult(null)
+ this.input = null
+ this.progress = null
+ this.devtoolsBridge.resetRuns()
this.setJobId(null)
this.setVideoStatus(null)
this.setError(undefined)
this.setStatus('idle')
+ this.devtoolsBridge.emitState()
}
/**
@@ -299,6 +402,12 @@ export class VideoGenerationClient {
}
}
+ dispose(): void {
+ this.stop()
+ this.devtoolsBridge.dispose()
+ this.devtoolsMounted = false
+ }
+
// ===========================
// Getters
// ===========================
@@ -335,50 +444,116 @@ export class VideoGenerationClient {
if (rawResult === null) {
this.result = null
this.callbacksRef.onResultChange?.(null)
+ this.devtoolsBridge.recordResultChange()
return
}
+ const completedStatus = this.createCompletedVideoStatus(rawResult)
+ if (this.progress?.value !== 100) {
+ this.setProgress(100, this.progress?.message)
+ }
+ this.setJobId(rawResult.jobId)
+ this.setVideoStatus(completedStatus)
+
if (this.callbacksRef.onResult) {
const transformed = this.callbacksRef.onResult(rawResult)
if (transformed === null) {
- // null return → keep previous result unchanged
+ // null return → keep previous result unchanged, just re-emit
+ this.devtoolsBridge.emitState()
return
}
if (transformed !== undefined) {
// Non-null, non-undefined → use transformed value
this.result = transformed
this.callbacksRef.onResultChange?.(this.result)
+ this.devtoolsBridge.recordResultChange()
return
}
}
- // No onResult callback, or callback returned void → use raw value
- this.result = rawResult as TOutput
+ // No onResult callback, or callback returned void → use raw value as
+ // TOutput. When the caller did not supply an onResult transform,
+ // `TOutput` defaults to `VideoGenerateResult`, so the runtime cast is
+ // sound.
+ // eslint-disable-next-line no-restricted-syntax -- TOutput defaults to VideoGenerateResult when no onResult transform is supplied
+ this.result = rawResult as unknown as TOutput
this.callbacksRef.onResultChange?.(this.result)
+ this.devtoolsBridge.recordResultChange()
}
private setJobId(jobId: string | null): void {
this.jobId = jobId
this.callbacksRef.onJobIdChange?.(jobId)
+ this.devtoolsBridge.recordJobIdChange()
}
private setVideoStatus(status: VideoStatusInfo | null): void {
this.videoStatus = status
this.callbacksRef.onVideoStatusChange?.(status)
+ this.devtoolsBridge.recordVideoStatusChange()
}
private setIsLoading(isLoading: boolean): void {
this.isLoading = isLoading
this.callbacksRef.onLoadingChange?.(isLoading)
+ this.devtoolsBridge.recordLoadingChange()
}
private setError(error: Error | undefined): void {
this.error = error
this.callbacksRef.onErrorChange?.(error)
+ this.devtoolsBridge.recordErrorChange(error)
}
private setStatus(status: GenerationClientState): void {
this.status = status
this.callbacksRef.onStatusChange?.(status)
+ this.devtoolsBridge.recordStatusChange(status)
+ }
+
+ private setProgress(value: number, message?: string): void {
+ this.progress = {
+ value,
+ ...(message ? { message } : {}),
+ }
+ if (message === undefined) {
+ this.callbacksRef.onProgress?.(value)
+ } else {
+ this.callbacksRef.onProgress?.(value, message)
+ }
+ this.devtoolsBridge.recordProgressChange()
+ }
+
+ private createCompletedVideoStatus(
+ result: VideoGenerateResult,
+ ): VideoStatusInfo {
+ return {
+ jobId: result.jobId,
+ status: result.status,
+ progress: 100,
+ url: result.url,
+ }
+ }
+
+ private createDevtoolsMetadata(
+ metadata?: Partial,
+ ): AIDevtoolsClientMetadata {
+ return {
+ hookName: metadata?.hookName ?? 'useGenerateVideo',
+ outputKind: metadata?.outputKind ?? 'video',
+ ...(metadata?.framework ? { framework: metadata.framework } : {}),
+ ...(metadata?.name ? { name: metadata.name } : {}),
+ }
+ }
+
+ private generateUniqueId(prefix: string): string {
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}`
+ }
+
+ private createRunContext(runId: string): RunAgentInputContext {
+ return {
+ threadId: this.threadId,
+ runId,
+ }
}
}
diff --git a/packages/ai-client/tests/devtools.test.ts b/packages/ai-client/tests/devtools.test.ts
new file mode 100644
index 000000000..3b1585340
--- /dev/null
+++ b/packages/ai-client/tests/devtools.test.ts
@@ -0,0 +1,1702 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { EventType, toolDefinition } from '@tanstack/ai'
+import { aiEventClient } from '@tanstack/ai-event-client'
+import { z } from 'zod'
+import { ChatClient } from '../src/chat-client'
+import {
+ createMockConnectionAdapter,
+ createTextChunks,
+ createToolCallChunks,
+} from './test-utils'
+import type { AnyClientTool, StreamChunk } from '@tanstack/ai'
+import type {
+ ConnectConnectionAdapter,
+ RunAgentInputContext,
+} from '../src/connection-adapters'
+import type { AIDevtoolsToolFixture } from '../src/devtools'
+import type { MessagePart, UIMessage } from '../src/types'
+
+interface DevtoolsEvent {
+ type: string
+ payload: TPayload
+ pluginId?: string
+}
+
+type DevtoolsEventCallback = (event: DevtoolsEvent) => void
+
+const eventClientMock = vi.hoisted(() => {
+ const listeners = new Map>()
+ const unsubscribe = vi.fn()
+
+ return {
+ emit: vi.fn(),
+ emitAIDevtoolsEvent: vi.fn((eventName: string, payload: unknown) => {
+ eventClientMock.emit(eventName, payload)
+ }),
+ unsubscribe,
+ on: vi.fn((eventName: string, callback: DevtoolsEventCallback) => {
+ const currentListeners = listeners.get(eventName) ?? []
+ currentListeners.push(callback)
+ listeners.set(eventName, currentListeners)
+
+ return () => {
+ unsubscribe()
+ const nextListeners = (listeners.get(eventName) ?? []).filter(
+ (listener) => listener !== callback,
+ )
+ listeners.set(eventName, nextListeners)
+ }
+ }),
+ dispatch(eventName: string, payload: unknown) {
+ for (const listener of listeners.get(eventName) ?? []) {
+ listener({
+ type: `tanstack-ai-devtools:${eventName}`,
+ payload,
+ pluginId: 'tanstack-ai-devtools',
+ })
+ }
+ },
+ emitted(eventName: string) {
+ return eventClientMock.emit.mock.calls.filter(
+ ([name]) => name === eventName,
+ )
+ },
+ reset() {
+ listeners.clear()
+ unsubscribe.mockClear()
+ eventClientMock.emitAIDevtoolsEvent.mockClear()
+ },
+ }
+})
+
+vi.mock('@tanstack/ai-event-client', () => ({
+ aiEventClient: {
+ emit: eventClientMock.emit,
+ on: eventClientMock.on,
+ },
+ emitAIDevtoolsEvent: eventClientMock.emitAIDevtoolsEvent,
+ createAIDevtoolsEventEnvelope: (input: {
+ eventType: string
+ timestamp: number
+ }) => ({
+ ...input,
+ eventId: `event:${input.eventType}:${input.timestamp}`,
+ }),
+}))
+
+describe('ChatClient devtools bridge', () => {
+ const userMessage: UIMessage = {
+ id: 'msg-user',
+ role: 'user',
+ parts: [{ type: 'text', content: 'Hello' }],
+ }
+
+ const assistantMessage: UIMessage = {
+ id: 'msg-assistant',
+ role: 'assistant',
+ parts: [{ type: 'text', content: 'Hi' }],
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ eventClientMock.reset()
+ })
+
+ function createClient(options?: {
+ id?: string
+ threadId?: string
+ connection?: ConnectConnectionAdapter
+ tools?: ReadonlyArray
+ initialMessages?: Array
+ mountDevtools?: boolean
+ devtoolsName?: string
+ }) {
+ const client = new ChatClient({
+ id: options?.id ?? 'chat-1',
+ threadId: options?.threadId ?? 'thread-1',
+ connection: options?.connection ?? createMockConnectionAdapter(),
+ ...(options?.tools ? { tools: options.tools } : {}),
+ ...(options?.initialMessages
+ ? { initialMessages: options.initialMessages }
+ : {}),
+ devtools: {
+ ...(options?.devtoolsName ? { name: options.devtoolsName } : {}),
+ framework: 'react',
+ hookName: 'useChat',
+ },
+ })
+ if (options?.mountDevtools ?? true) {
+ client.mountDevtools()
+ }
+ return client
+ }
+
+ function createRunTrackingAdapter(
+ chunkSets: Array>,
+ runContexts: Array,
+ ): ConnectConnectionAdapter {
+ let connectCount = 0
+ return {
+ async *connect(_messages, _data, abortSignal, runContext) {
+ if (runContext) {
+ runContexts.push(runContext)
+ }
+ const chunks = chunkSets[connectCount] ?? []
+ connectCount++
+ for (const chunk of chunks) {
+ if (abortSignal?.aborted) {
+ return
+ }
+ yield chunk
+ }
+ },
+ }
+ }
+
+ function textContentChunk(args: {
+ messageId: string
+ delta: string
+ content: string
+ }) {
+ return {
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId: args.messageId,
+ timestamp: Date.now(),
+ delta: args.delta,
+ content: args.content,
+ } satisfies StreamChunk
+ }
+
+ function dispatchToolFixture(overrides: Partial = {}) {
+ const fixture: AIDevtoolsToolFixture = {
+ hookId: 'chat-1',
+ threadId: 'thread-1',
+ runId: 'run-fixture',
+ toolName: 'weather',
+ input: { city: 'Paris' },
+ output: { temperature: 21 },
+ toolCallId: 'fixture-call',
+ messageId: 'fixture-message',
+ ...overrides,
+ }
+
+ eventClientMock.dispatch('devtools:tool-fixture:apply', fixture)
+ return fixture
+ }
+
+ function latestSnapshotMessages(): Array {
+ const latestSnapshot = eventClientMock
+ .emitted('hook:state-snapshot')
+ .at(-1)?.[1] as { state?: { messages?: Array } } | undefined
+ return latestSnapshot?.state?.messages ?? []
+ }
+
+ function findToolCallPart(messages: Array, toolCallId: string) {
+ return messages
+ .flatMap((message) => message.parts)
+ .find(
+ (part): part is Extract =>
+ part.type === 'tool-call' && part.id === toolCallId,
+ )
+ }
+
+ function findStructuredOutputPart(
+ messages: Array,
+ messageId: string,
+ ) {
+ return messages
+ .find((message) => message.id === messageId)
+ ?.parts.find(
+ (part): part is Extract =>
+ part.type === 'structured-output',
+ )
+ }
+
+ function runStartedChunk(args: { threadId: string; runId: string }) {
+ return {
+ type: EventType.RUN_STARTED,
+ threadId: args.threadId,
+ runId: args.runId,
+ timestamp: Date.now(),
+ } satisfies StreamChunk
+ }
+
+ function runFinishedChunk(args: { threadId: string; runId: string }) {
+ return {
+ type: EventType.RUN_FINISHED,
+ threadId: args.threadId,
+ runId: args.runId,
+ timestamp: Date.now(),
+ finishReason: 'stop',
+ } satisfies StreamChunk
+ }
+
+ async function waitForCondition(assertion: () => boolean) {
+ for (let attempt = 0; attempt < 50; attempt++) {
+ if (assertion()) {
+ return
+ }
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ }
+ throw new Error('Timed out waiting for condition')
+ }
+
+ it('does not emit hook lifecycle events before devtools is mounted', () => {
+ const client = createClient({ mountDevtools: false })
+
+ expect(eventClientMock.emitted('hook:registered')).toEqual([])
+ expect(eventClientMock.emitted('hook:state-snapshot')).toEqual([])
+
+ client.dispose()
+
+ expect(eventClientMock.emitted('hook:unregistered')).toEqual([])
+ })
+
+ it('can register again after a mount cleanup cycle', () => {
+ const client = createClient({ mountDevtools: false })
+
+ client.mountDevtools()
+ client.dispose()
+ vi.clearAllMocks()
+
+ client.mountDevtools()
+
+ expect(eventClientMock.emitted('hook:registered')).toEqual([
+ [
+ 'hook:registered',
+ expect.objectContaining({
+ hookId: 'chat-1',
+ lifecycle: 'mounted',
+ }),
+ ],
+ ])
+
+ client.dispose()
+ })
+
+ it('registers the chat hook and emits the initial state snapshot', () => {
+ const client = createClient()
+
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'hook:registered',
+ expect.objectContaining({
+ eventId: expect.any(String),
+ eventType: 'hook:registered',
+ timestamp: expect.any(Number),
+ source: 'client',
+ visibility: 'client-state',
+ hookId: 'chat-1',
+ clientId: 'chat-1',
+ threadId: 'thread-1',
+ hookName: 'useChat',
+ framework: 'react',
+ outputKind: 'chat',
+ lifecycle: 'mounted',
+ }),
+ )
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'hook:state-snapshot',
+ expect.objectContaining({
+ eventId: expect.any(String),
+ eventType: 'hook:state-snapshot',
+ source: 'client',
+ visibility: 'client-state',
+ hookId: 'chat-1',
+ clientId: 'chat-1',
+ threadId: 'thread-1',
+ hookName: 'useChat',
+ framework: 'react',
+ outputKind: 'chat',
+ state: expect.objectContaining({
+ messages: [],
+ status: 'ready',
+ isLoading: false,
+ activeRunIds: [],
+ }),
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('emits the configured devtools display name', () => {
+ const client = createClient({ devtoolsName: 'Recipe Assistant' })
+
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'hook:registered',
+ expect.objectContaining({
+ hookId: 'chat-1',
+ hookName: 'useChat',
+ displayName: 'Recipe Assistant',
+ }),
+ )
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'hook:state-snapshot',
+ expect.objectContaining({
+ hookId: 'chat-1',
+ hookName: 'useChat',
+ displayName: 'Recipe Assistant',
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('registers client tool metadata for devtools discovery', () => {
+ const weather = toolDefinition({
+ name: 'weather',
+ description: 'Lookup weather',
+ needsApproval: true,
+ metadata: { fixture: true },
+ inputSchema: z.object({ city: z.string() }),
+ outputSchema: z.object({ temperature: z.number() }),
+ }).client()
+
+ const client = createClient({ tools: [weather] })
+
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'tools:registered',
+ expect.objectContaining({
+ hookId: 'chat-1',
+ hookName: 'useChat',
+ framework: 'react',
+ outputKind: 'chat',
+ tools: [
+ expect.objectContaining({
+ name: 'weather',
+ description: 'Lookup weather',
+ inputSchema: expect.objectContaining({ type: 'object' }),
+ outputSchema: expect.objectContaining({ type: 'object' }),
+ needsApproval: true,
+ metadata: { fixture: true },
+ }),
+ ],
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('responds to devtools state requests for its hook', () => {
+ const client = createClient()
+ vi.clearAllMocks()
+
+ eventClientMock.dispatch('devtools:request-state', {
+ targetHookId: 'chat-1',
+ })
+
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'hook:registered',
+ expect.objectContaining({
+ hookId: 'chat-1',
+ clientId: 'chat-1',
+ threadId: 'thread-1',
+ hookName: 'useChat',
+ outputKind: 'chat',
+ }),
+ )
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'tools:registered',
+ expect.objectContaining({
+ hookId: 'chat-1',
+ tools: [],
+ }),
+ )
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'hook:state-snapshot',
+ expect.objectContaining({
+ hookId: 'chat-1',
+ clientId: 'chat-1',
+ threadId: 'thread-1',
+ state: expect.objectContaining({
+ messages: [],
+ status: 'ready',
+ isLoading: false,
+ activeRunIds: [],
+ }),
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('uses the resilient devtools emitter for hook registration sync', () => {
+ const client = createClient()
+
+ expect(eventClientMock.emitAIDevtoolsEvent).toHaveBeenCalledWith(
+ 'hook:registered',
+ expect.objectContaining({
+ hookId: 'chat-1',
+ threadId: 'thread-1',
+ hookName: 'useChat',
+ lifecycle: 'mounted',
+ }),
+ )
+
+ eventClientMock.emitAIDevtoolsEvent.mockClear()
+ eventClientMock.dispatch('devtools:request-state', {
+ targetHookId: 'chat-1',
+ })
+
+ expect(eventClientMock.emitAIDevtoolsEvent).toHaveBeenCalledWith(
+ 'hook:registered',
+ expect.objectContaining({
+ hookId: 'chat-1',
+ threadId: 'thread-1',
+ hookName: 'useChat',
+ lifecycle: 'mounted',
+ }),
+ )
+ expect(eventClientMock.emitAIDevtoolsEvent).toHaveBeenCalledWith(
+ 'hook:state-snapshot',
+ expect.objectContaining({
+ hookId: 'chat-1',
+ threadId: 'thread-1',
+ state: expect.objectContaining({
+ messages: [],
+ }),
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('does not respond to devtools state requests for another hook', () => {
+ const client = createClient()
+ vi.clearAllMocks()
+
+ eventClientMock.dispatch('devtools:request-state', {
+ targetHookId: 'other-hook',
+ })
+
+ expect(aiEventClient.emit).not.toHaveBeenCalledWith(
+ 'hook:registered',
+ expect.anything(),
+ )
+ expect(aiEventClient.emit).not.toHaveBeenCalledWith(
+ 'hook:state-snapshot',
+ expect.anything(),
+ )
+
+ client.dispose()
+ })
+
+ it('applies a devtools tool fixture as a normal assistant message', async () => {
+ const client = createClient()
+ vi.clearAllMocks()
+
+ const fixture = dispatchToolFixture()
+
+ await waitForCondition(() => client.getMessages().length === 1)
+ const messages = client.getMessages()
+
+ expect(messages).toEqual([
+ expect.objectContaining({
+ id: 'fixture-message',
+ role: 'assistant',
+ parts: [
+ {
+ type: 'tool-call',
+ id: 'fixture-call',
+ name: 'weather',
+ arguments: '{"city":"Paris"}',
+ input: { city: 'Paris' },
+ state: 'input-complete',
+ output: { temperature: 21 },
+ },
+ {
+ type: 'tool-result',
+ toolCallId: 'fixture-call',
+ content: '{"temperature":21}',
+ state: 'complete',
+ },
+ ],
+ createdAt: expect.any(Date),
+ }),
+ ])
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'devtools:tool-fixture:applied',
+ expect.objectContaining({
+ hookId: 'chat-1',
+ threadId: 'thread-1',
+ runId: 'run-fixture',
+ toolName: fixture.toolName,
+ input: fixture.input,
+ output: fixture.output,
+ messageId: 'fixture-message',
+ toolCallId: 'fixture-call',
+ visibility: 'user-visible',
+ }),
+ )
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'text:message:created',
+ expect.objectContaining({
+ hookId: 'chat-1',
+ threadId: 'thread-1',
+ runId: 'run-fixture',
+ toolCallId: 'fixture-call',
+ messageId: 'fixture-message',
+ role: 'assistant',
+ parts: messages[0]?.parts,
+ visibility: 'user-visible',
+ }),
+ )
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'hook:state-snapshot',
+ expect.objectContaining({
+ state: expect.objectContaining({
+ messages,
+ }),
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('generates fresh ids when replaying a fixture from an existing tool call', async () => {
+ const existingMessage: UIMessage = {
+ id: 'fixture-message',
+ role: 'assistant',
+ parts: [
+ {
+ type: 'tool-call',
+ id: 'fixture-call',
+ name: 'weather',
+ arguments: '{"city":"Paris"}',
+ input: { city: 'Paris' },
+ state: 'input-complete',
+ output: { temperature: 21 },
+ },
+ {
+ type: 'tool-result',
+ toolCallId: 'fixture-call',
+ content: '{"temperature":21}',
+ state: 'complete',
+ },
+ ],
+ }
+ const client = createClient({ initialMessages: [existingMessage] })
+ vi.clearAllMocks()
+
+ dispatchToolFixture()
+
+ await waitForCondition(() => client.getMessages().length === 2)
+ const replayedMessage = client.getMessages()[1]
+ const replayedToolCall = replayedMessage?.parts?.[0]
+ const replayedToolResult = replayedMessage?.parts?.[1]
+
+ expect(replayedMessage?.id).not.toBe('fixture-message')
+ expect(replayedToolCall).toEqual(
+ expect.objectContaining({
+ type: 'tool-call',
+ name: 'weather',
+ input: { city: 'Paris' },
+ }),
+ )
+ expect(replayedToolCall).toEqual(
+ expect.objectContaining({
+ id: expect.not.stringMatching(/^fixture-call$/),
+ }),
+ )
+ expect(replayedToolResult).toEqual(
+ expect.objectContaining({
+ type: 'tool-result',
+ toolCallId: (replayedToolCall as { id: string }).id,
+ }),
+ )
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'text:message:created',
+ expect.objectContaining({
+ messageId: replayedMessage?.id,
+ toolCallId: (replayedToolCall as { id: string }).id,
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('replays the full source message when a fixture includes one', async () => {
+ const existingMessage: UIMessage = {
+ id: 'source-message',
+ role: 'assistant',
+ parts: [
+ { type: 'thinking', content: 'Need to inspect the catalog.' },
+ {
+ type: 'tool-call',
+ id: 'source-tool-call',
+ name: 'weather',
+ arguments: '{"city":"Paris"}',
+ input: { city: 'Paris' },
+ state: 'input-complete',
+ },
+ {
+ type: 'tool-result',
+ toolCallId: 'source-tool-call',
+ content: '{"temperature":21}',
+ state: 'complete',
+ },
+ { type: 'text', content: 'Paris is mild today.' },
+ ],
+ }
+ const client = createClient({ initialMessages: [existingMessage] })
+ vi.clearAllMocks()
+
+ dispatchToolFixture({
+ messageId: existingMessage.id,
+ toolCallId: 'source-tool-call',
+ message: {
+ id: existingMessage.id,
+ role: existingMessage.role,
+ parts: existingMessage.parts,
+ },
+ })
+
+ await waitForCondition(() => client.getMessages().length === 2)
+ const replayedMessage = client.getMessages()[1]
+ const replayedToolCall = replayedMessage?.parts.find(
+ (part) => part.type === 'tool-call',
+ )
+ const replayedToolResult = replayedMessage?.parts.find(
+ (part) => part.type === 'tool-result',
+ )
+
+ expect(replayedMessage?.id).not.toBe(existingMessage.id)
+ expect(replayedMessage?.role).toBe(existingMessage.role)
+ expect(replayedMessage?.parts).toEqual([
+ { type: 'thinking', content: 'Need to inspect the catalog.' },
+ expect.objectContaining({
+ type: 'tool-call',
+ id: expect.not.stringMatching(/^source-tool-call$/),
+ name: 'weather',
+ input: { city: 'Paris' },
+ output: { temperature: 21 },
+ }),
+ expect.objectContaining({
+ type: 'tool-result',
+ toolCallId: (replayedToolCall as { id: string }).id,
+ content: '{"temperature":21}',
+ }),
+ { type: 'text', content: 'Paris is mild today.' },
+ ])
+ expect(replayedToolResult).toEqual(
+ expect.objectContaining({
+ toolCallId: (replayedToolCall as { id: string }).id,
+ }),
+ )
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'text:message:created',
+ expect.objectContaining({
+ messageId: replayedMessage?.id,
+ toolCallId: (replayedToolCall as { id: string }).id,
+ parts: replayedMessage?.parts,
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('executes the registered client tool when firing a fixture', async () => {
+ const execute = vi.fn((input: { city: string }) => ({
+ city: input.city,
+ temperature: 23,
+ }))
+ const weatherTool = toolDefinition({
+ name: 'weather',
+ description: 'Get the weather for a city',
+ inputSchema: z.object({ city: z.string() }),
+ outputSchema: z.object({
+ city: z.string(),
+ temperature: z.number(),
+ }),
+ }).client(execute)
+ const client = createClient({ tools: [weatherTool] })
+ vi.clearAllMocks()
+
+ dispatchToolFixture({
+ input: { city: 'Berlin' },
+ output: null,
+ execute: true,
+ })
+
+ await waitForCondition(
+ () =>
+ client
+ .getMessages()[0]
+ ?.parts.some(
+ (part) =>
+ part.type === 'tool-call' &&
+ part.name === 'weather' &&
+ part.output !== undefined,
+ ) ?? false,
+ )
+
+ const message = client.getMessages()[0]
+ const toolCall = message?.parts.find((part) => part.type === 'tool-call')
+ const toolResult = message?.parts.find(
+ (part) => part.type === 'tool-result',
+ )
+
+ expect(execute).toHaveBeenCalledWith({ city: 'Berlin' })
+ expect(toolCall).toEqual(
+ expect.objectContaining({
+ type: 'tool-call',
+ name: 'weather',
+ input: { city: 'Berlin' },
+ output: { city: 'Berlin', temperature: 23 },
+ }),
+ )
+ expect(toolResult).toEqual(
+ expect.objectContaining({
+ type: 'tool-result',
+ content: '{"city":"Berlin","temperature":23}',
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('routes hook-scoped fixture events to the latest bridge for a hook id', async () => {
+ const staleClient = createClient({ threadId: 'thread-stale' })
+ const activeClient = createClient({ threadId: 'thread-active' })
+ vi.clearAllMocks()
+
+ eventClientMock.dispatch('devtools:tool-fixture:apply', {
+ hookId: 'chat-1',
+ threadId: 'thread-stale',
+ toolName: 'weather',
+ input: { city: 'Paris' },
+ output: { temperature: 21 },
+ toolCallId: 'fixture-call',
+ messageId: 'fixture-message',
+ } satisfies AIDevtoolsToolFixture)
+
+ await waitForCondition(() => activeClient.getMessages().length === 1)
+
+ expect(staleClient.getMessages()).toEqual([])
+ expect(activeClient.getMessages()).toEqual([
+ expect.objectContaining({
+ id: 'fixture-message',
+ parts: [
+ expect.objectContaining({
+ type: 'tool-call',
+ name: 'weather',
+ output: { temperature: 21 },
+ }),
+ expect.objectContaining({
+ type: 'tool-result',
+ content: '{"temperature":21}',
+ }),
+ ],
+ }),
+ ])
+
+ staleClient.dispose()
+ activeClient.dispose()
+ })
+
+ it('keeps superseded duplicate hook bridges silent when they emit later', async () => {
+ const runContexts: Array = []
+ const firstClient = createClient({
+ threadId: 'thread-first',
+ connection: createRunTrackingAdapter(
+ [createTextChunks('from first', 'msg-first')],
+ runContexts,
+ ),
+ })
+ const duplicateClient = createClient({ threadId: 'thread-duplicate' })
+ vi.clearAllMocks()
+
+ await firstClient.sendMessage('start')
+
+ expect(runContexts[0]).toBeDefined()
+ expect(eventClientMock.emitted('run:created')).toEqual([])
+ expect(eventClientMock.emitted('hook:updated')).toEqual([])
+ expect(eventClientMock.emitted('hook:state-snapshot')).toEqual([])
+
+ firstClient.dispose()
+ expect(eventClientMock.emitted('hook:unregistered')).toEqual([])
+
+ duplicateClient.dispose()
+ expect(eventClientMock.emitted('hook:unregistered')).toEqual([
+ [
+ 'hook:unregistered',
+ expect.objectContaining({
+ hookId: 'chat-1',
+ threadId: 'thread-duplicate',
+ }),
+ ],
+ ])
+ })
+
+ it('includes thread and tool call context when applying a fixture without a run id', async () => {
+ const client = createClient()
+ vi.clearAllMocks()
+
+ const fixture: AIDevtoolsToolFixture = {
+ hookId: 'chat-1',
+ threadId: 'thread-1',
+ toolName: 'weather',
+ input: { city: 'Rome' },
+ output: { temperature: 24 },
+ toolCallId: 'thread-only-call',
+ messageId: 'thread-only-message',
+ }
+ eventClientMock.dispatch('devtools:tool-fixture:apply', fixture)
+
+ await waitForCondition(() => client.getMessages().length === 1)
+
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'text:message:created',
+ expect.objectContaining({
+ threadId: 'thread-1',
+ toolCallId: 'thread-only-call',
+ messageId: 'thread-only-message',
+ visibility: 'user-visible',
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('marks fixture tool results as errored when devtools provides error text', async () => {
+ const client = createClient()
+ vi.clearAllMocks()
+
+ dispatchToolFixture({
+ output: null,
+ errorText: 'Tool failed',
+ })
+
+ await waitForCondition(() => client.getMessages().length === 1)
+
+ expect(client.getMessages()).toEqual([
+ expect.objectContaining({
+ parts: [
+ expect.objectContaining({
+ type: 'tool-call',
+ output: null,
+ }),
+ {
+ type: 'tool-result',
+ toolCallId: 'fixture-call',
+ content: 'null',
+ state: 'error',
+ error: 'Tool failed',
+ },
+ ],
+ }),
+ ])
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'devtools:tool-fixture:applied',
+ expect.objectContaining({
+ errorText: 'Tool failed',
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('ignores devtools tool fixtures for another thread', async () => {
+ const client = createClient()
+ vi.clearAllMocks()
+
+ const fixture: AIDevtoolsToolFixture = {
+ threadId: 'other-thread',
+ toolName: 'weather',
+ input: { city: 'Paris' },
+ output: { temperature: 21 },
+ toolCallId: 'fixture-call',
+ messageId: 'fixture-message',
+ }
+ eventClientMock.dispatch('devtools:tool-fixture:apply', fixture)
+ await new Promise((resolve) => setTimeout(resolve, 0))
+
+ expect(client.getMessages()).toEqual([])
+ expect(aiEventClient.emit).not.toHaveBeenCalledWith(
+ 'devtools:tool-fixture:applied',
+ expect.anything(),
+ )
+
+ client.dispose()
+ })
+
+ it('ignores unscoped devtools tool fixtures', async () => {
+ const client = createClient()
+ vi.clearAllMocks()
+
+ const fixture: AIDevtoolsToolFixture = {
+ toolName: 'weather',
+ input: { city: 'Paris' },
+ output: { temperature: 21 },
+ toolCallId: 'fixture-call',
+ messageId: 'fixture-message',
+ }
+ eventClientMock.dispatch('devtools:tool-fixture:apply', fixture)
+ await new Promise((resolve) => setTimeout(resolve, 0))
+
+ expect(client.getMessages()).toEqual([])
+ expect(aiEventClient.emit).not.toHaveBeenCalledWith(
+ 'devtools:tool-fixture:applied',
+ expect.anything(),
+ )
+
+ client.dispose()
+ })
+
+ it('ignores devtools tool fixtures for another hook', async () => {
+ const client = createClient()
+ vi.clearAllMocks()
+
+ dispatchToolFixture({ hookId: 'other-hook' })
+ await new Promise((resolve) => setTimeout(resolve, 0))
+
+ expect(client.getMessages()).toEqual([])
+ expect(aiEventClient.emit).not.toHaveBeenCalledWith(
+ 'devtools:tool-fixture:applied',
+ expect.anything(),
+ )
+
+ client.dispose()
+ })
+
+ it('disposes the hook bridge idempotently', () => {
+ const client = createClient()
+ vi.clearAllMocks()
+
+ client.dispose()
+ client.dispose()
+
+ expect(eventClientMock.emitted('hook:unregistered')).toHaveLength(1)
+ expect(eventClientMock.unsubscribe).toHaveBeenCalledTimes(2)
+ })
+
+ it('emits a snapshot when messages are set manually', () => {
+ const client = createClient()
+ vi.clearAllMocks()
+
+ client.setMessagesManually([userMessage])
+
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'hook:state-snapshot',
+ expect.objectContaining({
+ state: expect.objectContaining({
+ messages: [userMessage],
+ }),
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('emits a snapshot when messages are cleared', () => {
+ const client = createClient()
+ client.setMessagesManually([userMessage])
+ vi.clearAllMocks()
+
+ client.clear()
+
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'hook:state-snapshot',
+ expect.objectContaining({
+ state: expect.objectContaining({
+ messages: [],
+ }),
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('emits a snapshot after reload removes messages after the last user message', async () => {
+ const client = createClient({
+ connection: createMockConnectionAdapter({
+ chunks: createTextChunks('regenerated', 'msg-reload'),
+ }),
+ })
+ client.setMessagesManually([userMessage, assistantMessage])
+ vi.clearAllMocks()
+
+ await client.reload()
+
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'hook:state-snapshot',
+ expect.objectContaining({
+ state: expect.objectContaining({
+ messages: [userMessage],
+ }),
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('emits chat run lifecycle events for hook run tracking', async () => {
+ const runContexts: Array = []
+ const client = createClient({
+ connection: createRunTrackingAdapter(
+ [createTextChunks('tracked', 'msg-run')],
+ runContexts,
+ ),
+ })
+ vi.clearAllMocks()
+
+ await client.sendMessage('start')
+
+ const runContext = runContexts[0]
+ expect(runContext).toBeDefined()
+ expect(eventClientMock.emitted('run:created')).toEqual([
+ [
+ 'run:created',
+ expect.objectContaining({
+ hookId: 'chat-1',
+ threadId: 'thread-1',
+ runId: runContext?.runId,
+ status: 'created',
+ }),
+ ],
+ ])
+ expect(eventClientMock.emitted('run:started')).toEqual([
+ [
+ 'run:started',
+ expect.objectContaining({
+ hookId: 'chat-1',
+ threadId: 'thread-1',
+ runId: runContext?.runId,
+ status: 'started',
+ }),
+ ],
+ ])
+ expect(eventClientMock.emitted('run:completed')).toEqual([
+ [
+ 'run:completed',
+ expect.objectContaining({
+ hookId: 'chat-1',
+ threadId: 'thread-1',
+ runId: runContext?.runId,
+ status: 'completed',
+ }),
+ ],
+ ])
+
+ client.dispose()
+ })
+
+ it('links streamed text and tool events to the current run context', async () => {
+ const runContexts: Array = []
+ const adapter = createRunTrackingAdapter(
+ [
+ [
+ textContentChunk({
+ messageId: 'msg-text',
+ delta: 'h',
+ content: 'h',
+ }),
+ textContentChunk({
+ messageId: 'msg-text',
+ delta: 'i',
+ content: 'hi',
+ }),
+ ...createToolCallChunks(
+ [{ id: 'call-1', name: 'weather', arguments: '{"city":"Paris"}' }],
+ 'msg-tool',
+ 'test',
+ false,
+ ),
+ ],
+ ],
+ runContexts,
+ )
+ const client = createClient({ connection: adapter })
+ vi.clearAllMocks()
+
+ await client.sendMessage('start')
+
+ const runContext = runContexts[0]
+ expect(runContext).toBeDefined()
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'text:chunk:content',
+ expect.objectContaining({
+ threadId: runContext?.threadId,
+ runId: runContext?.runId,
+ }),
+ )
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'tools:call:updated',
+ expect.objectContaining({
+ threadId: runContext?.threadId,
+ runId: runContext?.runId,
+ toolCallId: 'call-1',
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('uses stream lifecycle run ids when the server emits them', async () => {
+ const runContexts: Array = []
+ const adapter = createRunTrackingAdapter(
+ [
+ [
+ runStartedChunk({
+ threadId: 'server-thread',
+ runId: 'server-run',
+ }),
+ textContentChunk({
+ messageId: 'msg-server',
+ delta: 's',
+ content: 's',
+ }),
+ runFinishedChunk({
+ threadId: 'server-thread',
+ runId: 'server-run',
+ }),
+ ],
+ ],
+ runContexts,
+ )
+ const client = createClient({ connection: adapter })
+ vi.clearAllMocks()
+
+ await client.sendMessage('start')
+
+ expect(runContexts[0]?.runId).not.toBe('server-run')
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'text:chunk:content',
+ expect.objectContaining({
+ threadId: 'server-thread',
+ runId: 'server-run',
+ messageId: 'msg-server',
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('emits structured output updates and snapshots streamed structured parts', async () => {
+ const runContexts: Array = []
+ const finalObject = { title: 'Pasta', servings: 2 }
+ const chunks: Array = [
+ {
+ type: EventType.CUSTOM,
+ model: 'test',
+ timestamp: Date.now(),
+ name: 'structured-output.start',
+ value: { messageId: 'msg-structured' },
+ },
+ textContentChunk({
+ messageId: 'msg-structured',
+ delta: '{"title":"Pasta"',
+ content: '{"title":"Pasta"',
+ }),
+ textContentChunk({
+ messageId: 'msg-structured',
+ delta: ',"servings":2}',
+ content: '{"title":"Pasta","servings":2}',
+ }),
+ {
+ type: EventType.CUSTOM,
+ model: 'test',
+ timestamp: Date.now(),
+ name: 'structured-output.complete',
+ value: {
+ object: finalObject,
+ raw: '{"title":"Pasta","servings":2}',
+ messageId: 'msg-structured',
+ },
+ },
+ ]
+ const client = createClient({
+ connection: createRunTrackingAdapter([chunks], runContexts),
+ })
+ vi.clearAllMocks()
+
+ await client.sendMessage('make recipe')
+
+ expect(eventClientMock.emitted('structured-output:started')).toEqual([
+ [
+ 'structured-output:started',
+ expect.objectContaining({
+ hookId: 'chat-1',
+ clientId: 'chat-1',
+ threadId: runContexts[0]?.threadId,
+ runId: runContexts[0]?.runId,
+ messageId: 'msg-structured',
+ status: 'streaming',
+ }),
+ ],
+ ])
+ expect(eventClientMock.emitted('structured-output:updated')).toEqual(
+ expect.arrayContaining([
+ [
+ 'structured-output:updated',
+ expect.objectContaining({
+ hookId: 'chat-1',
+ clientId: 'chat-1',
+ threadId: runContexts[0]?.threadId,
+ runId: runContexts[0]?.runId,
+ messageId: 'msg-structured',
+ status: 'streaming',
+ raw: '{"title":"Pasta","servings":2}',
+ partial: finalObject,
+ }),
+ ],
+ ]),
+ )
+ expect(eventClientMock.emitted('structured-output:completed')).toEqual([
+ [
+ 'structured-output:completed',
+ expect.objectContaining({
+ hookId: 'chat-1',
+ clientId: 'chat-1',
+ threadId: runContexts[0]?.threadId,
+ runId: runContexts[0]?.runId,
+ messageId: 'msg-structured',
+ status: 'complete',
+ raw: '{"title":"Pasta","servings":2}',
+ data: finalObject,
+ }),
+ ],
+ ])
+
+ const structuredPart = findStructuredOutputPart(
+ latestSnapshotMessages(),
+ 'msg-structured',
+ )
+ expect(structuredPart).toEqual(
+ expect.objectContaining({
+ type: 'structured-output',
+ status: 'complete',
+ raw: '{"title":"Pasta","servings":2}',
+ data: finalObject,
+ partial: finalObject,
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('batches structured output update events while preserving final state', async () => {
+ const runContexts: Array = []
+ const finalObject = { title: 'Pasta', servings: 2 }
+ const raw = JSON.stringify(finalObject)
+ const chunks: Array = [
+ {
+ type: EventType.CUSTOM,
+ model: 'test',
+ timestamp: Date.now(),
+ name: 'structured-output.start',
+ value: { messageId: 'msg-structured-batched' },
+ },
+ ...Array.from(raw).map((character, index) =>
+ textContentChunk({
+ messageId: 'msg-structured-batched',
+ delta: character,
+ content: raw.slice(0, index + 1),
+ }),
+ ),
+ {
+ type: EventType.CUSTOM,
+ model: 'test',
+ timestamp: Date.now(),
+ name: 'structured-output.complete',
+ value: {
+ object: finalObject,
+ raw,
+ messageId: 'msg-structured-batched',
+ },
+ },
+ ]
+ const client = createClient({
+ connection: createRunTrackingAdapter([chunks], runContexts),
+ })
+ vi.clearAllMocks()
+
+ await client.sendMessage('make recipe')
+
+ const updateEvents = eventClientMock.emitted('structured-output:updated')
+ expect(updateEvents).toHaveLength(3)
+ expect(updateEvents.map(([, payload]) => payload)).toEqual([
+ expect.objectContaining({
+ messageId: 'msg-structured-batched',
+ raw: raw.slice(0, 12),
+ delta: raw.slice(0, 12),
+ }),
+ expect.objectContaining({
+ messageId: 'msg-structured-batched',
+ raw: raw.slice(0, 24),
+ delta: raw.slice(12, 24),
+ }),
+ expect.objectContaining({
+ messageId: 'msg-structured-batched',
+ raw,
+ delta: raw.slice(24),
+ partial: finalObject,
+ }),
+ ])
+ expect(eventClientMock.emitted('structured-output:completed')).toEqual([
+ [
+ 'structured-output:completed',
+ expect.objectContaining({
+ messageId: 'msg-structured-batched',
+ status: 'complete',
+ raw,
+ data: finalObject,
+ }),
+ ],
+ ])
+
+ const structuredPart = findStructuredOutputPart(
+ latestSnapshotMessages(),
+ 'msg-structured-batched',
+ )
+ expect(structuredPart).toEqual(
+ expect.objectContaining({
+ type: 'structured-output',
+ status: 'complete',
+ raw,
+ data: finalObject,
+ partial: finalObject,
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('emits structured output completion without streamed deltas', async () => {
+ const runContexts: Array = []
+ const finalObject = { title: 'Risotto', servings: 4 }
+ const chunks: Array = [
+ {
+ type: EventType.CUSTOM,
+ model: 'test',
+ timestamp: Date.now(),
+ name: 'structured-output.start',
+ value: { messageId: 'msg-structured-terminal' },
+ },
+ {
+ type: EventType.CUSTOM,
+ model: 'test',
+ timestamp: Date.now(),
+ name: 'structured-output.complete',
+ value: {
+ object: finalObject,
+ messageId: 'msg-structured-terminal',
+ },
+ },
+ ]
+ const client = createClient({
+ connection: createRunTrackingAdapter([chunks], runContexts),
+ })
+ vi.clearAllMocks()
+
+ await client.sendMessage('make risotto')
+
+ expect(eventClientMock.emitted('structured-output:completed')).toEqual([
+ [
+ 'structured-output:completed',
+ expect.objectContaining({
+ hookId: 'chat-1',
+ clientId: 'chat-1',
+ threadId: runContexts[0]?.threadId,
+ runId: runContexts[0]?.runId,
+ messageId: 'msg-structured-terminal',
+ status: 'complete',
+ raw: JSON.stringify(finalObject),
+ data: finalObject,
+ }),
+ ],
+ ])
+
+ const structuredPart = findStructuredOutputPart(
+ latestSnapshotMessages(),
+ 'msg-structured-terminal',
+ )
+ expect(structuredPart).toEqual(
+ expect.objectContaining({
+ type: 'structured-output',
+ status: 'complete',
+ raw: JSON.stringify(finalObject),
+ data: finalObject,
+ partial: finalObject,
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('preserves structured output parts across multiple chat turns', async () => {
+ const runContexts: Array = []
+ const firstObject = { title: 'Pasta', servings: 2 }
+ const secondObject = { title: 'Soup', servings: 3 }
+ const client = createClient({
+ connection: createRunTrackingAdapter(
+ [
+ [
+ {
+ type: EventType.CUSTOM,
+ model: 'test',
+ timestamp: Date.now(),
+ name: 'structured-output.start',
+ value: { messageId: 'msg-structured-first' },
+ },
+ {
+ type: EventType.CUSTOM,
+ model: 'test',
+ timestamp: Date.now(),
+ name: 'structured-output.complete',
+ value: {
+ object: firstObject,
+ messageId: 'msg-structured-first',
+ },
+ },
+ ],
+ [
+ {
+ type: EventType.CUSTOM,
+ model: 'test',
+ timestamp: Date.now(),
+ name: 'structured-output.start',
+ value: { messageId: 'msg-structured-second' },
+ },
+ textContentChunk({
+ messageId: 'msg-structured-second',
+ delta: '{"title":"Soup","servings":3}',
+ content: '{"title":"Soup","servings":3}',
+ }),
+ {
+ type: EventType.CUSTOM,
+ model: 'test',
+ timestamp: Date.now(),
+ name: 'structured-output.complete',
+ value: {
+ object: secondObject,
+ raw: JSON.stringify(secondObject),
+ messageId: 'msg-structured-second',
+ },
+ },
+ ],
+ ],
+ runContexts,
+ ),
+ })
+ vi.clearAllMocks()
+
+ await client.sendMessage('make pasta')
+ await client.sendMessage('make soup')
+
+ const messages = latestSnapshotMessages()
+ expect(findStructuredOutputPart(messages, 'msg-structured-first')).toEqual(
+ expect.objectContaining({
+ type: 'structured-output',
+ status: 'complete',
+ data: firstObject,
+ }),
+ )
+ expect(findStructuredOutputPart(messages, 'msg-structured-second')).toEqual(
+ expect.objectContaining({
+ type: 'structured-output',
+ status: 'complete',
+ data: secondObject,
+ }),
+ )
+ expect(eventClientMock.emitted('structured-output:completed')).toHaveLength(
+ 2,
+ )
+
+ client.dispose()
+ })
+
+ it('emits approval requests that arrive after run finish', async () => {
+ const runContexts: Array = []
+ const chunks: Array = [
+ ...createToolCallChunks(
+ [
+ {
+ id: 'approval-call-1',
+ name: 'addToCart',
+ arguments: '{"guitarId":"6","quantity":1}',
+ },
+ ],
+ 'msg-approval',
+ 'test',
+ false,
+ ),
+ {
+ type: EventType.CUSTOM,
+ model: 'test',
+ timestamp: Date.now(),
+ name: 'approval-requested',
+ value: {
+ toolCallId: 'approval-call-1',
+ toolName: 'addToCart',
+ input: { guitarId: '6', quantity: 1 },
+ approval: { id: 'approval-approval-call-1', needsApproval: true },
+ },
+ },
+ ]
+ const client = createClient({
+ connection: createRunTrackingAdapter([chunks], runContexts),
+ })
+ vi.clearAllMocks()
+
+ await client.sendMessage('add it to cart')
+
+ await waitForCondition(
+ () => eventClientMock.emitted('tools:approval:requested').length > 0,
+ )
+ expect(eventClientMock.emitted('tools:approval:requested')).toEqual([
+ [
+ 'tools:approval:requested',
+ expect.objectContaining({
+ hookId: 'chat-1',
+ clientId: 'chat-1',
+ threadId: runContexts[0]?.threadId,
+ runId: runContexts[0]?.runId,
+ streamId: expect.any(String),
+ messageId: expect.any(String),
+ toolCallId: 'approval-call-1',
+ toolName: 'addToCart',
+ input: { guitarId: '6', quantity: 1 },
+ approvalId: 'approval-approval-call-1',
+ }),
+ ],
+ ])
+
+ await waitForCondition(() => {
+ const toolCall = findToolCallPart(
+ latestSnapshotMessages(),
+ 'approval-call-1',
+ )
+ return (
+ toolCall?.state === 'approval-requested' &&
+ toolCall.approval?.id === 'approval-approval-call-1'
+ )
+ })
+
+ client.dispose()
+ })
+
+ it('emits approval responses and snapshots the approval decision', async () => {
+ const runContexts: Array = []
+ const client = createClient({
+ connection: createRunTrackingAdapter(
+ [createTextChunks('approved', 'msg-after-approval')],
+ runContexts,
+ ),
+ initialMessages: [
+ userMessage,
+ {
+ id: 'msg-approval',
+ role: 'assistant',
+ parts: [
+ {
+ type: 'tool-call',
+ id: 'approval-call-1',
+ name: 'addToCart',
+ arguments: '{"guitarId":"6","quantity":1}',
+ input: { guitarId: '6', quantity: 1 },
+ state: 'approval-requested',
+ approval: {
+ id: 'approval-approval-call-1',
+ needsApproval: true,
+ },
+ },
+ ],
+ },
+ ],
+ })
+ vi.clearAllMocks()
+
+ await client.addToolApprovalResponse({
+ id: 'approval-approval-call-1',
+ approved: true,
+ })
+
+ expect(eventClientMock.emitted('tools:approval:responded')).toEqual([
+ [
+ 'tools:approval:responded',
+ expect.objectContaining({
+ hookId: 'chat-1',
+ clientId: 'chat-1',
+ toolCallId: 'approval-call-1',
+ approvalId: 'approval-approval-call-1',
+ approved: true,
+ }),
+ ],
+ ])
+ await waitForCondition(() => {
+ const toolCall = findToolCallPart(
+ latestSnapshotMessages(),
+ 'approval-call-1',
+ )
+ return (
+ toolCall?.state === 'approval-responded' &&
+ toolCall.approval?.approved === true
+ )
+ })
+
+ client.dispose()
+ })
+
+ it('keeps delayed client tool results linked to their original run context', async () => {
+ let resolveTool!: (output: unknown) => void
+ let markToolStarted!: () => void
+ const toolStarted = new Promise((resolve) => {
+ markToolStarted = resolve
+ })
+ const toolOutput = new Promise((resolve) => {
+ resolveTool = resolve
+ })
+ const runContexts: Array = []
+ const adapter = createRunTrackingAdapter(
+ [
+ createToolCallChunks([
+ { id: 'call-1', name: 'delayed_tool', arguments: '{}' },
+ ]),
+ createTextChunks('new run', 'msg-2'),
+ ],
+ runContexts,
+ )
+ const delayedTool = toolDefinition({
+ name: 'delayed_tool',
+ description: 'Delayed tool',
+ }).client(async () => {
+ markToolStarted()
+ return toolOutput
+ })
+ const client = createClient({
+ connection: adapter,
+ tools: [delayedTool],
+ })
+ vi.clearAllMocks()
+
+ const firstRun = client.sendMessage('first')
+ await toolStarted
+ client.stop()
+ const secondRun = client.sendMessage('second')
+ await waitForCondition(() => runContexts.length === 2)
+
+ resolveTool({ ok: true })
+ await Promise.allSettled([firstRun, secondRun])
+
+ expect(runContexts[0]?.runId).not.toBe(runContexts[1]?.runId)
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'tools:result:added',
+ expect.objectContaining({
+ threadId: runContexts[0]?.threadId,
+ runId: runContexts[0]?.runId,
+ toolCallId: 'call-1',
+ toolName: 'delayed_tool',
+ }),
+ )
+ expect(aiEventClient.emit).not.toHaveBeenCalledWith(
+ 'tools:result:added',
+ expect.objectContaining({
+ runId: runContexts[1]?.runId,
+ toolCallId: 'call-1',
+ }),
+ )
+
+ client.dispose()
+ })
+})
diff --git a/packages/ai-client/tests/events.test.ts b/packages/ai-client/tests/events.test.ts
index f0fa89388..be53de075 100644
--- a/packages/ai-client/tests/events.test.ts
+++ b/packages/ai-client/tests/events.test.ts
@@ -3,11 +3,17 @@ import { aiEventClient } from '@tanstack/ai-event-client'
import { DefaultChatClientEventEmitter } from '../src/events'
import type { UIMessage } from '../src/types'
-// Mock the event client
vi.mock('@tanstack/ai-event-client', () => ({
aiEventClient: {
emit: vi.fn(),
},
+ createAIDevtoolsEventEnvelope: (input: {
+ eventType: string
+ timestamp: number
+ }) => ({
+ ...input,
+ eventId: `event:${input.eventType}:${input.timestamp}`,
+ }),
}))
describe('events', () => {
@@ -15,6 +21,21 @@ describe('events', () => {
vi.clearAllMocks()
})
+ function expectedEnvelope(
+ eventType: string,
+ visibility: 'client-state' | 'user-visible' = 'client-state',
+ ) {
+ return {
+ clientId: 'test-client-id',
+ hookId: 'test-client-id',
+ eventId: expect.any(String),
+ eventType,
+ source: 'client',
+ visibility,
+ timestamp: expect.any(Number),
+ }
+ }
+
describe('DefaultChatClientEventEmitter', () => {
let emitter: DefaultChatClientEventEmitter
@@ -22,67 +43,79 @@ describe('events', () => {
emitter = new DefaultChatClientEventEmitter('test-client-id')
})
- it('should emit client:created event with clientId and timestamp', () => {
+ it('emits client:created with client-state envelope fields', () => {
emitter.clientCreated(5)
expect(aiEventClient.emit).toHaveBeenCalledWith('client:created', {
initialMessageCount: 5,
- clientId: 'test-client-id',
- source: 'client',
- timestamp: expect.any(Number),
+ ...expectedEnvelope('client:created'),
})
})
- it('should emit client:loading:changed event', () => {
+ it('emits client:loading:changed with client-state envelope fields', () => {
emitter.loadingChanged(true)
expect(aiEventClient.emit).toHaveBeenCalledWith(
'client:loading:changed',
{
isLoading: true,
- clientId: 'test-client-id',
- source: 'client',
- timestamp: expect.any(Number),
+ ...expectedEnvelope('client:loading:changed'),
},
)
})
- it('should emit client:error:changed event with null', () => {
+ it('emits client:error:changed with null', () => {
emitter.errorChanged(null)
expect(aiEventClient.emit).toHaveBeenCalledWith('client:error:changed', {
error: null,
- clientId: 'test-client-id',
- source: 'client',
- timestamp: expect.any(Number),
+ ...expectedEnvelope('client:error:changed'),
})
})
- it('should emit client:error:changed event with error string', () => {
+ it('emits client:error:changed with an error string', () => {
emitter.errorChanged('Something went wrong')
expect(aiEventClient.emit).toHaveBeenCalledWith('client:error:changed', {
error: 'Something went wrong',
- clientId: 'test-client-id',
- source: 'client',
- timestamp: expect.any(Number),
+ ...expectedEnvelope('client:error:changed'),
})
})
- it('should emit text:chunk:content event for text updates', () => {
- emitter.textUpdated('stream-1', 'msg-1', 'Hello world')
+ it('emits text:chunk:content with user-visible envelope and run context', () => {
+ emitter.textUpdated('stream-1', 'msg-1', 'Hello world', {
+ threadId: 'thread-1',
+ runId: 'run-1',
+ })
expect(aiEventClient.emit).toHaveBeenCalledWith('text:chunk:content', {
streamId: 'stream-1',
messageId: 'msg-1',
content: 'Hello world',
- clientId: 'test-client-id',
- source: 'client',
- timestamp: expect.any(Number),
+ threadId: 'thread-1',
+ runId: 'run-1',
+ ...expectedEnvelope('text:chunk:content', 'user-visible'),
})
})
- it('should emit tools:call:updated event', () => {
+ it('emits text:chunk:thinking with user-visible envelope and run context', () => {
+ emitter.thinkingUpdated('stream-1', 'msg-1', 'reasoning', 'ing', {
+ threadId: 'thread-1',
+ runId: 'run-1',
+ })
+
+ expect(aiEventClient.emit).toHaveBeenCalledWith('text:chunk:thinking', {
+ streamId: 'stream-1',
+ messageId: 'msg-1',
+ content: 'reasoning',
+ delta: 'ing',
+ threadId: 'thread-1',
+ runId: 'run-1',
+ ...expectedEnvelope('text:chunk:thinking', 'user-visible'),
+ })
+ })
+
+ it('emits tools:call:updated with user-visible envelope and run context', () => {
emitter.toolCallStateChanged(
'stream-1',
'msg-1',
@@ -90,6 +123,7 @@ describe('events', () => {
'get_weather',
'input-complete',
'{"city": "NYC"}',
+ { threadId: 'thread-1', runId: 'run-1' },
)
expect(aiEventClient.emit).toHaveBeenCalledWith('tools:call:updated', {
@@ -99,13 +133,13 @@ describe('events', () => {
toolName: 'get_weather',
state: 'input-complete',
arguments: '{"city": "NYC"}',
- clientId: 'test-client-id',
- source: 'client',
- timestamp: expect.any(Number),
+ threadId: 'thread-1',
+ runId: 'run-1',
+ ...expectedEnvelope('tools:call:updated', 'user-visible'),
})
})
- it('should emit tools:approval:requested event', () => {
+ it('emits tools:approval:requested with user-visible envelope and run context', () => {
emitter.approvalRequested(
'stream-1',
'msg-1',
@@ -113,6 +147,7 @@ describe('events', () => {
'get_weather',
{ city: 'NYC' },
'approval-1',
+ { threadId: 'thread-1', runId: 'run-1' },
)
expect(aiEventClient.emit).toHaveBeenCalledWith(
@@ -124,14 +159,14 @@ describe('events', () => {
toolName: 'get_weather',
input: { city: 'NYC' },
approvalId: 'approval-1',
- clientId: 'test-client-id',
- source: 'client',
- timestamp: expect.any(Number),
+ threadId: 'thread-1',
+ runId: 'run-1',
+ ...expectedEnvelope('tools:approval:requested', 'user-visible'),
},
)
})
- it('should emit text:message:created with full content', () => {
+ it('emits text:message:created with full content and run context', () => {
const uiMessage: UIMessage = {
id: 'msg-1',
role: 'user',
@@ -142,21 +177,24 @@ describe('events', () => {
createdAt: new Date(),
}
- emitter.messageAppended(uiMessage)
+ emitter.messageAppended(uiMessage, 'stream-1', {
+ threadId: 'thread-1',
+ runId: 'run-1',
+ })
expect(aiEventClient.emit).toHaveBeenCalledWith('text:message:created', {
- streamId: undefined,
+ streamId: 'stream-1',
messageId: 'msg-1',
role: 'user',
content: 'Hello World',
parts: uiMessage.parts,
- clientId: 'test-client-id',
- source: 'client',
- timestamp: expect.any(Number),
+ threadId: 'thread-1',
+ runId: 'run-1',
+ ...expectedEnvelope('text:message:created', 'user-visible'),
})
})
- it('should handle message with no text parts', () => {
+ it('handles a message with no text parts', () => {
const uiMessage: UIMessage = {
id: 'msg-1',
role: 'assistant',
@@ -180,13 +218,11 @@ describe('events', () => {
role: 'assistant',
content: '',
parts: uiMessage.parts,
- clientId: 'test-client-id',
- source: 'client',
- timestamp: expect.any(Number),
+ ...expectedEnvelope('text:message:created', 'user-visible'),
})
})
- it('should emit text:message:created and text:message:user for sent messages', () => {
+ it('emits text:message:created and text:message:user for sent messages', () => {
emitter.messageSent('msg-1', 'Hello world')
expect(aiEventClient.emit).toHaveBeenCalledTimes(2)
@@ -197,9 +233,7 @@ describe('events', () => {
messageId: 'msg-1',
role: 'user',
content: 'Hello world',
- clientId: 'test-client-id',
- source: 'client',
- timestamp: expect.any(Number),
+ ...expectedEnvelope('text:message:created', 'user-visible'),
},
)
expect(aiEventClient.emit).toHaveBeenNthCalledWith(
@@ -209,53 +243,46 @@ describe('events', () => {
messageId: 'msg-1',
role: 'user',
content: 'Hello world',
- clientId: 'test-client-id',
- source: 'client',
- timestamp: expect.any(Number),
+ ...expectedEnvelope('text:message:user', 'user-visible'),
},
)
})
- it('should emit client:reloaded event', () => {
+ it('emits client:reloaded with client-state envelope fields', () => {
emitter.reloaded(3)
expect(aiEventClient.emit).toHaveBeenCalledWith('client:reloaded', {
fromMessageIndex: 3,
- clientId: 'test-client-id',
- source: 'client',
- timestamp: expect.any(Number),
+ ...expectedEnvelope('client:reloaded'),
})
})
- it('should emit client:stopped event', () => {
+ it('emits client:stopped with client-state envelope fields', () => {
emitter.stopped()
expect(aiEventClient.emit).toHaveBeenCalledWith('client:stopped', {
- clientId: 'test-client-id',
- source: 'client',
- timestamp: expect.any(Number),
+ ...expectedEnvelope('client:stopped'),
})
})
- it('should emit client:messages:cleared event', () => {
+ it('emits client:messages:cleared with client-state envelope fields', () => {
emitter.messagesCleared()
expect(aiEventClient.emit).toHaveBeenCalledWith(
'client:messages:cleared',
{
- clientId: 'test-client-id',
- source: 'client',
- timestamp: expect.any(Number),
+ ...expectedEnvelope('client:messages:cleared'),
},
)
})
- it('should emit tools:result:added event', () => {
+ it('emits tools:result:added with user-visible envelope and run context', () => {
emitter.toolResultAdded(
'call-1',
'get_weather',
{ temp: 72 },
'output-available',
+ { threadId: 'thread-1', runId: 'run-1' },
)
expect(aiEventClient.emit).toHaveBeenCalledWith('tools:result:added', {
@@ -263,14 +290,17 @@ describe('events', () => {
toolName: 'get_weather',
output: { temp: 72 },
state: 'output-available',
- clientId: 'test-client-id',
- source: 'client',
- timestamp: expect.any(Number),
+ threadId: 'thread-1',
+ runId: 'run-1',
+ ...expectedEnvelope('tools:result:added', 'user-visible'),
})
})
- it('should emit tools:approval:responded event', () => {
- emitter.toolApprovalResponded('approval-1', 'call-1', true)
+ it('emits tools:approval:responded with user-visible envelope and run context', () => {
+ emitter.toolApprovalResponded('approval-1', 'call-1', true, {
+ threadId: 'thread-1',
+ runId: 'run-1',
+ })
expect(aiEventClient.emit).toHaveBeenCalledWith(
'tools:approval:responded',
@@ -278,9 +308,36 @@ describe('events', () => {
approvalId: 'approval-1',
toolCallId: 'call-1',
approved: true,
- clientId: 'test-client-id',
- source: 'client',
- timestamp: expect.any(Number),
+ threadId: 'thread-1',
+ runId: 'run-1',
+ ...expectedEnvelope('tools:approval:responded', 'user-visible'),
+ },
+ )
+ })
+
+ it('emits devtools:tool-fixture:applied with a user-visible envelope', () => {
+ emitter.toolFixtureApplied({
+ hookId: 'test-client-id',
+ threadId: 'thread-1',
+ runId: 'run-1',
+ toolName: 'get_weather',
+ input: { city: 'NYC' },
+ output: { temp: 72 },
+ messageId: 'msg-fixture',
+ toolCallId: 'call-fixture',
+ })
+
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'devtools:tool-fixture:applied',
+ {
+ threadId: 'thread-1',
+ runId: 'run-1',
+ toolName: 'get_weather',
+ input: { city: 'NYC' },
+ output: { temp: 72 },
+ messageId: 'msg-fixture',
+ toolCallId: 'call-fixture',
+ ...expectedEnvelope('devtools:tool-fixture:applied', 'user-visible'),
},
)
})
diff --git a/packages/ai-client/tests/generation-client.test.ts b/packages/ai-client/tests/generation-client.test.ts
index 216bfded9..e248c44f9 100644
--- a/packages/ai-client/tests/generation-client.test.ts
+++ b/packages/ai-client/tests/generation-client.test.ts
@@ -76,7 +76,10 @@ describe('GenerationClient', () => {
it('should pass abort signal to fetcher', async () => {
const fetcherSpy = vi.fn(
- async (_input: any, options?: { signal: AbortSignal }) => {
+ async (
+ _input: { prompt: string },
+ options?: { signal: AbortSignal },
+ ) => {
expect(options).toBeDefined()
expect(options!.signal).toBeInstanceOf(AbortSignal)
expect(options!.signal.aborted).toBe(false)
@@ -98,13 +101,13 @@ describe('GenerationClient', () => {
})
it('should not allow concurrent requests', async () => {
- let resolveFirst: (value: any) => void
+ let resolveFirst: (value: { id: string }) => void
let callCount = 0
const client = new GenerationClient({
fetcher: async () => {
callCount++
- return new Promise((resolve) => {
+ return new Promise<{ id: string }>((resolve) => {
resolveFirst = resolve
})
},
@@ -304,17 +307,21 @@ describe('GenerationClient', () => {
[],
{ model: 'dall-e-3', prompt: 'sunset', size: '1024x1024' },
expect.any(AbortSignal),
+ expect.objectContaining({
+ threadId: expect.stringMatching(/^generation-/),
+ runId: expect.stringMatching(/^run-/),
+ }),
)
})
})
describe('stop()', () => {
it('should abort in-flight request and reset to idle', async () => {
- let resolvePromise: (value: any) => void
+ let resolvePromise: (value: { id: string }) => void
const client = new GenerationClient({
fetcher: async () => {
- return new Promise((resolve) => {
+ return new Promise<{ id: string }>((resolve) => {
resolvePromise = resolve
})
},
@@ -375,6 +382,10 @@ describe('GenerationClient', () => {
[],
{ model: 'new', prompt: 'test' },
expect.any(AbortSignal),
+ expect.objectContaining({
+ threadId: expect.stringMatching(/^generation-/),
+ runId: expect.stringMatching(/^run-/),
+ }),
)
})
})
@@ -423,12 +434,12 @@ describe('GenerationClient', () => {
})
it('should not set result if fetcher resolves after stop()', async () => {
- let resolvePromise: (value: any) => void
+ let resolvePromise: (value: { id: string }) => void
const onResult = vi.fn()
const client = new GenerationClient({
fetcher: async () => {
- return new Promise((resolve) => {
+ return new Promise<{ id: string }>((resolve) => {
resolvePromise = resolve
})
},
@@ -466,9 +477,10 @@ describe('GenerationClient', () => {
it('should throw if neither connection nor fetcher is provided', async () => {
const onError = vi.fn()
+ // @ts-expect-error verifying the runtime guard for JavaScript callers
const client = new GenerationClient({
onError,
- } as any)
+ })
await client.generate({ prompt: 'test' })
@@ -576,7 +588,7 @@ describe('GenerationClient', () => {
const onResultChange = vi.fn()
const client = new GenerationClient<
- Record,
+ { prompt: string },
{ id: string },
{ transformed: boolean }
>({
@@ -648,8 +660,8 @@ describe('GenerationClient', () => {
])
const client = new GenerationClient<
- Record,
- { id: string; images: Array },
+ { prompt: string },
+ { id: string; images: Array<{ url?: string }> },
{ imageCount: number }
>({
connection,
@@ -663,7 +675,7 @@ describe('GenerationClient', () => {
it('should reset transformed result to null on reset()', async () => {
const client = new GenerationClient<
- Record,
+ { prompt: string },
{ id: string },
{ transformed: boolean }
>({
@@ -681,7 +693,7 @@ describe('GenerationClient', () => {
it('should keep previous transformed result on second generation when onResult returns null', async () => {
let callCount = 0
const client = new GenerationClient<
- Record,
+ { prompt: string },
{ id: string },
{ transformed: string }
>({
diff --git a/packages/ai-client/tests/generation-devtools.test.ts b/packages/ai-client/tests/generation-devtools.test.ts
new file mode 100644
index 000000000..a9e5b1677
--- /dev/null
+++ b/packages/ai-client/tests/generation-devtools.test.ts
@@ -0,0 +1,923 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { aiEventClient } from '@tanstack/ai-event-client'
+import { EventType } from '@tanstack/ai'
+import { createAIDevtoolsGenerationPreview } from '../src/devtools'
+import { GenerationClient } from '../src/generation-client'
+import { VideoGenerationClient } from '../src/video-generation-client'
+import type { StreamChunk } from '@tanstack/ai'
+import type { ConnectConnectionAdapter } from '../src/connection-adapters'
+
+interface DevtoolsEvent {
+ type: string
+ payload: TPayload
+ pluginId?: string
+}
+
+type DevtoolsEventCallback = (event: DevtoolsEvent) => void
+
+const eventClientMock = vi.hoisted(() => {
+ const listeners = new Map>()
+ const unsubscribe = vi.fn()
+
+ return {
+ emit: vi.fn(),
+ emitAIDevtoolsEvent: vi.fn((eventName: string, payload: unknown) => {
+ eventClientMock.emit(eventName, payload)
+ }),
+ unsubscribe,
+ on: vi.fn((eventName: string, callback: DevtoolsEventCallback) => {
+ const currentListeners = listeners.get(eventName) ?? []
+ currentListeners.push(callback)
+ listeners.set(eventName, currentListeners)
+
+ return () => {
+ unsubscribe()
+ const nextListeners = (listeners.get(eventName) ?? []).filter(
+ (listener) => listener !== callback,
+ )
+ listeners.set(eventName, nextListeners)
+ }
+ }),
+ dispatch(eventName: string, payload: unknown) {
+ for (const listener of listeners.get(eventName) ?? []) {
+ listener({
+ type: `tanstack-ai-devtools:${eventName}`,
+ payload,
+ pluginId: 'tanstack-ai-devtools',
+ })
+ }
+ },
+ emitted(eventName: string) {
+ return eventClientMock.emit.mock.calls.filter(
+ ([name]) => name === eventName,
+ )
+ },
+ reset() {
+ listeners.clear()
+ unsubscribe.mockClear()
+ },
+ }
+})
+
+vi.mock('@tanstack/ai-event-client', () => ({
+ aiEventClient: {
+ emit: eventClientMock.emit,
+ on: eventClientMock.on,
+ },
+ emitAIDevtoolsEvent: eventClientMock.emitAIDevtoolsEvent,
+ createAIDevtoolsEventEnvelope: (input: {
+ eventType: string
+ timestamp: number
+ }) => ({
+ ...input,
+ eventId: `event:${input.eventType}:${input.timestamp}`,
+ }),
+}))
+
+describe('generation client devtools bridge', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ eventClientMock.reset()
+ })
+
+ function resultChunk(value: unknown) {
+ return {
+ type: EventType.CUSTOM,
+ name: 'generation:result',
+ value,
+ timestamp: Date.now(),
+ } satisfies StreamChunk
+ }
+
+ function runStartedChunk(runId: string) {
+ return {
+ type: EventType.RUN_STARTED,
+ runId,
+ threadId: 'thread-1',
+ timestamp: Date.now(),
+ } satisfies StreamChunk
+ }
+
+ function runFinishedChunk(runId: string) {
+ return {
+ type: EventType.RUN_FINISHED,
+ runId,
+ threadId: 'thread-1',
+ timestamp: Date.now(),
+ finishReason: 'stop',
+ } satisfies StreamChunk
+ }
+
+ function createDeferred() {
+ let resolve!: (value: T) => void
+ const promise = new Promise((nextResolve) => {
+ resolve = nextResolve
+ })
+ return { promise, resolve }
+ }
+
+ function latestSnapshotState() {
+ const snapshot = eventClientMock.emitted('hook:state-snapshot').at(-1)?.[1]
+ if (!isSnapshotStatePayload(snapshot)) {
+ throw new Error('Expected a hook state snapshot payload')
+ }
+ return snapshot.state
+ }
+
+ function latestGenerationRuns() {
+ const runs = latestSnapshotState().runs
+ if (!Array.isArray(runs)) {
+ throw new Error('Expected generation snapshot runs')
+ }
+ return runs
+ }
+
+ function isSnapshotStatePayload(
+ value: unknown,
+ ): value is { state: Record } {
+ return Boolean(
+ value &&
+ typeof value === 'object' &&
+ 'state' in value &&
+ value.state &&
+ typeof value.state === 'object' &&
+ !Array.isArray(value.state),
+ )
+ }
+
+ it('normalizes generation results into renderable devtools previews', () => {
+ expect(
+ createAIDevtoolsGenerationPreview({
+ outputKind: 'image',
+ result: {
+ id: 'img-1',
+ model: 'image-model',
+ images: [
+ { url: 'https://example.com/image.png' },
+ { b64Json: 'iVBORw0KGgo=' },
+ ],
+ },
+ }),
+ ).toEqual({
+ kind: 'image',
+ items: [
+ {
+ src: 'https://example.com/image.png',
+ sourceType: 'url',
+ },
+ {
+ src: 'data:image/png;base64,iVBORw0KGgo=',
+ sourceType: 'base64',
+ mimeType: 'image/png',
+ },
+ ],
+ })
+
+ expect(
+ createAIDevtoolsGenerationPreview({
+ outputKind: 'audio',
+ result: {
+ id: 'speech-1',
+ model: 'tts-model',
+ audio: 'UklGRg==',
+ format: 'wav',
+ contentType: 'audio/wav',
+ },
+ }),
+ ).toEqual({
+ kind: 'audio',
+ items: [
+ {
+ src: 'data:audio/wav;base64,UklGRg==',
+ sourceType: 'base64',
+ mimeType: 'audio/wav',
+ format: 'wav',
+ },
+ ],
+ })
+
+ expect(
+ createAIDevtoolsGenerationPreview({
+ outputKind: 'text',
+ result: {
+ id: 'transcription-1',
+ model: 'whisper',
+ text: 'Hello world',
+ },
+ }),
+ ).toEqual({
+ kind: 'text',
+ text: 'Hello world',
+ })
+
+ expect(
+ createAIDevtoolsGenerationPreview({
+ outputKind: 'video',
+ result: null,
+ videoStatus: {
+ jobId: 'job-1',
+ status: 'processing',
+ progress: 50,
+ url: 'https://example.com/video.mp4',
+ },
+ }),
+ ).toEqual({
+ kind: 'video',
+ items: [
+ {
+ src: 'https://example.com/video.mp4',
+ sourceType: 'url',
+ },
+ ],
+ job: {
+ jobId: 'job-1',
+ status: 'processing',
+ progress: 50,
+ },
+ })
+ })
+
+ it('registers a generation hook and emits run lifecycle for fetcher mode', async () => {
+ const client = new GenerationClient({
+ id: 'gen-1',
+ fetcher: async () => ({ text: 'done' }),
+ devtools: {
+ framework: 'react',
+ hookName: 'useGenerateObject',
+ outputKind: 'structured',
+ },
+ })
+ vi.clearAllMocks()
+
+ await client.generate({ prompt: 'make object' })
+
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'run:started',
+ expect.objectContaining({
+ hookId: 'gen-1',
+ source: 'client',
+ visibility: 'client-state',
+ runId: expect.any(String),
+ status: 'started',
+ }),
+ )
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'run:completed',
+ expect.objectContaining({
+ hookId: 'gen-1',
+ source: 'client',
+ visibility: 'client-state',
+ runId: expect.any(String),
+ status: 'completed',
+ }),
+ )
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'hook:state-snapshot',
+ expect.objectContaining({
+ hookId: 'gen-1',
+ hookName: 'useGenerateObject',
+ framework: 'react',
+ outputKind: 'structured',
+ state: expect.objectContaining({
+ status: 'success',
+ isLoading: false,
+ result: { text: 'done' },
+ }),
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('includes input, progress, and renderable previews in generation snapshots', async () => {
+ const client = new GenerationClient({
+ id: 'image-hook',
+ fetcher: async () => ({
+ id: 'img-1',
+ model: 'image-model',
+ images: [
+ { url: 'https://example.com/image.png' },
+ { b64Json: 'iVBORw0KGgo=' },
+ ],
+ }),
+ devtools: {
+ framework: 'react',
+ hookName: 'useGenerateImage',
+ outputKind: 'image',
+ },
+ })
+ vi.clearAllMocks()
+
+ await client.generate({ prompt: 'A quiet desk', numberOfImages: 2 })
+
+ expect(latestSnapshotState()).toEqual(
+ expect.objectContaining({
+ input: { prompt: 'A quiet desk', numberOfImages: 2 },
+ progress: null,
+ preview: {
+ kind: 'image',
+ items: [
+ {
+ src: 'https://example.com/image.png',
+ sourceType: 'url',
+ },
+ {
+ src: 'data:image/png;base64,iVBORw0KGgo=',
+ sourceType: 'base64',
+ mimeType: 'image/png',
+ },
+ ],
+ },
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('tracks streamed progress in generation snapshots', async () => {
+ const connect: ConnectConnectionAdapter['connect'] = async function* () {
+ yield runStartedChunk('run-progress')
+ yield {
+ type: EventType.CUSTOM,
+ name: 'generation:progress',
+ value: { progress: 40, message: 'Rendering preview' },
+ timestamp: Date.now(),
+ } satisfies StreamChunk
+ yield resultChunk({ summary: 'short version' })
+ yield runFinishedChunk('run-progress')
+ }
+
+ const client = new GenerationClient({
+ id: 'summary-hook',
+ connection: { connect },
+ devtools: {
+ hookName: 'useSummarize',
+ outputKind: 'text',
+ },
+ })
+ vi.clearAllMocks()
+
+ await client.generate({ text: 'Long text' })
+
+ expect(latestSnapshotState()).toEqual(
+ expect.objectContaining({
+ input: { text: 'Long text' },
+ progress: {
+ value: 100,
+ message: 'Rendering preview',
+ },
+ preview: {
+ kind: 'text',
+ text: 'short version',
+ },
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('retains grouped generation snapshots for previous runs', async () => {
+ const connect: ConnectConnectionAdapter['connect'] = async function* (
+ _messages,
+ data,
+ ) {
+ const prompt = typeof data?.prompt === 'string' ? data.prompt : 'unknown'
+ const runId = `run-${prompt}`
+ yield runStartedChunk(runId)
+ yield {
+ type: EventType.CUSTOM,
+ name: 'generation:progress',
+ value: { progress: 70, message: `Rendering ${prompt}` },
+ timestamp: Date.now(),
+ } satisfies StreamChunk
+ yield resultChunk({
+ id: `img-${prompt}`,
+ model: 'image-model',
+ images: [{ url: `https://example.com/${prompt}.png` }],
+ })
+ yield runFinishedChunk(runId)
+ }
+
+ const client = new GenerationClient({
+ id: 'image-history',
+ connection: { connect },
+ devtools: {
+ hookName: 'useGenerateImage',
+ outputKind: 'image',
+ },
+ })
+ vi.clearAllMocks()
+
+ await client.generate({ prompt: 'one' })
+ await client.generate({ prompt: 'two' })
+
+ expect(latestGenerationRuns()).toEqual([
+ expect.objectContaining({
+ id: 'run-one',
+ input: { prompt: 'one' },
+ status: 'success',
+ isLoading: false,
+ progress: {
+ value: 100,
+ message: 'Rendering one',
+ },
+ result: expect.objectContaining({
+ id: 'img-one',
+ }),
+ preview: {
+ kind: 'image',
+ items: [
+ {
+ src: 'https://example.com/one.png',
+ sourceType: 'url',
+ },
+ ],
+ },
+ }),
+ expect.objectContaining({
+ id: 'run-two',
+ input: { prompt: 'two' },
+ status: 'success',
+ isLoading: false,
+ progress: {
+ value: 100,
+ message: 'Rendering two',
+ },
+ result: expect.objectContaining({
+ id: 'img-two',
+ }),
+ preview: {
+ kind: 'image',
+ items: [
+ {
+ src: 'https://example.com/two.png',
+ sourceType: 'url',
+ },
+ ],
+ },
+ }),
+ ])
+
+ client.dispose()
+ })
+
+ it('responds to devtools state requests for a generation hook', async () => {
+ const client = new GenerationClient({
+ id: 'gen-1',
+ fetcher: async () => ({ text: 'done' }),
+ devtools: {
+ framework: 'react',
+ hookName: 'useGenerateText',
+ outputKind: 'text',
+ },
+ })
+ client.mountDevtools()
+ vi.clearAllMocks()
+
+ eventClientMock.dispatch('devtools:request-state', {
+ targetHookId: 'gen-1',
+ })
+
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'hook:registered',
+ expect.objectContaining({
+ hookId: 'gen-1',
+ hookName: 'useGenerateText',
+ framework: 'react',
+ outputKind: 'text',
+ }),
+ )
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'hook:state-snapshot',
+ expect.objectContaining({
+ hookId: 'gen-1',
+ state: expect.objectContaining({
+ status: 'idle',
+ isLoading: false,
+ result: null,
+ activeRunId: null,
+ }),
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('emits errored run lifecycle for generation failures', async () => {
+ const client = new GenerationClient({
+ id: 'gen-1',
+ fetcher: async () => {
+ throw new Error('Generation failed')
+ },
+ devtools: {
+ hookName: 'useGenerateObject',
+ outputKind: 'structured',
+ },
+ })
+ vi.clearAllMocks()
+
+ await client.generate({ prompt: 'fail' })
+
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'run:errored',
+ expect.objectContaining({
+ hookId: 'gen-1',
+ runId: expect.any(String),
+ status: 'errored',
+ error: 'Generation failed',
+ }),
+ )
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'hook:state-snapshot',
+ expect.objectContaining({
+ hookId: 'gen-1',
+ state: expect.objectContaining({
+ status: 'error',
+ isLoading: false,
+ error: 'Generation failed',
+ }),
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('registers a generation hook for streaming connection mode', async () => {
+ const connect: ConnectConnectionAdapter['connect'] = async function* (
+ _messages,
+ _data,
+ _signal,
+ ) {
+ yield runStartedChunk('server-run-1')
+ yield resultChunk({ text: 'streamed' })
+ yield runFinishedChunk('server-run-1')
+ }
+ const connectSpy = vi.fn(connect)
+
+ const client = new GenerationClient({
+ id: 'gen-stream',
+ connection: {
+ connect: connectSpy,
+ },
+ devtools: {
+ hookName: 'useGenerateText',
+ outputKind: 'text',
+ },
+ })
+ vi.clearAllMocks()
+
+ await client.generate({ prompt: 'stream' })
+ const runContext = connectSpy.mock.calls[0]?.[3]
+
+ expect(runContext).toEqual(
+ expect.objectContaining({
+ threadId: 'gen-stream',
+ runId: expect.any(String),
+ }),
+ )
+
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'run:started',
+ expect.objectContaining({
+ hookId: 'gen-stream',
+ runId: 'server-run-1',
+ }),
+ )
+ expect(aiEventClient.emit).not.toHaveBeenCalledWith(
+ 'run:started',
+ expect.objectContaining({
+ hookId: 'gen-stream',
+ runId: runContext?.runId,
+ }),
+ )
+ expect(eventClientMock.emitted('run:started')).toHaveLength(1)
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'run:completed',
+ expect.objectContaining({
+ hookId: 'gen-stream',
+ runId: 'server-run-1',
+ }),
+ )
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'hook:state-snapshot',
+ expect.objectContaining({
+ hookId: 'gen-stream',
+ state: expect.objectContaining({
+ status: 'success',
+ isLoading: false,
+ result: { text: 'streamed' },
+ }),
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('does not emit hook updates after disposal', async () => {
+ const deferred = createDeferred<{ text: string }>()
+ const client = new GenerationClient({
+ id: 'gen-1',
+ fetcher: async () => deferred.promise,
+ devtools: {
+ hookName: 'useGenerateText',
+ outputKind: 'text',
+ },
+ })
+ const generatePromise = client.generate({ prompt: 'slow' })
+ await Promise.resolve()
+
+ client.dispose()
+ const unregisteredIndex = eventClientMock.emit.mock.calls.findIndex(
+ ([eventName]) => eventName === 'hook:unregistered',
+ )
+ deferred.resolve({ text: 'late' })
+ await generatePromise
+
+ expect(unregisteredIndex).toBeGreaterThanOrEqual(0)
+ const emittedAfterDispose = eventClientMock.emit.mock.calls
+ .slice(unregisteredIndex + 1)
+ .map(([eventName]) => eventName)
+ expect(emittedAfterDispose).not.toContain('hook:updated')
+ expect(emittedAfterDispose).not.toContain('hook:state-snapshot')
+ })
+
+ it('uses the stream run id for video connection lifecycle events', async () => {
+ const connect: ConnectConnectionAdapter['connect'] = async function* (
+ _messages,
+ _data,
+ _signal,
+ ) {
+ yield runStartedChunk('server-video-run-1')
+ yield {
+ type: EventType.CUSTOM,
+ name: 'generation:result',
+ value: {
+ jobId: 'job-1',
+ status: 'completed',
+ url: 'https://example.com/video.mp4',
+ },
+ timestamp: Date.now(),
+ } satisfies StreamChunk
+ yield runFinishedChunk('server-video-run-1')
+ }
+ const connectSpy = vi.fn(connect)
+
+ const client = new VideoGenerationClient({
+ id: 'video-stream',
+ connection: {
+ connect: connectSpy,
+ },
+ devtools: {
+ hookName: 'useGenerateVideo',
+ outputKind: 'video',
+ },
+ })
+ vi.clearAllMocks()
+
+ await client.generate({ prompt: 'stream video' })
+ const runId = connectSpy.mock.calls[0]?.[3]?.runId ?? 'missing-run'
+
+ expect(aiEventClient.emit).not.toHaveBeenCalledWith(
+ 'run:started',
+ expect.objectContaining({
+ hookId: 'video-stream',
+ runId,
+ }),
+ )
+ expect(eventClientMock.emitted('run:started')).toHaveLength(1)
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'run:completed',
+ expect.objectContaining({
+ hookId: 'video-stream',
+ runId: 'server-video-run-1',
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('registers a video hook and includes job state in snapshots', async () => {
+ const client = new VideoGenerationClient({
+ id: 'video-1',
+ fetcher: async () => ({
+ jobId: 'job-1',
+ status: 'completed',
+ url: 'https://example.com/video.mp4',
+ }),
+ devtools: {
+ framework: 'react',
+ hookName: 'useGenerateVideo',
+ outputKind: 'video',
+ },
+ })
+
+ await client.generate({ prompt: 'make video' })
+
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'hook:registered',
+ expect.objectContaining({
+ hookId: 'video-1',
+ hookName: 'useGenerateVideo',
+ framework: 'react',
+ outputKind: 'video',
+ }),
+ )
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'run:completed',
+ expect.objectContaining({
+ hookId: 'video-1',
+ status: 'completed',
+ }),
+ )
+ expect(aiEventClient.emit).toHaveBeenCalledWith(
+ 'hook:state-snapshot',
+ expect.objectContaining({
+ hookId: 'video-1',
+ state: expect.objectContaining({
+ status: 'success',
+ isLoading: false,
+ jobId: 'job-1',
+ result: expect.objectContaining({
+ url: 'https://example.com/video.mp4',
+ }),
+ }),
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('includes input, progress, and renderable previews in video snapshots', async () => {
+ const connect: ConnectConnectionAdapter['connect'] = async function* () {
+ yield runStartedChunk('video-run')
+ yield {
+ type: EventType.CUSTOM,
+ name: 'video:job:created',
+ value: { jobId: 'job-1' },
+ timestamp: Date.now(),
+ } satisfies StreamChunk
+ yield {
+ type: EventType.CUSTOM,
+ name: 'video:status',
+ value: {
+ jobId: 'job-1',
+ status: 'processing',
+ progress: 60,
+ url: 'https://example.com/preview.mp4',
+ },
+ timestamp: Date.now(),
+ } satisfies StreamChunk
+ yield {
+ type: EventType.CUSTOM,
+ name: 'generation:result',
+ value: {
+ jobId: 'job-1',
+ status: 'completed',
+ url: 'https://example.com/final.mp4',
+ },
+ timestamp: Date.now(),
+ } satisfies StreamChunk
+ yield runFinishedChunk('video-run')
+ }
+
+ const client = new VideoGenerationClient({
+ id: 'video-hook',
+ connection: { connect },
+ devtools: {
+ hookName: 'useGenerateVideo',
+ outputKind: 'video',
+ },
+ })
+ vi.clearAllMocks()
+
+ await client.generate({ prompt: 'A flying car', duration: 4 })
+
+ expect(latestSnapshotState()).toEqual(
+ expect.objectContaining({
+ input: { prompt: 'A flying car', duration: 4 },
+ progress: {
+ value: 100,
+ },
+ preview: {
+ kind: 'video',
+ items: [
+ {
+ src: 'https://example.com/final.mp4',
+ sourceType: 'url',
+ },
+ ],
+ job: {
+ jobId: 'job-1',
+ status: 'completed',
+ progress: 100,
+ },
+ },
+ }),
+ )
+
+ client.dispose()
+ })
+
+ it('retains grouped video generation snapshots for previous runs', async () => {
+ const connect: ConnectConnectionAdapter['connect'] = async function* (
+ _messages,
+ data,
+ ) {
+ const prompt = typeof data?.prompt === 'string' ? data.prompt : 'unknown'
+ const runId = `video-run-${prompt}`
+ const jobId = `job-${prompt}`
+ yield runStartedChunk(runId)
+ yield {
+ type: EventType.CUSTOM,
+ name: 'video:job:created',
+ value: { jobId },
+ timestamp: Date.now(),
+ } satisfies StreamChunk
+ yield {
+ type: EventType.CUSTOM,
+ name: 'video:status',
+ value: {
+ jobId,
+ status: 'processing',
+ progress: 70,
+ },
+ timestamp: Date.now(),
+ } satisfies StreamChunk
+ yield {
+ type: EventType.CUSTOM,
+ name: 'generation:result',
+ value: {
+ jobId,
+ status: 'completed',
+ url: `https://example.com/${prompt}.mp4`,
+ },
+ timestamp: Date.now(),
+ } satisfies StreamChunk
+ yield runFinishedChunk(runId)
+ }
+
+ const client = new VideoGenerationClient({
+ id: 'video-history',
+ connection: { connect },
+ devtools: {
+ hookName: 'useGenerateVideo',
+ outputKind: 'video',
+ },
+ })
+ vi.clearAllMocks()
+
+ await client.generate({ prompt: 'one' })
+ await client.generate({ prompt: 'two' })
+
+ expect(latestGenerationRuns()).toEqual([
+ expect.objectContaining({
+ id: 'video-run-one',
+ input: { prompt: 'one' },
+ status: 'success',
+ isLoading: false,
+ progress: {
+ value: 100,
+ },
+ jobId: 'job-one',
+ videoStatus: expect.objectContaining({
+ jobId: 'job-one',
+ status: 'completed',
+ progress: 100,
+ url: 'https://example.com/one.mp4',
+ }),
+ preview: {
+ kind: 'video',
+ items: [
+ {
+ src: 'https://example.com/one.mp4',
+ sourceType: 'url',
+ },
+ ],
+ job: {
+ jobId: 'job-one',
+ status: 'completed',
+ progress: 100,
+ },
+ },
+ }),
+ expect.objectContaining({
+ id: 'video-run-two',
+ input: { prompt: 'two' },
+ status: 'success',
+ isLoading: false,
+ progress: {
+ value: 100,
+ },
+ jobId: 'job-two',
+ videoStatus: expect.objectContaining({
+ jobId: 'job-two',
+ status: 'completed',
+ progress: 100,
+ url: 'https://example.com/two.mp4',
+ }),
+ }),
+ ])
+
+ client.dispose()
+ })
+})
diff --git a/packages/ai-client/tests/use-real-devtools-bridges.ts b/packages/ai-client/tests/use-real-devtools-bridges.ts
new file mode 100644
index 000000000..f2bb3fbc0
--- /dev/null
+++ b/packages/ai-client/tests/use-real-devtools-bridges.ts
@@ -0,0 +1,15 @@
+// Test-only: replace the no-op devtools factories with the real ones so
+// the existing test suite (which asserts on emitted devtools events) keeps
+// working under the no-op-by-default architecture. Production consumers
+// opt in via `@tanstack/ai-client/devtools`.
+import { vi } from 'vitest'
+
+vi.mock('../src/devtools-noop', async () => {
+ const real =
+ await vi.importActual('../src/devtools')
+ return {
+ createNoOpChatDevtoolsBridge: real.createChatDevtoolsBridge,
+ createNoOpGenerationDevtoolsBridge: real.createGenerationDevtoolsBridge,
+ createNoOpVideoDevtoolsBridge: real.createVideoDevtoolsBridge,
+ }
+})
diff --git a/packages/ai-client/tests/video-generation-client.test.ts b/packages/ai-client/tests/video-generation-client.test.ts
index 449e6ec3d..855e05044 100644
--- a/packages/ai-client/tests/video-generation-client.test.ts
+++ b/packages/ai-client/tests/video-generation-client.test.ts
@@ -82,7 +82,10 @@ describe('VideoGenerationClient', () => {
it('should pass abort signal to fetcher', async () => {
const fetcherSpy = vi.fn(
- async (_input: any, options?: { signal: AbortSignal }) => {
+ async (
+ _input: { prompt: string },
+ options?: { signal: AbortSignal },
+ ) => {
expect(options).toBeDefined()
expect(options!.signal).toBeInstanceOf(AbortSignal)
expect(options!.signal.aborted).toBe(false)
@@ -108,13 +111,21 @@ describe('VideoGenerationClient', () => {
})
it('should not allow concurrent requests', async () => {
- let resolveFirst: (value: any) => void
+ let resolveFirst: (value: {
+ jobId: string
+ status: 'completed'
+ url: string
+ }) => void
let callCount = 0
const client = new VideoGenerationClient({
fetcher: async () => {
callCount++
- return new Promise((resolve) => {
+ return new Promise<{
+ jobId: string
+ status: 'completed'
+ url: string
+ }>((resolve) => {
resolveFirst = resolve
})
},
@@ -260,7 +271,6 @@ describe('VideoGenerationClient', () => {
await client.generate({ prompt: 'test' })
- // Called with null (reset), then the status, then null (would be if reset called)
expect(onVideoStatusChange).toHaveBeenCalledWith({
jobId: 'job-1',
status: 'processing',
@@ -268,8 +278,9 @@ describe('VideoGenerationClient', () => {
})
expect(client.getVideoStatus()).toEqual({
jobId: 'job-1',
- status: 'processing',
- progress: 25,
+ status: 'completed',
+ progress: 100,
+ url: 'https://example.com/video.mp4',
})
})
@@ -441,17 +452,29 @@ describe('VideoGenerationClient', () => {
[],
{ model: 'runway-gen3', prompt: 'A sunset', size: '1280x720' },
expect.any(AbortSignal),
+ expect.objectContaining({
+ threadId: expect.stringMatching(/^video-/),
+ runId: expect.stringMatching(/^run-/),
+ }),
)
})
})
describe('stop()', () => {
it('should abort in-flight request and reset to idle', async () => {
- let resolvePromise: (value: any) => void
+ let resolvePromise: (value: {
+ jobId: string
+ status: 'completed'
+ url: string
+ }) => void
const client = new VideoGenerationClient({
fetcher: async () => {
- return new Promise((resolve) => {
+ return new Promise<{
+ jobId: string
+ status: 'completed'
+ url: string
+ }>((resolve) => {
resolvePromise = resolve
})
},
@@ -566,6 +589,10 @@ describe('VideoGenerationClient', () => {
[],
{ model: 'new', prompt: 'test' },
expect.any(AbortSignal),
+ expect.objectContaining({
+ threadId: expect.stringMatching(/^video-/),
+ runId: expect.stringMatching(/^run-/),
+ }),
)
})
})
@@ -647,9 +674,10 @@ describe('VideoGenerationClient', () => {
it('should throw if neither connection nor fetcher is provided', async () => {
const onError = vi.fn()
+ // @ts-expect-error verifying the runtime guard for JavaScript callers
const client = new VideoGenerationClient({
onError,
- } as any)
+ })
await client.generate({ prompt: 'test' })
diff --git a/packages/ai-client/vite.config.ts b/packages/ai-client/vite.config.ts
index 77bcc2e60..b36468a9a 100644
--- a/packages/ai-client/vite.config.ts
+++ b/packages/ai-client/vite.config.ts
@@ -10,6 +10,12 @@ const config = defineConfig({
globals: true,
environment: 'node',
include: ['tests/**/*.test.ts'],
+ // Re-route the no-op devtools factories to the real implementations
+ // for the whole test suite. The shipping default is no-op (so the
+ // heavy bridge classes stay out of `@tanstack/ai-client`'s main
+ // bundle); tests assert on devtools event emission and need the
+ // real bridges.
+ setupFiles: ['./tests/use-real-devtools-bridges.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
@@ -29,7 +35,12 @@ const config = defineConfig({
export default mergeConfig(
config,
tanstackViteConfig({
- entry: ['./src/index.ts'],
+ // `devtools.ts` is a separately-published subpath
+ // (`@tanstack/ai-client/devtools`) holding the heavy bridge
+ // implementations; declare it as its own entry so the build emits
+ // it independently and the main entry can stay free of the bridge
+ // classes (they're imported only via `import type` from clients).
+ entry: ['./src/index.ts', './src/devtools.ts'],
srcDir: './src',
cjs: false,
}),
diff --git a/packages/ai-devtools/src/components/ConversationDetails.tsx b/packages/ai-devtools/src/components/ConversationDetails.tsx
deleted file mode 100644
index dd241c5a3..000000000
--- a/packages/ai-devtools/src/components/ConversationDetails.tsx
+++ /dev/null
@@ -1,216 +0,0 @@
-import { Show, createEffect, createSignal } from 'solid-js'
-import { useStyles } from '../styles/use-styles'
-import { useAIStore } from '../store/ai-context'
-import {
- ActivityEventsTab,
- ChunksTab,
- ConversationHeader,
- ConversationTabs,
- IterationTimeline,
- MessagesTab,
- SummariesTab,
-} from './conversation'
-import type { TabType } from './conversation'
-import type { Conversation } from '../store/ai-context'
-import type { Component } from 'solid-js'
-
-export const ConversationDetails: Component = () => {
- const { state } = useAIStore()
- const styles = useStyles()
- const [activeTab, setActiveTab] = createSignal('messages')
-
- const activeConversation = (): Conversation | undefined => {
- if (!state.activeConversationId) return undefined
- return state.conversations[state.activeConversationId]
- }
-
- const hasIterations = () => {
- const conv = activeConversation()
- return conv && conv.iterations.length > 0
- }
-
- const hasActivityTabs = () => {
- const conv = activeConversation()
- if (!conv) return false
- return (
- conv.hasSummarize ||
- conv.hasImage ||
- conv.hasSpeech ||
- conv.hasTranscription ||
- conv.hasVideo
- )
- }
-
- // Update active tab when conversation changes (only for non-iteration views)
- createEffect(() => {
- const conv = activeConversation()
- if (!conv) return
-
- // If iterations exist, the timeline is the primary view — only set tab for activity
- if (conv.iterations.length > 0) {
- if (conv.hasSummarize || (conv.summaries && conv.summaries.length > 0)) {
- setActiveTab('summaries')
- } else if (
- conv.hasImage ||
- (conv.imageEvents && conv.imageEvents.length > 0)
- ) {
- setActiveTab('image')
- }
- return
- }
-
- // No iterations — use flat message/chunk view
- if (conv.type === 'server') {
- if (conv.chunks.length > 0) {
- setActiveTab('chunks')
- } else if (
- conv.hasSummarize ||
- (conv.summaries && conv.summaries.length > 0)
- ) {
- setActiveTab('summaries')
- } else if (
- conv.hasImage ||
- (conv.imageEvents && conv.imageEvents.length > 0)
- ) {
- setActiveTab('image')
- } else if (
- conv.hasSpeech ||
- (conv.speechEvents && conv.speechEvents.length > 0)
- ) {
- setActiveTab('speech')
- } else if (
- conv.hasTranscription ||
- (conv.transcriptionEvents && conv.transcriptionEvents.length > 0)
- ) {
- setActiveTab('transcription')
- } else if (
- conv.hasVideo ||
- (conv.videoEvents && conv.videoEvents.length > 0)
- ) {
- setActiveTab('video')
- } else {
- setActiveTab('chunks')
- }
- } else {
- if (conv.messages.length > 0) {
- setActiveTab('messages')
- } else if (
- conv.hasImage ||
- (conv.imageEvents && conv.imageEvents.length > 0)
- ) {
- setActiveTab('image')
- } else {
- setActiveTab('messages')
- }
- }
- })
-
- return (
-
- Select a conversation to view details
-
- }
- >
- {(conv) => (
-
-
-
- {/* Primary view: iteration timeline when iterations exist */}
-
-
-
-
-
-
- {/* Fallback: flat message/chunk view when no iterations */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Activity tabs shown below iterations when relevant */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )}
-
- )
-}
diff --git a/packages/ai-devtools/src/components/ConversationsList.tsx b/packages/ai-devtools/src/components/ConversationsList.tsx
deleted file mode 100644
index 456a565af..000000000
--- a/packages/ai-devtools/src/components/ConversationsList.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { For } from 'solid-js'
-import { useStyles } from '../styles/use-styles'
-import { useAIStore } from '../store/ai-context'
-import { ConversationRow } from './list'
-import type { Conversation } from '../store/ai-context'
-import type { Component } from 'solid-js'
-
-export const ConversationsList: Component = () => {
- const { state } = useAIStore()
- const styles = useStyles()
-
- const conversations = () => Object.values(state.conversations)
-
- return (
-
-
- {(conv: Conversation) => }
-
-
- )
-}
diff --git a/packages/ai-devtools/src/components/Shell.tsx b/packages/ai-devtools/src/components/Shell.tsx
index fcb126a49..025d01902 100644
--- a/packages/ai-devtools/src/components/Shell.tsx
+++ b/packages/ai-devtools/src/components/Shell.tsx
@@ -5,10 +5,14 @@ import {
MainPanel,
ThemeContextProvider,
} from '@tanstack/devtools-ui'
+import {
+ aiEventClient,
+ createAIDevtoolsEventEnvelope,
+ emitAIDevtoolsEvent,
+} from '@tanstack/ai-event-client'
import { useStyles } from '../styles/use-styles'
import { AIProvider } from '../store/ai-context'
-import { ConversationsList } from './ConversationsList'
-import { ConversationDetails } from './ConversationDetails'
+import { HookDashboard, HookDetails } from './hooks'
import type { TanStackDevtoolsTheme } from '@tanstack/devtools-ui'
@@ -62,43 +66,83 @@ function DevtoolsContent() {
onMount(() => {
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
+
+ const openedAt = Date.now()
+ emitAIDevtoolsEvent('devtools:opened', {
+ ...createAIDevtoolsEventEnvelope({
+ eventType: 'devtools:opened',
+ source: 'devtools',
+ visibility: 'devtools-action',
+ timestamp: openedAt,
+ }),
+ })
+ emitAIDevtoolsEvent('devtools:request-state', {
+ ...createAIDevtoolsEventEnvelope({
+ eventType: 'devtools:request-state',
+ source: 'devtools',
+ visibility: 'devtools-action',
+ timestamp: openedAt + 1,
+ }),
+ })
})
onCleanup(() => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
+ // If the panel unmounts mid-drag, the mouseup handler never fires;
+ // reset the global drag styles so the host page isn't stuck with
+ // col-resize cursor / unselectable body.
+ if (isDragging()) {
+ setIsDragging(false)
+ document.body.style.cursor = ''
+ document.body.style.userSelect = ''
+ }
+ aiEventClient.emit('devtools:closed', {
+ ...createAIDevtoolsEventEnvelope({
+ eventType: 'devtools:closed',
+ source: 'devtools',
+ visibility: 'devtools-action',
+ timestamp: Date.now(),
+ }),
+ })
})
return (
-
-
-
-
- {/* Section header */}
-
-
-
-
-
-
-
-
-
+
diff --git a/packages/ai-devtools/src/components/conversation/ActivityEventsTab.tsx b/packages/ai-devtools/src/components/conversation/ActivityEventsTab.tsx
deleted file mode 100644
index f6018dd02..000000000
--- a/packages/ai-devtools/src/components/conversation/ActivityEventsTab.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { For, Show } from 'solid-js'
-import { useStyles } from '../../styles/use-styles'
-import type { Component } from 'solid-js'
-import type { ActivityEvent } from '../../store/ai-context'
-
-interface ActivityEventsTabProps {
- title: string
- events: Array
-}
-
-export const ActivityEventsTab: Component = (props) => {
- const styles = useStyles()
-
- const formattedTimestamp = (timestamp: number) =>
- new Date(timestamp).toLocaleTimeString()
-
- return (
- 0}
- fallback={
- No events yet
- }
- >
-
-