From 4b0f6ce79b6d4d6a5217368ce58a6ea3aaab7450 Mon Sep 17 00:00:00 2001
From: Alem Tuzlak
Date: Sun, 24 May 2026 22:22:33 +0200
Subject: [PATCH 1/4] feat: overhaul ai devtools
---
.changeset/ai-devtools-hook-dashboard.md | 12 +
docs/getting-started/devtools.md | 19 +
.../ts-react-chat/src/components/Header.tsx | 14 +
examples/ts-react-chat/src/routeTree.gen.ts | 21 +
.../src/routes/api.structured-output.ts | 205 +-
.../src/routes/generation-hooks.tsx | 804 ++++++++
.../routes/generations.structured-output.tsx | 226 +--
.../typescript/ai-client/src/chat-client.ts | 740 +++++++-
packages/typescript/ai-client/src/devtools.ts | 691 +++++++
packages/typescript/ai-client/src/events.ts | 153 +-
.../ai-client/src/generation-client.ts | 397 +++-
.../ai-client/src/generation-types.ts | 4 +
packages/typescript/ai-client/src/index.ts | 8 +
packages/typescript/ai-client/src/types.ts | 21 +-
.../ai-client/src/video-generation-client.ts | 441 ++++-
.../ai-client/tests/devtools.test.ts | 1677 +++++++++++++++++
.../typescript/ai-client/tests/events.test.ts | 193 +-
.../ai-client/tests/generation-client.test.ts | 38 +-
.../tests/generation-devtools.test.ts | 923 +++++++++
.../tests/video-generation-client.test.ts | 46 +-
.../src/components/ConversationDetails.tsx | 216 ---
.../src/components/ConversationsList.tsx | 21 -
.../ai-devtools/src/components/Shell.tsx | 91 +-
.../conversation/ActivityEventsTab.tsx | 57 -
.../components/conversation/ChunkBadges.tsx | 51 -
.../src/components/conversation/ChunkItem.tsx | 273 ---
.../conversation/ChunksCollapsible.tsx | 56 -
.../src/components/conversation/ChunksTab.tsx | 87 -
.../conversation/ConversationHeader.tsx | 106 --
.../conversation/ConversationTabs.tsx | 205 --
.../components/conversation/IterationCard.tsx | 259 ++-
.../conversation/IterationTimeline.tsx | 54 +-
.../components/conversation/MessageCard.tsx | 174 --
.../components/conversation/MessageGroup.tsx | 101 -
.../components/conversation/MessagesTab.tsx | 30 -
.../components/conversation/SummariesTab.tsx | 91 -
.../conversation/ToolCallDisplay.tsx | 99 -
.../src/components/conversation/index.ts | 9 -
.../src/components/hooks/GenerationPanel.tsx | 802 ++++++++
.../src/components/hooks/HookDashboard.tsx | 223 +++
.../src/components/hooks/HookDetails.tsx | 1618 ++++++++++++++++
.../src/components/hooks/ToolFixtureForm.tsx | 324 ++++
.../components/hooks/hook-dashboard-model.ts | 102 +
.../ai-devtools/src/components/hooks/index.ts | 4 +
.../src/components/hooks/preview-messages.ts | 117 ++
.../src/components/hooks/preview-model.ts | 195 ++
.../src/components/list/ConversationRow.tsx | 85 -
.../ai-devtools/src/components/list/index.ts | 1 -
.../src/components/utils/format.ts | 58 -
.../ai-devtools/src/store/ai-context.tsx | 1267 +++++++++++--
.../ai-devtools/src/store/hook-registry.ts | 668 +++++++
.../src/store/message-event-utils.ts | 60 +
.../ai-devtools/src/styles/use-styles.ts | 1655 +++++++++++++++-
.../tests/hook-dashboard-model.test.ts | 111 ++
.../ai-devtools/tests/hook-registry.test.ts | 475 +++++
.../tests/message-event-utils.test.ts | 115 ++
.../tests/preview-messages.test.ts | 218 +++
.../ai-devtools/tests/preview-model.test.ts | 146 ++
.../src/devtools-middleware.ts | 4 +
.../ai-event-client/src/envelope.ts | 135 ++
.../typescript/ai-event-client/src/index.ts | 242 ++-
.../ai-event-client/tests/emit.test.ts | 133 ++
.../ai-event-client/tests/envelope.test.ts | 126 ++
packages/typescript/ai-preact/src/types.ts | 9 +-
packages/typescript/ai-preact/src/use-chat.ts | 9 +
packages/typescript/ai-react/src/use-chat.ts | 9 +
.../ai-react/src/use-generate-audio.ts | 9 +-
.../ai-react/src/use-generate-image.ts | 9 +-
.../ai-react/src/use-generate-speech.ts | 9 +-
.../ai-react/src/use-generate-video.ts | 9 +-
.../typescript/ai-react/src/use-generation.ts | 12 +-
.../typescript/ai-react/src/use-summarize.ts | 11 +-
.../ai-react/src/use-transcription.ts | 11 +-
packages/typescript/ai-solid/src/use-chat.ts | 12 +
.../ai-solid/src/use-generate-audio.ts | 9 +-
.../ai-solid/src/use-generate-image.ts | 9 +-
.../ai-solid/src/use-generate-speech.ts | 9 +-
.../ai-solid/src/use-generate-video.ts | 19 +-
.../typescript/ai-solid/src/use-generation.ts | 22 +-
.../typescript/ai-solid/src/use-summarize.ts | 11 +-
.../ai-solid/src/use-transcription.ts | 11 +-
.../ai-svelte/src/create-chat.svelte.ts | 13 +
.../src/create-generate-audio.svelte.ts | 9 +-
.../src/create-generate-image.svelte.ts | 9 +-
.../src/create-generate-speech.svelte.ts | 11 +-
.../src/create-generate-video.svelte.ts | 18 +-
.../ai-svelte/src/create-generation.svelte.ts | 21 +-
.../ai-svelte/src/create-summarize.svelte.ts | 9 +-
.../src/create-transcription.svelte.ts | 9 +-
packages/typescript/ai-svelte/src/types.ts | 5 +
packages/typescript/ai-vue/src/use-chat.ts | 12 +
.../ai-vue/src/use-generate-audio.ts | 9 +-
.../ai-vue/src/use-generate-image.ts | 9 +-
.../ai-vue/src/use-generate-speech.ts | 9 +-
.../ai-vue/src/use-generate-video.ts | 22 +-
.../typescript/ai-vue/src/use-generation.ts | 25 +-
.../typescript/ai-vue/src/use-summarize.ts | 11 +-
.../ai-vue/src/use-transcription.ts | 11 +-
.../ai/src/activities/chat/index.ts | 1 +
.../src/activities/chat/middleware/types.ts | 2 +
.../src/activities/chat/stream/processor.ts | 92 +
101 files changed, 15592 insertions(+), 2411 deletions(-)
create mode 100644 .changeset/ai-devtools-hook-dashboard.md
create mode 100644 examples/ts-react-chat/src/routes/generation-hooks.tsx
create mode 100644 packages/typescript/ai-client/src/devtools.ts
create mode 100644 packages/typescript/ai-client/tests/devtools.test.ts
create mode 100644 packages/typescript/ai-client/tests/generation-devtools.test.ts
delete mode 100644 packages/typescript/ai-devtools/src/components/ConversationDetails.tsx
delete mode 100644 packages/typescript/ai-devtools/src/components/ConversationsList.tsx
delete mode 100644 packages/typescript/ai-devtools/src/components/conversation/ActivityEventsTab.tsx
delete mode 100644 packages/typescript/ai-devtools/src/components/conversation/ChunkBadges.tsx
delete mode 100644 packages/typescript/ai-devtools/src/components/conversation/ChunkItem.tsx
delete mode 100644 packages/typescript/ai-devtools/src/components/conversation/ChunksCollapsible.tsx
delete mode 100644 packages/typescript/ai-devtools/src/components/conversation/ChunksTab.tsx
delete mode 100644 packages/typescript/ai-devtools/src/components/conversation/ConversationHeader.tsx
delete mode 100644 packages/typescript/ai-devtools/src/components/conversation/ConversationTabs.tsx
delete mode 100644 packages/typescript/ai-devtools/src/components/conversation/MessageCard.tsx
delete mode 100644 packages/typescript/ai-devtools/src/components/conversation/MessageGroup.tsx
delete mode 100644 packages/typescript/ai-devtools/src/components/conversation/MessagesTab.tsx
delete mode 100644 packages/typescript/ai-devtools/src/components/conversation/SummariesTab.tsx
delete mode 100644 packages/typescript/ai-devtools/src/components/conversation/ToolCallDisplay.tsx
create mode 100644 packages/typescript/ai-devtools/src/components/hooks/GenerationPanel.tsx
create mode 100644 packages/typescript/ai-devtools/src/components/hooks/HookDashboard.tsx
create mode 100644 packages/typescript/ai-devtools/src/components/hooks/HookDetails.tsx
create mode 100644 packages/typescript/ai-devtools/src/components/hooks/ToolFixtureForm.tsx
create mode 100644 packages/typescript/ai-devtools/src/components/hooks/hook-dashboard-model.ts
create mode 100644 packages/typescript/ai-devtools/src/components/hooks/index.ts
create mode 100644 packages/typescript/ai-devtools/src/components/hooks/preview-messages.ts
create mode 100644 packages/typescript/ai-devtools/src/components/hooks/preview-model.ts
delete mode 100644 packages/typescript/ai-devtools/src/components/list/ConversationRow.tsx
delete mode 100644 packages/typescript/ai-devtools/src/components/list/index.ts
create mode 100644 packages/typescript/ai-devtools/src/store/hook-registry.ts
create mode 100644 packages/typescript/ai-devtools/src/store/message-event-utils.ts
create mode 100644 packages/typescript/ai-devtools/tests/hook-dashboard-model.test.ts
create mode 100644 packages/typescript/ai-devtools/tests/hook-registry.test.ts
create mode 100644 packages/typescript/ai-devtools/tests/message-event-utils.test.ts
create mode 100644 packages/typescript/ai-devtools/tests/preview-messages.test.ts
create mode 100644 packages/typescript/ai-devtools/tests/preview-model.test.ts
create mode 100644 packages/typescript/ai-event-client/src/envelope.ts
create mode 100644 packages/typescript/ai-event-client/tests/emit.test.ts
create mode 100644 packages/typescript/ai-event-client/tests/envelope.test.ts
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..d1f9d857d 100644
--- a/docs/getting-started/devtools.md
+++ b/docs/getting-started/devtools.md
@@ -16,11 +16,30 @@ 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.
+
+## 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 cba72ffdb..7c5928fe6 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 { grokText } from '@tanstack/ai-grok'
import { groqText } from '@tanstack/ai-groq'
@@ -45,19 +49,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
@@ -75,29 +95,22 @@ const GuitarRecommendationSchema = z.object({
nextSteps: z.array(z.string()).describe('Practical follow-up actions'),
})
-type Provider =
- | 'openai'
- | 'openai-chat'
- | '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',
- 'grok',
- 'groq',
- 'openrouter',
- 'openrouter-responses',
- ])
- .optional(),
- model: z.string().optional(),
- stream: z.boolean().optional(),
-})
+const PROVIDERS = [
+ 'openai',
+ 'openai-chat',
+ '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)
+}
function adapterFor(provider: Provider, model?: string): AnyTextAdapter {
switch (provider) {
@@ -181,42 +194,104 @@ 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)
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,
}) as AsyncIterable
const withCounts = withTrailingPhaseCounts(
@@ -229,28 +304,26 @@ 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,
})
- 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'
@@ -259,6 +332,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 runImage()}
+ onReset={image.reset}
+ >
+
+ {[1, 2, 3, 4].map((count) => (
+
+ ))}
+
+
+ {image.result?.images.map((item, index) => (
+

+ ))}
+
+
+
+ void runAudio()}
+ onReset={audio.reset}
+ >
+
+ {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}
+
+
+
+
+
+
+
+
+ {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 = ``
+ 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 e0155eb06..478d7bb32 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.'
@@ -131,18 +134,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)
@@ -153,160 +155,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/src/chat-client.ts b/packages/typescript/ai-client/src/chat-client.ts
index 505f36e3a..2070bde04 100644
--- a/packages/typescript/ai-client/src/chat-client.ts
+++ b/packages/typescript/ai-client/src/chat-client.ts
@@ -5,6 +5,7 @@ import {
normalizeToUIMessage,
} from '@tanstack/ai'
import { DefaultChatClientEventEmitter } from './events'
+import { ClientDevtoolsBridge } from './devtools'
import { normalizeConnectionAdapter } from './connection-adapters'
import type {
AnyClientTool,
@@ -16,7 +17,11 @@ import type {
ConnectionAdapter,
SubscribeConnectionAdapter,
} from './connection-adapters'
-import type { ChatClientEventEmitter } from './events'
+import type {
+ ChatClientEventEmitter,
+ ChatClientRunEventContext,
+} from './events'
+import type { AIDevtoolsChatSnapshot, AIDevtoolsToolFixture } from './devtools'
import type {
ChatClientOptions,
ChatClientState,
@@ -47,8 +52,13 @@ export class ChatClient {
private abortController: AbortController | null = null
private readonly events: ChatClientEventEmitter
private readonly clientToolsRef: { current: Map }
+ private readonly devtoolsBridge: ClientDevtoolsBridge
private currentStreamId: string | null = null
private currentMessageId: string | null = null
+ private currentRunId: string | null = null
+ private currentRunThreadId: string | null = null
+ private lastStreamId: string | null = null
+ private lastRunEventContext: ChatClientRunEventContext | undefined
private readonly postStreamActions: Array<() => Promise> = []
// Track pending client tool executions to await them before stream finalization
private readonly pendingToolExecutions: Map> = new Map()
@@ -64,6 +74,7 @@ export class ChatClient {
private draining = false
private sessionGenerating = false
private readonly activeRunIds = new Set()
+ private devtoolsMounted = false
private readonly callbacksRef: {
current: {
@@ -107,6 +118,24 @@ export class ChatClient {
}
}
+ this.devtoolsBridge = new ClientDevtoolsBridge({
+ hookId: this.uniqueId,
+ clientId: this.uniqueId,
+ threadId: this.threadId,
+ metadata: {
+ hookName: options.devtools?.hookName ?? 'useChat',
+ outputKind: options.devtools?.outputKind ?? 'chat',
+ ...(options.devtools?.framework
+ ? { framework: options.devtools.framework }
+ : {}),
+ },
+ getSnapshot: () => this.getDevtoolsSnapshot(),
+ getTools: () => this.clientToolsRef.current.values(),
+ applyToolFixture: (fixture) => {
+ return this.applyToolFixture(fixture)
+ },
+ })
+
this.callbacksRef = {
current: {
onResponse: options.onResponse || (() => {}),
@@ -137,8 +166,10 @@ export class ChatClient {
? { initialMessages: options.initialMessages }
: {}),
events: {
- onMessagesChange: (messages: Array) => {
- this.callbacksRef.current.onMessagesChange(messages)
+ onMessagesChange: (messages) => {
+ this.callbacksRef.current.onMessagesChange(
+ messages as Array,
+ )
},
onStreamStart: () => {
this.setStatus('streaming')
@@ -147,20 +178,21 @@ export class ChatClient {
if (!assistantMessageId) {
return
}
- const messages = this.processor.getMessages()
+ const messages = this.processor.getMessages() as Array
const assistantMessage = messages.find(
- (m: UIMessage) => m.id === assistantMessageId,
+ (m) => m.id === assistantMessageId,
)
if (assistantMessage) {
this.currentMessageId = assistantMessage.id
this.events.messageAppended(
assistantMessage,
this.currentStreamId || undefined,
+ this.getCurrentRunEventContext(),
)
}
},
- onStreamEnd: (message: UIMessage) => {
- this.callbacksRef.current.onFinish(message)
+ onStreamEnd: (message) => {
+ this.callbacksRef.current.onFinish(message as UIMessage)
this.setStatus('ready')
// Resolve the processing-complete promise so streamResponse can continue
this.resolveProcessing()
@@ -171,7 +203,12 @@ export class ChatClient {
onTextUpdate: (messageId: string, content: string) => {
// Emit text update to devtools
if (this.currentStreamId) {
- this.events.textUpdated(this.currentStreamId, messageId, content)
+ this.events.textUpdated(
+ this.currentStreamId,
+ messageId,
+ content,
+ this.getCurrentRunEventContext(),
+ )
}
},
onThinkingUpdate: (messageId: string, content: string) => {
@@ -181,9 +218,47 @@ export class ChatClient {
this.currentStreamId,
messageId,
content,
+ undefined,
+ this.getCurrentRunEventContext(),
)
}
},
+ onStructuredOutputChange: (args) => {
+ const streamId =
+ this.currentStreamId ??
+ this.lastStreamId ??
+ this.generateUniqueId('stream')
+ 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 } : {}),
+ },
+ this.getCurrentOrLastRunEventContext(),
+ )
+ this.emitDevtoolsSnapshot()
+ },
onToolCallStateChange: (
messageId: string,
toolCallId: string,
@@ -191,8 +266,8 @@ export class ChatClient {
args: string,
) => {
// Get the tool name from the messages
- const messages = this.processor.getMessages()
- const message = messages.find((m: UIMessage) => m.id === messageId)
+ const messages = this.processor.getMessages() as Array
+ const message = messages.find((m) => m.id === messageId)
const toolCallPart = message?.parts.find(
(p: MessagePart): p is ToolCallPart =>
p.type === 'tool-call' && p.id === toolCallId,
@@ -208,6 +283,7 @@ export class ChatClient {
toolName,
state,
args,
+ this.getCurrentRunEventContext(),
)
}
},
@@ -220,24 +296,31 @@ export class ChatClient {
const clientTool = this.clientToolsRef.current.get(args.toolName)
const executeFunc = clientTool?.execute
if (executeFunc) {
+ const runEventContext = this.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 +337,22 @@ 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 toolCallContext = this.findToolCallContext(args.toolCallId)
+ const streamId =
+ this.currentStreamId ??
+ this.lastStreamId ??
+ this.generateUniqueId('stream')
+
+ this.events.approvalRequested(
+ streamId,
+ toolCallContext?.messageId ?? this.currentMessageId ?? '',
+ args.toolCallId,
+ args.toolName,
+ args.input,
+ args.approvalId,
+ this.getCurrentOrLastRunEventContext(),
+ )
+ this.emitDevtoolsSnapshot()
},
onCustomEvent: (
eventType: string,
@@ -274,8 +363,18 @@ export class ChatClient {
},
},
})
+ }
+ mountDevtools(): void {
+ if (this.devtoolsMounted) {
+ return
+ }
+
+ this.devtoolsMounted = true
this.events.clientCreated(this.processor.getMessages().length)
+ this.devtoolsBridge.emitRegistered()
+ this.devtoolsBridge.emitToolsRegistered()
+ this.devtoolsBridge.emitSnapshot()
}
private generateUniqueId(prefix: string): string {
@@ -286,27 +385,32 @@ export class ChatClient {
this.isLoading = isLoading
this.callbacksRef.current.onLoadingChange(isLoading)
this.events.loadingChanged(isLoading)
+ this.emitDevtoolsSnapshot()
}
private setStatus(status: ChatClientState): void {
this.status = status
this.callbacksRef.current.onStatusChange(status)
+ this.emitDevtoolsSnapshot()
}
private setIsSubscribed(isSubscribed: boolean): void {
this.isSubscribed = isSubscribed
this.callbacksRef.current.onSubscriptionChange(isSubscribed)
+ this.emitDevtoolsSnapshot()
}
private setConnectionStatus(status: ConnectionStatus): void {
this.connectionStatus = status
this.callbacksRef.current.onConnectionStatusChange(status)
+ this.emitDevtoolsSnapshot()
}
private setSessionGenerating(isGenerating: boolean): void {
if (this.sessionGenerating === isGenerating) return
this.sessionGenerating = isGenerating
this.callbacksRef.current.onSessionGeneratingChange(isGenerating)
+ this.emitDevtoolsSnapshot()
}
private resetSessionGenerating(): void {
@@ -318,6 +422,455 @@ export class ChatClient {
this.error = error
this.callbacksRef.current.onErrorChange(error)
this.events.errorChanged(error?.message || null)
+ this.emitDevtoolsSnapshot()
+ }
+
+ private getDevtoolsSnapshot(): AIDevtoolsChatSnapshot {
+ return {
+ messages: this.processor.getMessages() as Array,
+ 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 emitDevtoolsSnapshot(): void {
+ this.devtoolsBridge.emitSnapshot()
+ }
+
+ private getCurrentRunEventContext(): ChatClientRunEventContext | undefined {
+ if (!this.currentRunId) {
+ return undefined
+ }
+
+ return {
+ threadId: this.currentRunThreadId ?? this.threadId,
+ runId: this.currentRunId,
+ }
+ }
+
+ private getCurrentOrLastRunEventContext():
+ | ChatClientRunEventContext
+ | undefined {
+ return this.getCurrentRunEventContext() ?? this.lastRunEventContext
+ }
+
+ private findToolCallContext(
+ toolCallId: string,
+ ): { messageId: string; part: ToolCallPart } | undefined {
+ const messages = this.processor.getMessages() as Array
+ for (const message of messages) {
+ const part = message.parts.find(
+ (candidate): candidate is ToolCallPart =>
+ candidate.type === 'tool-call' && candidate.id === toolCallId,
+ )
+ if (part) {
+ return { messageId: message.id, part }
+ }
+ }
+ return undefined
+ }
+
+ private prepareRunContextForChunk(chunk: StreamChunk): void {
+ if (chunk.type !== 'RUN_STARTED') {
+ return
+ }
+
+ this.currentRunId = chunk.runId
+ this.currentRunThreadId =
+ typeof chunk.threadId === 'string' ? chunk.threadId : this.threadId
+ this.lastRunEventContext = {
+ threadId: this.currentRunThreadId,
+ runId: this.currentRunId,
+ }
+ }
+
+ private clearRunContextAfterChunk(chunk: StreamChunk): void {
+ if (chunk.type !== 'RUN_FINISHED' && chunk.type !== 'RUN_ERROR') {
+ return
+ }
+
+ const runId =
+ 'runId' in chunk && typeof chunk.runId === 'string'
+ ? chunk.runId
+ : undefined
+
+ if (!runId || runId === this.currentRunId) {
+ this.currentRunId = null
+ this.currentRunThreadId = null
+ }
+ }
+
+ private async applyToolFixture(
+ _fixture: AIDevtoolsToolFixture,
+ ): Promise {
+ const fixture = _fixture
+ const messages = this.processor.getMessages() as Array
+ const threadId = fixture.threadId ?? this.threadId
+ if (fixture.execute) {
+ await this.executeToolFixture(fixture, messages, threadId)
+ return
+ }
+
+ const replay = this.createReplayMessageFromFixture(fixture, messages)
+ const message = replay.message
+ const toolCallId = replay.toolCallId
+ const messageId = message.id
+
+ this.events.messageAppended(message, undefined, {
+ threadId,
+ toolCallId,
+ ...(fixture.runId ? { runId: fixture.runId } : {}),
+ })
+
+ this.processor.setMessages([...messages, message])
+ this.events.toolFixtureApplied({
+ hookId: this.uniqueId,
+ 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.emitDevtoolsSnapshot()
+ }
+
+ private async executeToolFixture(
+ 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: this.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.processor.setMessages([...messages, message])
+ this.emitDevtoolsSnapshot()
+
+ const clientTool = this.clientToolsRef.current.get(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(options: {
+ fixture: AIDevtoolsToolFixture
+ messageId: string
+ toolCallId: string
+ threadId: string
+ output: unknown
+ errorText?: string
+ }): void {
+ const state = options.errorText ? 'output-error' : 'output-available'
+ this.events.toolResultAdded(
+ options.toolCallId,
+ options.fixture.toolName,
+ options.output,
+ state,
+ {
+ threadId: options.threadId,
+ ...(options.fixture.runId ? { runId: options.fixture.runId } : {}),
+ toolCallId: options.toolCallId,
+ },
+ )
+ this.processor.addToolResult(
+ options.toolCallId,
+ options.output,
+ options.errorText,
+ )
+ this.events.toolFixtureApplied({
+ hookId: this.uniqueId,
+ threadId: options.threadId,
+ ...(options.fixture.runId ? { runId: options.fixture.runId } : {}),
+ toolName: options.fixture.toolName,
+ input: options.fixture.input,
+ output: options.output,
+ execute: true,
+ messageId: options.messageId,
+ toolCallId: options.toolCallId,
+ ...(options.errorText ? { errorText: options.errorText } : {}),
+ })
+ this.emitDevtoolsSnapshot()
+ }
+
+ private createReplayMessageFromFixture(
+ fixture: AIDevtoolsToolFixture,
+ messages: Array,
+ ): { message: UIMessage; toolCallId: string } {
+ const clonedMessage = this.cloneFixtureSourceMessage(fixture, messages)
+ if (clonedMessage) return clonedMessage
+
+ 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: this.stringifyFixtureValue(fixture.input),
+ input: fixture.input,
+ state: 'input-complete',
+ output: fixture.output,
+ },
+ {
+ type: 'tool-result',
+ toolCallId,
+ content: this.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) => this.cloneFixtureMessagePart(part, toolCallIds))
+ .filter((part): part is MessagePart => Boolean(part))
+ const mappedFixtureToolCallId = fixture.toolCallId
+ ? toolCallIds.get(fixture.toolCallId)
+ : undefined
+ this.hydrateToolCallOutputs(parts, {
+ ...(mappedFixtureToolCallId
+ ? { mappedToolCallId: mappedFixtureToolCallId }
+ : {}),
+ output: fixture.output,
+ })
+
+ if (parts.length === 0) return undefined
+
+ const toolCallId =
+ (fixture.toolCallId ? toolCallIds.get(fixture.toolCallId) : undefined) ??
+ this.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 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
+ }
+
+ private firstToolCallId(parts: Array): string | undefined {
+ const toolCall = parts.find((part) => part.type === 'tool-call')
+ return toolCall?.type === 'tool-call' ? toolCall.id : undefined
+ }
+
+ private 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 = this.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
+ }
+ }
+ }
+
+ private parseFixtureResultContent(content: string): unknown {
+ try {
+ return JSON.parse(content)
+ } catch {
+ return content
+ }
+ }
+
+ private resolveFixtureMessageId(
+ messageId: string | undefined,
+ messages: Array,
+ ): string {
+ if (messageId && !messages.some((message) => message.id === messageId)) {
+ return messageId
+ }
+ return this.generateUniqueId('fixture-msg')
+ }
+
+ private resolveFixtureToolCallId(
+ toolCallId: string | undefined,
+ messages: Array,
+ ): string {
+ if (toolCallId && !this.hasToolCallId(messages, toolCallId)) {
+ return toolCallId
+ }
+ return this.generateUniqueId('fixture-tool-call')
+ }
+
+ private 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
+ }),
+ )
+ }
+
+ private 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)
+ }
}
private abortSubscriptionLoop(): void {
@@ -409,11 +962,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.prepareRunContextForChunk(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') {
@@ -434,6 +988,7 @@ export class ChatClient {
this.setSessionGenerating(this.activeRunIds.size > 0)
this.resolveProcessing()
}
+ this.clearRunContextAfterChunk(chunk)
// Yield control back to event loop for UI updates
await new Promise((resolve) => setTimeout(resolve, 0))
}
@@ -510,6 +1065,7 @@ export class ChatClient {
content: string | MultimodalContent,
body?: Record,
): Promise {
+ this.mountDevtools()
const emptyMessage = typeof content === 'string' && !content.trim()
if (emptyMessage || this.isLoading) {
return
@@ -526,6 +1082,7 @@ export class ChatClient {
normalizedContent.id,
)
this.events.messageSent(userMessage.id, normalizedContent.content)
+ this.emitDevtoolsSnapshot()
await this.streamResponse()
}
@@ -548,6 +1105,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)
@@ -563,8 +1121,9 @@ export class ChatClient {
this.events.messageAppended(uiMessage)
// Add to messages
- const messages = this.processor.getMessages()
+ const messages = this.processor.getMessages() as Array
this.processor.setMessages([...messages, uiMessage])
+ this.emitDevtoolsSnapshot()
// If stream is in progress, queue the response for after it ends
if (this.isLoading) {
@@ -602,10 +1161,12 @@ 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)
- const messages = this.processor.getMessages()
+ const messages = this.processor.getMessages() as Array
// Call onResponse callback
await this.callbacksRef.current.onResponse()
@@ -639,6 +1200,7 @@ export class ChatClient {
// Generate stream ID — assistant message will be created by stream events
this.currentStreamId = this.generateUniqueId('stream')
+ this.lastStreamId = this.currentStreamId
this.currentMessageId = null
// Reset processor stream state for new response — prevents stale
@@ -674,6 +1236,23 @@ export class ChatClient {
),
forwardedProps: { ...mergedBody },
}
+ this.currentRunId = runContext.runId
+ this.lastRunEventContext = {
+ threadId: this.threadId,
+ runId: runContext.runId,
+ }
+ activeDevtoolsRunId = runContext.runId
+ this.devtoolsBridge.emitRunLifecycle(
+ 'run:created',
+ runContext.runId,
+ 'created',
+ )
+ this.devtoolsBridge.emitRunLifecycle(
+ 'run:started',
+ runContext.runId,
+ 'started',
+ )
+ this.emitDevtoolsSnapshot()
// Send through normalized connection (pushes chunks to subscription queue)
await this.connection.send(messages, mergedBody, signal, runContext)
@@ -690,6 +1269,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 +1292,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 {
@@ -717,10 +1322,28 @@ export class ChatClient {
if (generation === this.streamGeneration) {
this.currentStreamId = null
this.currentMessageId = null
+ this.currentRunId = null
+ this.currentRunThreadId = 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()
@@ -728,7 +1351,7 @@ export class ChatClient {
// but ONLY if the model indicated it wants to continue (finishReason !== 'stop').
// When finishReason is 'stop', the model is done — don't re-send.
if (streamCompletedSuccessfully) {
- const messages = this.processor.getMessages()
+ const messages = this.processor.getMessages() as Array
const lastPart = messages.at(-1)?.parts.at(-1)
const { finishReason } = this.processor.getState()
@@ -787,12 +1410,12 @@ export class ChatClient {
* Reload the last assistant message
*/
async reload(): Promise {
- const messages = this.processor.getMessages()
+ const messages = this.processor.getMessages() as Array
if (messages.length === 0) return
// Find the last user message
const lastUserMessageIndex = messages.findLastIndex(
- (m: UIMessage) => m.role === 'user',
+ (m) => m.role === 'user',
)
if (lastUserMessageIndex === -1) return
@@ -806,6 +1429,7 @@ export class ChatClient {
// Remove all messages after the last user message
this.processor.removeMessagesAfter(lastUserMessageIndex)
+ this.emitDevtoolsSnapshot()
// Resend
await this.streamResponse()
@@ -826,6 +1450,7 @@ export class ChatClient {
this.processor.clearMessages()
this.setError(undefined)
this.events.messagesCleared()
+ this.emitDevtoolsSnapshot()
}
/**
@@ -838,11 +1463,25 @@ export class ChatClient {
state?: 'output-available' | 'output-error'
errorText?: string
}): Promise {
+ await this.addToolResultInternal(result, this.getCurrentRunEventContext())
+ }
+
+ 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
@@ -869,7 +1508,7 @@ export class ChatClient {
approved: boolean
}): Promise {
// Find the tool call ID from the approval ID
- const messages = this.processor.getMessages()
+ const messages = this.processor.getMessages() as Array
let foundToolCallId: string | undefined
for (const msg of messages) {
@@ -888,11 +1527,13 @@ export class ChatClient {
response.id,
foundToolCallId,
response.approved,
+ this.getCurrentOrLastRunEventContext(),
)
}
// Add response via processor
this.processor.addToolApprovalResponse(response.id, response.approved)
+ this.emitDevtoolsSnapshot()
// If stream is in progress, queue continuation check for after it ends
if (this.isLoading) {
@@ -963,7 +1604,7 @@ export class ChatClient {
* a text-only response has nothing to auto-send.
*/
private shouldAutoSend(): boolean {
- const messages = this.processor.getMessages()
+ const messages = this.processor.getMessages() as Array
const lastAssistant = messages.findLast((m) => m.role === 'assistant')
if (!lastAssistant) return false
const hasToolCalls = lastAssistant.parts.some((p) => p.type === 'tool-call')
@@ -975,7 +1616,7 @@ export class ChatClient {
* Get current messages
*/
getMessages(): Array {
- return this.processor.getMessages()
+ return this.processor.getMessages() as Array
}
/**
@@ -1028,6 +1669,7 @@ export class ChatClient {
*/
setMessagesManually(messages: Array): void {
this.processor.setMessages(messages)
+ this.emitDevtoolsSnapshot()
}
/**
@@ -1075,7 +1717,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 +1729,8 @@ export class ChatClient {
for (const tool of options.tools) {
this.clientToolsRef.current.set(tool.name, tool)
}
+ this.devtoolsBridge.emitToolsRegistered()
+ this.emitDevtoolsSnapshot()
}
if (options.onResponse !== undefined) {
this.callbacksRef.current.onResponse = options.onResponse
@@ -1116,4 +1760,14 @@ export class ChatClient {
this.callbacksRef.current.onCustomEvent = options.onCustomEvent
}
}
+
+ dispose(): void {
+ this.unsubscribe()
+ this.devtoolsBridge.dispose()
+ this.devtoolsMounted = false
+ }
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null
}
diff --git a/packages/typescript/ai-client/src/devtools.ts b/packages/typescript/ai-client/src/devtools.ts
new file mode 100644
index 000000000..0807adb29
--- /dev/null
+++ b/packages/typescript/ai-client/src/devtools.ts
@@ -0,0 +1,691 @@
+import {
+ aiEventClient,
+ createAIDevtoolsEventEnvelope,
+ emitAIDevtoolsEvent,
+} from '@tanstack/ai-event-client'
+import { convertSchemaToJsonSchema } from '@tanstack/ai'
+import type { AnyClientTool } from '@tanstack/ai'
+import type { AIDevtoolsEventVisibility } from '@tanstack/ai-event-client'
+import type { ChatClientState, ConnectionStatus, UIMessage } from './types'
+
+export interface AIDevtoolsClientMetadata {
+ 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> {
+ private 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.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}`
+}
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..b513f91f1 100644
--- a/packages/typescript/ai-client/src/generation-client.ts
+++ b/packages/typescript/ai-client/src/generation-client.ts
@@ -1,7 +1,20 @@
import { GENERATION_EVENTS } from './generation-types'
+import {
+ ClientDevtoolsBridge,
+ createAIDevtoolsGenerationPreview,
+} from './devtools'
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,
+ AIDevtoolsGenerationPreview,
+ AIDevtoolsGenerationProgress,
+ AIDevtoolsGenerationRunSnapshot,
+} from './devtools'
import type {
GenerationClientOptions,
GenerationClientState,
@@ -25,6 +38,33 @@ interface GenerationCallbacks {
onStatusChange?: ((status: GenerationClientState) => void) | undefined
}
+interface AIDevtoolsGenerationSnapshot extends Record<
+ string,
+ unknown
+> {
+ input: unknown | null
+ result: TOutput | null
+ preview: AIDevtoolsGenerationPreview
+ progress: AIDevtoolsGenerationProgress | null
+ status: GenerationClientState
+ isLoading: boolean
+ activeRunId: string | null
+ runs: Array>
+ error?: string
+}
+
+interface GenerationRunPatch {
+ input?: unknown | null
+ result?: TOutput | null
+ preview?: AIDevtoolsGenerationPreview
+ progress?: AIDevtoolsGenerationProgress | null
+ status?: string
+ isLoading?: boolean
+ completedAt?: number
+ error?: string
+ clearError?: boolean
+}
+
/**
* A lightweight, generic client for one-shot generation tasks
* (image, speech, transcription, summarize).
@@ -67,13 +107,26 @@ export class GenerationClient<
> {
private readonly connection: ConnectConnectionAdapter | undefined
private readonly fetcher: GenerationFetcher | undefined
+ private readonly uniqueId: string
+ private readonly devtoolsMetadata: AIDevtoolsClientMetadata
+ private readonly devtoolsBridge: ClientDevtoolsBridge<
+ AIDevtoolsGenerationSnapshot
+ >
+ 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 activeRunId: string | null = null
+ private activeRunStarted = false
+ private devtoolsRuns: Array> = []
+ private readonly maxDevtoolsRuns = 20
private abortController: AbortController | null = null
private readonly callbacksRef: GenerationCallbacks
+ private devtoolsMounted = false
constructor(
options: GenerationClientOptions &
@@ -85,6 +138,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 +154,25 @@ export class GenerationClient<
onErrorChange: options.onErrorChange,
onStatusChange: options.onStatusChange,
}
+
+ this.devtoolsMetadata = this.createDevtoolsMetadata(options.devtools)
+ this.devtoolsBridge = new ClientDevtoolsBridge({
+ hookId: this.uniqueId,
+ clientId: this.uniqueId,
+ threadId: this.threadId,
+ metadata: this.devtoolsMetadata,
+ getSnapshot: () => this.getDevtoolsSnapshot(),
+ })
+ }
+
+ mountDevtools(): void {
+ if (this.devtoolsMounted) {
+ return
+ }
+
+ this.devtoolsMounted = true
+ this.devtoolsBridge.emitRegistered()
+ this.devtoolsBridge.emitSnapshot()
}
/**
@@ -107,8 +181,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.beginDevtoolsRun(input)
this.setIsLoading(true)
this.setStatus('generating')
this.setError(undefined)
@@ -124,26 +202,45 @@ 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.ensureDevtoolsRunStarted(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') {
+ this.finishDevtoolsRun(
+ this.activeRunId ?? 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.finishDevtoolsRun(
+ this.activeRunId ?? runId,
+ 'run:errored',
+ 'errored',
+ error.message,
+ )
this.callbacksRef.onError?.(error)
} finally {
this.abortController = null
@@ -156,15 +253,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.ensureDevtoolsRunStarted(chunk.runId)
+ break
+ }
case 'CUSTOM': {
+ this.ensureDevtoolsRunStarted(streamRunId ?? fallbackRunId)
if (chunk.name === GENERATION_EVENTS.RESULT) {
this.setResult(chunk.value as TResult)
} else if (chunk.name === GENERATION_EVENTS.PROGRESS) {
@@ -172,15 +282,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.ensureDevtoolsRunStarted(chunk.runId)
this.setStatus('success')
break
}
case 'RUN_ERROR': {
+ this.ensureDevtoolsRunStarted(
+ chunkRunId ?? streamRunId ?? fallbackRunId,
+ )
// Prefer spec `message`; fall back to deprecated `error.message`
const msg =
(chunk.message as string | undefined) ||
@@ -198,6 +313,7 @@ export class GenerationClient<
* Abort any in-flight generation request.
*/
stop(): void {
+ const runId = this.activeRunId
if (this.abortController) {
this.abortController.abort()
this.abortController = null
@@ -205,6 +321,9 @@ export class GenerationClient<
this.setIsLoading(false)
if (this.status === 'generating') {
this.setStatus('idle')
+ if (runId) {
+ this.finishDevtoolsRun(runId, 'run:cancelled', 'cancelled')
+ }
}
}
@@ -214,6 +333,8 @@ export class GenerationClient<
reset(): void {
this.stop()
this.setResult(null)
+ this.input = null
+ this.progress = null
this.setError(undefined)
this.setStatus('idle')
}
@@ -246,6 +367,12 @@ export class GenerationClient<
}
}
+ dispose(): void {
+ this.stop()
+ this.devtoolsBridge.dispose()
+ this.devtoolsMounted = false
+ }
+
// ===========================
// Getters
// ===========================
@@ -273,41 +400,299 @@ export class GenerationClient<
private setResult(rawResult: TResult | null): void {
if (rawResult === null) {
this.result = null
+ this.updateActiveDevtoolsRun({
+ result: null,
+ preview: this.createDevtoolsPreview(null),
+ })
this.callbacksRef.onResultChange?.(null)
+ this.emitDevtoolsState()
return
}
if (this.callbacksRef.onResult) {
const transformed = this.callbacksRef.onResult(rawResult)
if (transformed === null) {
+ this.emitDevtoolsState()
// null return → keep previous result unchanged
return
}
if (transformed !== undefined) {
// Non-null, non-undefined → use transformed value
this.result = transformed
+ this.updateActiveDevtoolsRun({
+ result: this.result,
+ preview: this.createDevtoolsPreview(this.result),
+ clearError: true,
+ })
this.callbacksRef.onResultChange?.(this.result)
+ this.emitDevtoolsState()
return
}
}
// No onResult callback, or callback returned void → use raw value
this.result = rawResult as TOutput
+ this.updateActiveDevtoolsRun({
+ result: this.result,
+ preview: this.createDevtoolsPreview(this.result),
+ clearError: true,
+ })
this.callbacksRef.onResultChange?.(this.result)
+ this.emitDevtoolsState()
}
private setIsLoading(isLoading: boolean): void {
this.isLoading = isLoading
+ this.updateActiveDevtoolsRun({ isLoading })
this.callbacksRef.onLoadingChange?.(isLoading)
+ this.emitDevtoolsState()
}
private setError(error: Error | undefined): void {
this.error = error
+ this.updateActiveDevtoolsRun(
+ error ? { error: error.message } : { clearError: true },
+ )
this.callbacksRef.onErrorChange?.(error)
+ this.emitDevtoolsState()
}
private setStatus(status: GenerationClientState): void {
this.status = status
+ this.updateActiveDevtoolsRun({ status })
this.callbacksRef.onStatusChange?.(status)
+ this.emitDevtoolsState()
+ }
+
+ 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.updateActiveDevtoolsRun({ progress: this.progress })
+ this.emitDevtoolsState()
+ }
+
+ private beginDevtoolsRun(input: TInput): string {
+ const runId = this.generateUniqueId('run')
+ this.activeRunId = runId
+ this.activeRunStarted = false
+ this.upsertDevtoolsRun(runId, {
+ input,
+ result: null,
+ preview: this.createDevtoolsPreview(null),
+ progress: null,
+ status: 'generating',
+ isLoading: true,
+ clearError: true,
+ })
+ return runId
+ }
+
+ private ensureDevtoolsRunStarted(runId: string): void {
+ if (this.activeRunStarted && this.activeRunId === runId) {
+ return
+ }
+
+ if (
+ !this.activeRunStarted &&
+ this.activeRunId &&
+ this.activeRunId !== runId
+ ) {
+ this.renameDevtoolsRun(this.activeRunId, runId)
+ }
+
+ this.activeRunId = runId
+ this.activeRunStarted = true
+ this.upsertDevtoolsRun(runId, {
+ status: 'generating',
+ isLoading: true,
+ clearError: true,
+ })
+ this.devtoolsBridge.emitRunLifecycle('run:started', runId, 'started')
+ this.emitDevtoolsState()
+ }
+
+ private finishDevtoolsRun(
+ runId: string,
+ eventType: 'run:completed' | 'run:errored' | 'run:cancelled',
+ status: 'completed' | 'errored' | 'cancelled',
+ error?: string,
+ ): void {
+ this.ensureDevtoolsRunStarted(runId)
+ const completedAt = Date.now()
+ const completedProgress =
+ status === 'completed' ? this.completeDevtoolsProgress() : this.progress
+ const runStatus =
+ status === 'completed'
+ ? 'success'
+ : status === 'errored'
+ ? 'error'
+ : 'cancelled'
+
+ this.upsertDevtoolsRun(runId, {
+ status: runStatus,
+ isLoading: false,
+ progress: completedProgress,
+ completedAt,
+ ...(error ? { error } : { clearError: true }),
+ })
+
+ if (this.activeRunId === runId) {
+ this.activeRunId = null
+ }
+ this.activeRunStarted = false
+ this.devtoolsBridge.emitRunLifecycle(eventType, runId, status, {
+ ...(error ? { error } : {}),
+ })
+ this.emitDevtoolsState()
+ }
+
+ private getDevtoolsSnapshot(): AIDevtoolsGenerationSnapshot {
+ return {
+ input: this.input,
+ result: this.result,
+ preview: this.createDevtoolsPreview(this.result),
+ progress: this.progress,
+ status: this.status,
+ isLoading: this.isLoading,
+ activeRunId: this.activeRunId,
+ runs: this.devtoolsRuns,
+ ...(this.error ? { error: this.error.message } : {}),
+ }
+ }
+
+ private updateActiveDevtoolsRun(patch: GenerationRunPatch): void {
+ if (!this.activeRunId) return
+ this.upsertDevtoolsRun(this.activeRunId, patch)
+ }
+
+ private upsertDevtoolsRun(
+ 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.input,
+ result: null,
+ preview: this.createDevtoolsPreview(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.maxDevtoolsRuns) {
+ this.devtoolsRuns = this.devtoolsRuns.slice(-this.maxDevtoolsRuns)
+ }
+ }
+
+ private renameDevtoolsRun(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))
+ }
+
+ private completeDevtoolsProgress(): AIDevtoolsGenerationProgress | null {
+ if (!this.progress) return null
+
+ this.progress = {
+ value: 100,
+ ...(this.progress.message ? { message: this.progress.message } : {}),
+ }
+ return this.progress
+ }
+
+ private createDevtoolsPreview(
+ result: TOutput | null,
+ ): AIDevtoolsGenerationPreview {
+ return createAIDevtoolsGenerationPreview({
+ outputKind: this.devtoolsMetadata.outputKind,
+ result,
+ })
+ }
+
+ private emitDevtoolsState(): void {
+ this.devtoolsBridge.emitUpdated()
+ this.devtoolsBridge.emitSnapshot()
+ }
+
+ private createDevtoolsMetadata(
+ metadata?: Partial,
+ ): AIDevtoolsClientMetadata {
+ return {
+ hookName: metadata?.hookName ?? 'useGeneration',
+ ...(metadata?.framework ? { framework: metadata.framework } : {}),
+ ...(metadata?.outputKind ? { outputKind: metadata.outputKind } : {}),
+ }
+ }
+
+ 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/src/generation-types.ts b/packages/typescript/ai-client/src/generation-types.ts
index 88573bd59..71a4e4d7b 100644
--- a/packages/typescript/ai-client/src/generation-types.ts
+++ b/packages/typescript/ai-client/src/generation-types.ts
@@ -1,5 +1,6 @@
import type { StreamChunk } from '@tanstack/ai'
import type { ConnectConnectionAdapter } from './connection-adapters'
+import type { AIDevtoolsClientMetadata } from './devtools'
// ===========================
// Inference Utilities
@@ -106,6 +107,9 @@ 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
+
/**
* Callback when a result is received. Can optionally return a transformed value
* that replaces the stored result.
diff --git a/packages/typescript/ai-client/src/index.ts b/packages/typescript/ai-client/src/index.ts
index 98f75a999..8e8f42d39 100644
--- a/packages/typescript/ai-client/src/index.ts
+++ b/packages/typescript/ai-client/src/index.ts
@@ -40,6 +40,14 @@ export type {
} from './generation-types'
export { GENERATION_EVENTS } from './generation-types'
export { clientTools, createChatClientOptions } from './types'
+export {
+ createAIDevtoolsGenerationPreview,
+ type AIDevtoolsClientMetadata,
+ 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..dfd489336 100644
--- a/packages/typescript/ai-client/src/types.ts
+++ b/packages/typescript/ai-client/src/types.ts
@@ -13,6 +13,7 @@ import type {
VideoPart,
} from '@tanstack/ai'
import type { ConnectionAdapter } from './connection-adapters'
+import type { AIDevtoolsClientMetadata } from './devtools'
export type { StructuredOutputPart } from '@tanstack/ai'
@@ -204,6 +205,7 @@ export interface UIMessage<
export interface ChatClientOptions<
TTools extends ReadonlyArray = any,
+ TContext = unknown,
> {
/**
* Connection adapter for streaming.
@@ -328,6 +330,16 @@ 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
+
/**
* Stream processing options (optional)
* Configure chunking strategy
@@ -385,7 +397,10 @@ export function clientTools>(
*/
export function createChatClientOptions<
const TTools extends ReadonlyArray,
->(options: ChatClientOptions): ChatClientOptions {
+ TContext = unknown,
+>(
+ options: ChatClientOptions,
+): ChatClientOptions {
return options
}
@@ -404,4 +419,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..79b82ca9b 100644
--- a/packages/typescript/ai-client/src/video-generation-client.ts
+++ b/packages/typescript/ai-client/src/video-generation-client.ts
@@ -1,7 +1,20 @@
import { GENERATION_EVENTS } from './generation-types'
+import {
+ ClientDevtoolsBridge,
+ createAIDevtoolsGenerationPreview,
+} from './devtools'
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,
+ AIDevtoolsGenerationPreview,
+ AIDevtoolsGenerationProgress,
+ AIDevtoolsGenerationRunSnapshot,
+} from './devtools'
import type {
GenerationClientState,
GenerationFetcher,
@@ -34,6 +47,34 @@ interface VideoCallbacks {
onVideoStatusChange?: ((status: VideoStatusInfo | null) => void) | undefined
}
+interface AIDevtoolsVideoSnapshot extends Record {
+ input: VideoGenerateInput | null
+ result: TOutput | null
+ preview: AIDevtoolsGenerationPreview
+ progress: AIDevtoolsGenerationProgress | null
+ jobId: string | null
+ videoStatus: VideoStatusInfo | null
+ status: GenerationClientState
+ isLoading: boolean
+ activeRunId: string | null
+ runs: Array>
+ error?: string
+}
+
+interface VideoRunPatch {
+ input?: unknown | null
+ result?: TOutput | null
+ preview?: AIDevtoolsGenerationPreview
+ progress?: AIDevtoolsGenerationProgress | null
+ jobId?: string | null
+ videoStatus?: VideoStatusInfo | null
+ status?: string
+ isLoading?: boolean
+ completedAt?: number
+ error?: string
+ clearError?: boolean
+}
+
/**
* A specialized client for job-based video generation.
*
@@ -74,16 +115,29 @@ export class VideoGenerationClient {
private readonly fetcher:
| GenerationFetcher
| undefined
+ private readonly uniqueId: string
+ private readonly devtoolsMetadata: AIDevtoolsClientMetadata
+ private readonly devtoolsBridge: ClientDevtoolsBridge<
+ AIDevtoolsVideoSnapshot
+ >
+ 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
private error: Error | undefined = undefined
private status: GenerationClientState = 'idle'
+ private activeRunId: string | null = null
+ private activeRunStarted = false
+ private devtoolsRuns: Array> = []
+ private readonly maxDevtoolsRuns = 20
private abortController: AbortController | null = null
private readonly callbacksRef: VideoCallbacks
+ private devtoolsMounted = false
constructor(
options: VideoGenerationClientOptions &
@@ -95,6 +149,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 +169,25 @@ export class VideoGenerationClient {
onJobIdChange: options.onJobIdChange,
onVideoStatusChange: options.onVideoStatusChange,
}
+
+ this.devtoolsMetadata = this.createDevtoolsMetadata(options.devtools)
+ this.devtoolsBridge = new ClientDevtoolsBridge({
+ hookId: this.uniqueId,
+ clientId: this.uniqueId,
+ threadId: this.threadId,
+ metadata: this.devtoolsMetadata,
+ getSnapshot: () => this.getDevtoolsSnapshot(),
+ })
+ }
+
+ mountDevtools(): void {
+ if (this.devtoolsMounted) {
+ return
+ }
+
+ this.devtoolsMounted = true
+ this.devtoolsBridge.emitRegistered()
+ this.devtoolsBridge.emitSnapshot()
}
/**
@@ -120,8 +195,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.beginDevtoolsRun(input)
this.setIsLoading(true)
this.setStatus('generating')
this.setError(undefined)
@@ -134,21 +213,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.finishDevtoolsRun(
+ this.activeRunId ?? 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.finishDevtoolsRun(
+ this.activeRunId ?? runId,
+ 'run:errored',
+ 'errored',
+ error.message,
+ )
this.callbacksRef.onError?.(error)
} finally {
this.abortController = null
@@ -162,6 +259,7 @@ export class VideoGenerationClient {
private async generateWithFetcher(
input: VideoGenerateInput,
signal: AbortSignal,
+ runId: string,
): Promise {
if (!this.fetcher) return
@@ -171,8 +269,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.ensureDevtoolsRunStarted(runId)
this.setResult(result)
this.setStatus('success')
}
@@ -184,15 +283,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.ensureDevtoolsRunStarted(chunk.runId)
+ break
+ }
case 'CUSTOM': {
+ this.ensureDevtoolsRunStarted(streamRunId ?? fallbackRunId)
if (chunk.name === GENERATION_EVENTS.VIDEO_JOB_CREATED) {
const { jobId } = chunk.value as { jobId: string }
this.setJobId(jobId)
@@ -202,7 +314,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 +323,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.ensureDevtoolsRunStarted(chunk.runId)
this.setStatus('success')
break
}
case 'RUN_ERROR': {
+ this.ensureDevtoolsRunStarted(
+ chunkRunId ?? streamRunId ?? fallbackRunId,
+ )
// Prefer spec `message`; fall back to deprecated `error.message`
const msg =
(chunk.message as string | undefined) ||
@@ -237,6 +354,7 @@ export class VideoGenerationClient {
* Abort any in-flight generation or polling.
*/
stop(): void {
+ const runId = this.activeRunId
if (this.abortController) {
this.abortController.abort()
this.abortController = null
@@ -244,6 +362,9 @@ export class VideoGenerationClient {
this.setIsLoading(false)
if (this.status === 'generating') {
this.setStatus('idle')
+ if (runId) {
+ this.finishDevtoolsRun(runId, 'run:cancelled', 'cancelled')
+ }
}
}
@@ -253,6 +374,8 @@ export class VideoGenerationClient {
reset(): void {
this.stop()
this.setResult(null)
+ this.input = null
+ this.progress = null
this.setJobId(null)
this.setVideoStatus(null)
this.setError(undefined)
@@ -299,6 +422,12 @@ export class VideoGenerationClient {
}
}
+ dispose(): void {
+ this.stop()
+ this.devtoolsBridge.dispose()
+ this.devtoolsMounted = false
+ }
+
// ===========================
// Getters
// ===========================
@@ -334,51 +463,347 @@ export class VideoGenerationClient {
private setResult(rawResult: VideoGenerateResult | null): void {
if (rawResult === null) {
this.result = null
+ this.updateActiveDevtoolsRun({
+ result: null,
+ preview: this.createDevtoolsPreview(null, this.videoStatus),
+ })
this.callbacksRef.onResultChange?.(null)
+ this.emitDevtoolsState()
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) {
+ this.emitDevtoolsState()
// null return → keep previous result unchanged
return
}
if (transformed !== undefined) {
// Non-null, non-undefined → use transformed value
this.result = transformed
+ this.updateActiveDevtoolsRun({
+ result: this.result,
+ preview: this.createDevtoolsPreview(this.result, this.videoStatus),
+ clearError: true,
+ })
this.callbacksRef.onResultChange?.(this.result)
+ this.emitDevtoolsState()
return
}
}
// No onResult callback, or callback returned void → use raw value
this.result = rawResult as TOutput
+ this.updateActiveDevtoolsRun({
+ result: this.result,
+ preview: this.createDevtoolsPreview(this.result, this.videoStatus),
+ clearError: true,
+ })
this.callbacksRef.onResultChange?.(this.result)
+ this.emitDevtoolsState()
}
private setJobId(jobId: string | null): void {
this.jobId = jobId
+ this.updateActiveDevtoolsRun({ jobId })
this.callbacksRef.onJobIdChange?.(jobId)
+ this.emitDevtoolsState()
}
private setVideoStatus(status: VideoStatusInfo | null): void {
this.videoStatus = status
+ this.updateActiveDevtoolsRun({
+ videoStatus: status,
+ preview: this.createDevtoolsPreview(this.result, status),
+ })
this.callbacksRef.onVideoStatusChange?.(status)
+ this.emitDevtoolsState()
}
private setIsLoading(isLoading: boolean): void {
this.isLoading = isLoading
+ this.updateActiveDevtoolsRun({ isLoading })
this.callbacksRef.onLoadingChange?.(isLoading)
+ this.emitDevtoolsState()
}
private setError(error: Error | undefined): void {
this.error = error
+ this.updateActiveDevtoolsRun(
+ error ? { error: error.message } : { clearError: true },
+ )
this.callbacksRef.onErrorChange?.(error)
+ this.emitDevtoolsState()
}
private setStatus(status: GenerationClientState): void {
this.status = status
+ this.updateActiveDevtoolsRun({ status })
this.callbacksRef.onStatusChange?.(status)
+ this.emitDevtoolsState()
+ }
+
+ 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.updateActiveDevtoolsRun({ progress: this.progress })
+ this.emitDevtoolsState()
+ }
+
+ private beginDevtoolsRun(input: VideoGenerateInput): string {
+ const runId = this.generateUniqueId('run')
+ this.activeRunId = runId
+ this.activeRunStarted = false
+ this.upsertDevtoolsRun(runId, {
+ input,
+ result: null,
+ preview: this.createDevtoolsPreview(null, null),
+ progress: null,
+ jobId: null,
+ videoStatus: null,
+ status: 'generating',
+ isLoading: true,
+ clearError: true,
+ })
+ return runId
+ }
+
+ private ensureDevtoolsRunStarted(runId: string): void {
+ if (this.activeRunStarted && this.activeRunId === runId) {
+ return
+ }
+
+ if (
+ !this.activeRunStarted &&
+ this.activeRunId &&
+ this.activeRunId !== runId
+ ) {
+ this.renameDevtoolsRun(this.activeRunId, runId)
+ }
+
+ this.activeRunId = runId
+ this.activeRunStarted = true
+ this.upsertDevtoolsRun(runId, {
+ status: 'generating',
+ isLoading: true,
+ clearError: true,
+ })
+ this.devtoolsBridge.emitRunLifecycle('run:started', runId, 'started')
+ this.emitDevtoolsState()
+ }
+
+ private finishDevtoolsRun(
+ runId: string,
+ eventType: 'run:completed' | 'run:errored' | 'run:cancelled',
+ status: 'completed' | 'errored' | 'cancelled',
+ error?: string,
+ ): void {
+ this.ensureDevtoolsRunStarted(runId)
+ const completedAt = Date.now()
+ const completedProgress =
+ status === 'completed' ? this.completeDevtoolsProgress() : this.progress
+ const runStatus =
+ status === 'completed'
+ ? 'success'
+ : status === 'errored'
+ ? 'error'
+ : 'cancelled'
+
+ this.upsertDevtoolsRun(runId, {
+ status: runStatus,
+ isLoading: false,
+ progress: completedProgress,
+ jobId: this.jobId,
+ videoStatus: this.videoStatus,
+ preview: this.createDevtoolsPreview(this.result, this.videoStatus),
+ completedAt,
+ ...(error ? { error } : { clearError: true }),
+ })
+
+ if (this.activeRunId === runId) {
+ this.activeRunId = null
+ }
+ this.activeRunStarted = false
+ this.devtoolsBridge.emitRunLifecycle(eventType, runId, status, {
+ ...(error ? { error } : {}),
+ })
+ this.emitDevtoolsState()
+ }
+
+ private getDevtoolsSnapshot(): AIDevtoolsVideoSnapshot {
+ return {
+ input: this.input,
+ result: this.result,
+ preview: this.createDevtoolsPreview(this.result, this.videoStatus),
+ progress: this.progress,
+ jobId: this.jobId,
+ videoStatus: this.videoStatus,
+ status: this.status,
+ isLoading: this.isLoading,
+ activeRunId: this.activeRunId,
+ runs: this.devtoolsRuns,
+ ...(this.error ? { error: this.error.message } : {}),
+ }
+ }
+
+ private updateActiveDevtoolsRun(patch: VideoRunPatch): void {
+ if (!this.activeRunId) return
+ this.upsertDevtoolsRun(this.activeRunId, patch)
+ }
+
+ private upsertDevtoolsRun(
+ runId: string,
+ patch: VideoRunPatch,
+ ): 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.input,
+ result: null,
+ preview: this.createDevtoolsPreview(null, this.videoStatus),
+ 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 ('jobId' in patch) {
+ next.jobId = patch.jobId ?? null
+ }
+ if ('videoStatus' in patch) {
+ next.videoStatus = patch.videoStatus ?? 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.maxDevtoolsRuns) {
+ this.devtoolsRuns = this.devtoolsRuns.slice(-this.maxDevtoolsRuns)
+ }
+ }
+
+ private renameDevtoolsRun(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))
+ }
+
+ private completeDevtoolsProgress(): AIDevtoolsGenerationProgress {
+ this.progress = {
+ value: 100,
+ ...(this.progress?.message ? { message: this.progress.message } : {}),
+ }
+ return this.progress
+ }
+
+ private createCompletedVideoStatus(
+ result: VideoGenerateResult,
+ ): VideoStatusInfo {
+ return {
+ jobId: result.jobId,
+ status: result.status,
+ progress: 100,
+ url: result.url,
+ }
+ }
+
+ private createDevtoolsPreview(
+ result: TOutput | null,
+ videoStatus: VideoStatusInfo | null,
+ ): AIDevtoolsGenerationPreview {
+ return createAIDevtoolsGenerationPreview({
+ outputKind: this.devtoolsMetadata.outputKind,
+ result,
+ videoStatus,
+ })
+ }
+
+ private emitDevtoolsState(): void {
+ this.devtoolsBridge.emitUpdated()
+ this.devtoolsBridge.emitSnapshot()
+ }
+
+ private createDevtoolsMetadata(
+ metadata?: Partial,
+ ): AIDevtoolsClientMetadata {
+ return {
+ hookName: metadata?.hookName ?? 'useGenerateVideo',
+ outputKind: metadata?.outputKind ?? 'video',
+ ...(metadata?.framework ? { framework: metadata.framework } : {}),
+ }
+ }
+
+ 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..4e631c6c1
--- /dev/null
+++ b/packages/typescript/ai-client/tests/devtools.test.ts
@@ -0,0 +1,1677 @@
+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
+ }) {
+ 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: {
+ 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('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/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-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..aba09d93d 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,
+ dispatchAIDevtoolsEvent,
+} 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,70 @@ function DevtoolsContent() {
onMount(() => {
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
+
+ const openedAt = Date.now()
+ dispatchAIDevtoolsEvent('devtools:opened', {
+ ...createAIDevtoolsEventEnvelope({
+ eventType: 'devtools:opened',
+ source: 'devtools',
+ visibility: 'devtools-action',
+ timestamp: openedAt,
+ }),
+ })
+ dispatchAIDevtoolsEvent('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)
+ 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 (
-
- {/* Chunk Header */}
-
- {/* Chunk Number */}
-
- #{props.index + 1}
- 1}>
-
- ({chunkCount()} chunks)
-
-
-
-
- {/* Type Badge */}
-
-
-
- {props.chunk.type}
-
-
-
- {/* Tool Name Badge */}
-
-
- 🔧 {props.chunk.toolName}
-
-
-
- {/* Timestamp */}
-
- {formatTimestamp(props.chunk.timestamp)}
-
-
- {/* Toggle Raw JSON Button */}
-
-
-
- {/* Chunk Content */}
-
-
-
- {props.chunk.content}
-
-
-
-
- ❌ {props.chunk.error}
-
-
-
-
- ✓{' '}
- {isLarge()
- ? `Finish: ${props.chunk.finishReason}`
- : props.chunk.finishReason}
-
-
-
-
-
- ⚠️ Approval Required
-
-
-
- Input: {JSON.stringify(props.chunk.input, null, 2)}
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Raw JSON View */}
-
-
- {JSON.stringify(props.chunk, null, 2)}
-
-
-
- )
-}
diff --git a/packages/typescript/ai-devtools/src/components/conversation/ChunksCollapsible.tsx b/packages/typescript/ai-devtools/src/components/conversation/ChunksCollapsible.tsx
deleted file mode 100644
index b47375c67..000000000
--- a/packages/typescript/ai-devtools/src/components/conversation/ChunksCollapsible.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { For, Show } from 'solid-js'
-import { useStyles } from '../../styles/use-styles'
-import { ChunkItem } from './ChunkItem'
-import { ChunkBadges } from './ChunkBadges'
-import type { Component } from 'solid-js'
-import type { Chunk } from '../../store/ai-store'
-
-interface ChunksCollapsibleProps {
- chunks: Array
-}
-
-export const ChunksCollapsible: Component = (props) => {
- const styles = useStyles()
-
- const accumulatedContent = () =>
- props.chunks
- .filter((c) => c.type === 'content' && (c.content || c.delta))
- .map((c) => c.delta || c.content)
- .join('')
-
- // Total raw chunks = sum of all chunkCounts
- const totalRawChunks = () =>
- props.chunks.reduce((sum, c) => sum + (c.chunkCount || 1), 0)
-
- return (
-
-
-
- ▶
-
- 📦 {totalRawChunks()} chunks
-
-
-
- {/* Accumulated Content Preview */}
-
-
- {accumulatedContent()}
-
-
-
-
-
-
- {(chunk, index) => (
-
- )}
-
-
-
-
- )
-}
diff --git a/packages/typescript/ai-devtools/src/components/conversation/ChunksTab.tsx b/packages/typescript/ai-devtools/src/components/conversation/ChunksTab.tsx
deleted file mode 100644
index b5e2710b4..000000000
--- a/packages/typescript/ai-devtools/src/components/conversation/ChunksTab.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-import { For, Show } from 'solid-js'
-import { useStyles } from '../../styles/use-styles'
-import { MessageGroup } from './MessageGroup'
-import type { Component } from 'solid-js'
-import type { Chunk, Message } from '../../store/ai-store'
-
-interface ChunksTabProps {
- chunks: Array
- messages?: Array
-}
-
-export const ChunksTab: Component = (props) => {
- const styles = useStyles()
-
- // Create a map of messageId to usage for quick lookup
- const usageByMessageId = () => {
- const map = new Map()
- props.messages?.forEach((msg) => {
- if (msg.usage) {
- map.set(msg.id, msg.usage)
- }
- })
- return map
- }
-
- const groupedChunks = () => {
- const groups = new Map>()
-
- props.chunks.forEach((chunk) => {
- const key = chunk.messageId || 'no-message-id'
- let group = groups.get(key)
- if (!group) {
- group = []
- groups.set(key, group)
- }
- group.push(chunk)
- })
-
- return Array.from(groups.entries())
- }
-
- // Calculate total raw chunks (sum of all chunkCounts)
- const totalRawChunks = () =>
- props.chunks.reduce((sum, c) => sum + (c.chunkCount || 1), 0)
-
- return (
- 0}
- fallback={
- No chunks yet
- }
- >
-
- {/* Stream Header */}
-
-
- {/* Message Groups */}
-
-
- {([messageId, chunks], groupIndex) => (
-
- )}
-
-
-
-
- )
-}
diff --git a/packages/typescript/ai-devtools/src/components/conversation/ConversationHeader.tsx b/packages/typescript/ai-devtools/src/components/conversation/ConversationHeader.tsx
deleted file mode 100644
index d2a43851f..000000000
--- a/packages/typescript/ai-devtools/src/components/conversation/ConversationHeader.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-import { Show } from 'solid-js'
-import { useStyles } from '../../styles/use-styles'
-import { formatDuration } from '../utils'
-import type { Component } from 'solid-js'
-import type { Conversation } from '../../store/ai-context'
-
-interface ConversationHeaderProps {
- conversation: Conversation
-}
-
-export const ConversationHeader: Component = (
- props,
-) => {
- const styles = useStyles()
- const conv = () => props.conversation
-
- const iterationCount = () => conv().iterationCount ?? conv().iterations.length
- const totalDuration = () => {
- const completedAt = conv().completedAt
- if (!completedAt) return undefined
- return completedAt - conv().startedAt
- }
-
- const totalMessages = () => conv().messages.length
-
- const totalToolCalls = () => {
- let count = 0
- for (const iter of conv().iterations) {
- if (iter.finishReason === 'tool_calls') count++
- }
- return count
- }
-
- // Sum usage across all iterations
- const totalUsage = () => {
- if (conv().usage) return conv().usage
- if (conv().iterations.length === 0) return undefined
- let promptTokens = 0
- let completionTokens = 0
- for (const iter of conv().iterations) {
- if (iter.usage) {
- promptTokens += iter.usage.promptTokens
- completionTokens += iter.usage.completionTokens
- }
- }
- if (promptTokens === 0 && completionTokens === 0) return undefined
- return {
- promptTokens,
- completionTokens,
- totalTokens: promptTokens + completionTokens,
- }
- }
-
- return (
-
- )
-}
diff --git a/packages/typescript/ai-devtools/src/components/conversation/ConversationTabs.tsx b/packages/typescript/ai-devtools/src/components/conversation/ConversationTabs.tsx
deleted file mode 100644
index c003feeb8..000000000
--- a/packages/typescript/ai-devtools/src/components/conversation/ConversationTabs.tsx
+++ /dev/null
@@ -1,205 +0,0 @@
-import { Show, createEffect, createSignal } from 'solid-js'
-import { useStyles } from '../../styles/use-styles'
-import type { Component } from 'solid-js'
-import type { Conversation } from '../../store/ai-context'
-
-export type TabType =
- | 'messages'
- | 'chunks'
- | 'summaries'
- | 'image'
- | 'speech'
- | 'transcription'
- | 'video'
-
-interface ConversationTabsProps {
- conversation: Conversation
- activeTab: TabType
- onTabChange: (tab: TabType) => void
-}
-
-export const ConversationTabs: Component = (props) => {
- const styles = useStyles()
- const conv = () => props.conversation
- const hasIterations = () => conv().iterations.length > 0
-
- // Total raw chunks = sum of all chunkCounts
- const totalRawChunks = () =>
- conv().chunks.reduce((sum, c) => sum + (c.chunkCount || 1), 0)
-
- const summariesCount = () => conv().summaries?.length ?? 0
- const imageCount = () => conv().imageEvents?.length ?? 0
- const speechCount = () => conv().speechEvents?.length ?? 0
- const transcriptionCount = () => conv().transcriptionEvents?.length ?? 0
- const videoCount = () => conv().videoEvents?.length ?? 0
-
- const [imagePulse, setImagePulse] = createSignal(false)
- const [speechPulse, setSpeechPulse] = createSignal(false)
- const [transcriptionPulse, setTranscriptionPulse] = createSignal(false)
- const [videoPulse, setVideoPulse] = createSignal(false)
-
- const triggerPulse = (setter: (value: boolean) => void) => {
- setter(true)
- setTimeout(() => setter(false), 2000)
- }
-
- let previousImageCount = 0
- let previousSpeechCount = 0
- let previousTranscriptionCount = 0
- let previousVideoCount = 0
-
- createEffect(() => {
- const count = imageCount()
- if (count > 0 && previousImageCount === 0) {
- triggerPulse(setImagePulse)
- }
- previousImageCount = count
- })
-
- createEffect(() => {
- const count = speechCount()
- if (count > 0 && previousSpeechCount === 0) {
- triggerPulse(setSpeechPulse)
- }
- previousSpeechCount = count
- })
-
- createEffect(() => {
- const count = transcriptionCount()
- if (count > 0 && previousTranscriptionCount === 0) {
- triggerPulse(setTranscriptionPulse)
- }
- previousTranscriptionCount = count
- })
-
- createEffect(() => {
- const count = videoCount()
- if (count > 0 && previousVideoCount === 0) {
- triggerPulse(setVideoPulse)
- }
- previousVideoCount = count
- })
-
- // When iterations exist, only show activity tabs (no messages/chunks)
- const hasMessages = () =>
- !hasIterations() && conv().type === 'client' && conv().messages.length > 0
- const hasChunks = () =>
- !hasIterations() && (conv().chunks.length > 0 || conv().type === 'server')
- const hasSummaries = () => conv().hasSummarize || summariesCount() > 0
- const hasImage = () => conv().hasImage || imageCount() > 0
- const hasSpeech = () => conv().hasSpeech || speechCount() > 0
- const hasTranscription = () =>
- conv().hasTranscription || transcriptionCount() > 0
- const hasVideo = () => conv().hasVideo || videoCount() > 0
-
- // Count how many tabs would be visible
- const visibleTabCount = () => {
- let count = 0
- if (hasMessages()) count++
- if (hasChunks() && conv().type === 'server') count++
- if (hasSummaries()) count++
- if (hasImage()) count++
- if (hasSpeech()) count++
- if (hasTranscription()) count++
- if (hasVideo()) count++
- return count
- }
-
- // Don't render tabs if only one tab would be visible
- if (visibleTabCount() <= 1) {
- return null
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/packages/typescript/ai-devtools/src/components/conversation/IterationCard.tsx b/packages/typescript/ai-devtools/src/components/conversation/IterationCard.tsx
index fd096e3c1..64a28529f 100644
--- a/packages/typescript/ai-devtools/src/components/conversation/IterationCard.tsx
+++ b/packages/typescript/ai-devtools/src/components/conversation/IterationCard.tsx
@@ -9,8 +9,18 @@ import {
} from 'solid-js'
import { JsonTree } from '@tanstack/devtools-ui'
import { useStyles } from '../../styles/use-styles'
+import {
+ createHoverTarget,
+ getHoverDataAttributes,
+ isMessageHighlighted,
+ structuredOutputJsonItems,
+ structuredOutputPartId,
+ toolCallPartId,
+ toolResultPartId,
+} from '../hooks/preview-model'
import { formatDuration } from '../utils'
import { SystemPromptItem } from './IterationTimeline'
+import type { HoverTarget } from '../hooks/preview-model'
import type {
Iteration,
Message,
@@ -25,6 +35,12 @@ interface IterationCardProps {
messages: Array
index: number
isLast: boolean
+ hoverTarget?: HoverTarget | null
+ onHoverTarget?: (target: HoverTarget | null) => void
+}
+
+type StructuredOutputMessagePart = NonNullable[number] & {
+ type: 'structured-output'
}
// --- Step types ---
@@ -32,12 +48,37 @@ interface IterationCardProps {
type IterationStep =
| { kind: 'middleware'; event: MiddlewareEvent }
| { kind: 'thinking'; message: Message }
+ | {
+ kind: 'structured_output'
+ message: Message
+ part: StructuredOutputMessagePart
+ }
| { kind: 'assistant'; message: Message }
| { kind: 'tool_call'; toolCall: ToolCall; message: Message }
| { kind: 'tool_result'; message: Message }
// --- Helpers ---
+function getApprovalStatus(toolCall: ToolCall): string | undefined {
+ if (toolCall.approvalApproved === true || toolCall.state === 'approved') {
+ return 'approved'
+ }
+ if (toolCall.approvalApproved === false || toolCall.state === 'denied') {
+ return 'denied'
+ }
+ if (
+ toolCall.approvalRequired ||
+ toolCall.approvalId ||
+ toolCall.state === 'approval-requested'
+ ) {
+ return 'approval requested'
+ }
+ if (toolCall.state === 'approval-responded') {
+ return 'responded'
+ }
+ return undefined
+}
+
function getIterationLabel(iter: Iteration, displayIndex: number): string {
if (!iter.completedAt) return `Iteration ${displayIndex} — Generating...`
if (iter.finishReason === 'error') return `Iteration ${displayIndex} — Error`
@@ -70,6 +111,15 @@ function buildSteps(
if (msg.thinkingContent) {
steps.push({ kind: 'thinking', message: msg })
}
+ for (const part of msg.parts ?? []) {
+ if (part.type === 'structured-output') {
+ steps.push({
+ kind: 'structured_output',
+ message: msg,
+ part: part as StructuredOutputMessagePart,
+ })
+ }
+ }
if (msg.toolCalls && msg.toolCalls.length > 0) {
for (const tc of msg.toolCalls) {
steps.push({ kind: 'tool_call', toolCall: tc, message: msg })
@@ -169,6 +219,7 @@ const MiddlewareStep: Component<{
const ThinkingStep: Component<{
step: Extract
+ onHoverTarget?: (target: HoverTarget | null) => void
}> = (props) => {
const styles = useStyles()
const s = () => styles().iterationTimeline
@@ -180,7 +231,19 @@ const ThinkingStep: Component<{
return (
<>
-
+
+ props.onHoverTarget?.(
+ createHoverTarget({
+ messageIds: [msg().id],
+ origin: 'timeline',
+ }),
+ )
+ }
+ onMouseLeave={() => props.onHoverTarget?.(null)}
+ >
Thinking
@@ -201,8 +264,78 @@ const ThinkingStep: Component<{
)
}
+const StructuredOutputStep: Component<{
+ step: Extract
+ onHoverTarget?: (target: HoverTarget | null) => void
+}> = (props) => {
+ const styles = useStyles()
+ const s = () => styles().iterationTimeline
+ const [expanded, setExpanded] = createSignal(true)
+ const msg = () => props.step.message
+ const part = () => props.step.part
+ const partId = () => structuredOutputPartId(msg().id)
+ const jsonItems = createMemo(() => structuredOutputJsonItems(part()))
+
+ const badgeClass = () => {
+ if (part().status === 'complete') return s().mwBadgeApproved
+ if (part().status === 'error') return s().mwBadgeError
+ return s().mwBadgeDefault
+ }
+
+ return (
+ <>
+ setExpanded(!expanded())}
+ onMouseEnter={() =>
+ props.onHoverTarget?.(
+ createHoverTarget({
+ messageIds: [msg().id],
+ partIds: [partId()],
+ origin: 'timeline',
+ }),
+ )
+ }
+ onMouseLeave={() => props.onHoverTarget?.(null)}
+ style={{ cursor: 'pointer' }}
+ >
+
+ Structured
+
+ {part().status}
+
+ {'\u25B6'}
+
+
+
+
+
+ {(item) => (
+
+
{item.label}
+
+ }
+ defaultExpansionDepth={1}
+ copyable
+ />
+
+
+ )}
+
+
+
+ >
+ )
+}
+
const AssistantStep: Component<{
step: Extract
+ onHoverTarget?: (target: HoverTarget | null) => void
}> = (props) => {
const styles = useStyles()
const s = () => styles().iterationTimeline
@@ -220,7 +353,19 @@ const AssistantStep: Component<{
return (
<>
-
+
+ props.onHoverTarget?.(
+ createHoverTarget({
+ messageIds: [msg().id],
+ origin: 'timeline',
+ }),
+ )
+ }
+ onMouseLeave={() => props.onHoverTarget?.(null)}
+ >
Response
@@ -245,12 +390,16 @@ const AssistantStep: Component<{
const ToolCallStep: Component<{
step: Extract
+ onHoverTarget?: (target: HoverTarget | null) => void
}> = (props) => {
const styles = useStyles()
const s = () => styles().iterationTimeline
const [argsOpen, setArgsOpen] = createSignal(false)
const [resultOpen, setResultOpen] = createSignal(false)
const tc = () => props.step.toolCall
+ const callPartId = () => toolCallPartId(tc().id)
+ const resultPartId = () => toolResultPartId(tc().id)
+ const messageId = () => props.step.message.id
const parsedArgs = () => {
const raw = tc().arguments || '{}'
@@ -258,6 +407,13 @@ const ToolCallStep: Component<{
}
const hasResult = () => tc().result !== undefined
+ const approvalStatus = () => getApprovalStatus(tc())
+ const approvalBadgeClass = () => {
+ const status = approvalStatus()
+ if (status === 'approved') return s().mwBadgeApproved
+ if (status === 'denied') return s().mwBadgeDenied
+ return s().mwBadgeApproval
+ }
const parsedResult = () => {
if (!hasResult()) return null
@@ -270,14 +426,35 @@ const ToolCallStep: Component<{
return (
<>
setArgsOpen(!argsOpen())}
+ onMouseEnter={() =>
+ props.onHoverTarget?.(
+ createHoverTarget({
+ messageIds: [messageId()],
+ partIds: [callPartId()],
+ origin: 'timeline',
+ }),
+ )
+ }
+ onMouseLeave={() => props.onHoverTarget?.(null)}
style={{ cursor: 'pointer' }}
>
Tool Call
{tc().name}
+
+ {(status) => (
+
+ {status()}
+
+ )}
+
{tc().duration}ms
@@ -298,8 +475,22 @@ const ToolCallStep: Component<{
setResultOpen(!resultOpen())}
+ onMouseEnter={() =>
+ props.onHoverTarget?.(
+ createHoverTarget({
+ messageIds: [messageId()],
+ partIds: [callPartId(), resultPartId()],
+ origin: 'timeline',
+ }),
+ )
+ }
+ onMouseLeave={() => props.onHoverTarget?.(null)}
style={{ cursor: 'pointer' }}
>
@@ -333,6 +524,7 @@ const ToolCallStep: Component<{
const ToolResultStep: Component<{
step: Extract
+ onHoverTarget?: (target: HoverTarget | null) => void
}> = (props) => {
const styles = useStyles()
const s = () => styles().iterationTimeline
@@ -347,8 +539,18 @@ const ToolResultStep: Component<{
return (
<>
setIsOpen(!isOpen())}
+ onMouseEnter={() =>
+ props.onHoverTarget?.(
+ createHoverTarget({
+ messageIds: [msg().id],
+ origin: 'timeline',
+ }),
+ )
+ }
+ onMouseLeave={() => props.onHoverTarget?.(null)}
style={{ cursor: 'pointer' }}
>
@@ -395,6 +597,18 @@ export const IterationCard: Component = (props) => {
const label = () => getIterationLabel(iter(), props.index)
const steps = createMemo(() => buildSteps(iter(), props.messages))
+ const iterationPartIds = createMemo(() =>
+ steps().flatMap((step) => {
+ if (step.kind === 'structured_output') {
+ return [structuredOutputPartId(step.message.id)]
+ }
+ if (step.kind !== 'tool_call') return []
+ const callId = step.toolCall.id
+ return step.toolCall.result === undefined
+ ? [toolCallPartId(callId)]
+ : [toolCallPartId(callId), toolResultPartId(callId)]
+ }),
+ )
/**
* Compute delta usage for display.
@@ -443,6 +657,14 @@ export const IterationCard: Component = (props) => {
return ''
}
+ const isIterationHighlighted = () =>
+ iter().messageIds.some((messageId) =>
+ isMessageHighlighted(messageId, props.hoverTarget ?? null),
+ ) ||
+ iterationPartIds().some((partId) =>
+ (props.hoverTarget?.partIds ?? []).includes(partId),
+ )
+
// Config data from this iteration
const configSubtitle = () => {
const { model, provider } = iter()
@@ -531,13 +753,29 @@ export const IterationCard: Component = (props) => {
return (
{/* Header */}