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/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 d0b02b740..5d2e5e4e0 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,
@@ -75,6 +76,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 d0966d88a..4551a8375 100644
--- a/examples/ts-react-chat/src/routeTree.gen.ts
+++ b/examples/ts-react-chat/src/routeTree.gen.ts
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
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'
@@ -49,6 +50,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: '/',
@@ -160,6 +166,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
@@ -186,6 +193,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
@@ -213,6 +221,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
@@ -241,6 +250,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
+ | '/generation-hooks'
| '/image-gen'
| '/issue-176-tool-result'
| '/realtime'
@@ -267,6 +277,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
+ | '/generation-hooks'
| '/image-gen'
| '/issue-176-tool-result'
| '/realtime'
@@ -293,6 +304,7 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/'
+ | '/generation-hooks'
| '/image-gen'
| '/issue-176-tool-result'
| '/realtime'
@@ -320,6 +332,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
+ GenerationHooksRoute: typeof GenerationHooksRoute
ImageGenRoute: typeof ImageGenRoute
Issue176ToolResultRoute: typeof Issue176ToolResultRoute
RealtimeRoute: typeof RealtimeRoute
@@ -368,6 +381,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: '/'
@@ -520,6 +540,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/typescript/ai-client/package.json b/packages/typescript/ai-client/package.json
index c56c52900..396b1b2ba 100644
--- a/packages/typescript/ai-client/package.json
+++ b/packages/typescript/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/typescript/ai-client/src/chat-client.ts b/packages/typescript/ai-client/src/chat-client.ts
index 505f36e3a..cae4e2e65 100644
--- a/packages/typescript/ai-client/src/chat-client.ts
+++ b/packages/typescript/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 { normalizeConnectionAdapter } from './connection-adapters'
import type {
AnyClientTool,
@@ -16,7 +16,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,
@@ -45,8 +53,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> = []
@@ -64,6 +79,7 @@ export class ChatClient {
private draining = false
private sessionGenerating = false
private readonly activeRunIds = new Set()
+ private devtoolsMounted = false
private readonly callbacksRef: {
current: {
@@ -97,7 +113,6 @@ export class ChatClient {
this.bodyOption = options.body || {}
this.forwardedPropsOption = options.forwardedProps || {}
this.connection = normalizeConnectionAdapter(options.connection)
- this.events = new DefaultChatClientEventEmitter(this.uniqueId)
// Build client tools map
this.clientToolsRef = { current: new Map() }
@@ -107,6 +122,11 @@ export class ChatClient {
}
}
+ this.devtoolsBridge = (
+ options.devtoolsBridgeFactory ?? createNoOpChatDevtoolsBridge
+ )(this.buildDevtoolsBridgeOptions(options.devtools))
+ this.events = this.devtoolsBridge.events
+
this.callbacksRef = {
current: {
onResponse: options.onResponse || (() => {}),
@@ -181,9 +201,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,
@@ -220,24 +272,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)
@@ -254,16 +318,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,
@@ -274,8 +342,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 {
@@ -291,22 +366,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 {
@@ -320,6 +399,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
@@ -409,11 +539,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') {
@@ -510,6 +641,7 @@ export class ChatClient {
content: string | MultimodalContent,
body?: Record,
): Promise {
+ this.mountDevtools()
const emptyMessage = typeof content === 'string' && !content.trim()
if (emptyMessage || this.isLoading) {
return
@@ -548,6 +680,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)
@@ -565,6 +698,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) {
@@ -602,6 +736,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)
@@ -639,6 +775,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
@@ -674,6 +811,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)
@@ -690,6 +840,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
}
@@ -704,10 +863,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 {
@@ -716,11 +892,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()
@@ -792,7 +985,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
@@ -806,6 +999,7 @@ export class ChatClient {
// Remove all messages after the last user message
this.processor.removeMessagesAfter(lastUserMessageIndex)
+ this.devtoolsBridge.emitSnapshot()
// Resend
await this.streamResponse()
@@ -838,11 +1032,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
@@ -893,6 +1101,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) {
@@ -1028,6 +1237,7 @@ export class ChatClient {
*/
setMessagesManually(messages: Array): void {
this.processor.setMessages(messages)
+ this.devtoolsBridge.emitSnapshot()
}
/**
@@ -1075,7 +1285,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
}
@@ -1087,6 +1297,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
@@ -1116,4 +1327,10 @@ export class ChatClient {
this.callbacksRef.current.onCustomEvent = options.onCustomEvent
}
}
+
+ dispose(): void {
+ this.unsubscribe()
+ this.devtoolsBridge.dispose()
+ this.devtoolsMounted = false
+ }
}
diff --git a/packages/typescript/ai-client/src/devtools-noop.ts b/packages/typescript/ai-client/src/devtools-noop.ts
new file mode 100644
index 000000000..70b6a0064
--- /dev/null
+++ b/packages/typescript/ai-client/src/devtools-noop.ts
@@ -0,0 +1,189 @@
+/**
+ * No-op devtools bridge implementations + factories.
+ *
+ * The chat / generation / video clients depend on the *types* of the
+ * real bridge classes (via `import type`) and accept a factory in
+ * options. When no factory is supplied, the client falls back to the
+ * no-op factories exported from this file, which never touch
+ * `aiEventClient` or any of the heavy preview/fixture machinery in
+ * `./devtools`.
+ *
+ * This keeps `./devtools` (the real implementations) outside the
+ * dependency graph of the main entry — consumers who want functional
+ * devtools must opt in by importing from `@tanstack/ai-client/devtools`
+ * (see `package.json#exports`) and passing the resulting factory.
+ */
+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(_status: string): void {}
+ recordProgressChange(): void {}
+ emitState(): void {}
+}
+
+export class NoOpVideoDevtoolsBridge<
+ TOutput,
+> extends NoOpGenerationDevtoolsBridge {
+ constructor(options: VideoDevtoolsBridgeOptions) {
+ super(options)
+ }
+
+ recordJobIdChange(): void {}
+ recordVideoStatusChange(): void {}
+}
+
+// ===========================================================================
+// Factories — these are what the clients call when no real factory was
+// supplied in options.
+// ===========================================================================
+
+export const createNoOpChatDevtoolsBridge: ChatDevtoolsBridgeFactory = (
+ options,
+) => {
+ // Cast through `unknown`: the no-op class is structurally compatible
+ // with the real class's public surface but does not extend it (so the
+ // real class stays out of the main-entry import graph).
+ // eslint-disable-next-line no-restricted-syntax -- no-op bridge is structurally compatible with the real bridge but intentionally does not extend it
+ return new NoOpChatDevtoolsBridge(options) as unknown as ChatDevtoolsBridge
+}
+
+export const createNoOpGenerationDevtoolsBridge: GenerationDevtoolsBridgeFactory =
+ (options: GenerationDevtoolsBridgeOptions) =>
+ // eslint-disable-next-line no-restricted-syntax -- no-op bridge is structurally compatible with the real bridge but intentionally does not extend it
+ new NoOpGenerationDevtoolsBridge(
+ options,
+ ) as unknown as GenerationDevtoolsBridge
+
+export const createNoOpVideoDevtoolsBridge: VideoDevtoolsBridgeFactory = <
+ TOutput,
+>(
+ options: VideoDevtoolsBridgeOptions,
+) =>
+ // eslint-disable-next-line no-restricted-syntax -- no-op bridge is structurally compatible with the real bridge but intentionally does not extend it
+ new NoOpVideoDevtoolsBridge(
+ options,
+ ) as unknown as VideoDevtoolsBridge
diff --git a/packages/typescript/ai-client/src/devtools.ts b/packages/typescript/ai-client/src/devtools.ts
new file mode 100644
index 000000000..c8b8224b4
--- /dev/null
+++ b/packages/typescript/ai-client/src/devtools.ts
@@ -0,0 +1,1911 @@
+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 interface AIDevtoolsGenerationRunSnapshot<
+ TOutput = unknown,
+> extends Record {
+ id: string
+ input: unknown | null
+ result: TOutput | null
+ preview: AIDevtoolsGenerationPreview
+ progress: AIDevtoolsGenerationProgress | null
+ status: string
+ isLoading: boolean
+ startedAt: number
+ updatedAt: number
+ completedAt?: number
+ error?: string
+ jobId?: string | null
+ videoStatus?: unknown | null
+}
+
+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<
+ TSnapshot extends Record,
+> {
+ 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(),
+ state: this.options.getSnapshot(),
+ })
+ }
+
+ 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}`
+}
+
+// ===========================================================================
+// ChatDevtoolsBridge
+// ---------------------------------------------------------------------------
+// Owns the chat-client side of the devtools contract so the client itself
+// stays a pure transport. Everything devtools-only — fixture replay,
+// per-run / per-stream event context, snapshot emission — lives here so a
+// production no-op bridge can replace it without affecting chat behavior.
+// ===========================================================================
+
+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 {
+ /**
+ * Public event emitter consumed by the chat client. Owned here so that
+ * a no-op bridge in production swaps the emitter out wholesale and no
+ * client-side event work happens.
+ */
+ 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,
+ // Route the base bridge's fixture subscription back through this
+ // subclass. We can't reference `this.applyFixture` directly in the
+ // super call, so use a thunk that defers the lookup.
+ applyToolFixture: (fixture) => this.applyFixture(fixture),
+ })
+ this.chatOptions = options
+ // Replace the plain emitter with one that auto-attaches the current
+ // run/thread context and auto-emits a snapshot after every event so
+ // the chat client only ever calls `this.events.X(...)` exactly as it
+ // did before the devtools work landed — no context arg, no manual
+ // `emitSnapshot()` calls.
+ 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
+ }
+
+ /**
+ * One-call helper for the chat client's `mountDevtools()`: registers
+ * the hook, publishes the initial tool list, and emits the first
+ * snapshot. Wraps three lower-level emits so the client doesn't need
+ * to know the order.
+ */
+ mountWithTools(initialMessageCount: number): void {
+ this.events.clientCreated(initialMessageCount)
+ this.emitRegistered()
+ this.emitToolsRegistered()
+ this.emitSnapshot()
+ }
+
+ /** Re-publish the tool list (e.g. after `updateOptions({ tools })`). */
+ notifyToolsChanged(): void {
+ this.emitToolsRegistered()
+ this.emitSnapshot()
+ }
+
+ getCurrentStreamId(): string | null {
+ return this.currentStreamId
+ }
+
+ getLastStreamId(): string | null {
+ return this.lastStreamId
+ }
+
+ /** Resolve a usable stream id without mutating tracking state. */
+ resolveStreamId(): string {
+ return (
+ this.currentStreamId ??
+ this.lastStreamId ??
+ this.chatOptions.generateId('stream')
+ )
+ }
+
+ /**
+ * Mark a run as starting before any chunks arrive. The chat client
+ * calls this when it has just generated a runId for outbound emit
+ * events; the matching `RUN_STARTED` chunk from the adapter will land
+ * later and `observeChunk` keeps the same context.
+ */
+ beginRun(runId: string, threadId: string): void {
+ this.currentRunId = runId
+ this.currentRunThreadId = threadId
+ this.lastRunEventContext = { runId, threadId }
+ }
+
+ /**
+ * Update run-context tracking based on a streaming chunk. Called from
+ * the chat client's subscription loop so the bridge knows which run is
+ * currently active without the chat client owning any of this state.
+ */
+ 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) {
+ this.addToolResultForFixture({
+ fixture,
+ messageId,
+ toolCallId,
+ threadId,
+ output: fixture.output,
+ errorText: fixture.errorText,
+ })
+ return
+ }
+
+ try {
+ const output = await executeFunc(fixture.input)
+ this.addToolResultForFixture({
+ fixture,
+ messageId,
+ toolCallId,
+ threadId,
+ output,
+ })
+ } catch (error) {
+ this.addToolResultForFixture({
+ fixture,
+ messageId,
+ toolCallId,
+ threadId,
+ output: null,
+ errorText:
+ error instanceof Error ? error.message : 'Tool execution failed.',
+ })
+ }
+ }
+
+ 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 {
+ return String(value)
+ }
+}
+
+function parseFixtureResultContent(content: string): unknown {
+ try {
+ return JSON.parse(content)
+ } catch {
+ 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
+ }),
+ )
+}
+
+// ===========================================================================
+// GenerationDevtoolsBridge
+// ---------------------------------------------------------------------------
+// Owns the devtools side of `GenerationClient` / `VideoGenerationClient`:
+// per-run history, active-run lifecycle, and snapshot emission. The
+// generation client keeps its own core state (result, progress, loading,
+// status, error, input) and pushes it into the bridge via `record*` methods.
+// ===========================================================================
+
+export interface AIDevtoolsGenerationSnapshotBase extends Record<
+ string,
+ unknown
+> {
+ input: unknown | null
+ result: TOutput | null
+ preview: AIDevtoolsGenerationPreview
+ progress: AIDevtoolsGenerationProgress | null
+ status: string
+ 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 | null
+ result: TOutput | null
+ progress: AIDevtoolsGenerationProgress | null
+ status: string
+ isLoading: boolean
+ error?: string | undefined
+}
+
+export interface GenerationRunPatch {
+ input?: unknown | null
+ result?: TOutput | null
+ preview?: AIDevtoolsGenerationPreview
+ progress?: AIDevtoolsGenerationProgress | null
+ status?: string
+ 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: string): 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)}`
+ }
+}
+
+// ===========================================================================
+// VideoDevtoolsBridge
+// ---------------------------------------------------------------------------
+// Specialization of GenerationDevtoolsBridge for video jobs: snapshots also
+// carry the job id and the latest provider-reported status, and the preview
+// pipeline knows how to consume that status so the devtools panel can show
+// streaming progress before the final URL lands.
+// ===========================================================================
+
+export interface AIDevtoolsVideoSnapshotBase<
+ TOutput,
+> extends AIDevtoolsGenerationSnapshotBase {
+ jobId: string | null
+ videoStatus: unknown | null
+}
+
+export interface VideoDevtoolsCoreState<
+ TOutput,
+> extends GenerationDevtoolsCoreState {
+ jobId: string | null
+ videoStatus: unknown | null
+}
+
+export interface VideoDevtoolsBridgeOptions extends Omit<
+ GenerationDevtoolsBridgeOptions,
+ 'getCoreState'
+> {
+ getCoreState: () => VideoDevtoolsCoreState
+}
+
+export interface VideoRunPatch extends GenerationRunPatch {
+ jobId?: string | null
+ videoStatus?: unknown | null
+}
+
+export class VideoDevtoolsBridge<
+ TOutput,
+> extends GenerationDevtoolsBridge {
+ constructor(options: VideoDevtoolsBridgeOptions) {
+ super(options)
+ }
+
+ /** Record changes to `jobId` from the core video client. */
+ recordJobIdChange(): void {
+ this.updateActiveRun({
+ jobId: (this.getCoreState() as VideoDevtoolsCoreState).jobId,
+ } as VideoRunPatch)
+ this.emitState()
+ }
+
+ /** Record changes to the provider's `videoStatus` payload. */
+ 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 {
+ // Reuse the base run-upsert machinery, then layer on the video-only
+ // patch fields so devtools sees jobId / videoStatus history per run.
+ 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 the base `createPreview` so any record* method inherited
+ * from `GenerationDevtoolsBridge` (e.g. `recordResultChange`) also
+ * threads the latest videoStatus through the preview pipeline.
+ */
+ 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 | null,
+ ): AIDevtoolsGenerationPreview {
+ return createAIDevtoolsGenerationPreview({
+ outputKind: this.options.metadata.outputKind,
+ result,
+ videoStatus,
+ })
+ }
+}
+
+// ===========================================================================
+// Real factories — re-exported from `@tanstack/ai-client/devtools` so that
+// consumers can opt into the heavy bridge implementations without dragging
+// them into the main entry's bundle. Match the factory signatures defined
+// in `./devtools-noop` so the clients can swap factories at runtime.
+// ===========================================================================
+
+// ===========================================================================
+// ChatDevtoolsAwareEventEmitter
+// ---------------------------------------------------------------------------
+// Replaces the plain event emitter on `ChatDevtoolsBridge` so the chat
+// client can call `this.events.X(...)` exactly like it did before the
+// devtools work landed:
+// - auto-attach the current run/thread context to every event that
+// accepts one (textUpdated, messageAppended, tool*, structuredOutput,
+// approval*, etc.)
+// - auto-emit a snapshot after every event so chat-client no longer
+// has to sprinkle explicit `emitSnapshot()` calls after every state
+// change
+// - passively learn the latest streamId from outgoing events 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/typescript/ai-client/src/events.ts b/packages/typescript/ai-client/src/events.ts
index f6cee6757..e06a072e1 100644
--- a/packages/typescript/ai-client/src/events.ts
+++ b/packages/typescript/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/typescript/ai-client/src/generation-client.ts b/packages/typescript/ai-client/src/generation-client.ts
index c5395770c..0dd991e31 100644
--- a/packages/typescript/ai-client/src/generation-client.ts
+++ b/packages/typescript/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/typescript/ai-client/src/generation-types.ts b/packages/typescript/ai-client/src/generation-types.ts
index 88573bd59..347be9d1b 100644
--- a/packages/typescript/ai-client/src/generation-types.ts
+++ b/packages/typescript/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/typescript/ai-client/src/index.ts b/packages/typescript/ai-client/src/index.ts
index 98f75a999..aa385ce15 100644
--- a/packages/typescript/ai-client/src/index.ts
+++ b/packages/typescript/ai-client/src/index.ts
@@ -40,6 +40,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/typescript/ai-client/src/types.ts b/packages/typescript/ai-client/src/types.ts
index 1159150c1..1e043a3e0 100644
--- a/packages/typescript/ai-client/src/types.ts
+++ b/packages/typescript/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'
@@ -204,6 +206,7 @@ export interface UIMessage<
export interface ChatClientOptions<
TTools extends ReadonlyArray = any,
+ TContext = unknown,
> {
/**
* Connection adapter for streaming.
@@ -328,6 +331,25 @@ export interface 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
@@ -385,7 +407,10 @@ export function clientTools>(
*/
export function createChatClientOptions<
const TTools extends ReadonlyArray,
->(options: ChatClientOptions): ChatClientOptions {
+ TContext = unknown,
+>(
+ options: ChatClientOptions,
+): ChatClientOptions {
return options
}
@@ -404,4 +429,6 @@ export function createChatClientOptions<
* ```
*/
export type InferChatMessages =
- T extends ChatClientOptions ? Array> : never
+ T extends ChatClientOptions
+ ? Array>
+ : never
diff --git a/packages/typescript/ai-client/src/video-generation-client.ts b/packages/typescript/ai-client/src/video-generation-client.ts
index ae5765894..d61e4b0b3 100644
--- a/packages/typescript/ai-client/src/video-generation-client.ts
+++ b/packages/typescript/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/typescript/ai-client/tests/devtools.test.ts b/packages/typescript/ai-client/tests/devtools.test.ts
new file mode 100644
index 000000000..3b1585340
--- /dev/null
+++ b/packages/typescript/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/typescript/ai-client/tests/events.test.ts b/packages/typescript/ai-client/tests/events.test.ts
index f0fa89388..be53de075 100644
--- a/packages/typescript/ai-client/tests/events.test.ts
+++ b/packages/typescript/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/typescript/ai-client/tests/generation-client.test.ts b/packages/typescript/ai-client/tests/generation-client.test.ts
index 216bfded9..e248c44f9 100644
--- a/packages/typescript/ai-client/tests/generation-client.test.ts
+++ b/packages/typescript/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/typescript/ai-client/tests/generation-devtools.test.ts b/packages/typescript/ai-client/tests/generation-devtools.test.ts
new file mode 100644
index 000000000..a9e5b1677
--- /dev/null
+++ b/packages/typescript/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/typescript/ai-client/tests/use-real-devtools-bridges.ts b/packages/typescript/ai-client/tests/use-real-devtools-bridges.ts
new file mode 100644
index 000000000..74b814c5f
--- /dev/null
+++ b/packages/typescript/ai-client/tests/use-real-devtools-bridges.ts
@@ -0,0 +1,19 @@
+/**
+ * Test-only hook: 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 new "no-op by default" architecture.
+ *
+ * Production consumers must explicitly opt in via
+ * `@tanstack/ai-client/devtools`; the tests do that here once per file.
+ */
+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/typescript/ai-client/tests/video-generation-client.test.ts b/packages/typescript/ai-client/tests/video-generation-client.test.ts
index 449e6ec3d..855e05044 100644
--- a/packages/typescript/ai-client/tests/video-generation-client.test.ts
+++ b/packages/typescript/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/typescript/ai-client/vite.config.ts b/packages/typescript/ai-client/vite.config.ts
index 77bcc2e60..b36468a9a 100644
--- a/packages/typescript/ai-client/vite.config.ts
+++ b/packages/typescript/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/typescript/ai-devtools/src/components/ConversationDetails.tsx b/packages/typescript/ai-devtools/src/components/ConversationDetails.tsx
deleted file mode 100644
index dd241c5a3..000000000
--- a/packages/typescript/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/typescript/ai-devtools/src/components/ConversationsList.tsx b/packages/typescript/ai-devtools/src/components/ConversationsList.tsx
deleted file mode 100644
index 456a565af..000000000
--- a/packages/typescript/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/typescript/ai-devtools/src/components/Shell.tsx b/packages/typescript/ai-devtools/src/components/Shell.tsx
index fcb126a49..025d01902 100644
--- a/packages/typescript/ai-devtools/src/components/Shell.tsx
+++ b/packages/typescript/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/typescript/ai-devtools/src/components/conversation/ActivityEventsTab.tsx b/packages/typescript/ai-devtools/src/components/conversation/ActivityEventsTab.tsx
deleted file mode 100644
index f6018dd02..000000000
--- a/packages/typescript/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
- }
- >
-
-
-
-
- {(event) => (
-
-
-
- {JSON.stringify(event.payload, null, 2)}
-
-
- )}
-
-
-
-
- )
-}
diff --git a/packages/typescript/ai-devtools/src/components/conversation/ChunkBadges.tsx b/packages/typescript/ai-devtools/src/components/conversation/ChunkBadges.tsx
deleted file mode 100644
index cf8935cd3..000000000
--- a/packages/typescript/ai-devtools/src/components/conversation/ChunkBadges.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { Show } from 'solid-js'
-import { useStyles } from '../../styles/use-styles'
-import type { Component } from 'solid-js'
-import type { Chunk } from '../../store/ai-store'
-
-interface ChunkBadgesProps {
- chunks: Array
-}
-
-export const ChunkBadges: Component = (props) => {
- const styles = useStyles()
-
- const hasToolCalls = () => props.chunks.some((c) => c.type === 'tool_call')
- const hasErrors = () => props.chunks.some((c) => c.type === 'error')
- const hasApproval = () => props.chunks.some((c) => c.type === 'approval')
- const finishReason = () =>
- props.chunks.find((c) => c.type === 'done')?.finishReason
-
- return (
- <>
-
-
- 🔧 Tool Calls
-
-
-
-
- ❌ Error
-
-
-
-
- ⚠️ Approval
-
-
-
-
- ✓ {finishReason()}
-
-
- >
- )
-}
diff --git a/packages/typescript/ai-devtools/src/components/conversation/ChunkItem.tsx b/packages/typescript/ai-devtools/src/components/conversation/ChunkItem.tsx
deleted file mode 100644
index fce8449ca..000000000
--- a/packages/typescript/ai-devtools/src/components/conversation/ChunkItem.tsx
+++ /dev/null
@@ -1,273 +0,0 @@
-import { Show, createSignal } from 'solid-js'
-import { JsonTree } from '@tanstack/devtools-ui'
-import { useStyles } from '../../styles/use-styles'
-import { formatDuration, formatTimestamp, getChunkTypeColor } from '../utils'
-import type { Component } from 'solid-js'
-import type { Chunk } from '../../store/ai-store'
-
-interface ChunkItemProps {
- chunk: Chunk
- index: number
- variant?: 'small' | 'large'
-}
-
-export const ChunkItem: Component = (props) => {
- const styles = useStyles()
- const [showRaw, setShowRaw] = createSignal(false)
- const isLarge = () => props.variant === 'large'
- const chunkCount = () => props.chunk.chunkCount || 1
-
- const parseToolArguments = (): Record => {
- try {
- return JSON.parse(props.chunk.arguments || '{}') as Record<
- string,
- unknown
- >
- } catch {
- return { raw: props.chunk.arguments }
- }
- }
-
- const parseToolResult = (): Record => {
- try {
- if (typeof props.chunk.result === 'string') {
- return JSON.parse(props.chunk.result) as Record
- }
- return props.chunk.result as Record
- } catch {
- return { raw: props.chunk.result }
- }
- }
-
- return (
-