diff --git a/.changeset/ai-octane-port.md b/.changeset/ai-octane-port.md new file mode 100644 index 0000000000..c5125ad639 --- /dev/null +++ b/.changeset/ai-octane-port.md @@ -0,0 +1,45 @@ +--- +'@tanstack/ai-octane': minor +--- + +Add `@tanstack/ai-octane` — [Octane](https://github.com/octanejs/octane) bindings for TanStack AI. + +This is a port of `@octanejs/tanstack-ai@0.0.11`, which lived in the +octanejs/octane repo as a temporary stopgap. The code moves here essentially +unchanged apart from the rename; the runtime surface is the same. + +The package covers the `@tanstack/ai-react` hook surface — `useChat`, +`useRealtimeChat`, `useMcpAppBridge`, `useGeneration`, `useGenerateImage` / +`Audio` / `Speech` / `Video`, `useTranscription`, `useSummarize`, +`useAudioRecorder` — plus the 30 `@tanstack/ai-client` convenience re-exports, +reusing `@tanstack/ai` and `@tanstack/ai-client` unchanged. SSR through +`octane/server` is supported and tested. + +Three defects found while reviewing the port were fixed rather than mirrored, and +are covered by tests (each verified to fail if the fix is reverted). Issues are +filed upstream so the React adapter can catch up: + +- `useAudioRecorder`'s transforming overload now requires `onComplete`. + Previously, passing any unrelated option (`useAudioRecorder({ onError })`) + matched it, inferred `TOnComplete` as `unknown`, and silently collapsed + `recording`/`stop()` to `unknown`. +- `useGeneration` spreads caller `devtools` metadata before the hardcoded + `framework`/`hookName`, so a caller can no longer misattribute the binding in + the devtools. The sibling hooks already ordered it this way. +- `UseGenerationReturn` is now `` and types `generate` as + `(input: TInput)` instead of widening to `(input: Record)`, so + required and narrow input fields are checked at the call site. This is the one + place the public _type_ surface differs in shape from `@tanstack/ai-react`; + the runtime surface is unchanged. + +Two other things to know: + +- Like Svelte packages shipping `.svelte`, this one publishes **uncompiled + source**. The hook modules are `.tsrx` and are compiled by the consumer's + Octane plugin, so there is no `dist` and `octane` is a required peer. The + `.tsrx.d.ts` companions are checked declaration emits, so the full generic + surface is preserved for TypeScript consumers. +- `useChat` matches the current ChatClient shape: `threadId` identity, queue, + `runId`, interrupts, `attach`/`detach`, and `SendMessageOptions`. The + `./mcp-apps` subpath is not ported (it renders a React-only component). See + `packages/ai-octane/status.json` for the full scope and divergence list. diff --git a/docs/api/ai-octane.md b/docs/api/ai-octane.md new file mode 100644 index 0000000000..8dcbe3d777 --- /dev/null +++ b/docs/api/ai-octane.md @@ -0,0 +1,294 @@ +--- +title: "@tanstack/ai-octane" +id: ai-octane +order: 8 +description: "API reference for @tanstack/ai-octane. Octane hooks including useChat for streaming chat with full type safety." +keywords: + - tanstack ai + - "@tanstack/ai-octane" + - octane + - useChat + - octane hooks + - api reference +--- + +Install `@tanstack/ai-octane`, then call `useChat` the same way you would in React. The hook modules are `.tsrx` and compile in your Octane plugin. + +## Installation + +```bash +npm install @tanstack/ai-octane octane +``` + +`octane` is a required peer. This package publishes uncompiled source, like Svelte packages that ship `.svelte`. + +## `useChat(options)` + +Manages chat state in an Octane component. + +```tsx +import { useState } from 'octane' +import { useChat, fetchServerSentEvents } from '@tanstack/ai-octane' +import { + createChatClientOptions, + type InferChatMessages, +} from '@tanstack/ai-client' +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +const updateUIDef = toolDefinition({ + name: 'updateUI', + description: 'Show a notification in the UI', + inputSchema: z.object({ message: z.string() }), +}) + +export function ChatComponent() { + const [notification, setNotification] = useState(null) + const updateUI = updateUIDef.client((input) => { + setNotification(input.message) + return { success: true } + }) + const tools = [updateUI] + + const chatOptions = createChatClientOptions({ + connection: fetchServerSentEvents('/api/chat'), + tools, + }) + + type ChatMessages = InferChatMessages + + const { messages, sendMessage, isLoading, error, addToolApprovalResponse } = + useChat(chatOptions) + + return ( +
+ {notification} + {isLoading ? 'Loading' : null} + {error ? error.message : null} + + + {messages.length} +
+ ) +} +``` + +The matching server route still runs `chat({ adapter, messages })` and returns SSE. See [Quick Start: Octane](../getting-started/quick-start-octane). + +### Options you pass first + +Extends `ChatClientOptions` from `@tanstack/ai-client`. Pass `connection` or `fetcher`, not both. + +- `connection` or `fetcher` - how the hook talks to your server +- `tools?` - client tool implementations from `.client()` +- `threadId?` - the only identity for this chat. Required when persistence is on +- `initialMessages?` - starting transcript +- `forwardedProps?` - JSON sent to the server on the AG-UI `forwardedProps` field + +### Options you add later + +- `live?` - subscribe on mount, unsubscribe on unmount +- `queue?` - what to do when `sendMessage` runs while a turn is in flight. Default queues +- `interrupts?` - typed interrupt definitions +- `context?` - client-only runtime context for client tools. Not sent to the server +- `onResponse?` / `onChunk?` / `onFinish?` / `onError?` / `onInterruptStateChange?` +- `devtools?` - display options. The hook always tags `framework: 'octane'` +- `body?` - deprecated. Use `forwardedProps` + +Client tools run automatically. There is no `onToolCall` callback. + +Changing `connection` or `fetcher` updates the live `ChatClient`. Changing `threadId` creates a new client. + +### Returns + +```typescript +import type { UIMessage } from '@tanstack/ai-octane' +import type { ModelMessage } from '@tanstack/ai/client' +import type { + MultimodalContent, + ChatClientState, + ConnectionStatus, + QueuedMessage, + SendMessageOptions, +} from '@tanstack/ai-client' + +interface UseChatReturn { + messages: Array + sendMessage: ( + content: string | MultimodalContent, + options?: SendMessageOptions, + ) => Promise + append: (message: ModelMessage | UIMessage) => Promise + addToolResult: (result: { + toolCallId: string + tool: string + output: unknown + state?: 'output-available' | 'output-error' + errorText?: string + }) => Promise + addToolApprovalResponse: (response: { + id: string + approved: boolean + }) => Promise + reload: () => Promise + stop: () => void + isLoading: boolean + error: Error | undefined + status: ChatClientState + isSubscribed: boolean + connectionStatus: ConnectionStatus + sessionGenerating: boolean + setMessages: (messages: Array) => void + clear: () => void + queue: Array + cancelQueued: (id: string) => void + runId: string | null +} +``` + +`queue` holds sends that wait while a run is busy. `runId` is the in-flight turn, or `null`. Interrupt helpers (`interrupts`, `resolveInterrupts`, `cancelInterrupts`, `retryInterrupts`) are on the same object when you pass `interrupts`. + +## Connection adapters + +Re-exported from `@tanstack/ai-client`: + +```typescript +import { + fetchServerSentEvents, + fetchHttpStream, + stream, + type ConnectionAdapter, +} from '@tanstack/ai-octane' +``` + +## Example: basic chat + +```tsx +import { useState } from 'octane' +import { useChat, fetchServerSentEvents } from '@tanstack/ai-octane' + +export function Chat() { + const [input, setInput] = useState('') + const { messages, sendMessage, isLoading } = useChat({ + connection: fetchServerSentEvents('/api/chat'), + }) + + return ( +
+ {messages.map((message) => ( +
+ {message.role}: + {message.parts + .filter((part) => part.type === 'text') + .map((part) => part.content) + .join('')} +
+ ))} + setInput(event.currentTarget.value)} + /> + +
+ ) +} +``` + +## Example: tool approval + +```tsx +import { useChat, fetchServerSentEvents } from '@tanstack/ai-octane' + +export function ChatWithApproval() { + const { messages, sendMessage, addToolApprovalResponse } = useChat({ + connection: fetchServerSentEvents('/api/chat'), + }) + + return ( +
+ + {messages.map((message) => + message.parts.map((part) => { + if ( + part.type !== 'tool-call' || + part.state !== 'approval-requested' || + !part.approval + ) { + return null + } + const approvalId = part.approval.id + return ( +
+

Approve: {part.name}

+ + +
+ ) + }), + )} +
+ ) +} +``` + +## Other hooks + +The package also exports `useRealtimeChat`, `useMcpAppBridge`, `useGeneration`, `useGenerateImage`, `useGenerateAudio`, `useGenerateSpeech`, `useGenerateVideo`, `useTranscription`, `useSummarize`, and `useAudioRecorder`. + +The `./mcp-apps` React `AppRenderer` subpath is not in this package. `useMcpAppBridge` is. + +## Types + +Re-exported from `@tanstack/ai-client`: + +- `UIMessage` +- `ChatClientOptions` +- `InferChatMessages` +- `QueuedMessage`, `SendMessageOptions`, `WhenBusy` + +## Next + +- [Quick Start: Octane](../getting-started/quick-start-octane) +- [Tools](../tools/tools) +- [Client tools](../tools/client-tools) diff --git a/docs/config.json b/docs/config.json index f55bf64f3d..6427d364ee 100644 --- a/docs/config.json +++ b/docs/config.json @@ -13,12 +13,13 @@ "label": "Overview", "to": "getting-started/overview", "addedAt": "2026-04-15", - "updatedAt": "2026-08-19" + "updatedAt": "2026-08-21" }, { "label": "Quick Start: React", "to": "getting-started/quick-start", - "addedAt": "2026-04-15" + "addedAt": "2026-04-15", + "updatedAt": "2026-08-21" }, { "label": "Quick Start: React Native", @@ -46,6 +47,11 @@ "to": "getting-started/quick-start-angular", "addedAt": "2026-06-15" }, + { + "label": "Quick Start: Octane", + "to": "getting-started/quick-start-octane", + "addedAt": "2026-08-21" + }, { "label": "Quick Start: Server Only", "to": "getting-started/quick-start-server", @@ -872,6 +878,11 @@ "to": "api/ai-angular", "addedAt": "2026-06-15", "updatedAt": "2026-08-20" + }, + { + "label": "@tanstack/ai-octane", + "to": "api/ai-octane", + "addedAt": "2026-08-21" } ] }, diff --git a/docs/getting-started/overview.md b/docs/getting-started/overview.md index 98aaeca783..8367d87edf 100644 --- a/docs/getting-started/overview.md +++ b/docs/getting-started/overview.md @@ -101,6 +101,12 @@ Solid hooks for TanStack AI: - Tool approval flow support - Type-safe message handling with `InferChatMessages` +### `@tanstack/ai-octane` +Octane hooks for TanStack AI: +- `useChat` hook for chat interfaces +- Same hook names as `@tanstack/ai-react` +- Compiled by the Octane plugin from `.tsrx` source + ## Adapters With the help of adapters, TanStack AI can connect to various LLM providers. Available adapters include: diff --git a/docs/getting-started/quick-start-octane.md b/docs/getting-started/quick-start-octane.md new file mode 100644 index 0000000000..54d95daf7a --- /dev/null +++ b/docs/getting-started/quick-start-octane.md @@ -0,0 +1,162 @@ +--- +title: "Quick Start: Octane" +id: quick-start-octane +order: 5 +description: "Add a streaming TanStack AI chat component to an Octane app using the useChat hook and the OpenAI adapter." +keywords: + - tanstack ai + - octane + - quick start + - useChat + - streaming chat + - openai + - tsrx +--- + +You have an Octane app and want AI chat. At the end of this page you have a streaming chat component powered by TanStack AI. + +> **Tip:** If you do not want a key per provider, [OpenRouter](../adapters/openrouter) gives you 300+ models with one API key. + +## 1. Install + +```bash +npm install @tanstack/ai @tanstack/ai-octane @tanstack/ai-openai octane +# or +pnpm add @tanstack/ai @tanstack/ai-octane @tanstack/ai-openai octane +``` + +`@tanstack/ai-octane` publishes uncompiled `.tsrx` source. Your Octane plugin compiles it. Add `octane/compiler/vite` (or the rspack / rspeedy equivalent) to the app build. + +## 2. Stream from the server + +Any backend that returns TanStack AI SSE works. This Express handler is one: + +```typescript ignore +import express from 'express' +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' + +const app = express() +app.use(express.json()) + +app.post('/api/chat', async (req, res) => { + const { messages } = req.body + + if (!process.env.OPENAI_API_KEY) { + res.status(500).json({ error: 'OPENAI_API_KEY not configured' }) + return + } + + try { + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + }) + + const response = toServerSentEventsResponse(stream) + res.writeHead(response.status, Object.fromEntries(response.headers)) + + const body = response.body + if (body) { + const reader = body.getReader() + const pump = async () => { + const { done, value } = await reader.read() + if (done) { + res.end() + return + } + res.write(value) + await pump() + } + await pump() + } + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : 'An error occurred', + }) + } +}) + +app.listen(3000, () => console.log('Server running on port 3000')) +``` + +`chat()` uses the AG-UI `threadId` for devtools correlation when the client sends one. + +## 3. Call `useChat` in Octane + +```tsx +import { useState } from 'octane' +import { useChat, fetchServerSentEvents } from '@tanstack/ai-octane' + +export function Chat() { + const [input, setInput] = useState('') + const { messages, sendMessage, isLoading } = useChat({ + connection: fetchServerSentEvents('/api/chat'), + }) + + function handleSubmit() { + if (input.trim() && !isLoading) { + void sendMessage(input) + setInput('') + } + } + + return ( +
+ {messages.map((message) => ( +
+ {message.role === 'assistant' ? 'Assistant' : 'You'} +

+ {message.parts + .filter((part) => part.type === 'text') + .map((part) => part.content) + .join('')} +

+
+ ))} +
{ + event.preventDefault() + handleSubmit() + }} + > + setInput(event.currentTarget.value)} + /> + +
+
+ ) +} +``` + +`useChat` does not own the text box. Hold the value in `useState` and pass it to `sendMessage`. Octane text controls fire `onInput`, not a synthetic `onChange`. + +## 4. Put the API key on the server + +```bash +# OpenRouter (one key, many models) +OPENROUTER_API_KEY=sk-or-... + +# OpenAI +OPENAI_API_KEY=your-openai-api-key +``` + +The server reads this key. Do not send it to the browser. + +## Octane notes + +- **Uncompiled source.** The package has no `dist`. The Octane compiler must see `@tanstack/ai-octane`. +- **Same hook names as React.** `useChat`, `useGeneration`, `useAudioRecorder`, and the rest. Change the import path from `@tanstack/ai-react` to `@tanstack/ai-octane`. +- **Cleanup is automatic.** The hook calls `attach()` on mount and `detach()` plus `dispose()` on unmount. + +## Next + +- [Octane API](../api/ai-octane) for options, queue, interrupts, and tools +- [Tools](../tools/tools) for function calling +- [Adapters](../adapters/openai) for other providers diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index c61269464b..f3ac192b00 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -16,7 +16,7 @@ keywords: Get started with TanStack AI in minutes. This guide will walk you through creating a simple chat application using the React integration and OpenAI adapter. -> **Using a different framework?** See quick-starts for [Vue](./quick-start-vue), [Svelte](./quick-start-svelte), or [server-only Node.js](./quick-start-server). +> **Using a different framework?** See quick-starts for [Vue](./quick-start-vue), [Svelte](./quick-start-svelte), [Octane](./quick-start-octane), or [server-only Node.js](./quick-start-server). > **React Native or Expo app?** Use the headless React hooks with an absolute > server URL and a mobile-compatible transport. See diff --git a/kiira.config.ts b/kiira.config.ts index 6e1fb6c393..b85ad1212a 100644 --- a/kiira.config.ts +++ b/kiira.config.ts @@ -56,6 +56,8 @@ export default defineConfig({ '@mynthio/tanstack-ai-adapter': '^0.0.14', '@cencori/ai-sdk': '^0.4.0', '@soniox/tanstack-ai-adapter': '^0.1.2', + // Octane is a peer of @tanstack/ai-octane, not a root workspace dep. + octane: '^0.1.17', }, overrides: [ // React (and anything not overridden below) uses the automatic JSX @@ -82,5 +84,18 @@ export default defineConfig({ jsx: 'react-jsx', jsxImportSource: 'preact', }, + // Octane snippets compile JSX through octane. Standard TSX works; + // TSRX (`@{`, `@for`) is not TypeScript, so docs use TSX here. + // `octane/jsx-runtime` is types-only via package.json exports; + // kiira's paths["*"] does not read exports, so `octane/dist` points + // at dist/jsx-runtime.d.ts, which exists on disk. + { + include: [ + 'docs/api/ai-octane.md', + 'docs/getting-started/quick-start-octane.md', + ], + jsx: 'react-jsx', + jsxImportSource: 'octane/dist', + }, ], }) diff --git a/packages/ai-octane/README.md b/packages/ai-octane/README.md new file mode 100644 index 0000000000..90352c4184 --- /dev/null +++ b/packages/ai-octane/README.md @@ -0,0 +1,154 @@ +# @tanstack/ai-octane + +[TanStack AI](https://tanstack.com/ai) bindings for the +[Octane](https://github.com/octanejs/octane) UI framework. + +This package ports the `@tanstack/ai-react` hook surface onto Octane while +reusing `@tanstack/ai` and `@tanstack/ai-client` unchanged. The runtime export +surface matches the React adapter, so migration starts by changing the package +import: + +```ts +// before +import { useChat } from '@tanstack/ai-react' + +// after +import { useChat } from '@tanstack/ai-octane' +``` + +The renderer-bearing hook modules are authored as `.tsrx` and compiled by +Octane. Matching `.tsrx.d.ts` companions are checked declaration emits of those +implementations, preserving the complete generic surface for TypeScript +consumers. + +Like Svelte packages shipping `.svelte`, this package publishes **uncompiled +source**: your Octane plugin (`octane/compiler/vite`, or the rspack / rspeedy +equivalents) compiles the `.tsrx` modules as part of your build. There is no +`dist`. + +## Install + +```bash +pnpm add @tanstack/ai-octane @tanstack/ai @tanstack/ai-client octane +``` + +## Usage + +```tsx +import { useState } from 'octane' +import { useChat } from '@tanstack/ai-octane' + +export function Chat() @{ + const [input, setInput] = useState('') + const chat = useChat({ + fetcher: myFetcher, + }) + +
+ @for (const message of chat.messages; key message.id) { +

+ {message.role}: + {message.parts + .filter((part) => part.type === 'text') + .map((part) => part.content) + .join('')} +

+ } + setInput(event.currentTarget.value)} + /> + +
+} +``` + +`useChat` has no input state of its own — hold the text box value in a local +`useState` and pass it to `sendMessage`. Note the `onInput` handler: Octane +drives text controls per keystroke through the native `input` event, not a +synthetic `onChange`. + +## API + +The adapter includes `useChat`, `useRealtimeChat`, `useMcpAppBridge`, +`useGeneration`, `useGenerateImage`, `useGenerateAudio`, `useGenerateSpeech`, +`useGenerateVideo`, `useTranscription`, `useSummarize`, and +`useAudioRecorder`. It also re-exports all 30 `@tanstack/ai-client` +convenience helpers and types (`fetchServerSentEvents`, `fetchHttpStream`, +`xhrServerSentEvents`, `xhrHttpStream`, `stream`, `rpcStream`, +`createChatClientOptions`, `createMcpAppBridge`, and their associated types) +unchanged, mirroring the `@tanstack/ai-react` index. + +Server rendering through `octane/server` is supported. `useChat` renders its +initial message snapshot without browser-only setup. + +## Divergences from `@tanstack/ai-react` + +- The `./mcp-apps` subpath and its `MCPAppResource` component are not ported: + they render `AppRenderer` from the React-only `@mcp-ui/client`, which has no + Octane equivalent. The framework-agnostic `useMcpAppBridge` hook is ported + and available on the main entry. +- Octane uses native events: text/file/recorder inputs drive updates via + `onInput`; there is no synthetic `onChange` layer. +- Octane has no StrictMode double-invoke and always provides `useId`, so no + random-id fallback is needed. +- The devtools bridge is tagged `framework: 'octane'` (upstream sends + `'react'`), so the devtools identify this binding correctly. +- Realtime reconnects and token refreshes use the latest `getToken` and adapter + supplied to the hook; upstream captures the first render's callbacks. +- The declared realtime `onStatusChange` callback is invoked alongside the + hook's state update; upstream `@tanstack/ai-react` currently drops the + external callback. +- Changing `useChat`'s connection or fetcher updates the active `ChatClient` in + place and preserves conversation state. + +### Fixed here, still present upstream + +Three defects were found during review of the port and fixed rather than +mirrored. All are documented in [`status.json`](./status.json) and covered by +tests, and each is tracked upstream so the other adapters can catch up. + +- `useAudioRecorder`'s transforming overload requires `onComplete`. Upstream, + passing any unrelated option (`useAudioRecorder({ onError })`) matched the + transforming overload, inferred `TOnComplete` as `unknown`, and silently + collapsed `recording` and `stop()` to `unknown`. + ([#1001](https://github.com/TanStack/ai/issues/1001)) +- `useGeneration` spreads caller `devtools` metadata _before_ the hardcoded + `framework`/`hookName`, so a caller can't misattribute the binding in the + devtools. `ai-react` spreads it after; `ai-vue` and `ai-solid` already order it + this way. ([#1002](https://github.com/TanStack/ai/issues/1002)) +- `UseGenerationReturn` types `generate` as `(input: TInput)`. + Upstream declares `UseGenerationReturn` and widens `generate` to + `(input: Record)`, so narrow or required input fields go + unchecked. **This is the one place the public type surface differs in shape + from `@tanstack/ai-react`** — the runtime surface is unchanged, so the + "change the import" migration still holds. + ([#1003](https://github.com/TanStack/ai/issues/1003)) + +## Status + +This binding was developed as `@octanejs/tanstack-ai` in the +[octanejs/octane](https://github.com/octanejs/octane) repo as a temporary +stopgap, and moved here — apart from the rename, the only changes are the three +fixes listed above and the test-helper hardening noted in `status.json`. + +Current scope, divergences, and verification state are tracked in +[`status.json`](./status.json). `useChat` matches the current ChatClient shape +(`threadId` identity, queue, interrupts, `attach`/`detach`). + +The port runs TanStack AI's React adapter tests against Octane across all eleven +hooks, with no skipped, todo, or expected-failure cases (except the untestable +auto-resume case noted in `status.json`). An SSR fixture and the upstream +compile-time type tests are also included. + +## License + +MIT — contains source derived from +[TanStack AI](https://github.com/TanStack/ai) (MIT), adapted for Octane. diff --git a/packages/ai-octane/package.json b/packages/ai-octane/package.json new file mode 100644 index 0000000000..546a31c16a --- /dev/null +++ b/packages/ai-octane/package.json @@ -0,0 +1,73 @@ +{ + "name": "@tanstack/ai-octane", + "version": "0.0.0", + "description": "Octane bindings for TanStack AI streaming chat, structured outputs, and media generation.", + "author": "Dominic Gannaway", + "license": "MIT", + "homepage": "https://tanstack.com/ai", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-octane" + }, + "bugs": { + "url": "https://github.com/TanStack/ai/issues" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "type": "module", + "engines": { + "node": ">=22" + }, + "keywords": [ + "ai", + "ai-sdk", + "typescript", + "tanstack", + "octane", + "chat", + "streaming", + "tool-calling", + "structured-outputs", + "media-generation" + ], + "//": "Like Svelte packages shipping .svelte, this package publishes uncompiled source: the hook modules are .tsrx and are compiled by the consumer's Octane plugin. There is therefore no build/dist and no publint test:build target. The .tsrx.d.ts companions are checked declaration emits, so `tsc` still type-checks the full public surface.", + "main": "./src/index.ts", + "module": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src", + "README.md" + ], + "//lint": "The .tsrx.d.ts companions are generated declaration emits of the .tsrx implementations, so they are not hand-formatted and are excluded from lint rather than edited in place (a regeneration would undo any fix).", + "scripts": { + "lint:fix": "oxlint src --type-aware --ignore-pattern '**/*.tsrx.d.ts' --fix", + "test:oxlint": "oxlint src --type-aware --ignore-pattern '**/*.tsrx.d.ts'", + "test:lib": "vitest run", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc && tsc -p typetests/tsconfig.json" + }, + "dependencies": { + "@tanstack/ai-client": "workspace:*" + }, + "peerDependencies": { + "@tanstack/ai": "workspace:^", + "octane": "^0.1.17" + }, + "devDependencies": { + "@octanejs/testing-library": "0.1.14", + "@standard-schema/spec": "^1.1.0", + "@tanstack/ai": "workspace:*", + "@types/node": "^24.10.1", + "@vitest/coverage-v8": "4.1.10", + "happy-dom": "^20.11.2", + "octane": "0.1.17", + "vite": "^8.2.1", + "zod": "^4.2.0" + } +} diff --git a/packages/ai-octane/src/index.ts b/packages/ai-octane/src/index.ts new file mode 100644 index 0000000000..396b37755f --- /dev/null +++ b/packages/ai-octane/src/index.ts @@ -0,0 +1,95 @@ +export { useChat } from './use-chat.tsrx' +export { useRealtimeChat } from './use-realtime-chat.tsrx' +export { useMcpAppBridge } from './use-mcp-app-bridge.tsrx' +export type { UseMcpAppBridgeOptions } from './use-mcp-app-bridge.tsrx' +export type { + DeepPartial, + UseChatOptions, + UseChatReturn, + UIMessage, + ChatRequestBody, + QueuedMessage, + SendMessageOptions, + WhenBusy, + QueueConfig, + QueueStrategy, + QueueOption, +} from './types' +export type { + UseRealtimeChatOptions, + UseRealtimeChatReturn, +} from './realtime-types' + +export { useGeneration } from './use-generation.tsrx' +export type { + UseGenerationOptions, + UseGenerationReturn, +} from './use-generation.tsrx' +export { useGenerateImage } from './use-generate-image.tsrx' +export type { + UseGenerateImageOptions, + UseGenerateImageReturn, +} from './use-generate-image.tsrx' +export { useGenerateAudio } from './use-generate-audio.tsrx' +export type { + UseGenerateAudioOptions, + UseGenerateAudioReturn, +} from './use-generate-audio.tsrx' +export { useGenerateSpeech } from './use-generate-speech.tsrx' +export type { + UseGenerateSpeechOptions, + UseGenerateSpeechReturn, +} from './use-generate-speech.tsrx' +export { useTranscription } from './use-transcription.tsrx' +export type { + UseTranscriptionOptions, + UseTranscriptionReturn, +} from './use-transcription.tsrx' +export { useSummarize } from './use-summarize.tsrx' +export type { + UseSummarizeOptions, + UseSummarizeReturn, +} from './use-summarize.tsrx' +export { useGenerateVideo } from './use-generate-video.tsrx' +export type { + UseGenerateVideoOptions, + UseGenerateVideoReturn, +} from './use-generate-video.tsrx' +export { useAudioRecorder } from './use-audio-recorder.tsrx' +export type { + UseAudioRecorderOptions, + UseAudioRecorderReturn, +} from './use-audio-recorder.tsrx' + +// Re-export from ai-client for convenience (mirror upstream index.ts) +export { + fetchServerSentEvents, + fetchHttpStream, + xhrServerSentEvents, + xhrHttpStream, + stream, + rpcStream, + createChatClientOptions, + createMcpAppBridge, + type McpAppBridge, + type CreateMcpAppBridgeOptions, + type ChatFetcher, + type ChatFetcherInput, + type ChatFetcherOptions, + type ConnectionAdapter, + type ConnectConnectionAdapter, + type SubscribeConnectionAdapter, + type RunAgentInputContext, + type FetchConnectionOptions, + type XhrConnectionOptions, + type InferChatMessages, + type GenerationClientState, + type ImageGenerateInput, + type AudioGenerateInput, + type SpeechGenerateInput, + type TranscriptionGenerateInput, + type SummarizeGenerateInput, + type VideoGenerateInput, + type VideoGenerateResult, + type VideoStatusInfo, +} from '@tanstack/ai-client' diff --git a/packages/ai-octane/src/realtime-types.ts b/packages/ai-octane/src/realtime-types.ts new file mode 100644 index 0000000000..4bf2e45a94 --- /dev/null +++ b/packages/ai-octane/src/realtime-types.ts @@ -0,0 +1,146 @@ +import type { + AnyClientTool, + RealtimeMessage, + RealtimeMode, + RealtimeSessionConfig, + RealtimeStatus, + RealtimeToken, + UsageInfo, +} from '@tanstack/ai' +import type { RealtimeAdapter } from '@tanstack/ai-client' + +/** + * Options for the useRealtimeChat hook. + */ +export interface UseRealtimeChatOptions { + /** + * Function to fetch a realtime token from the server. + * Called on connect and when token needs refresh. + */ + getToken: () => Promise + + /** + * The realtime adapter to use (e.g., openaiRealtime()) + */ + adapter: RealtimeAdapter + + /** + * Client-side tools with execution logic + */ + tools?: ReadonlyArray + + /** + * Auto-play assistant audio (default: true) + */ + autoPlayback?: boolean + + /** + * Request microphone access on connect (default: true) + */ + autoCapture?: boolean + + /** + * System instructions for the assistant + */ + instructions?: string + + /** + * Voice to use for audio output + */ + voice?: string + + /** + * Voice activity detection mode (default: 'server') + */ + vadMode?: 'server' | 'semantic' | 'manual' + + /** + * Output modalities for responses (e.g., ['audio', 'text']) + */ + outputModalities?: Array<'audio' | 'text'> + + /** + * Temperature for generation (provider-specific range) + */ + temperature?: number + + /** + * Maximum number of tokens in a response + */ + maxOutputTokens?: number | 'inf' + + /** + * Eagerness level for semantic VAD ('low', 'medium', 'high') + */ + semanticEagerness?: 'low' | 'medium' | 'high' + + // Callbacks + onConnect?: () => void + onDisconnect?: () => void + onError?: (error: Error) => void + onMessage?: (message: RealtimeMessage) => void + onModeChange?: (mode: RealtimeMode) => void + onInterrupted?: () => void + onUsage?: (usage: UsageInfo) => void + onGoAway?: (timeLeft?: string) => void + onStatusChange?: (status: RealtimeStatus) => void +} + +/** + * Return type for the useRealtimeChat hook. + */ +export interface UseRealtimeChatReturn { + // Connection state + /** Current connection status */ + status: RealtimeStatus + /** Current error, if any */ + error: Error | null + /** Connect to the realtime session */ + connect: () => Promise + /** Disconnect from the realtime session */ + disconnect: () => Promise + + // Conversation state + /** Current mode (idle, listening, thinking, speaking) */ + mode: RealtimeMode + /** Conversation messages */ + messages: Array + /** User transcript while speaking (before finalized) */ + pendingUserTranscript: string | null + /** Assistant transcript while speaking (before finalized) */ + pendingAssistantTranscript: string | null + + // Voice control + /** Start listening for voice input (manual VAD mode) */ + startListening: () => void + /** Stop listening for voice input (manual VAD mode) */ + stopListening: () => void + /** Interrupt the current assistant response */ + interrupt: () => void + + // Text input + /** Send a text message instead of voice */ + sendText: (text: string) => void + + // Image input + /** Send an image to the conversation */ + sendImage: (imageData: string, mimeType: string) => void + + // Audio visualization (0-1 normalized) + /** Current input (microphone) volume level */ + inputLevel: number + /** Current output (speaker) volume level */ + outputLevel: number + /** Get frequency data for input audio visualization */ + getInputFrequencyData: () => Uint8Array + /** Get frequency data for output audio visualization */ + getOutputFrequencyData: () => Uint8Array + /** Get time domain data for input waveform */ + getInputTimeDomainData: () => Uint8Array + /** Get time domain data for output waveform */ + getOutputTimeDomainData: () => Uint8Array + + // Session control + /** Update the active session and persist the configuration for reconnects. */ + updateSession: (config: RealtimeSessionConfig) => void +} diff --git a/packages/ai-octane/src/types.ts b/packages/ai-octane/src/types.ts new file mode 100644 index 0000000000..39291627df --- /dev/null +++ b/packages/ai-octane/src/types.ts @@ -0,0 +1,305 @@ +import type { + AnyClientTool, + InterruptDefinition, + InferSchemaType, + ModelMessage, + RunAgentResumeItem, + SchemaInput, +} from '@tanstack/ai/client' +import type { + AIDevtoolsDisplayOptions, + BoundInterrupts, + ChatClientOptions, + ChatClientState, + ResolvableChatInterrupt, + ChatInterruptState, + ChatRequestBody, + ChatResumeState, + ClientContextOptionFromTools, + ConnectionStatus, + DistributedOmit, + InferredClientContext, + MultimodalContent, + QueueConfig, + QueueOption, + QueueStrategy, + QueuedMessage, + SendMessageOptions, + UIMessage, + WhenBusy, +} from '@tanstack/ai-client' + +// Re-export types from ai-client +export type { + ChatRequestBody, + MultimodalContent, + QueueConfig, + QueuedMessage, + QueueOption, + QueueStrategy, + SendMessageOptions, + UIMessage, + WhenBusy, +} + +/** + * Recursive partial — every property and every nested array element is optional. + * Used to type the in-flight `partial` value the hook exposes while a structured + * output stream is still arriving (the JSON has shape but is incomplete). + */ +export type DeepPartial = + T extends ReadonlyArray + ? Array> + : T extends object + ? { [K in keyof T]?: DeepPartial } + : T + +/** + * Options for the useChat hook. + * + * Pass either `connection` or `fetcher` — the XOR is enforced at the type + * level via `ChatTransport`. + * + * This extends ChatClientOptions but omits the state change callbacks that are + * managed internally by hook state: + * - `onMessagesChange` - Managed by hook state (exposed as `messages`) + * - `onLoadingChange` - Managed by hook state (exposed as `isLoading`) + * - `onErrorChange` - Managed by hook state (exposed as `error`) + * - `onStatusChange` - Managed by hook state (exposed as `status`) + * + * All other callbacks (onResponse, onChunk, onFinish, onError) are + * passed through to the underlying ChatClient and can be used for side effects. + * + * When `outputSchema` is supplied, the hook returns a typed `partial` (live + * progressive object, updated from `TEXT_MESSAGE_CONTENT` deltas via + * `parsePartialJSON`) and `final` (validated terminal payload from the + * `structured-output.complete` event). The schema is used purely for type + * inference on the client — server-side validation still runs against the + * schema you pass to `chat({ outputSchema })` on the server route. + * + * Changing `connection` or `fetcher` updates the active ChatClient in place, + * preserving its state. Changing `threadId` creates a fresh client. + */ +export type UseChatOptions< + TTools extends ReadonlyArray = any, + TSchema extends SchemaInput | undefined = undefined, + TContext = InferredClientContext, + TInterrupts extends ReadonlyArray> = + readonly [], +> = DistributedOmit< + ChatClientOptions, + | 'onMessagesChange' + | 'onLoadingChange' + | 'onErrorChange' + | 'onStatusChange' + | 'onSubscriptionChange' + | 'onConnectionStatusChange' + | 'onSessionGeneratingChange' + | 'onQueueChange' + | 'onResumeStateChange' + | 'onRunIdChange' + | 'context' + | 'devtools' +> & { + /** Display options for TanStack AI Devtools. */ + devtools?: AIDevtoolsDisplayOptions + /** + * Opt into mount-time live subscription behavior. + * When enabled, the hook subscribes on mount and unsubscribes on unmount. + */ + live?: boolean + /** + * Standard-schema-compatible schema (Zod, Valibot, ArkType, or a plain JSON + * Schema). Used to infer the shape of `partial` and `final` in the return. + * The schema is **not** sent to the server — server-side validation runs + * against the schema passed to `chat({ outputSchema })` on the server route. + */ + outputSchema?: TSchema +} & ClientContextOptionFromTools + +/** + * Discriminated return shape: when `outputSchema` is supplied, the hook adds + * typed `partial` / `final` fields; when it is omitted (default), the return + * is unchanged. + */ +export type UseChatReturn< + TTools extends ReadonlyArray = any, + TSchema extends SchemaInput | undefined = undefined, + TInterrupts extends ReadonlyArray> = + readonly [], +> = BaseUseChatReturn< + TTools, + TSchema extends SchemaInput ? InferSchemaType : unknown, + TInterrupts +> & + (TSchema extends SchemaInput + ? { + /** + * Live, progressively-parsed structured output. Updated from + * `TEXT_MESSAGE_CONTENT` deltas via `parsePartialJSON` while the stream + * is still arriving, and snapped to the validated payload when + * `structured-output.complete` fires. Resets on every new run + * (`sendMessage` / `reload`). + */ + partial: DeepPartial> + /** + * Final, schema-validated structured output. `null` until the terminal + * `structured-output.complete` event arrives. Resets on every new run. + */ + final: InferSchemaType | null + } + : Record) + +interface BaseUseChatReturn< + TTools extends ReadonlyArray = any, + TData = unknown, + TInterrupts extends ReadonlyArray> = + readonly [], +> { + /** + * Current messages in the conversation. When `outputSchema` is supplied, + * `messages[i].parts.find(p => p.type === 'structured-output')` is typed + * with the schema's inferred shape — `data: T`, `partial: DeepPartial`. + */ + messages: Array> + + /** + * Send a message and get a response. + * Can be a simple string or multimodal content with images, audio, etc. + * By default, sends while busy are queued until the run settles successfully + * (`queue: 'drop'` restores the old drop-while-busy behavior). + * Pass `{ whenBusy }` to override the policy for a single send. + */ + sendMessage: ( + content: string | MultimodalContent, + options?: SendMessageOptions, + ) => Promise + + /** + * Pending messages queued while the client is busy (streaming, claiming a + * send, or draining). Separate from `messages` until they drain. + */ + queue: Array + + /** + * Cancel a queued message before it drains. No-op if already sent. + */ + cancelQueued: (id: string) => void + + /** + * Append a message to the conversation + */ + append: (message: ModelMessage | UIMessage) => Promise + + /** + * Add the result of a client-side tool execution + */ + addToolResult: (result: { + toolCallId: string + tool: string + output: any + state?: 'output-available' | 'output-error' + errorText?: string + }) => Promise + + /** + * Respond to a tool approval request + */ + addToolApprovalResponse: (response: { + id: string // approval.id, not toolCallId + approved: boolean + }) => Promise + + /** + * The id of the run this client has in flight — one it started or rejoined — + * or `null` when there is none (including while a run sits paused on an + * interrupt, waiting on approval). + * + * A run is one turn of the conversation, so this changes from turn to turn. A + * whole tool loop stays inside one run, while resuming after an interrupt + * continues the turn under a new id — so one user message can produce several + * run ids. Use it to talk to your own server about that run (cancel it, poll + * it, correlate a log line). + */ + runId: string | null + interrupts: BoundInterrupts + /** @deprecated Use `interrupts`. */ + pendingInterrupts: BoundInterrupts + interruptErrors: ChatInterruptState['interruptErrors'] + resuming: boolean + resolveInterrupts: { + (approved: boolean): void + ( + resolver: ( + interrupt: ResolvableChatInterrupt, + ) => undefined, + ): void + } + cancelInterrupts: () => void + retryInterrupts: () => void + resumeInterruptsUnsafe: ( + resume: Array, + state?: ChatResumeState, + ) => Promise + /** @deprecated Use bound interrupt methods or `resumeInterruptsUnsafe`. */ + resumeInterrupts: ( + resume: Array, + state?: ChatResumeState, + ) => Promise + + /** + * Reload the last assistant message + */ + reload: () => Promise + + /** + * Stop the current response generation + */ + stop: () => void + + /** + * Whether a response is currently being generated + */ + isLoading: boolean + + /** + * Current error, if any + */ + error: Error | undefined + + /** + * Current status of the chat client + */ + status: ChatClientState + + /** + * Whether the subscription loop is currently active + */ + isSubscribed: boolean + + /** + * Current connection lifecycle status + */ + connectionStatus: ConnectionStatus + + /** + * Whether the shared session is actively generating. + * Derived from stream run events (RUN_STARTED / RUN_FINISHED / RUN_ERROR). + * Unlike `isLoading` (request-local), this reflects shared generation + * activity visible to all subscribers (e.g. across tabs/devices). + */ + sessionGenerating: boolean + + /** + * Set messages manually + */ + setMessages: (messages: Array>) => void + + /** + * Clear all messages + */ + clear: () => void +} + +// Note: createChatClientOptions and InferChatMessages are now in @tanstack/ai-client +// and re-exported from there for convenience diff --git a/packages/ai-octane/src/use-audio-recorder.tsrx b/packages/ai-octane/src/use-audio-recorder.tsrx new file mode 100644 index 0000000000..2a470aa095 --- /dev/null +++ b/packages/ai-octane/src/use-audio-recorder.tsrx @@ -0,0 +1,118 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'octane' +import { AudioRecorder } from '@tanstack/ai-client' +import type { + AudioRecorderOptions, + AudioRecording, + InferAudioRecordingOutput, +} from '@tanstack/ai-client' + +export type UseAudioRecorderOptions = AudioRecorderOptions & { + /** + * Optional transform applied to the recording when `stop()` resolves. Its + * (awaited) return value becomes `recording` and the resolved value of + * `stop()`. Return nothing to keep the raw `AudioRecording`. + */ + onComplete?: TOnComplete +} + +export interface UseAudioRecorderReturn { + /** Latest recording (transformed if `onComplete` provided), or null. */ + recording: TOutput | null + /** True while actively capturing audio. */ + isRecording: boolean + /** Whether the browser supports recording (getUserMedia + MediaRecorder). */ + isSupported: boolean + /** Acquire the mic and begin recording. */ + start: () => Promise + /** Stop and resolve with the completed recording (transformed if `onComplete` provided). */ + stop: () => Promise + /** Discard the in-progress recording and release the mic. */ + cancel: () => void +} + +/** + * Octane hook for recording an audio message. The resolved + * {@link AudioRecording} carries `.part` (an audio content part for + * `useChat.sendMessage`) and `.base64` (for the generation hooks). + * + * Errors are delivered via `onError`. `start()` and `stop()` also reject on + * failure (and `stop()` rejects with `Recording cancelled` if the component + * unmounts while a stop is in flight) — handle one channel, not both. + * + * @example + * ```tsx + * const { isRecording, start, stop, recording } = useAudioRecorder() + * const { sendMessage } = useChat({ connection }) + * // ... + * const rec = await stop() + * sendMessage({ content: [rec.part] }) + * ``` + */ +// The transforming overload requires `onComplete`. Without that constraint an +// options object carrying only unrelated keys (`useAudioRecorder({ onError })`) +// still matches it, `TOnComplete` infers as `unknown`, and `recording`/`stop()` +// collapse to `unknown` — so passing any option would silently cost you the +// `AudioRecording` type. Requiring it here sends those calls to the second +// overload instead. +export function useAudioRecorder< + TOnComplete extends (recording: AudioRecording) => unknown, +>( + options: UseAudioRecorderOptions & { onComplete: TOnComplete }, +): UseAudioRecorderReturn> +export function useAudioRecorder( + options?: UseAudioRecorderOptions, +): UseAudioRecorderReturn +export function useAudioRecorder( + options: UseAudioRecorderOptions<(recording: AudioRecording) => unknown> = {}, +): UseAudioRecorderReturn { + const [isRecording, setIsRecording] = useState(false) + const [recording, setRecording] = useState(null) + // Read the freshest callbacks at fire time without recreating the recorder. + const optionsRef = useRef(options) + optionsRef.current = options + + const recorder = useMemo( + () => + new AudioRecorder({ + ...(options.audio !== undefined && { audio: options.audio }), + ...(options.mimeType !== undefined && { mimeType: options.mimeType }), + onError: (err) => optionsRef.current.onError?.(err), + }), + // Recorder config (audio/mimeType) is captured once at mount, matching the + // other hooks' create-once pattern. + [], + ) + + useEffect(() => { + const unsubscribe = recorder.subscribe((state) => { + setIsRecording(state === 'recording') + }) + return () => { + unsubscribe() + recorder.cancel() + } + }, [recorder]) + + const start = useCallback(() => recorder.start(), [recorder]) + const stop = useCallback(async () => { + const recording = await recorder.stop() + const transformed = await optionsRef.current.onComplete?.(recording) + // Only `undefined` (returning nothing) falls back to the raw recording, so + // a transform that returns null is preserved — matching the inferred type, + // which excludes only undefined/void/null from the transform's return. + const output = transformed === undefined ? recording : transformed + setRecording(() => output) + return output + }, [recorder]) + const cancel = useCallback(() => recorder.cancel(), [recorder]) + + return { + recording, + isRecording, + // recording is client-only; if SSR'd, gate UI on a mounted flag. + isSupported: AudioRecorder.isSupported(), + start, + stop, + cancel, + } +} diff --git a/packages/ai-octane/src/use-audio-recorder.tsrx.d.ts b/packages/ai-octane/src/use-audio-recorder.tsrx.d.ts new file mode 100644 index 0000000000..7fa8d9139c --- /dev/null +++ b/packages/ai-octane/src/use-audio-recorder.tsrx.d.ts @@ -0,0 +1,54 @@ +// Declaration companion generated from use-audio-recorder.tsrx. +import type { + AudioRecorderOptions, + AudioRecording, + InferAudioRecordingOutput, +} from '@tanstack/ai-client' +export type UseAudioRecorderOptions = AudioRecorderOptions & { + /** + * Optional transform applied to the recording when `stop()` resolves. Its + * (awaited) return value becomes `recording` and the resolved value of + * `stop()`. Return nothing to keep the raw `AudioRecording`. + */ + onComplete?: TOnComplete +} +export interface UseAudioRecorderReturn { + /** Latest recording (transformed if `onComplete` provided), or null. */ + recording: TOutput | null + /** True while actively capturing audio. */ + isRecording: boolean + /** Whether the browser supports recording (getUserMedia + MediaRecorder). */ + isSupported: boolean + /** Acquire the mic and begin recording. */ + start: () => Promise + /** Stop and resolve with the completed recording (transformed if `onComplete` provided). */ + stop: () => Promise + /** Discard the in-progress recording and release the mic. */ + cancel: () => void +} +/** + * Octane hook for recording an audio message. The resolved + * {@link AudioRecording} carries `.part` (an audio content part for + * `useChat.sendMessage`) and `.base64` (for the generation hooks). + * + * Errors are delivered via `onError`. `start()` and `stop()` also reject on + * failure (and `stop()` rejects with `Recording cancelled` if the component + * unmounts while a stop is in flight) — handle one channel, not both. + * + * @example + * ```tsx + * const { isRecording, start, stop, recording } = useAudioRecorder() + * const { sendMessage } = useChat({ connection }) + * // ... + * const rec = await stop() + * sendMessage({ content: [rec.part] }) + * ``` + */ +export declare function useAudioRecorder< + TOnComplete extends (recording: AudioRecording) => unknown, +>( + options: UseAudioRecorderOptions & { onComplete: TOnComplete }, +): UseAudioRecorderReturn> +export declare function useAudioRecorder( + options?: UseAudioRecorderOptions, +): UseAudioRecorderReturn diff --git a/packages/ai-octane/src/use-chat.tsrx b/packages/ai-octane/src/use-chat.tsrx new file mode 100644 index 0000000000..f95f7f9fb8 --- /dev/null +++ b/packages/ai-octane/src/use-chat.tsrx @@ -0,0 +1,587 @@ +import { ChatClient } from '@tanstack/ai-client' +import { createChatDevtoolsBridge } from '@tanstack/ai-client/devtools' +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'octane' +import type { + AnyClientTool, + InferSchemaType, + InterruptDefinition, + ModelMessage, + RunAgentResumeItem, + SchemaInput, + StreamChunk, +} from '@tanstack/ai/client' +import type { + ChatClientState, + ResolvableChatInterrupt, + ChatInterruptState, + ChatResumeState, + ConnectionStatus, + InferredClientContext, + QueuedMessage, + SendMessageOptions, + StructuredOutputPart, +} from '@tanstack/ai-client' + +import type { + DeepPartial, + MultimodalContent, + UIMessage, + UseChatOptions, + UseChatReturn, +} from './types' + +const EMPTY_INTERRUPTS = Object.freeze([]) +const EMPTY_INTERRUPT_ERRORS = Object.freeze([]) + +export function useChat< + const TTools extends ReadonlyArray = any, + TSchema extends SchemaInput | undefined = undefined, + TContext = InferredClientContext, + const TInterrupts extends ReadonlyArray< + InterruptDefinition + > = readonly [], +>( + options: UseChatOptions, +): UseChatReturn { + // The hook's identity is its `threadId`. Reload with the same `threadId` + // restores the same conversation. `hookId` is only a recreation key when no + // `threadId` is given. It is never sent on the wire. + const hookId = useId() + const clientId = options.threadId ?? hookId + + const [messages, setMessages] = useState>>( + options.initialMessages || [], + ) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(undefined) + const [status, setStatus] = useState('ready') + const [isSubscribed, setIsSubscribed] = useState(false) + const [connectionStatus, setConnectionStatus] = + useState('disconnected') + const [sessionGenerating, setSessionGenerating] = useState(false) + const [queue, setQueue] = useState>([]) + const [runId, setRunId] = useState(null) + const [interruptState, setInterruptState] = useState< + ChatInterruptState + >(() => ({ + interrupts: EMPTY_INTERRUPTS, + pendingInterrupts: EMPTY_INTERRUPTS, + interruptErrors: EMPTY_INTERRUPT_ERRORS, + resuming: false, + })) + + type Partial = DeepPartial>> + type Final = InferSchemaType> + + // Track the rendered snapshot so a replacement client only synchronizes + // state when its messages actually differ. + const messagesRef = useRef>>( + options.initialMessages || [], + ) + const subscribedRef = useRef(false) + const activeClientRef = useRef(null) + const seenClientRef = useRef(null) + + // Update ref synchronously during render so it's always current when useMemo runs. + messagesRef.current = messages + + // Track current options in a ref to avoid recreating client when options change + const optionsRef = + useRef>(options) + optionsRef.current = options + + const syncResumeState = useCallback((target: ChatClient | null) => { + if (!target) return + setRunId(target.getCurrentRunId()) + setInterruptState(target.getInterruptState()) + }, []) + + // Create ChatClient instance with callbacks to sync state + const { client, initialization } = useMemo(() => { + const messagesToUse = options.initialMessages || [] + + // Build options with conditional spreads for fields whose source + // type is `T | undefined` but the ChatClient target uses a strict + // optional (`field?: T`) — `exactOptionalPropertyTypes` rejects + // assigning `undefined` to those, so we omit the key when absent. + const initialOptions = optionsRef.current + const transport = initialOptions.connection + ? { connection: initialOptions.connection } + : { fetcher: initialOptions.fetcher } + + const instanceHolder: { + current: ChatClient | undefined + } = { current: undefined } + const getActiveInstance = () => { + const currentInstance = instanceHolder.current + if (!currentInstance || activeClientRef.current !== currentInstance) { + return undefined + } + return currentInstance + } + // ChatClient may publish while its constructor is running or while async + // persistence resolves before commit. Preserve those notifications until + // this render commits. + const initializationState = { + ready: false, + callbacks: [] as Array<() => void>, + } + const runOrQueueForActiveInstance = (callback: () => void) => { + if (!initializationState.ready) { + initializationState.callbacks.push(callback) + return + } + const currentInstance = instanceHolder.current + if (!currentInstance || activeClientRef.current !== currentInstance) + return + callback() + } + const instance = new ChatClient({ + devtoolsBridgeFactory: createChatDevtoolsBridge, + ...transport, + initialMessages: messagesToUse, + ...(typeof initialOptions.threadId === 'string' && + initialOptions.persistence + ? { + persistence: initialOptions.persistence, + threadId: initialOptions.threadId, + } + : { + ...(initialOptions.threadId !== undefined && { + threadId: initialOptions.threadId, + }), + }), + ...(initialOptions.body !== undefined && { body: initialOptions.body }), + ...(initialOptions.forwardedProps !== undefined && { + forwardedProps: initialOptions.forwardedProps, + }), + ...(initialOptions.initialResumeSnapshot !== undefined && { + initialResumeSnapshot: initialOptions.initialResumeSnapshot, + }), + ...(initialOptions.context !== undefined && { + context: initialOptions.context, + }), + devtools: { + ...initialOptions.devtools, + // OCTANE DIVERGENCE: identify this binding as Octane (not 'react') in TanStack AI Devtools. + // Spread AFTER caller metadata so callers cannot override identification. + framework: 'octane', + hookName: 'useChat', + outputKind: initialOptions.outputSchema ? 'structured' : 'chat', + }, + onResponse: (response) => { + // ChatClient awaits this return value. Dropping it would skip the promise. + if (!getActiveInstance()) return + return optionsRef.current.onResponse?.(response) + }, + onChunk: (chunk: StreamChunk) => { + runOrQueueForActiveInstance(() => { + optionsRef.current.onChunk?.(chunk) + }) + }, + onFinish: (message: UIMessage) => { + runOrQueueForActiveInstance(() => { + optionsRef.current.onFinish?.(message) + }) + }, + onError: (error: Error) => { + runOrQueueForActiveInstance(() => { + optionsRef.current.onError?.(error) + }) + }, + ...(initialOptions.tools !== undefined && { + tools: initialOptions.tools, + }), + ...(initialOptions.interrupts !== undefined && { + interrupts: initialOptions.interrupts, + }), + onCustomEvent: (eventType, data, context) => { + runOrQueueForActiveInstance(() => { + optionsRef.current.onCustomEvent?.(eventType, data, context) + }) + }, + ...(options.streamProcessor !== undefined && { + streamProcessor: options.streamProcessor, + }), + onMessagesChange: (newMessages: Array>) => { + runOrQueueForActiveInstance(() => { + setMessages(newMessages) + }) + }, + onLoadingChange: (newIsLoading: boolean) => { + runOrQueueForActiveInstance(() => { + const currentInstance = getActiveInstance() + if (!currentInstance) return + setIsLoading(newIsLoading) + syncResumeState(currentInstance) + }) + }, + onErrorChange: (newError: Error | undefined) => { + runOrQueueForActiveInstance(() => { + setError(newError) + }) + }, + onStatusChange: (status: ChatClientState) => { + runOrQueueForActiveInstance(() => { + setStatus(status) + }) + }, + onSubscriptionChange: (nextIsSubscribed: boolean) => { + runOrQueueForActiveInstance(() => { + setIsSubscribed(nextIsSubscribed) + }) + }, + onConnectionStatusChange: (nextStatus: ConnectionStatus) => { + runOrQueueForActiveInstance(() => { + setConnectionStatus(nextStatus) + }) + }, + onSessionGeneratingChange: (isGenerating: boolean) => { + runOrQueueForActiveInstance(() => { + setSessionGenerating(isGenerating) + }) + }, + ...(optionsRef.current.queue !== undefined && { + queue: optionsRef.current.queue, + }), + onQueueChange: (nextQueue: Array) => { + runOrQueueForActiveInstance(() => { + setQueue(nextQueue) + }) + }, + onRunIdChange: (nextRunId) => { + runOrQueueForActiveInstance(() => { + setRunId(nextRunId) + }) + }, + onResumeStateChange: (_nextResumeState, nextPendingInterrupts) => { + runOrQueueForActiveInstance(() => { + setInterruptState((current) => ({ + ...current, + interrupts: nextPendingInterrupts, + pendingInterrupts: nextPendingInterrupts, + })) + }) + }, + onInterruptStateChange: (nextInterruptState, context) => { + runOrQueueForActiveInstance(() => { + setInterruptState(nextInterruptState) + optionsRef.current.onInterruptStateChange?.( + nextInterruptState, + context, + ) + }) + }, + }) + instanceHolder.current = instance + return { client: instance, initialization: initializationState } + }, [clientId, syncResumeState]) + + useEffect(() => { + activeClientRef.current = client + try { + // Keep initialization closed while draining so callbacks published by a + // queued callback are appended and delivered in the same commit. + while (initialization.callbacks.length > 0) { + if (activeClientRef.current !== client) { + initialization.callbacks.length = 0 + break + } + initialization.callbacks.shift()?.() + } + } finally { + // A throw from a queued user callback must not leave the queue closed. + initialization.ready = true + } + }, [client, initialization]) + + useEffect(() => { + const clientMessages = client.getMessages() + if (clientMessages !== messagesRef.current) { + setMessages(clientMessages) + } + }, [client]) + + // OCTANE DIVERGENCE: upstream captures the initial transport. ChatClient + // owns transport swaps, including aborting an active request and + // resubscribing when needed, so keep its conversation state stable while + // applying the latest transport. Skip the first run for each client — it + // already has this transport from the constructor, and a no-op swap can + // abort constructor rejoin / interrupt resume. + useEffect(() => { + if (seenClientRef.current !== client) { + seenClientRef.current = client + return + } + const transport = options.connection + ? { connection: options.connection } + : { fetcher: options.fetcher } + client.updateOptions(transport) + }, [client, options.connection, options.fetcher]) + + // Sync each wire-payload slot in its own effect so an unrelated option + // changing doesn't re-run the others. `updateOptions` declares strict-optional + // fields and rejects explicit `undefined` under EOPT, so guard the optional + // slots before passing them. + useEffect(() => { + client.updateOptions({ body: options.body }) + }, [client, options.body]) + + useEffect(() => { + if (options.forwardedProps !== undefined) { + client.updateOptions({ forwardedProps: options.forwardedProps }) + } + }, [client, options.forwardedProps]) + + useEffect(() => { + if (options.tools !== undefined) { + client.updateOptions({ tools: options.tools }) + } + }, [client, options.tools]) + + useEffect(() => { + client.updateOptions({ context: options.context }) + }, [client, options.context]) + + useEffect(() => { + if (options.queue !== undefined) { + client.updateOptions({ queue: options.queue }) + } + }, [client, options.queue]) + + useEffect(() => { + if (options.live) { + client.subscribe() + subscribedRef.current = true + } else if (subscribedRef.current) { + // Only tear down a subscription we actually started. Calling + // `unsubscribe()` on initial mount (when `live` was never enabled) would + // abort an in-flight delivery resume — `resumeInFlightRun` is kicked off + // in the client constructor, and `unsubscribe()` cancels the shared + // in-flight stream. + client.unsubscribe() + subscribedRef.current = false + } + }, [client, options.live]) + + // Octane has no StrictMode double-invoke, so attach and dispose are + // immediate (Vue-style), not React's deferred setTimeout dispose. + useEffect(() => { + client.attach() + client.mountDevtools() + syncResumeState(client) + return () => { + client.detach() + if (optionsRef.current.live) { + client.unsubscribe() + } else { + client.stop() + } + client.dispose() + if (activeClientRef.current === client) { + activeClientRef.current = null + } + } + }, [client, syncResumeState]) + + const sendMessage = useCallback( + async ( + content: string | MultimodalContent, + sendOptions?: SendMessageOptions, + ) => { + try { + await client.sendMessage(content, undefined, sendOptions) + } finally { + syncResumeState(client) + } + }, + [client, syncResumeState], + ) + + const cancelQueued = useCallback( + (id: string) => { + client.cancelQueued(id) + }, + [client], + ) + + const append = useCallback( + async (message: ModelMessage | UIMessage) => { + try { + await client.append(message) + } finally { + syncResumeState(client) + } + }, + [client, syncResumeState], + ) + + const reload = useCallback(async () => { + try { + await client.reload() + } finally { + syncResumeState(client) + } + }, [client, syncResumeState]) + + const stop = useCallback(() => { + client.stop() + }, [client]) + + const clear = useCallback(() => { + client.clear() + syncResumeState(client) + }, [client, syncResumeState]) + + const setMessagesManually = useCallback( + (newMessages: Array>) => { + client.setMessagesManually(newMessages) + }, + [client], + ) + + const addToolResult = useCallback( + async (result: { + toolCallId: string + tool: string + output: any + state?: 'output-available' | 'output-error' + errorText?: string + }) => { + await client.addToolResult(result) + }, + [client], + ) + + const addToolApprovalResponse = useCallback( + async (response: { id: string; approved: boolean }) => { + await client.addToolApprovalResponse(response) + syncResumeState(client) + }, + [client, syncResumeState], + ) + + const resumeInterrupts = useCallback( + async (resumeItems: Array, state?: ChatResumeState) => { + const result = await client.resumeInterrupts( + resumeItems, + state ?? undefined, + ) + syncResumeState(client) + return result + }, + [client, syncResumeState], + ) + + const resolveInterrupts = useCallback( + ( + resolution: + | boolean + | (( + interrupt: ResolvableChatInterrupt, + ) => undefined), + ) => { + if (typeof resolution === 'boolean') { + client.resolveInterrupts(resolution) + } else { + client.resolveInterrupts(resolution) + } + }, + [client], + ) + + const cancelInterrupts = useCallback(() => { + client.cancelInterrupts() + }, [client]) + + const retryInterrupts = useCallback(() => { + client.retryInterrupts() + }, [client]) + + const resumeInterruptsUnsafe = useCallback( + (resumeItems: Array, state?: ChatResumeState) => + client.resumeInterruptsUnsafe(resumeItems, state), + [client], + ) + + // The "active" structured-output part is the one on the assistant message + // that follows the latest user message. No such message exists between + // sendMessage() and the first chunk, so partial/final naturally read as + // cleared. Historical parts on earlier assistant messages remain available + // via `messages` directly. + // + // When there is NO user message yet (e.g. `initialMessages` contains only + // a stale assistant turn or a system prompt) we deliberately return null + // rather than scanning historical assistants — otherwise a `final` from a + // previous session would leak into the hook value on first render. + const renderedMessages = client.getMessages() + + const activeStructuredPart = useMemo(() => { + let lastUserIndex = -1 + for (let i = renderedMessages.length - 1; i >= 0; i--) { + if (renderedMessages[i]?.role === 'user') { + lastUserIndex = i + break + } + } + if (lastUserIndex === -1) return null + for (let i = renderedMessages.length - 1; i > lastUserIndex; i--) { + const m = renderedMessages[i] + if (m?.role !== 'assistant') continue + const part = m.parts.find( + (p): p is StructuredOutputPart => p.type === 'structured-output', + ) + if (part) return part + } + return null + }, [renderedMessages]) + + const partial = useMemo(() => { + if (!activeStructuredPart) return {} as Partial + const v = activeStructuredPart.partial ?? activeStructuredPart.data + return (v ?? {}) as Partial + }, [activeStructuredPart]) + + const final = useMemo(() => { + if (!activeStructuredPart || activeStructuredPart.status !== 'complete') { + return null + } + return activeStructuredPart.data as Final + }, [activeStructuredPart]) + + // The runtime shape unconditionally exposes partial/final; the public + // return type hides them when no outputSchema was supplied. TS can't + // structurally narrow across that conditional, so the `as` is the seam. + // eslint-disable-next-line no-restricted-syntax -- hook return shape diverges from generic UseChatReturn due to conditional type on TSchema; TS can't structurally narrow + return { + messages: renderedMessages, + sendMessage, + append, + reload, + stop, + isLoading, + error, + status, + isSubscribed, + connectionStatus, + sessionGenerating, + setMessages: setMessagesManually, + clear, + addToolResult, + addToolApprovalResponse, + queue, + cancelQueued, + runId, + interrupts: interruptState.interrupts, + pendingInterrupts: interruptState.pendingInterrupts, + interruptErrors: interruptState.interruptErrors, + resuming: interruptState.resuming, + resolveInterrupts, + cancelInterrupts, + retryInterrupts, + resumeInterruptsUnsafe, + resumeInterrupts, + partial, + final, + } as unknown as UseChatReturn +} diff --git a/packages/ai-octane/src/use-chat.tsrx.d.ts b/packages/ai-octane/src/use-chat.tsrx.d.ts new file mode 100644 index 0000000000..d8b8b37f8e --- /dev/null +++ b/packages/ai-octane/src/use-chat.tsrx.d.ts @@ -0,0 +1,19 @@ +// Declaration companion generated from use-chat.tsrx. +import type { + AnyClientTool, + InterruptDefinition, + SchemaInput, +} from '@tanstack/ai/client' +import type { InferredClientContext } from '@tanstack/ai-client' +import type { UseChatOptions, UseChatReturn } from './types' + +export declare function useChat< + const TTools extends ReadonlyArray = any, + TSchema extends SchemaInput | undefined = undefined, + TContext = InferredClientContext, + const TInterrupts extends ReadonlyArray< + InterruptDefinition + > = readonly [], +>( + options: UseChatOptions, +): UseChatReturn diff --git a/packages/ai-octane/src/use-generate-audio.tsrx b/packages/ai-octane/src/use-generate-audio.tsrx new file mode 100644 index 0000000000..2857146920 --- /dev/null +++ b/packages/ai-octane/src/use-generate-audio.tsrx @@ -0,0 +1,125 @@ +import { useGeneration } from './use-generation.tsrx' +import type { AudioGenerationResult, StreamChunk } from '@tanstack/ai' +import type { + AIDevtoolsDisplayOptions, + AudioGenerateInput, + ConnectConnectionAdapter, + GenerationClientState, + GenerationFetcher, + InferGenerationOutputFromReturn, +} from '@tanstack/ai-client' + +/** + * Options for the useGenerateAudio hook. + * + * @template TOutput - The output type after optional transform (defaults to AudioGenerationResult) + */ +export interface UseGenerateAudioOptions { + /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ + connection?: ConnectConnectionAdapter + /** Direct async function for audio generation */ + fetcher?: GenerationFetcher + /** Unique identifier for this generation instance */ + id?: string + /** Additional body parameters to send with connect-based adapter requests */ + body?: Record + /** Display options for TanStack AI Devtools. */ + devtools?: AIDevtoolsDisplayOptions + /** + * Callback when audio is generated. Can optionally return a transformed value. + * + * - Return a non-null value to transform and store it as the result + * - Return `null` to keep the previous result unchanged + * - Return nothing (`void`) to store the raw result as-is + */ + onResult?: (result: AudioGenerationResult) => TOutput | null | void + /** Callback when an error occurs */ + onError?: (error: Error) => void + /** Callback when progress is reported (0-100) */ + onProgress?: (progress: number, message?: string) => void + /** Callback for each stream chunk (connect-based adapter mode only) */ + onChunk?: (chunk: StreamChunk) => void +} + +/** + * Return type for the useGenerateAudio hook. + * + * @template TOutput - The output type (after optional transform) + */ +export interface UseGenerateAudioReturn { + /** Trigger audio generation */ + generate: (input: AudioGenerateInput) => Promise + /** The generation result containing audio, or null */ + result: TOutput | null + /** Whether generation is in progress */ + isLoading: boolean + /** Current error, if any */ + error: Error | undefined + /** Current state of the generation */ + status: GenerationClientState + /** Abort the current generation */ + stop: () => void + /** Clear result, error, and return to idle */ + reset: () => void +} + +/** + * Octane hook for generating audio (music, sound effects) using AI models. + * + * Supports two transport modes: + * - **ConnectConnectionAdapter** — Streaming transport (SSE, HTTP stream, custom) + * - **Fetcher** — Direct async function call + * + * @example + * ```tsx + * import { useGenerateAudio } from '@tanstack/ai-octane' + * import { fetchServerSentEvents } from '@tanstack/ai-client' + * + * function AudioGenerator() { + * const { generate, result, isLoading, error, reset } = useGenerateAudio({ + * connection: fetchServerSentEvents('/api/generate/audio'), + * }) + * + * return ( + *
+ * + * {isLoading &&

Generating...

} + * {error &&

Error: {error.message}

} + * {result?.audio.url &&
+ * ) + * } + * ``` + */ +export function useGenerateAudio( + options: Omit & { + onResult?: (result: AudioGenerationResult) => TTransformed + }, +): UseGenerateAudioReturn< + InferGenerationOutputFromReturn +> { + const devtools = { + ...options.devtools, + // OCTANE DIVERGENCE: identify this binding as Octane (not 'react') in TanStack AI Devtools. + framework: 'octane', + hookName: 'useGenerateAudio', + outputKind: 'audio' as const, + } + const { generate, result, isLoading, error, status, stop, reset } = + useGeneration({ + ...options, + devtools, + }) + + return { + generate: generate as (input: AudioGenerateInput) => Promise, + result, + isLoading, + error, + status, + stop, + reset, + } +} diff --git a/packages/ai-octane/src/use-generate-audio.tsrx.d.ts b/packages/ai-octane/src/use-generate-audio.tsrx.d.ts new file mode 100644 index 0000000000..d5948e531c --- /dev/null +++ b/packages/ai-octane/src/use-generate-audio.tsrx.d.ts @@ -0,0 +1,99 @@ +// Declaration companion generated from use-generate-audio.tsrx. +import type { AudioGenerationResult, StreamChunk } from '@tanstack/ai' +import type { + AIDevtoolsDisplayOptions, + AudioGenerateInput, + ConnectConnectionAdapter, + GenerationClientState, + GenerationFetcher, + InferGenerationOutputFromReturn, +} from '@tanstack/ai-client' +/** + * Options for the useGenerateAudio hook. + * + * @template TOutput - The output type after optional transform (defaults to AudioGenerationResult) + */ +export interface UseGenerateAudioOptions { + /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ + connection?: ConnectConnectionAdapter + /** Direct async function for audio generation */ + fetcher?: GenerationFetcher + /** Unique identifier for this generation instance */ + id?: string + /** Additional body parameters to send with connect-based adapter requests */ + body?: Record + /** Display options for TanStack AI Devtools. */ + devtools?: AIDevtoolsDisplayOptions + /** + * Callback when audio is generated. Can optionally return a transformed value. + * + * - Return a non-null value to transform and store it as the result + * - Return `null` to keep the previous result unchanged + * - Return nothing (`void`) to store the raw result as-is + */ + onResult?: (result: AudioGenerationResult) => TOutput | null | void + /** Callback when an error occurs */ + onError?: (error: Error) => void + /** Callback when progress is reported (0-100) */ + onProgress?: (progress: number, message?: string) => void + /** Callback for each stream chunk (connect-based adapter mode only) */ + onChunk?: (chunk: StreamChunk) => void +} +/** + * Return type for the useGenerateAudio hook. + * + * @template TOutput - The output type (after optional transform) + */ +export interface UseGenerateAudioReturn { + /** Trigger audio generation */ + generate: (input: AudioGenerateInput) => Promise + /** The generation result containing audio, or null */ + result: TOutput | null + /** Whether generation is in progress */ + isLoading: boolean + /** Current error, if any */ + error: Error | undefined + /** Current state of the generation */ + status: GenerationClientState + /** Abort the current generation */ + stop: () => void + /** Clear result, error, and return to idle */ + reset: () => void +} +/** + * Octane hook for generating audio (music, sound effects) using AI models. + * + * Supports two transport modes: + * - **ConnectConnectionAdapter** — Streaming transport (SSE, HTTP stream, custom) + * - **Fetcher** — Direct async function call + * + * @example + * ```tsx + * import { useGenerateAudio } from '@tanstack/ai-octane' + * import { fetchServerSentEvents } from '@tanstack/ai-client' + * + * function AudioGenerator() { + * const { generate, result, isLoading, error, reset } = useGenerateAudio({ + * connection: fetchServerSentEvents('/api/generate/audio'), + * }) + * + * return ( + *
+ * + * {isLoading &&

Generating...

} + * {error &&

Error: {error.message}

} + * {result?.audio.url &&
+ * ) + * } + * ``` + */ +export declare function useGenerateAudio( + options: Omit & { + onResult?: (result: AudioGenerationResult) => TTransformed + }, +): UseGenerateAudioReturn< + InferGenerationOutputFromReturn +> diff --git a/packages/ai-octane/src/use-generate-image.tsrx b/packages/ai-octane/src/use-generate-image.tsrx new file mode 100644 index 0000000000..9aa4eb5f3c --- /dev/null +++ b/packages/ai-octane/src/use-generate-image.tsrx @@ -0,0 +1,127 @@ +import { useGeneration } from './use-generation.tsrx' +import type { ImageGenerationResult, StreamChunk } from '@tanstack/ai' +import type { + AIDevtoolsDisplayOptions, + ConnectConnectionAdapter, + GenerationClientState, + GenerationFetcher, + ImageGenerateInput, + InferGenerationOutputFromReturn, +} from '@tanstack/ai-client' + +/** + * Options for the useGenerateImage hook. + * + * @template TOutput - The output type after optional transform (defaults to ImageGenerationResult) + */ +export interface UseGenerateImageOptions { + /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ + connection?: ConnectConnectionAdapter + /** Direct async function for image generation */ + fetcher?: GenerationFetcher + /** Unique identifier for this generation instance */ + id?: string + /** Additional body parameters to send with connect-based adapter requests */ + body?: Record + /** Display options for TanStack AI Devtools. */ + devtools?: AIDevtoolsDisplayOptions + /** + * Callback when images are generated. Can optionally return a transformed value. + * + * - Return a non-null value to transform and store it as the result + * - Return `null` to keep the previous result unchanged + * - Return nothing (`void`) to store the raw result as-is + */ + onResult?: (result: ImageGenerationResult) => TOutput | null | void + /** Callback when an error occurs */ + onError?: (error: Error) => void + /** Callback when progress is reported (0-100) */ + onProgress?: (progress: number, message?: string) => void + /** Callback for each stream chunk (connect-based adapter mode only) */ + onChunk?: (chunk: StreamChunk) => void +} + +/** + * Return type for the useGenerateImage hook. + * + * @template TOutput - The output type (after optional transform) + */ +export interface UseGenerateImageReturn { + /** Trigger image generation */ + generate: (input: ImageGenerateInput) => Promise + /** The generation result containing images, or null */ + result: TOutput | null + /** Whether generation is in progress */ + isLoading: boolean + /** Current error, if any */ + error: Error | undefined + /** Current state of the generation */ + status: GenerationClientState + /** Abort the current generation */ + stop: () => void + /** Clear result, error, and return to idle */ + reset: () => void +} + +/** + * Octane hook for generating images using AI models. + * + * Supports two transport modes: + * - **ConnectConnectionAdapter** — Streaming transport (SSE, HTTP stream, custom) + * - **Fetcher** — Direct async function call + * + * @example + * ```tsx + * import { useGenerateImage } from '@tanstack/ai-octane' + * import { fetchServerSentEvents } from '@tanstack/ai-client' + * + * function ImageGenerator() { + * const { generate, result, isLoading, error, reset } = useGenerateImage({ + * connection: fetchServerSentEvents('/api/generate/image'), + * }) + * + * return ( + *
+ * + * {isLoading &&

Generating...

} + * {error &&

Error: {error.message}

} + * {result?.images.map((img, i) => ( + * + * ))} + *
+ * ) + * } + * ``` + */ +export function useGenerateImage( + options: Omit & { + onResult?: (result: ImageGenerationResult) => TTransformed + }, +): UseGenerateImageReturn< + InferGenerationOutputFromReturn +> { + const devtools = { + ...options.devtools, + // OCTANE DIVERGENCE: identify this binding as Octane (not 'react') in TanStack AI Devtools. + framework: 'octane', + hookName: 'useGenerateImage', + outputKind: 'image' as const, + } + const { generate, result, isLoading, error, status, stop, reset } = + useGeneration({ + ...options, + devtools, + }) + + return { + generate: generate as (input: ImageGenerateInput) => Promise, + result, + isLoading, + error, + status, + stop, + reset, + } +} diff --git a/packages/ai-octane/src/use-generate-image.tsrx.d.ts b/packages/ai-octane/src/use-generate-image.tsrx.d.ts new file mode 100644 index 0000000000..a28a37cefd --- /dev/null +++ b/packages/ai-octane/src/use-generate-image.tsrx.d.ts @@ -0,0 +1,101 @@ +// Declaration companion generated from use-generate-image.tsrx. +import type { ImageGenerationResult, StreamChunk } from '@tanstack/ai' +import type { + AIDevtoolsDisplayOptions, + ConnectConnectionAdapter, + GenerationClientState, + GenerationFetcher, + ImageGenerateInput, + InferGenerationOutputFromReturn, +} from '@tanstack/ai-client' +/** + * Options for the useGenerateImage hook. + * + * @template TOutput - The output type after optional transform (defaults to ImageGenerationResult) + */ +export interface UseGenerateImageOptions { + /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ + connection?: ConnectConnectionAdapter + /** Direct async function for image generation */ + fetcher?: GenerationFetcher + /** Unique identifier for this generation instance */ + id?: string + /** Additional body parameters to send with connect-based adapter requests */ + body?: Record + /** Display options for TanStack AI Devtools. */ + devtools?: AIDevtoolsDisplayOptions + /** + * Callback when images are generated. Can optionally return a transformed value. + * + * - Return a non-null value to transform and store it as the result + * - Return `null` to keep the previous result unchanged + * - Return nothing (`void`) to store the raw result as-is + */ + onResult?: (result: ImageGenerationResult) => TOutput | null | void + /** Callback when an error occurs */ + onError?: (error: Error) => void + /** Callback when progress is reported (0-100) */ + onProgress?: (progress: number, message?: string) => void + /** Callback for each stream chunk (connect-based adapter mode only) */ + onChunk?: (chunk: StreamChunk) => void +} +/** + * Return type for the useGenerateImage hook. + * + * @template TOutput - The output type (after optional transform) + */ +export interface UseGenerateImageReturn { + /** Trigger image generation */ + generate: (input: ImageGenerateInput) => Promise + /** The generation result containing images, or null */ + result: TOutput | null + /** Whether generation is in progress */ + isLoading: boolean + /** Current error, if any */ + error: Error | undefined + /** Current state of the generation */ + status: GenerationClientState + /** Abort the current generation */ + stop: () => void + /** Clear result, error, and return to idle */ + reset: () => void +} +/** + * Octane hook for generating images using AI models. + * + * Supports two transport modes: + * - **ConnectConnectionAdapter** — Streaming transport (SSE, HTTP stream, custom) + * - **Fetcher** — Direct async function call + * + * @example + * ```tsx + * import { useGenerateImage } from '@tanstack/ai-octane' + * import { fetchServerSentEvents } from '@tanstack/ai-client' + * + * function ImageGenerator() { + * const { generate, result, isLoading, error, reset } = useGenerateImage({ + * connection: fetchServerSentEvents('/api/generate/image'), + * }) + * + * return ( + *
+ * + * {isLoading &&

Generating...

} + * {error &&

Error: {error.message}

} + * {result?.images.map((img, i) => ( + * + * ))} + *
+ * ) + * } + * ``` + */ +export declare function useGenerateImage( + options: Omit & { + onResult?: (result: ImageGenerationResult) => TTransformed + }, +): UseGenerateImageReturn< + InferGenerationOutputFromReturn +> diff --git a/packages/ai-octane/src/use-generate-speech.tsrx b/packages/ai-octane/src/use-generate-speech.tsrx new file mode 100644 index 0000000000..bf20857cef --- /dev/null +++ b/packages/ai-octane/src/use-generate-speech.tsrx @@ -0,0 +1,121 @@ +import { useGeneration } from './use-generation.tsrx' +import type { StreamChunk, TTSResult } from '@tanstack/ai' +import type { + AIDevtoolsDisplayOptions, + ConnectConnectionAdapter, + GenerationClientState, + GenerationFetcher, + InferGenerationOutputFromReturn, + SpeechGenerateInput, +} from '@tanstack/ai-client' + +/** + * Options for the useGenerateSpeech hook. + * + * @template TOutput - The output type after optional transform (defaults to TTSResult) + */ +export interface UseGenerateSpeechOptions { + /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ + connection?: ConnectConnectionAdapter + /** Direct async function for speech generation */ + fetcher?: GenerationFetcher + /** Unique identifier for this generation instance */ + id?: string + /** Additional body parameters to send with connect-based adapter requests */ + body?: Record + /** Display options for TanStack AI Devtools. */ + devtools?: AIDevtoolsDisplayOptions + /** + * Callback when speech is generated. Can optionally return a transformed value. + * + * - Return a non-null value to transform and store it as the result + * - Return `null` to keep the previous result unchanged + * - Return nothing (`void`) to store the raw result as-is + */ + onResult?: (result: TTSResult) => TOutput | null | void + /** Callback when an error occurs */ + onError?: (error: Error) => void + /** Callback when progress is reported (0-100) */ + onProgress?: (progress: number, message?: string) => void + /** Callback for each stream chunk (connect-based adapter mode only) */ + onChunk?: (chunk: StreamChunk) => void +} + +/** + * Return type for the useGenerateSpeech hook. + * + * @template TOutput - The output type (after optional transform) + */ +export interface UseGenerateSpeechReturn { + /** Trigger speech generation */ + generate: (input: SpeechGenerateInput) => Promise + /** The TTS result containing audio data, or null */ + result: TOutput | null + /** Whether generation is in progress */ + isLoading: boolean + /** Current error, if any */ + error: Error | undefined + /** Current state of the generation */ + status: GenerationClientState + /** Abort the current generation */ + stop: () => void + /** Clear result, error, and return to idle */ + reset: () => void +} + +/** + * Octane hook for generating speech (text-to-speech) using AI models. + * + * @example + * ```tsx + * import { useGenerateSpeech } from '@tanstack/ai-octane' + * import { fetchServerSentEvents } from '@tanstack/ai-client' + * + * function SpeechGenerator() { + * const { generate, result, isLoading } = useGenerateSpeech({ + * connection: fetchServerSentEvents('/api/generate/speech'), + * }) + * + * return ( + *
+ * + * {result && ( + *
+ * ) + * } + * ``` + */ +export function useGenerateSpeech( + options: Omit & { + onResult?: (result: TTSResult) => TTransformed + }, +): UseGenerateSpeechReturn< + InferGenerationOutputFromReturn +> { + const devtools = { + ...options.devtools, + // OCTANE DIVERGENCE: identify this binding as Octane (not 'react') in TanStack AI Devtools. + framework: 'octane', + hookName: 'useGenerateSpeech', + outputKind: 'audio' as const, + } + const { generate, result, isLoading, error, status, stop, reset } = + useGeneration({ + ...options, + devtools, + }) + + return { + generate: generate as (input: SpeechGenerateInput) => Promise, + result, + isLoading, + error, + status, + stop, + reset, + } +} diff --git a/packages/ai-octane/src/use-generate-speech.tsrx.d.ts b/packages/ai-octane/src/use-generate-speech.tsrx.d.ts new file mode 100644 index 0000000000..62410ea79a --- /dev/null +++ b/packages/ai-octane/src/use-generate-speech.tsrx.d.ts @@ -0,0 +1,95 @@ +// Declaration companion generated from use-generate-speech.tsrx. +import type { StreamChunk, TTSResult } from '@tanstack/ai' +import type { + AIDevtoolsDisplayOptions, + ConnectConnectionAdapter, + GenerationClientState, + GenerationFetcher, + InferGenerationOutputFromReturn, + SpeechGenerateInput, +} from '@tanstack/ai-client' +/** + * Options for the useGenerateSpeech hook. + * + * @template TOutput - The output type after optional transform (defaults to TTSResult) + */ +export interface UseGenerateSpeechOptions { + /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ + connection?: ConnectConnectionAdapter + /** Direct async function for speech generation */ + fetcher?: GenerationFetcher + /** Unique identifier for this generation instance */ + id?: string + /** Additional body parameters to send with connect-based adapter requests */ + body?: Record + /** Display options for TanStack AI Devtools. */ + devtools?: AIDevtoolsDisplayOptions + /** + * Callback when speech is generated. Can optionally return a transformed value. + * + * - Return a non-null value to transform and store it as the result + * - Return `null` to keep the previous result unchanged + * - Return nothing (`void`) to store the raw result as-is + */ + onResult?: (result: TTSResult) => TOutput | null | void + /** Callback when an error occurs */ + onError?: (error: Error) => void + /** Callback when progress is reported (0-100) */ + onProgress?: (progress: number, message?: string) => void + /** Callback for each stream chunk (connect-based adapter mode only) */ + onChunk?: (chunk: StreamChunk) => void +} +/** + * Return type for the useGenerateSpeech hook. + * + * @template TOutput - The output type (after optional transform) + */ +export interface UseGenerateSpeechReturn { + /** Trigger speech generation */ + generate: (input: SpeechGenerateInput) => Promise + /** The TTS result containing audio data, or null */ + result: TOutput | null + /** Whether generation is in progress */ + isLoading: boolean + /** Current error, if any */ + error: Error | undefined + /** Current state of the generation */ + status: GenerationClientState + /** Abort the current generation */ + stop: () => void + /** Clear result, error, and return to idle */ + reset: () => void +} +/** + * Octane hook for generating speech (text-to-speech) using AI models. + * + * @example + * ```tsx + * import { useGenerateSpeech } from '@tanstack/ai-octane' + * import { fetchServerSentEvents } from '@tanstack/ai-client' + * + * function SpeechGenerator() { + * const { generate, result, isLoading } = useGenerateSpeech({ + * connection: fetchServerSentEvents('/api/generate/speech'), + * }) + * + * return ( + *
+ * + * {result && ( + *
+ * ) + * } + * ``` + */ +export declare function useGenerateSpeech( + options: Omit & { + onResult?: (result: TTSResult) => TTransformed + }, +): UseGenerateSpeechReturn< + InferGenerationOutputFromReturn +> diff --git a/packages/ai-octane/src/use-generate-video.tsrx b/packages/ai-octane/src/use-generate-video.tsrx new file mode 100644 index 0000000000..5772a97326 --- /dev/null +++ b/packages/ai-octane/src/use-generate-video.tsrx @@ -0,0 +1,245 @@ +import { VideoGenerationClient } from '@tanstack/ai-client' +import { createVideoDevtoolsBridge } from '@tanstack/ai-client/devtools' +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'octane' +import type { StreamChunk } from '@tanstack/ai' +import type { + AIDevtoolsDisplayOptions, + ConnectConnectionAdapter, + GenerationClientState, + GenerationFetcher, + InferGenerationOutputFromReturn, + VideoGenerateInput, + VideoGenerateResult, + VideoStatusInfo, +} from '@tanstack/ai-client' + +/** + * Options for the useGenerateVideo hook. + */ +export interface UseGenerateVideoOptions { + /** Connect-based adapter for streaming transport (server handles polling) */ + connection?: ConnectConnectionAdapter + /** Direct async function that returns a completed video result */ + fetcher?: GenerationFetcher + /** Unique identifier for this generation instance */ + id?: string + /** Additional body parameters to send with connect-based adapter requests */ + body?: Record + /** Display options for TanStack AI Devtools. */ + devtools?: AIDevtoolsDisplayOptions + /** + * Callback when video generation completes. Can optionally return a transformed value. + * + * - Return a non-null value to transform and store it as the result + * - Return `null` to keep the previous result unchanged + * - Return nothing (`void`) to store the raw result as-is + */ + onResult?: (result: VideoGenerateResult) => TOutput | null | void + /** Callback when an error occurs */ + onError?: (error: Error) => void + /** Callback when progress is reported (0-100) */ + onProgress?: (progress: number, message?: string) => void + /** Callback when a video job is created */ + onJobCreated?: (jobId: string) => void + /** Callback on each status update */ + onStatusUpdate?: (status: VideoStatusInfo) => void + /** Callback for each stream chunk (connect-based adapter mode only) */ + onChunk?: (chunk: StreamChunk) => void +} + +/** + * Return type for the useGenerateVideo hook. + * + * @template TOutput - The output type (after optional transform) + */ +export interface UseGenerateVideoReturn { + /** Trigger video generation */ + generate: (input: VideoGenerateInput) => Promise + /** The final video result (with URL), or null */ + result: TOutput | null + /** The current job ID, or null */ + jobId: string | null + /** Current video generation status info, or null */ + videoStatus: VideoStatusInfo | null + /** Whether generation/polling is in progress */ + isLoading: boolean + /** Current error, if any */ + error: Error | undefined + /** Current state of the generation */ + status: GenerationClientState + /** Abort the current generation/polling */ + stop: () => void + /** Clear all state and return to idle */ + reset: () => void +} + +/** + * Octane hook for generating videos using AI models. + * + * Video generation is asynchronous: a job is created, then polled for status + * until completion. This hook handles the full lifecycle. + * + * @example + * ```tsx + * import { useGenerateVideo } from '@tanstack/ai-octane' + * import { fetchServerSentEvents } from '@tanstack/ai-client' + * + * function VideoGenerator() { + * const { generate, result, videoStatus, isLoading } = useGenerateVideo({ + * connection: fetchServerSentEvents('/api/generate/video'), + * onStatusUpdate: (status) => console.log(`Progress: ${status.progress}%`), + * }) + * + * return ( + *
+ * + * {isLoading && videoStatus && ( + *

Status: {videoStatus.status} ({videoStatus.progress}%)

+ * )} + * {result &&
+ * ) + * } + * ``` + */ +// `TTransformed` infers from the `onResult` return position so the callback +// parameter is typed as `VideoGenerateResult` and `result` narrows to the +// transform's return. See issue #848. +export function useGenerateVideo( + options: Omit & { + onResult?: (result: VideoGenerateResult) => TTransformed + }, +): UseGenerateVideoReturn< + InferGenerationOutputFromReturn +> { + type TOutput = InferGenerationOutputFromReturn< + VideoGenerateResult, + TTransformed + > + const hookId = useId() + const clientId = options.id || hookId + + const [result, setResult] = useState(null) + const [jobId, setJobId] = useState(null) + const [videoStatus, setVideoStatus] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(undefined) + const [status, setStatus] = useState('idle') + + const optionsRef = useRef(options) + optionsRef.current = options + + const client = useMemo(() => { + const opts = optionsRef.current + + // Conditional spread for `body` (strict-optional in target). + // Optional callbacks are wrapped in non-returning bodies so + // `?.()`'s implicit `undefined` doesn't widen the function + // return type (which `exactOptionalPropertyTypes` rejects + // against the strict-optional target). + const baseOptions = { + id: clientId, + body: opts.body, + devtoolsBridgeFactory: createVideoDevtoolsBridge, + devtools: { + ...opts.devtools, + // OCTANE DIVERGENCE: identify this binding as Octane (not 'react') in TanStack AI Devtools. + framework: 'octane', + hookName: 'useGenerateVideo', + outputKind: 'video' as const, + }, + // The transform's raw return type (`TTransformed`) and the stored output + // (`TOutput`, with null/void/undefined stripped) are identical at runtime; + // the cast bridges the relationship that the conditional type hides. + onResult: ((r: VideoGenerateResult) => + optionsRef.current.onResult?.(r)) as ( + result: VideoGenerateResult, + ) => TOutput | null | void, + onError: (e: Error) => { + optionsRef.current.onError?.(e) + }, + onProgress: (p: number, m?: string) => { + optionsRef.current.onProgress?.(p, m) + }, + onChunk: (c: StreamChunk) => { + optionsRef.current.onChunk?.(c) + }, + onJobCreated: (id: string) => { + optionsRef.current.onJobCreated?.(id) + }, + onStatusUpdate: (s: VideoStatusInfo) => { + optionsRef.current.onStatusUpdate?.(s) + }, + onResultChange: setResult, + onLoadingChange: setIsLoading, + onErrorChange: setError, + onStatusChange: setStatus, + onJobIdChange: setJobId, + onVideoStatusChange: setVideoStatus, + } + + if (opts.connection) { + return new VideoGenerationClient({ + ...baseOptions, + connection: opts.connection, + }) + } + + if (opts.fetcher) { + return new VideoGenerationClient({ + ...baseOptions, + fetcher: opts.fetcher, + }) + } + + throw new Error( + 'useGenerateVideo requires either a connection or fetcher option', + ) + }, [clientId]) + + // Sync body changes without recreating client + useEffect(() => { + // Conditional spread: target uses strict-optional `body?: T`. + client.updateOptions({ + ...(options.body !== undefined && { body: options.body }), + }) + }, [client, options.body]) + + // Cleanup on unmount + useEffect(() => { + client.mountDevtools() + + return () => { + client.dispose() + } + }, [client]) + + const generate = useCallback( + async (input: VideoGenerateInput) => { + await client.generate(input) + }, + [client], + ) + + const stop = useCallback(() => { + client.stop() + }, [client]) + + const reset = useCallback(() => { + client.reset() + }, [client]) + + return { + generate, + result, + jobId, + videoStatus, + isLoading, + error, + status, + stop, + reset, + } +} diff --git a/packages/ai-octane/src/use-generate-video.tsrx.d.ts b/packages/ai-octane/src/use-generate-video.tsrx.d.ts new file mode 100644 index 0000000000..e111039623 --- /dev/null +++ b/packages/ai-octane/src/use-generate-video.tsrx.d.ts @@ -0,0 +1,108 @@ +// Declaration companion generated from use-generate-video.tsrx. +import type { StreamChunk } from '@tanstack/ai' +import type { + AIDevtoolsDisplayOptions, + ConnectConnectionAdapter, + GenerationClientState, + GenerationFetcher, + InferGenerationOutputFromReturn, + VideoGenerateInput, + VideoGenerateResult, + VideoStatusInfo, +} from '@tanstack/ai-client' +/** + * Options for the useGenerateVideo hook. + */ +export interface UseGenerateVideoOptions { + /** Connect-based adapter for streaming transport (server handles polling) */ + connection?: ConnectConnectionAdapter + /** Direct async function that returns a completed video result */ + fetcher?: GenerationFetcher + /** Unique identifier for this generation instance */ + id?: string + /** Additional body parameters to send with connect-based adapter requests */ + body?: Record + /** Display options for TanStack AI Devtools. */ + devtools?: AIDevtoolsDisplayOptions + /** + * Callback when video generation completes. Can optionally return a transformed value. + * + * - Return a non-null value to transform and store it as the result + * - Return `null` to keep the previous result unchanged + * - Return nothing (`void`) to store the raw result as-is + */ + onResult?: (result: VideoGenerateResult) => TOutput | null | void + /** Callback when an error occurs */ + onError?: (error: Error) => void + /** Callback when progress is reported (0-100) */ + onProgress?: (progress: number, message?: string) => void + /** Callback when a video job is created */ + onJobCreated?: (jobId: string) => void + /** Callback on each status update */ + onStatusUpdate?: (status: VideoStatusInfo) => void + /** Callback for each stream chunk (connect-based adapter mode only) */ + onChunk?: (chunk: StreamChunk) => void +} +/** + * Return type for the useGenerateVideo hook. + * + * @template TOutput - The output type (after optional transform) + */ +export interface UseGenerateVideoReturn { + /** Trigger video generation */ + generate: (input: VideoGenerateInput) => Promise + /** The final video result (with URL), or null */ + result: TOutput | null + /** The current job ID, or null */ + jobId: string | null + /** Current video generation status info, or null */ + videoStatus: VideoStatusInfo | null + /** Whether generation/polling is in progress */ + isLoading: boolean + /** Current error, if any */ + error: Error | undefined + /** Current state of the generation */ + status: GenerationClientState + /** Abort the current generation/polling */ + stop: () => void + /** Clear all state and return to idle */ + reset: () => void +} +/** + * Octane hook for generating videos using AI models. + * + * Video generation is asynchronous: a job is created, then polled for status + * until completion. This hook handles the full lifecycle. + * + * @example + * ```tsx + * import { useGenerateVideo } from '@tanstack/ai-octane' + * import { fetchServerSentEvents } from '@tanstack/ai-client' + * + * function VideoGenerator() { + * const { generate, result, videoStatus, isLoading } = useGenerateVideo({ + * connection: fetchServerSentEvents('/api/generate/video'), + * onStatusUpdate: (status) => console.log(`Progress: ${status.progress}%`), + * }) + * + * return ( + *
+ * + * {isLoading && videoStatus && ( + *

Status: {videoStatus.status} ({videoStatus.progress}%)

+ * )} + * {result &&
+ * ) + * } + * ``` + */ +export declare function useGenerateVideo( + options: Omit & { + onResult?: (result: VideoGenerateResult) => TTransformed + }, +): UseGenerateVideoReturn< + InferGenerationOutputFromReturn +> diff --git a/packages/ai-octane/src/use-generation.tsrx b/packages/ai-octane/src/use-generation.tsrx new file mode 100644 index 0000000000..e3ca19c084 --- /dev/null +++ b/packages/ai-octane/src/use-generation.tsrx @@ -0,0 +1,229 @@ +import { GenerationClient } from '@tanstack/ai-client' +import { createGenerationDevtoolsBridge } from '@tanstack/ai-client/devtools' +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'octane' +import type { StreamChunk } from '@tanstack/ai' +import type { + AIDevtoolsDisplayOptions, + ConnectConnectionAdapter, + GenerationClientOptions, + GenerationClientState, + GenerationFetcher, + InferGenerationOutputFromReturn, +} from '@tanstack/ai-client' + +/** + * Options for the useGeneration hook. + * + * Accepts either a `connection` (streaming transport) or a `fetcher` (direct async call). + * + * @template TInput - The input type for the generation request + * @template TResult - The result type returned by the generation + * @template TOutput - The output type after optional transform (defaults to TResult) + */ +export interface UseGenerationOptions { + /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ + connection?: ConnectConnectionAdapter + /** Direct async function for one-shot generation (no streaming protocol needed) */ + fetcher?: GenerationFetcher + /** Unique identifier for this generation instance */ + id?: string + /** Additional body parameters to send with connect-based adapter requests */ + body?: Record + /** Display options for TanStack AI Devtools. */ + devtools?: AIDevtoolsDisplayOptions + /** + * Callback when a result is received. Can optionally return a transformed value. + * + * - Return a non-null value to transform and store it as the result + * - Return `null` to keep the previous result unchanged + * - Return nothing (`void`) to store the raw result as-is + */ + onResult?: (result: TResult) => TOutput | null | void + /** Callback when an error occurs */ + onError?: (error: Error) => void + /** Callback when progress is reported (0-100) */ + onProgress?: (progress: number, message?: string) => void + /** Callback for each stream chunk (connect-based adapter mode only) */ + onChunk?: (chunk: StreamChunk) => void +} + +/** + * Return type for the useGeneration hook. + * + * @template TInput - The input type accepted by `generate` + * @template TOutput - The output type (after optional transform) + */ +export interface UseGenerationReturn { + /** + * Trigger a generation request. + * + * Typed as `TInput` rather than `Record` so required and narrow + * input fields are actually checked at the call site. (Upstream + * `@tanstack/ai-react` widens this; see status.json.) + */ + generate: (input: TInput) => Promise + /** The generation result, or null if not yet generated */ + result: TOutput | null + /** Whether a generation is currently in progress */ + isLoading: boolean + /** Current error, if any */ + error: Error | undefined + /** Current state of the generation client */ + status: GenerationClientState + /** Abort the current generation */ + stop: () => void + /** Clear result, error, and return to idle */ + reset: () => void +} + +/** + * Generic Octane hook for one-shot generation tasks. + * + * This is the base hook used by `useGenerateImage`, `useGenerateSpeech`, + * `useTranscription`, and `useSummarize`. You can also use it directly + * for custom generation types. + * + * @template TInput - The input type for the generation request + * @template TResult - The result type returned by the generation + * + * @example + * ```tsx + * const { generate, result, isLoading } = useGeneration({ + * connection: fetchServerSentEvents('/api/generate/custom'), + * }) + * + * await generate({ prompt: 'Hello' }) + * ``` + */ +// `TTransformed` infers from the `onResult` return position (a covariant +// inference site that works even for an optional nested property), which types +// the callback parameter as `TResult` and narrows `result`. Inferring the +// whole callback as a defaulted type parameter instead collapses to the +// default, leaving the parameter `any` — a hard error under `strict`. See +// issue #848. +export function useGeneration< + TInput extends Record, + TResult, + TTransformed = void, +>( + options: Omit, 'onResult'> & { + onResult?: (result: TResult) => TTransformed + }, +): UseGenerationReturn< + TInput, + InferGenerationOutputFromReturn +> { + type TOutput = InferGenerationOutputFromReturn + const hookId = useId() + const clientId = options.id || hookId + + const [result, setResult] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(undefined) + const [status, setStatus] = useState('idle') + + const optionsRef = useRef(options) + optionsRef.current = options + + const client = useMemo(() => { + const opts = optionsRef.current + + // Conditional spread for `body` (strict-optional in target; + // local source is `Record | undefined`). Callbacks + // wrap optional ones in non-returning bodies so `?.()`'s + // implicit `undefined` doesn't pollute the function return type. + const clientOptions: GenerationClientOptions = { + id: clientId, + body: opts.body, + devtoolsBridgeFactory: createGenerationDevtoolsBridge, + devtools: { + ...opts.devtools, + // OCTANE DIVERGENCE: identify this binding as Octane (not 'react') in TanStack AI Devtools. + // The spread comes first so a caller-supplied `devtools.framework` can't + // silently misidentify the binding — `useGeneration` is public API, and + // the sibling hooks (useChat, useGenerateVideo) already order it this way. + framework: 'octane', + hookName: 'useGeneration', + }, + // The transform's raw return type (`TTransformed`) and the stored output + // (`TOutput`, with null/void/undefined stripped) are identical at runtime; + // the cast bridges the relationship that the conditional type hides. + onResult: ((r: TResult) => optionsRef.current.onResult?.(r)) as ( + result: TResult, + ) => TOutput | null | void, + onError: (e: Error) => { + optionsRef.current.onError?.(e) + }, + onProgress: (p: number, m?: string) => { + optionsRef.current.onProgress?.(p, m) + }, + onChunk: (c: StreamChunk) => { + optionsRef.current.onChunk?.(c) + }, + onResultChange: setResult, + onLoadingChange: setIsLoading, + onErrorChange: setError, + onStatusChange: setStatus, + } + + if (opts.connection) { + return new GenerationClient({ + ...clientOptions, + connection: opts.connection, + }) + } + + if (opts.fetcher) { + return new GenerationClient({ + ...clientOptions, + fetcher: opts.fetcher, + }) + } + + throw new Error( + 'useGeneration requires either a connection or fetcher option', + ) + }, [clientId]) + + // Sync body changes without recreating client + useEffect(() => { + // Conditional spread: target uses strict-optional `body?: T`. + client.updateOptions({ + ...(options.body !== undefined && { body: options.body }), + }) + }, [client, options.body]) + + // Cleanup on unmount + useEffect(() => { + client.mountDevtools() + + return () => { + client.dispose() + } + }, [client]) + + const generate = useCallback( + async (input: TInput) => { + await client.generate(input) + }, + [client], + ) + + const stop = useCallback(() => { + client.stop() + }, [client]) + + const reset = useCallback(() => { + client.reset() + }, [client]) + + return { + generate, + result, + isLoading, + error, + status, + stop, + reset, + } +} diff --git a/packages/ai-octane/src/use-generation.tsrx.d.ts b/packages/ai-octane/src/use-generation.tsrx.d.ts new file mode 100644 index 0000000000..a371d6abd8 --- /dev/null +++ b/packages/ai-octane/src/use-generation.tsrx.d.ts @@ -0,0 +1,103 @@ +// Declaration companion generated from use-generation.tsrx. +import type { StreamChunk } from '@tanstack/ai' +import type { + AIDevtoolsDisplayOptions, + ConnectConnectionAdapter, + GenerationClientState, + GenerationFetcher, + InferGenerationOutputFromReturn, +} from '@tanstack/ai-client' +/** + * Options for the useGeneration hook. + * + * Accepts either a `connection` (streaming transport) or a `fetcher` (direct async call). + * + * @template TInput - The input type for the generation request + * @template TResult - The result type returned by the generation + * @template TOutput - The output type after optional transform (defaults to TResult) + */ +export interface UseGenerationOptions { + /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ + connection?: ConnectConnectionAdapter + /** Direct async function for one-shot generation (no streaming protocol needed) */ + fetcher?: GenerationFetcher + /** Unique identifier for this generation instance */ + id?: string + /** Additional body parameters to send with connect-based adapter requests */ + body?: Record + /** Display options for TanStack AI Devtools. */ + devtools?: AIDevtoolsDisplayOptions + /** + * Callback when a result is received. Can optionally return a transformed value. + * + * - Return a non-null value to transform and store it as the result + * - Return `null` to keep the previous result unchanged + * - Return nothing (`void`) to store the raw result as-is + */ + onResult?: (result: TResult) => TOutput | null | void + /** Callback when an error occurs */ + onError?: (error: Error) => void + /** Callback when progress is reported (0-100) */ + onProgress?: (progress: number, message?: string) => void + /** Callback for each stream chunk (connect-based adapter mode only) */ + onChunk?: (chunk: StreamChunk) => void +} +/** + * Return type for the useGeneration hook. + * + * @template TInput - The input type accepted by `generate` + * @template TOutput - The output type (after optional transform) + */ +export interface UseGenerationReturn { + /** + * Trigger a generation request. + * + * Typed as `TInput` rather than `Record` so required and narrow + * input fields are actually checked at the call site. (Upstream + * `@tanstack/ai-react` widens this; see status.json.) + */ + generate: (input: TInput) => Promise + /** The generation result, or null if not yet generated */ + result: TOutput | null + /** Whether a generation is currently in progress */ + isLoading: boolean + /** Current error, if any */ + error: Error | undefined + /** Current state of the generation client */ + status: GenerationClientState + /** Abort the current generation */ + stop: () => void + /** Clear result, error, and return to idle */ + reset: () => void +} +/** + * Generic Octane hook for one-shot generation tasks. + * + * This is the base hook used by `useGenerateImage`, `useGenerateSpeech`, + * `useTranscription`, and `useSummarize`. You can also use it directly + * for custom generation types. + * + * @template TInput - The input type for the generation request + * @template TResult - The result type returned by the generation + * + * @example + * ```tsx + * const { generate, result, isLoading } = useGeneration({ + * connection: fetchServerSentEvents('/api/generate/custom'), + * }) + * + * await generate({ prompt: 'Hello' }) + * ``` + */ +export declare function useGeneration< + TInput extends Record, + TResult, + TTransformed = void, +>( + options: Omit, 'onResult'> & { + onResult?: (result: TResult) => TTransformed + }, +): UseGenerationReturn< + TInput, + InferGenerationOutputFromReturn +> diff --git a/packages/ai-octane/src/use-mcp-app-bridge.tsrx b/packages/ai-octane/src/use-mcp-app-bridge.tsrx new file mode 100644 index 0000000000..d347c836da --- /dev/null +++ b/packages/ai-octane/src/use-mcp-app-bridge.tsrx @@ -0,0 +1,60 @@ +import { useMemo, useRef } from 'octane' +import { createMcpAppBridge } from '@tanstack/ai-client' +import type { + CreateMcpAppBridgeOptions, + McpAppBridge, +} from '@tanstack/ai-client' + +export type UseMcpAppBridgeOptions = CreateMcpAppBridgeOptions + +/** + * Octane wrapper around `createMcpAppBridge` that returns a **stable** bridge for + * the given `threadId`/`callEndpoint`, while always invoking the latest + * `chat.sendMessage` and `onLink` (kept in refs). This avoids both recreating + * the bridge on every render and the stale-closure / `exhaustive-deps` dance + * you'd otherwise write by hand: + * + * ```tsx + * const { sendMessage } = useChat({ threadId, connection }) + * const bridge = useMcpAppBridge({ + * threadId, + * callEndpoint: '/api/mcp-apps-call', + * chat: { sendMessage: async (content) => void sendMessage(content) }, + * onLink: (url) => window.open(url, '_blank', 'noopener,noreferrer'), + * }) + * // pass `bridge` to + * ``` + * + * The bridge is recreated only when `threadId`, `callEndpoint`, `fetchImpl`, or + * the *presence* of `onLink` changes — passing a new inline `onLink`/`sendMessage` + * each render does not churn it. + */ +export function useMcpAppBridge(options: UseMcpAppBridgeOptions): McpAppBridge { + const { threadId, callEndpoint, chat, fetchImpl, onLink } = options + + // Latest-value refs so the bridge identity stays stable but its callbacks are + // never stale (the bridge calls `.current` at invocation time, not creation). + const chatRef = useRef(chat) + chatRef.current = chat + const onLinkRef = useRef(onLink) + onLinkRef.current = onLink + + // Whether a link handler was supplied governs the bridge's link behavior + // (forward vs. display-only warn), so it's part of the bridge's identity. + const hasOnLink = onLink != null + + return useMemo( + () => + createMcpAppBridge({ + threadId, + callEndpoint, + fetchImpl, + chat: { + sendMessage: (content, body) => + chatRef.current.sendMessage(content, body), + }, + onLink: hasOnLink ? (url) => onLinkRef.current?.(url) : undefined, + }), + [threadId, callEndpoint, fetchImpl, hasOnLink], + ) +} diff --git a/packages/ai-octane/src/use-mcp-app-bridge.tsrx.d.ts b/packages/ai-octane/src/use-mcp-app-bridge.tsrx.d.ts new file mode 100644 index 0000000000..a10953096c --- /dev/null +++ b/packages/ai-octane/src/use-mcp-app-bridge.tsrx.d.ts @@ -0,0 +1,31 @@ +// Declaration companion generated from use-mcp-app-bridge.tsrx. +import type { + CreateMcpAppBridgeOptions, + McpAppBridge, +} from '@tanstack/ai-client' +export type UseMcpAppBridgeOptions = CreateMcpAppBridgeOptions +/** + * Octane wrapper around `createMcpAppBridge` that returns a **stable** bridge for + * the given `threadId`/`callEndpoint`, while always invoking the latest + * `chat.sendMessage` and `onLink` (kept in refs). This avoids both recreating + * the bridge on every render and the stale-closure / `exhaustive-deps` dance + * you'd otherwise write by hand: + * + * ```tsx + * const { sendMessage } = useChat({ threadId, connection }) + * const bridge = useMcpAppBridge({ + * threadId, + * callEndpoint: '/api/mcp-apps-call', + * chat: { sendMessage: async (content) => void sendMessage(content) }, + * onLink: (url) => window.open(url, '_blank', 'noopener,noreferrer'), + * }) + * // pass `bridge` to + * ``` + * + * The bridge is recreated only when `threadId`, `callEndpoint`, `fetchImpl`, or + * the *presence* of `onLink` changes — passing a new inline `onLink`/`sendMessage` + * each render does not churn it. + */ +export declare function useMcpAppBridge( + options: UseMcpAppBridgeOptions, +): McpAppBridge diff --git a/packages/ai-octane/src/use-realtime-chat.tsrx b/packages/ai-octane/src/use-realtime-chat.tsrx new file mode 100644 index 0000000000..b486b8cc12 --- /dev/null +++ b/packages/ai-octane/src/use-realtime-chat.tsrx @@ -0,0 +1,307 @@ +import { useCallback, useEffect, useRef, useState } from 'octane' +import { RealtimeClient } from '@tanstack/ai-client' +import type { + RealtimeMessage, + RealtimeMode, + RealtimeSessionConfig, + RealtimeStatus, +} from '@tanstack/ai' +import type { + UseRealtimeChatOptions, + UseRealtimeChatReturn, +} from './realtime-types' + +// Empty frequency data for when client is not connected +const emptyFrequencyData = new Uint8Array(128) +const emptyTimeDomainData = new Uint8Array(128).fill(128) + +/** + * Octane hook for realtime voice conversations. + * + * Provides a simple interface for voice-to-voice AI interactions + * with support for multiple providers (OpenAI, ElevenLabs, etc.). + * + * @param options - Configuration options including adapter and callbacks + * @returns Hook return value with state and control methods + * + * @example + * ```typescript + * import { useRealtimeChat } from '@tanstack/ai-octane' + * import { openaiRealtime } from '@tanstack/ai-openai' + * + * function VoiceChat() { + * const { + * status, + * mode, + * messages, + * connect, + * disconnect, + * inputLevel, + * outputLevel, + * } = useRealtimeChat({ + * getToken: () => fetch('/api/realtime-token').then(r => r.json()), + * adapter: openaiRealtime(), + * }) + * + * return ( + *
+ *

Status: {status}

+ *

Mode: {mode}

+ * + *
+ * ) + * } + * ``` + */ +export function useRealtimeChat( + options: UseRealtimeChatOptions, +): UseRealtimeChatReturn { + // State + const [status, setStatus] = useState('idle') + const [mode, setMode] = useState('idle') + const [messages, setMessages] = useState>([]) + const [pendingUserTranscript, setPendingUserTranscript] = useState< + string | null + >(null) + const [pendingAssistantTranscript, setPendingAssistantTranscript] = useState< + string | null + >(null) + const [error, setError] = useState(null) + const [inputLevel, setInputLevel] = useState(0) + const [outputLevel, setOutputLevel] = useState(0) + + // Refs + const clientRef = useRef(null) + const optionsRef = useRef(options) + optionsRef.current = options + const animationFrameRef = useRef(null) + + // Create client instance - use ref to ensure we reuse the same instance + // This handles React StrictMode double-rendering + if (!clientRef.current) { + // Each optional source field is spread conditionally because the + // `RealtimeClientOptions` target declares strict optionals + // (`field?: T`) and `exactOptionalPropertyTypes` rejects passing + // `undefined` for absent values. + const initial = optionsRef.current + clientRef.current = new RealtimeClient({ + // OCTANE DIVERGENCE: upstream captures the first transport callbacks. + // Ref-backed delegates keep the client stable while reconnects and token + // refreshes observe the latest render's authentication and adapter. + getToken: () => optionsRef.current.getToken(), + adapter: { + get provider() { + return optionsRef.current.adapter.provider + }, + connect(token, tools) { + return optionsRef.current.adapter.connect(token, tools) + }, + }, + ...(initial.tools !== undefined && { tools: initial.tools }), + ...(initial.instructions !== undefined && { + instructions: initial.instructions, + }), + ...(initial.voice !== undefined && { voice: initial.voice }), + ...(initial.autoPlayback !== undefined && { + autoPlayback: initial.autoPlayback, + }), + ...(initial.autoCapture !== undefined && { + autoCapture: initial.autoCapture, + }), + ...(initial.vadMode !== undefined && { vadMode: initial.vadMode }), + ...(initial.outputModalities !== undefined && { + outputModalities: initial.outputModalities, + }), + ...(initial.temperature !== undefined && { + temperature: initial.temperature, + }), + ...(initial.maxOutputTokens !== undefined && { + maxOutputTokens: initial.maxOutputTokens, + }), + ...(initial.semanticEagerness !== undefined && { + semanticEagerness: initial.semanticEagerness, + }), + onStatusChange: (newStatus) => { + setStatus(newStatus) + // OCTANE DIVERGENCE: upstream declares this callback but does not + // forward status changes beyond its own hook state. + optionsRef.current.onStatusChange?.(newStatus) + }, + onModeChange: (newMode) => { + setMode(newMode) + optionsRef.current.onModeChange?.(newMode) + }, + onMessage: (message) => { + setMessages((prev) => [...prev, message]) + optionsRef.current.onMessage?.(message) + }, + onUsage: (usage) => { + optionsRef.current.onUsage?.(usage) + }, + onGoAway: (timeLeft) => { + optionsRef.current.onGoAway?.(timeLeft) + }, + onError: (err) => { + setError(err) + optionsRef.current.onError?.(err) + }, + onConnect: () => { + setError(null) + optionsRef.current.onConnect?.() + }, + onDisconnect: () => { + optionsRef.current.onDisconnect?.() + }, + onInterrupted: () => { + setPendingAssistantTranscript(null) + optionsRef.current.onInterrupted?.() + }, + }) + + // Subscribe to state changes for transcripts + clientRef.current.onStateChange((state) => { + setPendingUserTranscript(state.pendingUserTranscript) + setPendingAssistantTranscript(state.pendingAssistantTranscript) + }) + } + + const client = clientRef.current + + // Audio level animation loop + useEffect(() => { + function updateLevels() { + if (clientRef.current?.audio) { + setInputLevel(clientRef.current.audio.inputLevel) + setOutputLevel(clientRef.current.audio.outputLevel) + } + animationFrameRef.current = requestAnimationFrame(updateLevels) + } + + if (status === 'connected') { + updateLevels() + } + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current) + animationFrameRef.current = null + } + } + }, [status]) + + // Cleanup on unmount + useEffect(() => { + return () => { + clientRef.current?.destroy() + } + }, []) + + // Connection methods + const connect = useCallback(async () => { + setError(null) + setMessages([]) + setPendingUserTranscript(null) + setPendingAssistantTranscript(null) + await client.connect() + }, [client]) + + const disconnect = useCallback(async () => { + await client.disconnect() + }, [client]) + + // Voice control methods + const startListening = useCallback(() => { + client.startListening() + }, [client]) + + const stopListening = useCallback(() => { + client.stopListening() + }, [client]) + + const interrupt = useCallback(() => { + client.interrupt() + }, [client]) + + // Text input + const sendText = useCallback( + (text: string) => { + client.sendText(text) + }, + [client], + ) + + // Image input + const sendImage = useCallback( + (imageData: string, mimeType: string) => { + client.sendImage(imageData, mimeType) + }, + [client], + ) + + // Audio visualization + const getInputFrequencyData = useCallback(() => { + return ( + clientRef.current?.audio?.getInputFrequencyData() ?? emptyFrequencyData + ) + }, []) + + const getOutputFrequencyData = useCallback(() => { + return ( + clientRef.current?.audio?.getOutputFrequencyData() ?? emptyFrequencyData + ) + }, []) + + const getInputTimeDomainData = useCallback(() => { + return ( + clientRef.current?.audio?.getInputTimeDomainData() ?? emptyTimeDomainData + ) + }, []) + + const getOutputTimeDomainData = useCallback(() => { + return ( + clientRef.current?.audio?.getOutputTimeDomainData() ?? emptyTimeDomainData + ) + }, []) + + const updateSession = useCallback((config: RealtimeSessionConfig) => { + clientRef.current?.updateSession(config) + }, []) + + return { + // Connection state + status, + error, + connect, + disconnect, + + // Conversation state + mode, + messages, + pendingUserTranscript, + pendingAssistantTranscript, + + // Voice control + startListening, + stopListening, + interrupt, + + // Text input + sendText, + + // Image input + sendImage, + + // Audio visualization + inputLevel, + outputLevel, + getInputFrequencyData, + getOutputFrequencyData, + getInputTimeDomainData, + getOutputTimeDomainData, + + // Session control + updateSession, + } +} diff --git a/packages/ai-octane/src/use-realtime-chat.tsrx.d.ts b/packages/ai-octane/src/use-realtime-chat.tsrx.d.ts new file mode 100644 index 0000000000..384bd3f0b4 --- /dev/null +++ b/packages/ai-octane/src/use-realtime-chat.tsrx.d.ts @@ -0,0 +1,48 @@ +// Declaration companion generated from use-realtime-chat.tsrx. +import type { + UseRealtimeChatOptions, + UseRealtimeChatReturn, +} from './realtime-types' +/** + * Octane hook for realtime voice conversations. + * + * Provides a simple interface for voice-to-voice AI interactions + * with support for multiple providers (OpenAI, ElevenLabs, etc.). + * + * @param options - Configuration options including adapter and callbacks + * @returns Hook return value with state and control methods + * + * @example + * ```typescript + * import { useRealtimeChat } from '@tanstack/ai-octane' + * import { openaiRealtime } from '@tanstack/ai-openai' + * + * function VoiceChat() { + * const { + * status, + * mode, + * messages, + * connect, + * disconnect, + * inputLevel, + * outputLevel, + * } = useRealtimeChat({ + * getToken: () => fetch('/api/realtime-token').then(r => r.json()), + * adapter: openaiRealtime(), + * }) + * + * return ( + *
+ *

Status: {status}

+ *

Mode: {mode}

+ * + *
+ * ) + * } + * ``` + */ +export declare function useRealtimeChat( + options: UseRealtimeChatOptions, +): UseRealtimeChatReturn diff --git a/packages/ai-octane/src/use-summarize.tsrx b/packages/ai-octane/src/use-summarize.tsrx new file mode 100644 index 0000000000..d974f4f52f --- /dev/null +++ b/packages/ai-octane/src/use-summarize.tsrx @@ -0,0 +1,124 @@ +import { useGeneration } from './use-generation.tsrx' +import type { StreamChunk, SummarizationResult } from '@tanstack/ai' +import type { + AIDevtoolsDisplayOptions, + ConnectConnectionAdapter, + GenerationClientState, + GenerationFetcher, + InferGenerationOutputFromReturn, + SummarizeGenerateInput, +} from '@tanstack/ai-client' + +/** + * Options for the useSummarize hook. + * + * @template TOutput - The output type after optional transform (defaults to SummarizationResult) + */ +export interface UseSummarizeOptions { + /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ + connection?: ConnectConnectionAdapter + /** Direct async function for summarization */ + fetcher?: GenerationFetcher + /** Unique identifier for this generation instance */ + id?: string + /** Additional body parameters to send with connect-based adapter requests */ + body?: Record + /** Display options for TanStack AI Devtools. */ + devtools?: AIDevtoolsDisplayOptions + /** + * Callback when summarization is complete. Can optionally return a transformed value. + * + * - Return a non-null value to transform and store it as the result + * - Return `null` to keep the previous result unchanged + * - Return nothing (`void`) to store the raw result as-is + */ + onResult?: (result: SummarizationResult) => TOutput | null | void + /** Callback when an error occurs */ + onError?: (error: Error) => void + /** Callback when progress is reported (0-100) */ + onProgress?: (progress: number, message?: string) => void + /** Callback for each stream chunk (connect-based adapter mode only) */ + onChunk?: (chunk: StreamChunk) => void +} + +/** + * Return type for the useSummarize hook. + * + * @template TOutput - The output type (after optional transform) + */ +export interface UseSummarizeReturn { + /** Trigger summarization */ + generate: (input: SummarizeGenerateInput) => Promise + /** The summarization result, or null */ + result: TOutput | null + /** Whether summarization is in progress */ + isLoading: boolean + /** Current error, if any */ + error: Error | undefined + /** Current state of the generation */ + status: GenerationClientState + /** Abort the current summarization */ + stop: () => void + /** Clear result, error, and return to idle */ + reset: () => void +} + +/** + * Octane hook for summarizing text using AI models. + * + * @example + * ```tsx + * import { useSummarize } from '@tanstack/ai-octane' + * import { fetchServerSentEvents } from '@tanstack/ai-client' + * + * function Summarizer() { + * const { generate, result, isLoading } = useSummarize({ + * connection: fetchServerSentEvents('/api/summarize'), + * }) + * + * return ( + *
+ * + * {isLoading &&

Summarizing...

} + * {result &&

{result.summary}

} + *
+ * ) + * } + * ``` + */ +export function useSummarize( + options: Omit & { + onResult?: (result: SummarizationResult) => TTransformed + }, +): UseSummarizeReturn< + InferGenerationOutputFromReturn +> { + const devtools = { + ...options.devtools, + // OCTANE DIVERGENCE: identify this binding as Octane (not 'react') in TanStack AI Devtools. + framework: 'octane', + hookName: 'useSummarize', + outputKind: 'text' as const, + } + const { generate, result, isLoading, error, status, stop, reset } = + useGeneration({ + ...options, + devtools, + }) + + return { + generate: generate as (input: SummarizeGenerateInput) => Promise, + result, + isLoading, + error, + status, + stop, + reset, + } +} diff --git a/packages/ai-octane/src/use-summarize.tsrx.d.ts b/packages/ai-octane/src/use-summarize.tsrx.d.ts new file mode 100644 index 0000000000..d90a111463 --- /dev/null +++ b/packages/ai-octane/src/use-summarize.tsrx.d.ts @@ -0,0 +1,98 @@ +// Declaration companion generated from use-summarize.tsrx. +import type { StreamChunk, SummarizationResult } from '@tanstack/ai' +import type { + AIDevtoolsDisplayOptions, + ConnectConnectionAdapter, + GenerationClientState, + GenerationFetcher, + InferGenerationOutputFromReturn, + SummarizeGenerateInput, +} from '@tanstack/ai-client' +/** + * Options for the useSummarize hook. + * + * @template TOutput - The output type after optional transform (defaults to SummarizationResult) + */ +export interface UseSummarizeOptions { + /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ + connection?: ConnectConnectionAdapter + /** Direct async function for summarization */ + fetcher?: GenerationFetcher + /** Unique identifier for this generation instance */ + id?: string + /** Additional body parameters to send with connect-based adapter requests */ + body?: Record + /** Display options for TanStack AI Devtools. */ + devtools?: AIDevtoolsDisplayOptions + /** + * Callback when summarization is complete. Can optionally return a transformed value. + * + * - Return a non-null value to transform and store it as the result + * - Return `null` to keep the previous result unchanged + * - Return nothing (`void`) to store the raw result as-is + */ + onResult?: (result: SummarizationResult) => TOutput | null | void + /** Callback when an error occurs */ + onError?: (error: Error) => void + /** Callback when progress is reported (0-100) */ + onProgress?: (progress: number, message?: string) => void + /** Callback for each stream chunk (connect-based adapter mode only) */ + onChunk?: (chunk: StreamChunk) => void +} +/** + * Return type for the useSummarize hook. + * + * @template TOutput - The output type (after optional transform) + */ +export interface UseSummarizeReturn { + /** Trigger summarization */ + generate: (input: SummarizeGenerateInput) => Promise + /** The summarization result, or null */ + result: TOutput | null + /** Whether summarization is in progress */ + isLoading: boolean + /** Current error, if any */ + error: Error | undefined + /** Current state of the generation */ + status: GenerationClientState + /** Abort the current summarization */ + stop: () => void + /** Clear result, error, and return to idle */ + reset: () => void +} +/** + * Octane hook for summarizing text using AI models. + * + * @example + * ```tsx + * import { useSummarize } from '@tanstack/ai-octane' + * import { fetchServerSentEvents } from '@tanstack/ai-client' + * + * function Summarizer() { + * const { generate, result, isLoading } = useSummarize({ + * connection: fetchServerSentEvents('/api/summarize'), + * }) + * + * return ( + *
+ * + * {isLoading &&

Summarizing...

} + * {result &&

{result.summary}

} + *
+ * ) + * } + * ``` + */ +export declare function useSummarize( + options: Omit & { + onResult?: (result: SummarizationResult) => TTransformed + }, +): UseSummarizeReturn< + InferGenerationOutputFromReturn +> diff --git a/packages/ai-octane/src/use-transcription.tsrx b/packages/ai-octane/src/use-transcription.tsrx new file mode 100644 index 0000000000..79cc79f020 --- /dev/null +++ b/packages/ai-octane/src/use-transcription.tsrx @@ -0,0 +1,131 @@ +import { useGeneration } from './use-generation.tsrx' +import type { StreamChunk, TranscriptionResult } from '@tanstack/ai' +import type { + AIDevtoolsDisplayOptions, + ConnectConnectionAdapter, + GenerationClientState, + GenerationFetcher, + InferGenerationOutputFromReturn, + TranscriptionGenerateInput, +} from '@tanstack/ai-client' + +/** + * Options for the useTranscription hook. + * + * @template TOutput - The output type after optional transform (defaults to TranscriptionResult) + */ +export interface UseTranscriptionOptions { + /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ + connection?: ConnectConnectionAdapter + /** Direct async function for transcription */ + fetcher?: GenerationFetcher + /** Unique identifier for this generation instance */ + id?: string + /** Additional body parameters to send with connect-based adapter requests */ + body?: Record + /** Display options for TanStack AI Devtools. */ + devtools?: AIDevtoolsDisplayOptions + /** + * Callback when transcription is complete. Can optionally return a transformed value. + * + * - Return a non-null value to transform and store it as the result + * - Return `null` to keep the previous result unchanged + * - Return nothing (`void`) to store the raw result as-is + */ + onResult?: (result: TranscriptionResult) => TOutput | null | void + /** Callback when an error occurs */ + onError?: (error: Error) => void + /** Callback when progress is reported (0-100) */ + onProgress?: (progress: number, message?: string) => void + /** Callback for each stream chunk (connect-based adapter mode only) */ + onChunk?: (chunk: StreamChunk) => void +} + +/** + * Return type for the useTranscription hook. + * + * @template TOutput - The output type (after optional transform) + */ +export interface UseTranscriptionReturn { + /** Trigger transcription */ + generate: (input: TranscriptionGenerateInput) => Promise + /** The transcription result, or null */ + result: TOutput | null + /** Whether transcription is in progress */ + isLoading: boolean + /** Current error, if any */ + error: Error | undefined + /** Current state of the generation */ + status: GenerationClientState + /** Abort the current transcription */ + stop: () => void + /** Clear result, error, and return to idle */ + reset: () => void +} + +/** + * Octane hook for transcribing audio to text using AI models. + * + * @example + * ```tsx + * import { useTranscription } from '@tanstack/ai-octane' + * import { fetchServerSentEvents } from '@tanstack/ai-client' + * + * function Transcriber() { + * const { generate, result, isLoading } = useTranscription({ + * connection: fetchServerSentEvents('/api/transcribe'), + * }) + * + * const handleFile = (e: Event) => { + * const input = e.currentTarget as HTMLInputElement + * const file = input.files?.[0] + * if (file) { + * const reader = new FileReader() + * reader.onload = () => { + * generate({ audio: reader.result as string, language: 'en' }) + * } + * reader.readAsDataURL(file) + * } + * } + * + * return ( + *
+ * + * {isLoading &&

Transcribing...

} + * {result &&

{result.text}

} + *
+ * ) + * } + * ``` + */ +export function useTranscription( + options: Omit & { + onResult?: (result: TranscriptionResult) => TTransformed + }, +): UseTranscriptionReturn< + InferGenerationOutputFromReturn +> { + const devtools = { + ...options.devtools, + // OCTANE DIVERGENCE: identify this binding as Octane (not 'react') in TanStack AI Devtools. + framework: 'octane', + hookName: 'useTranscription', + outputKind: 'text' as const, + } + const { generate, result, isLoading, error, status, stop, reset } = + useGeneration< + TranscriptionGenerateInput, + TranscriptionResult, + TTransformed + >({ ...options, devtools }) + + return { + generate: generate as (input: TranscriptionGenerateInput) => Promise, + result, + isLoading, + error, + status, + stop, + reset, + } +} diff --git a/packages/ai-octane/src/use-transcription.tsrx.d.ts b/packages/ai-octane/src/use-transcription.tsrx.d.ts new file mode 100644 index 0000000000..2806df2738 --- /dev/null +++ b/packages/ai-octane/src/use-transcription.tsrx.d.ts @@ -0,0 +1,104 @@ +// Declaration companion generated from use-transcription.tsrx. +import type { StreamChunk, TranscriptionResult } from '@tanstack/ai' +import type { + AIDevtoolsDisplayOptions, + ConnectConnectionAdapter, + GenerationClientState, + GenerationFetcher, + InferGenerationOutputFromReturn, + TranscriptionGenerateInput, +} from '@tanstack/ai-client' +/** + * Options for the useTranscription hook. + * + * @template TOutput - The output type after optional transform (defaults to TranscriptionResult) + */ +export interface UseTranscriptionOptions { + /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ + connection?: ConnectConnectionAdapter + /** Direct async function for transcription */ + fetcher?: GenerationFetcher + /** Unique identifier for this generation instance */ + id?: string + /** Additional body parameters to send with connect-based adapter requests */ + body?: Record + /** Display options for TanStack AI Devtools. */ + devtools?: AIDevtoolsDisplayOptions + /** + * Callback when transcription is complete. Can optionally return a transformed value. + * + * - Return a non-null value to transform and store it as the result + * - Return `null` to keep the previous result unchanged + * - Return nothing (`void`) to store the raw result as-is + */ + onResult?: (result: TranscriptionResult) => TOutput | null | void + /** Callback when an error occurs */ + onError?: (error: Error) => void + /** Callback when progress is reported (0-100) */ + onProgress?: (progress: number, message?: string) => void + /** Callback for each stream chunk (connect-based adapter mode only) */ + onChunk?: (chunk: StreamChunk) => void +} +/** + * Return type for the useTranscription hook. + * + * @template TOutput - The output type (after optional transform) + */ +export interface UseTranscriptionReturn { + /** Trigger transcription */ + generate: (input: TranscriptionGenerateInput) => Promise + /** The transcription result, or null */ + result: TOutput | null + /** Whether transcription is in progress */ + isLoading: boolean + /** Current error, if any */ + error: Error | undefined + /** Current state of the generation */ + status: GenerationClientState + /** Abort the current transcription */ + stop: () => void + /** Clear result, error, and return to idle */ + reset: () => void +} +/** + * Octane hook for transcribing audio to text using AI models. + * + * @example + * ```tsx + * import { useTranscription } from '@tanstack/ai-octane' + * import { fetchServerSentEvents } from '@tanstack/ai-client' + * + * function Transcriber() { + * const { generate, result, isLoading } = useTranscription({ + * connection: fetchServerSentEvents('/api/transcribe'), + * }) + * + * const handleFile = (e: Event) => { + * const input = e.currentTarget as HTMLInputElement + * const file = input.files?.[0] + * if (file) { + * const reader = new FileReader() + * reader.onload = () => { + * generate({ audio: reader.result as string, language: 'en' }) + * } + * reader.readAsDataURL(file) + * } + * } + * + * return ( + *
+ * + * {isLoading &&

Transcribing...

} + * {result &&

{result.text}

} + *
+ * ) + * } + * ``` + */ +export declare function useTranscription( + options: Omit & { + onResult?: (result: TranscriptionResult) => TTransformed + }, +): UseTranscriptionReturn< + InferGenerationOutputFromReturn +> diff --git a/packages/ai-octane/status.json b/packages/ai-octane/status.json new file mode 100644 index 0000000000..7672ee6475 --- /dev/null +++ b/packages/ai-octane/status.json @@ -0,0 +1,32 @@ +{ + "upstream": { + "package": "@tanstack/ai-react", + "version": "0.21.1" + }, + "surface": "Ports the current @tanstack/ai-react hook surface (useChat, useRealtimeChat, useGeneration, useGenerateImage/Audio/Speech/Video, useTranscription, useSummarize, useAudioRecorder, useMcpAppBridge) while reusing @tanstack/ai and @tanstack/ai-client unchanged. useChat matches the current ChatClient shape: threadId identity, queue, runId, interrupts, attach/detach, and SendMessageOptions.", + "divergences": [ + "The `./mcp-apps` subpath and its `MCPAppResource` component are not ported: they render `AppRenderer` from the React-only `@mcp-ui/client`, which has no Octane equivalent. The framework-agnostic `useMcpAppBridge` hook is ported and available on the main entry.", + "Octane uses native events: text/file/recorder inputs drive updates via `onInput`; there is no synthetic `onChange` layer.", + "Octane has no StrictMode double-invoke and always provides `useId`, so no random-id fallback is needed. useChat attach/detach + dispose is immediate (Vue-style), not React's deferred setTimeout dispose.", + "The TanStack AI Devtools bridge is tagged `framework: 'octane'` (upstream `@tanstack/ai-react` sends `'react'`), so the devtools identify this binding correctly. Caller `devtools` is spread first so identification cannot be overridden.", + "Realtime reconnects and token refreshes use the latest `getToken` and adapter supplied to the hook; upstream `@tanstack/ai-react` captures the first render's callbacks.", + "The declared realtime `onStatusChange` callback is invoked alongside the hook's state update; upstream `@tanstack/ai-react` currently drops the external callback.", + "Changing `useChat`'s connection or fetcher updates the active ChatClient in place and preserves conversation state; upstream `@tanstack/ai-react` captures the initial transport.", + "FIXED HERE, STILL PRESENT UPSTREAM — `useGeneration` spreads caller `devtools` metadata BEFORE the hardcoded `framework`/`hookName`, so a caller supplying `devtools.framework` can no longer silently misattribute this binding in the devtools. Upstream @tanstack/ai-react (and this port, as received) spread it after, letting the caller win. The sibling hooks (useChat, useGenerateVideo) already ordered it correctly, so the port was also internally inconsistent. Tracked upstream: https://github.com/TanStack/ai/issues/1002", + "FIXED HERE, STILL PRESENT UPSTREAM — `useAudioRecorder`'s transforming overload now requires `onComplete`. Without that constraint an options object carrying only unrelated keys (e.g. `useAudioRecorder({ onError })`) still matched it, `TOnComplete` inferred as `unknown`, and `recording`/`stop()` collapsed to `unknown` — so passing any option silently cost you the `AudioRecording` type. Same defect in @tanstack/ai-react. Tracked upstream: https://github.com/TanStack/ai/issues/1001", + "FIXED HERE, STILL PRESENT UPSTREAM — `UseGenerationReturn` is parameterized as `` and types `generate` as `(input: TInput)`. Upstream (@tanstack/ai-react, ai-vue, ai-solid) declares `` and widens `generate` to `(input: Record)` via a cast, so required and narrow input fields go unchecked at the call site. This changes the arity of an exported type relative to the React adapter — the only such divergence in the public type surface; the runtime surface is unchanged. Tracked upstream: https://github.com/TanStack/ai/issues/1003", + "FIXED HERE — the `renderUseChat` test helper defaults its options to `{}` instead of asserting `options!`, which would have passed `undefined` into a hook that reads options eagerly." + ], + "ssr": "Supported and tested: useChat renders its initial message snapshot through octane/server without a DOM.", + "e2e": "None, deliberately. E2E coverage in this repo runs against a demo app under testing/e2e, which is TanStack Start-based; an Octane build of TanStack Start exists but is not officially released yet, so the app cannot be built. Held until that ships rather than worked around — this is a known external blocker, not an oversight.", + "notes": [ + "Hook modules are authored as TSRX with checked declaration companions; no ported hook renders JSX or references React types in its public signature.", + "Ported into the TanStack AI monorepo from @octanejs/tanstack-ai@0.0.11, which was a temporary stopgap home. Renamed to @tanstack/ai-octane.", + "Caught up to current ChatClient / useChat: threadId is the only hook identity (hookId from useId is a recreation key and is never sent on the wire), queue + cancelQueued, runId, interrupt generics and resolve/cancel/retry/resume, attach() on mount and detach()+dispose on unmount, SendMessageOptions on sendMessage, and onResponse returns the user callback so ChatClient can await it.", + "The conformance suite runs on happy-dom, not the jsdom the Octane repo used: this repo pins jsdom ^27, whose Blob has no arrayBuffer() (Octane pins ^29, which does), and the useAudioRecorder cases need it.", + "typetests/ is wired into CI via `test:types`. Tool inputs use real Zod — matching how @tanstack/ai-client's own type tests do it. The outputSchema tests keep a structural Standard Schema stand-in on purpose, to prove inference works for any spec-conforming validator.", + "typetests/use-generation.test-d.ts locks in the two type-level fixes listed under divergences; each was verified to fail if the corresponding fix is reverted. The devtools identity fix is covered by tests/conformance/devtools-identification.test.ts, likewise verified to fail against the un-fixed spread order.", + "The differential parity test did not come across: it esbuild-compiles a shared fixture for both Octane and React and byte-compares streamed output, which would make a sibling workspace package a pinned test dependency. It remains in the octanejs/octane repo." + ], + "verified": "2026-08-21" +} diff --git a/packages/ai-octane/tests/_fixtures/server.tsrx b/packages/ai-octane/tests/_fixtures/server.tsrx new file mode 100644 index 0000000000..d364ca81eb --- /dev/null +++ b/packages/ai-octane/tests/_fixtures/server.tsrx @@ -0,0 +1,30 @@ +import { useChat } from '@tanstack/ai-octane'; +import type { UIMessage } from '@tanstack/ai-octane'; + +const initialMessages: UIMessage[] = [ + { + id: 'msg-1', + role: 'user', + parts: [{ type: 'text', content: 'Hello Ada' }], + createdAt: new Date(0), + }, +]; + +export function ServerChat() @{ + const { messages } = useChat({ + fetcher: () => { + throw new Error('no fetch during SSR'); + }, + initialMessages, + }); + +
    + @for (const message of messages; key message.id) { +
  • + {message.parts + .map((part) => (part.type === 'text' ? part.content : '')) + .join('') as string} +
  • + } +
+} diff --git a/packages/ai-octane/tests/conformance/_ai-client-test-utils.ts b/packages/ai-octane/tests/conformance/_ai-client-test-utils.ts new file mode 100644 index 0000000000..ad3debe040 --- /dev/null +++ b/packages/ai-octane/tests/conformance/_ai-client-test-utils.ts @@ -0,0 +1,230 @@ +// Vendored from `@tanstack/ai-client`'s `tests/test-utils.ts` — the shared +// conformance helpers the upstream `@tanstack/ai-react` tests import. That +// file is not part of the package's published surface, and reaching across +// into a sibling package's tests/ directory would couple this suite to another +// package's internals, so the helpers live here instead. +// +// Their relative source imports are retargeted to the package's PUBLIC entry +// points: +// - `../src/connection-adapters` / `../src/types` → `@tanstack/ai-client` +// - core message/stream types → `@tanstack/ai/client` +// +// Only the helpers this suite actually uses are kept; the unused ones were +// dropped rather than carried as dead code. Pull more across from upstream as +// further test cases are ported. +import type { ConnectConnectionAdapter, UIMessage } from '@tanstack/ai-client' +import type { ModelMessage, StreamChunk } from '@tanstack/ai/client' + +/** + * Options for creating a mock connection adapter + */ +export interface MockConnectionAdapterOptions { + /** + * Chunks to yield from the stream + */ + chunks?: Array + + /** + * Delay between chunks (in ms) + */ + chunkDelay?: number + + /** + * Whether to throw an error + */ + shouldError?: boolean + + /** + * Error to throw + */ + error?: Error + + /** + * Callback when connect is called + */ + onConnect?: ( + messages: Array | Array, + data?: Record, + abortSignal?: AbortSignal, + ) => void + + /** + * Callback to check abort signal during streaming + */ + onAbort?: (abortSignal: AbortSignal) => void +} + +/** + * Create a mock connection adapter for testing + * + * @example + * ```typescript + * const adapter = createMockConnectionAdapter({ + * chunks: [ + * { type: "TEXT_MESSAGE_CONTENT", messageId: "1", model: "test", timestamp: Date.now(), delta: "Hello", content: "Hello" }, + * { type: "RUN_FINISHED", runId: "run-1", model: "test", timestamp: Date.now(), finishReason: "stop" } + * ] + * }); + * ``` + */ +export function createMockConnectionAdapter( + options: MockConnectionAdapterOptions = {}, +): ConnectConnectionAdapter { + const { + chunks = [], + chunkDelay = 0, + shouldError = false, + error = new Error('Mock adapter error'), + onConnect, + onAbort, + } = options + + return { + async *connect(messages, data, abortSignal) { + if (onConnect) { + // Type assertion: messages can be ModelMessage[] or UIMessage[] + // Filter out system messages if present + const filteredMessages = (messages as any[]).filter( + (m: any) => !('role' in m) || m.role !== 'system', + ) + onConnect(filteredMessages as any, data, abortSignal) + } + + if (shouldError) { + throw error + } + + for (const chunk of chunks) { + // Check abort signal before yielding + if (abortSignal?.aborted) { + if (onAbort) { + onAbort(abortSignal) + } + return + } + + if (chunkDelay > 0) { + await new Promise((resolve) => setTimeout(resolve, chunkDelay)) + } + + // Check again after delay + if (abortSignal?.aborted) { + if (onAbort) { + onAbort(abortSignal) + } + return + } + + yield chunk + } + }, + } +} + +/** + * Helper to create simple text content chunks (AG-UI format) + */ +export function createTextChunks( + text: string, + messageId: string = 'msg-1', + model: string = 'test', +): Array { + const chunks: Array = [] + let accumulated = '' + const runId = `run-${messageId}` + const threadId = `thread-${messageId}` + + for (const chunk of text) { + accumulated += chunk + chunks.push({ + type: 'TEXT_MESSAGE_CONTENT', + messageId, + model, + timestamp: Date.now(), + delta: chunk, + content: accumulated, + } as StreamChunk) + } + + chunks.push({ + type: 'RUN_FINISHED', + runId, + threadId, + model, + timestamp: Date.now(), + finishReason: 'stop', + } as StreamChunk) + + return chunks +} + +/** + * Helper to create tool call chunks (AG-UI format) + * Optionally includes tool-input-available chunks to trigger onToolCall + */ +export function createToolCallChunks( + toolCalls: Array<{ id: string; name: string; arguments: string }>, + messageId: string = 'msg-1', + model: string = 'test', + includeToolInputAvailable: boolean = true, +): Array { + const chunks: Array = [] + const runId = `run-${messageId}` + + for (let i = 0; i < toolCalls.length; i++) { + const toolCall = toolCalls[i]! + + // TOOL_CALL_START event + chunks.push({ + type: 'TOOL_CALL_START', + toolCallId: toolCall.id, + toolCallName: toolCall.name, + toolName: toolCall.name, + model, + timestamp: Date.now(), + index: i, + } as StreamChunk) + + // TOOL_CALL_ARGS event + chunks.push({ + type: 'TOOL_CALL_ARGS', + toolCallId: toolCall.id, + model, + timestamp: Date.now(), + delta: toolCall.arguments, + } as StreamChunk) + + // Add tool-input-available CUSTOM chunk if requested + if (includeToolInputAvailable) { + let parsedInput: any + try { + parsedInput = JSON.parse(toolCall.arguments) + } catch { + parsedInput = toolCall.arguments + } + + chunks.push({ + type: 'CUSTOM', + model, + timestamp: Date.now(), + name: 'tool-input-available', + value: { + toolCallId: toolCall.id, + toolName: toolCall.name, + input: parsedInput, + }, + } as StreamChunk) + } + } + + chunks.push({ + type: 'RUN_FINISHED', + runId, + threadId: `thread-${messageId}`, + model, + timestamp: Date.now(), + finishReason: 'tool_calls', + } as StreamChunk) + + return chunks +} diff --git a/packages/ai-octane/tests/conformance/devtools-identification.test.ts b/packages/ai-octane/tests/conformance/devtools-identification.test.ts new file mode 100644 index 0000000000..ccc25792ec --- /dev/null +++ b/packages/ai-octane/tests/conformance/devtools-identification.test.ts @@ -0,0 +1,95 @@ +import { renderHook } from '@octanejs/testing-library' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useChat } from '../../src/use-chat.tsrx' +import { useGeneration } from '../../src/use-generation.tsrx' +import { createMockConnectionAdapter } from './test-utils' + +/** + * These hooks tag the TanStack AI Devtools bridge with `framework: 'octane'` so + * the devtools attribute activity to this binding rather than to React. The tag + * is hardcoded, which means it has to win over anything a caller passes in + * `devtools` — `useGeneration` in particular is documented as public API for + * custom generation types, so callers do supply their own `devtools` metadata. + * + * Capture the options each bridge factory receives and assert the identity + * survives, then delegate to the real factory so the clients still work. + */ +const capturedGeneration: Array> = [] +const capturedChat: Array> = [] + +vi.mock('@tanstack/ai-client/devtools', async (importOriginal) => { + const actual = + await importOriginal() + return { + ...actual, + createGenerationDevtoolsBridge: (options: any) => { + capturedGeneration.push(options) + return actual.createGenerationDevtoolsBridge(options) + }, + createChatDevtoolsBridge: (options: any) => { + capturedChat.push(options) + return actual.createChatDevtoolsBridge(options) + }, + } +}) + +describe('devtools identification', () => { + beforeEach(() => { + capturedGeneration.length = 0 + capturedChat.length = 0 + }) + + it('tags useGeneration as octane', () => { + renderHook(() => + useGeneration({ connection: createMockConnectionAdapter() }), + ) + + expect(capturedGeneration[0]?.metadata).toMatchObject({ + framework: 'octane', + hookName: 'useGeneration', + }) + }) + + it('keeps the octane identity when a caller supplies its own devtools metadata', () => { + renderHook(() => + useGeneration({ + connection: createMockConnectionAdapter(), + devtools: { framework: 'react', hookName: 'somethingElse' }, + }), + ) + + // A caller must not be able to misattribute this binding. + expect(capturedGeneration[0]?.metadata).toMatchObject({ + framework: 'octane', + hookName: 'useGeneration', + }) + }) + + it('preserves unrelated caller devtools metadata', () => { + renderHook(() => + useGeneration({ + connection: createMockConnectionAdapter(), + devtools: { outputKind: 'image' }, + }), + ) + + expect(capturedGeneration[0]?.metadata).toMatchObject({ + framework: 'octane', + outputKind: 'image', + }) + }) + + it('tags useChat as octane and resists caller override', () => { + renderHook(() => + useChat({ + connection: createMockConnectionAdapter(), + devtools: { framework: 'react' }, + }), + ) + + expect(capturedChat[0]?.metadata).toMatchObject({ + framework: 'octane', + hookName: 'useChat', + }) + }) +}) diff --git a/packages/ai-octane/tests/conformance/exports.test.ts b/packages/ai-octane/tests/conformance/exports.test.ts new file mode 100644 index 0000000000..e6a0bc6b6d --- /dev/null +++ b/packages/ai-octane/tests/conformance/exports.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { + useChat, + useRealtimeChat, + useMcpAppBridge, + useGeneration, + useGenerateImage, + useGenerateAudio, + useGenerateSpeech, + useGenerateVideo, + useTranscription, + useSummarize, + useAudioRecorder, +} from '@tanstack/ai-octane' + +describe('package exports', () => { + it('exports every ported hook as a function', () => { + for (const hook of [ + useChat, + useRealtimeChat, + useMcpAppBridge, + useGeneration, + useGenerateImage, + useGenerateAudio, + useGenerateSpeech, + useGenerateVideo, + useTranscription, + useSummarize, + useAudioRecorder, + ]) { + expect(hook).toBeTypeOf('function') + } + }) +}) diff --git a/packages/ai-octane/tests/conformance/test-setup.ts b/packages/ai-octane/tests/conformance/test-setup.ts new file mode 100644 index 0000000000..3a0ca41643 --- /dev/null +++ b/packages/ai-octane/tests/conformance/test-setup.ts @@ -0,0 +1,20 @@ +import { cleanup } from '@octanejs/testing-library' +import { + getIsOctaneActEnvironment, + setOctaneActEnvironment, +} from '@octanejs/testing-library/act-environment' +import { afterAll, afterEach, beforeAll } from 'vitest' + +// https://testing-library.com/docs/react-testing-library/api#cleanup +afterEach(() => cleanup()) + +let previousIsActEnvironment: boolean | undefined + +beforeAll(() => { + previousIsActEnvironment = getIsOctaneActEnvironment() + setOctaneActEnvironment(true) +}) + +afterAll(() => { + setOctaneActEnvironment(previousIsActEnvironment) +}) diff --git a/packages/ai-octane/tests/conformance/test-utils.ts b/packages/ai-octane/tests/conformance/test-utils.ts new file mode 100644 index 0000000000..82ac7f5d69 --- /dev/null +++ b/packages/ai-octane/tests/conformance/test-utils.ts @@ -0,0 +1,71 @@ +// Shared conformance test-utils, mirroring the surface of `@tanstack/ai-react`'s +// own `tests/test-utils.ts`. The chunk/adapter helpers are vendored from +// ai-client (see `./_ai-client-test-utils`); `renderUseChat` is retargeted to +// octane's testing-library and the ported `useChat` hook. +export { + createMockConnectionAdapter, + createTextChunks, + createToolCallChunks, +} from './_ai-client-test-utils' + +export function createInterruptResumeSnapshot() { + const pendingInterrupts = [ + { + id: 'staged-interrupt', + reason: 'confirmation', + metadata: { + 'tanstack:interruptBinding': { + kind: 'generic' as const, + interruptId: 'staged-interrupt', + interruptedRunId: 'run-1', + generation: 1, + responseSchemaHash: 'none', + }, + }, + }, + { + id: 'invalid-interrupt', + reason: 'confirmation', + metadata: { + 'tanstack:interruptBinding': { + kind: 'generic' as const, + interruptId: 'invalid-interrupt', + interruptedRunId: 'run-1', + generation: 1, + responseSchemaHash: 'none', + }, + }, + }, + ] + return { + resumeState: { threadId: 'thread-1', runId: 'run-1' }, + pendingInterrupts, + } +} + +import { renderHook, type RenderHookResult } from '@octanejs/testing-library' +import type { UseChatOptions, UseChatReturn } from '../../src/types' +import { useChat } from '../../src/use-chat.tsrx' + +/** + * Render the useChat hook with testing utilities + * + * @example + * ```typescript + * const { result } = renderUseChat({ + * connection: createMockConnectionAdapter({ chunks: [...] }) + * }); + * + * await result.current.sendMessage("Hello"); + * ``` + */ +export function renderUseChat( + // Defaulted rather than asserted with `options!`: `useChat` reads its options + // eagerly, so a no-arg call would otherwise pass `undefined` straight through + // and crash instead of failing at the type level. + options: UseChatOptions = {}, +): RenderHookResult { + return renderHook((hookOptions: UseChatOptions) => useChat(hookOptions), { + initialProps: options, + }) +} diff --git a/packages/ai-octane/tests/conformance/use-audio-recorder.test.ts b/packages/ai-octane/tests/conformance/use-audio-recorder.test.ts new file mode 100644 index 0000000000..30ee292210 --- /dev/null +++ b/packages/ai-octane/tests/conformance/use-audio-recorder.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, renderHook } from '@octanejs/testing-library' +import { useAudioRecorder } from '../../src/use-audio-recorder.tsrx' + +class FakeMediaRecorder { + ondataavailable: ((e: { data: Blob }) => void) | null = null + onstop: (() => void) | null = null + onerror: (() => void) | null = null + state: 'inactive' | 'recording' = 'inactive' + constructor( + public stream: any, + public options?: { mimeType?: string }, + ) {} + get mimeType(): string { + return this.options?.mimeType ?? 'audio/webm' + } + start(): void { + this.state = 'recording' + } + stop(): void { + this.state = 'inactive' + this.ondataavailable?.({ data: new Blob([new Uint8Array([1, 2, 3])]) }) + this.onstop?.() + } +} + +beforeEach(() => { + vi.stubGlobal('navigator', { + mediaDevices: { + getUserMedia: vi.fn(async () => ({ + getTracks: () => [{ stop: vi.fn() }], + })), + }, + }) + vi.stubGlobal('MediaRecorder', FakeMediaRecorder) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('useAudioRecorder', () => { + it('exposes isSupported and toggles isRecording across start/stop', async () => { + const { result } = renderHook(() => useAudioRecorder()) + expect(result.current.isSupported).toBe(true) + expect(result.current.isRecording).toBe(false) + + await act(async () => { + await result.current.start() + }) + expect(result.current.isRecording).toBe(true) + + let recording: any + await act(async () => { + recording = await result.current.stop() + }) + expect(result.current.isRecording).toBe(false) + expect(recording.part.type).toBe('audio') + expect(recording.base64).toBe('AQID') + }) + + it('sets recording to the raw AudioRecording when no onComplete is provided', async () => { + const { result } = renderHook(() => useAudioRecorder()) + + expect(result.current.recording).toBeNull() + + await act(async () => { + await result.current.start() + }) + await act(async () => { + await result.current.stop() + }) + + expect(result.current.recording).not.toBeNull() + expect(result.current.recording?.base64).toBe('AQID') + expect(result.current.recording?.part.type).toBe('audio') + }) + + it('onComplete transform re-types stop() and recording', async () => { + const { result } = renderHook(() => + useAudioRecorder({ onComplete: (rec) => rec.base64 }), + ) + + expect(result.current.recording).toBeNull() + + await act(async () => { + await result.current.start() + }) + + let output: any + await act(async () => { + output = await result.current.stop() + }) + + expect(output).toBe('AQID') + expect(result.current.recording).toBe('AQID') + }) + + it('preserves a null returned from onComplete (only undefined keeps the raw recording)', async () => { + const { result } = renderHook(() => + useAudioRecorder({ onComplete: () => null }), + ) + + await act(async () => { + await result.current.start() + }) + + let output: any + await act(async () => { + output = await result.current.stop() + }) + + expect(output).toBeNull() + expect(result.current.recording).toBeNull() + }) + + it('surfaces a getUserMedia rejection through onError and rejects start()', async () => { + const denied = new Error('Permission denied') + vi.stubGlobal('navigator', { + mediaDevices: { getUserMedia: vi.fn(async () => Promise.reject(denied)) }, + }) + const onError = vi.fn() + const { result } = renderHook(() => useAudioRecorder({ onError })) + + await act(async () => { + await expect(result.current.start()).rejects.toThrow('Permission denied') + }) + expect(onError).toHaveBeenCalledWith(denied) + expect(result.current.isRecording).toBe(false) + }) + + it('releases the mic and clears isRecording on unmount', async () => { + const trackStop = vi.fn() + vi.stubGlobal('navigator', { + mediaDevices: { + getUserMedia: vi.fn(async () => ({ + getTracks: () => [{ stop: trackStop }], + })), + }, + }) + const { result, unmount } = renderHook(() => useAudioRecorder()) + + await act(async () => { + await result.current.start() + }) + expect(result.current.isRecording).toBe(true) + + // The effect cleanup must unsubscribe and cancel the in-flight recording so + // the microphone tracks stop — a dropped cleanup would leak a live mic. + unmount() + expect(trackStop).toHaveBeenCalled() + }) +}) diff --git a/packages/ai-octane/tests/conformance/use-chat-fetcher.test.ts b/packages/ai-octane/tests/conformance/use-chat-fetcher.test.ts new file mode 100644 index 0000000000..a55281f2a1 --- /dev/null +++ b/packages/ai-octane/tests/conformance/use-chat-fetcher.test.ts @@ -0,0 +1,146 @@ +import { renderHook, waitFor } from '@octanejs/testing-library' +import { describe, expect, it, vi } from 'vitest' +import type { ChatFetcher } from '@tanstack/ai-client' +import type { StreamChunk } from '@tanstack/ai' +import { useChat } from '../../src/use-chat.tsrx' +import { createTextChunks } from './test-utils' + +describe('useChat — fetcher transport', () => { + it('streams text into messages via an AsyncIterable fetcher', async () => { + const chunks = createTextChunks('Hello world', 'msg-1') + const fetcher: ChatFetcher = async function* () { + for (const chunk of chunks) { + yield chunk + } + } + + const { result } = renderHook(() => useChat({ fetcher })) + + await result.current.sendMessage('hi') + + await waitFor(() => { + expect(result.current.messages).toHaveLength(2) + }) + const assistant = result.current.messages[1]! + const textPart = assistant.parts.find((p) => p.type === 'text') + expect(textPart && 'content' in textPart && textPart.content).toBe( + 'Hello world', + ) + }) + + it('uses an updated fetcher without losing conversation state', async () => { + const firstFetcher = vi.fn(async function* () { + yield* createTextChunks('First response', 'first-response') + }) + const secondFetcher = vi.fn(async function* () { + yield* createTextChunks('Second response', 'second-response') + }) + + const { result, rerender } = renderHook( + ({ fetcher }: { fetcher: ChatFetcher }) => useChat({ fetcher }), + { initialProps: { fetcher: firstFetcher } }, + ) + + await result.current.sendMessage('First request') + await waitFor(() => { + expect(firstFetcher).toHaveBeenCalledTimes(1) + expect(result.current.messages).toHaveLength(2) + }) + + rerender({ fetcher: secondFetcher }) + await result.current.sendMessage('Second request') + + await waitFor(() => { + expect(secondFetcher).toHaveBeenCalledTimes(1) + expect(result.current.messages).toHaveLength(4) + }) + expect(firstFetcher).toHaveBeenCalledTimes(1) + const renderedText = result.current.messages + .flatMap((message) => message.parts) + .filter((part) => part.type === 'text') + .map((part) => part.content) + expect(renderedText).toContain('First response') + expect(renderedText).toContain('Second response') + }) + + it('parses an SSE Response returned by the fetcher', async () => { + const sseBody = + [ + `data: ${JSON.stringify({ + type: 'TEXT_MESSAGE_CONTENT', + messageId: 'm1', + model: 'test', + timestamp: Date.now(), + delta: 'Hi', + content: 'Hi', + })}`, + `data: ${JSON.stringify({ + type: 'RUN_FINISHED', + runId: 'r1', + threadId: 't1', + model: 'test', + timestamp: Date.now(), + finishReason: 'stop', + })}`, + '', + ].join('\n') + '\n' + + const fetcher: ChatFetcher = async () => + new Response(sseBody, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }) + + const { result } = renderHook(() => useChat({ fetcher })) + + await result.current.sendMessage('hi') + + await waitFor(() => { + expect(result.current.messages).toHaveLength(2) + }) + const assistant = result.current.messages[1]! + const textPart = assistant.parts.find((p) => p.type === 'text') + expect(textPart && 'content' in textPart && textPart.content).toBe('Hi') + }) + + it('surfaces fetcher errors as the hook error state', async () => { + const fetcher: ChatFetcher = async () => { + throw new Error('boom') + } + + const { result } = renderHook(() => useChat({ fetcher })) + + await result.current.sendMessage('hi') + + await waitFor(() => { + expect(result.current.error).toBeDefined() + }) + expect(result.current.error!.message).toBe('boom') + expect(result.current.status).toBe('error') + }) + + it('passes the merged body and full message history to the fetcher', async () => { + const fetcher = vi.fn(async function* () { + yield { + type: 'RUN_FINISHED', + runId: 'r1', + threadId: 't1', + model: 'test', + timestamp: Date.now(), + finishReason: 'stop', + } as StreamChunk + }) + + const { result } = renderHook(() => + useChat({ fetcher, body: { provider: 'openai' } }), + ) + + await result.current.sendMessage('hello') + + expect(fetcher).toHaveBeenCalledTimes(1) + const [input] = fetcher.mock.calls[0]! + expect(input.messages).toHaveLength(1) + expect(input.messages[0]!.role).toBe('user') + expect(input.data).toMatchObject({ provider: 'openai' }) + }) +}) diff --git a/packages/ai-octane/tests/conformance/use-chat-options-probe.test.ts b/packages/ai-octane/tests/conformance/use-chat-options-probe.test.ts new file mode 100644 index 0000000000..34eb9a5368 --- /dev/null +++ b/packages/ai-octane/tests/conformance/use-chat-options-probe.test.ts @@ -0,0 +1,24 @@ +// Type-only probe — verifies that UseChatOptions's ChatTransport XOR +// survives DistributedOmit. These assertions exercise type errors at +// compile time; the test file body is empty. +import { describe, it, expectTypeOf } from 'vitest' +import type { ChatFetcher, ConnectionAdapter } from '@tanstack/ai-client' +import type { UseChatOptions } from '../../src/types' + +describe('UseChatOptions XOR', () => { + it('rejects neither / both, accepts exactly one', () => { + // Accept connection only + expectTypeOf<{ + connection: ConnectionAdapter + }>().toMatchTypeOf() + // Accept fetcher only + expectTypeOf<{ fetcher: ChatFetcher }>().toMatchTypeOf() + // Reject empty + expectTypeOf<{}>().not.toMatchTypeOf() + // Reject both + expectTypeOf<{ + connection: ConnectionAdapter + fetcher: ChatFetcher + }>().not.toMatchTypeOf() + }) +}) diff --git a/packages/ai-octane/tests/conformance/use-chat-structured-output.test.ts b/packages/ai-octane/tests/conformance/use-chat-structured-output.test.ts new file mode 100644 index 0000000000..cfd63c0fcd --- /dev/null +++ b/packages/ai-octane/tests/conformance/use-chat-structured-output.test.ts @@ -0,0 +1,610 @@ +/** + * Runtime tests for `useChat({ outputSchema })`: + * + * - `partial` updates per `TEXT_MESSAGE_CONTENT` delta (progressive JSON parse) + * - `final` snaps on the terminal `CUSTOM structured-output.complete` event + * - State resets between `sendMessage` calls (on `RUN_STARTED`) + * - User's own `onChunk` callback fires after internal tracking + * - Without `outputSchema`, no partial/final tracking runs + */ + +import { act, renderHook, waitFor } from '@octanejs/testing-library' +import { describe, expect, it, vi } from 'vitest' +import type { StandardJSONSchemaV1 } from '@standard-schema/spec' +import type { StreamChunk } from '@tanstack/ai' +import { createMockConnectionAdapter } from './test-utils' +import { useChat } from '../../src/use-chat.tsrx' + +type Person = { name: string; age: number; email: string } +type PersonSchema = StandardJSONSchemaV1 +const personSchema = {} as PersonSchema + +/** + * Build a chunk sequence simulating a streaming structured-output run: + * RUN_STARTED → TEXT_MESSAGE_CONTENT deltas (each delta moves the buffer + * one character closer to `fullJson`) → CUSTOM structured-output.complete + * → RUN_FINISHED. + */ +function buildStructuredStream( + fullJson: string, + finalObject: Person, + runId = 'run-1', +): Array { + const chunks: Array = [ + { + type: 'RUN_STARTED', + runId, + threadId: `thread-${runId}`, + model: 'test', + timestamp: Date.now(), + } as StreamChunk, + ] + // Split fullJson into a few large-ish slices so we test progressive parsing + // without producing a flood of one-char chunks. + const sliceSize = Math.max(4, Math.floor(fullJson.length / 4)) + for (let i = 0; i < fullJson.length; i += sliceSize) { + chunks.push({ + type: 'TEXT_MESSAGE_CONTENT', + messageId: `msg-${runId}`, + delta: fullJson.slice(i, i + sliceSize), + content: fullJson.slice(0, i + sliceSize), + model: 'test', + timestamp: Date.now(), + } as StreamChunk) + } + chunks.push({ + type: 'CUSTOM', + name: 'structured-output.complete', + value: { object: finalObject, raw: fullJson }, + model: 'test', + timestamp: Date.now(), + } as StreamChunk) + chunks.push({ + type: 'RUN_FINISHED', + runId, + threadId: `thread-${runId}`, + model: 'test', + timestamp: Date.now(), + finishReason: 'stop', + } as StreamChunk) + return chunks +} + +describe('useChat({ outputSchema }) — runtime', () => { + const person: Person = { + name: 'John Doe', + age: 30, + email: 'john@example.com', + } + const json = JSON.stringify(person) + + it('updates `partial` progressively and snaps `final` on the terminal event', async () => { + const chunks = buildStructuredStream(json, person) + const adapter = createMockConnectionAdapter({ chunks }) + + const { result } = renderHook(() => + useChat({ connection: adapter, outputSchema: personSchema }), + ) + + // Initial state. + expect(result.current.partial).toEqual({}) + expect(result.current.final).toBeNull() + + await act(async () => { + await result.current.sendMessage('Extract') + }) + + // The schema-validated `final` lands once the terminal event fires. + await waitFor(() => { + expect(result.current.final).toEqual(person) + }) + + // `partial` should end with the same shape (parsePartialJSON on the + // complete buffer returns the fully-formed object). + expect(result.current.partial).toEqual(person) + }) + + it('resets `partial` and `final` between runs', async () => { + const personA: Person = { + name: 'Alice', + age: 25, + email: 'alice@example.com', + } + const personB: Person = { name: 'Bob', age: 40, email: 'bob@example.com' } + + // Stateful adapter that yields a different stream per connect() call. + // Without this, createMockConnectionAdapter would yield the same array + // on every sendMessage — the "reset" couldn't be observed between runs + // because final would race past personA straight to personB on call #1. + let call = 0 + const adapter = { + async *connect() { + const chunks = + call === 0 + ? buildStructuredStream(JSON.stringify(personA), personA, 'run-a') + : buildStructuredStream(JSON.stringify(personB), personB, 'run-b') + call++ + for (const chunk of chunks) yield chunk + }, + } + + const { result } = renderHook(() => + useChat({ connection: adapter, outputSchema: personSchema }), + ) + + await act(async () => { + await result.current.sendMessage('A') + }) + await waitFor(() => { + expect(result.current.final).toEqual(personA) + }) + expect(result.current.partial).toEqual(personA) + + // Second run — RUN_STARTED at the head must clear partial/final before + // run-b's deltas land. If the reset didn't happen, run-b's progressive + // partial would be shadowed by leftover state from run-a (since + // parsePartialJSON would parse run-b's accumulated buffer cleanly, but + // the spread-onto-stale-state class of bug would still surface in `final`). + await act(async () => { + await result.current.sendMessage('B') + }) + await waitFor(() => { + expect(result.current.final).toEqual(personB) + }) + expect(result.current.partial).toEqual(personB) + }) + + it('attaches a structured-output part to the assistant message', async () => { + // With structured-output.start emitted, the JSON deltas route into a + // StructuredOutputPart on the assistant message instead of a TextPart. + // History walks `messages` and finds the typed part on each turn. + const chunks: Array = [ + { + type: 'RUN_STARTED', + runId: 'run-x', + threadId: 'thread-x', + model: 'test', + timestamp: Date.now(), + } as StreamChunk, + { + type: 'TEXT_MESSAGE_START', + messageId: 'msg-x', + role: 'assistant', + model: 'test', + timestamp: Date.now(), + } as StreamChunk, + { + type: 'CUSTOM', + name: 'structured-output.start', + value: { messageId: 'msg-x' }, + model: 'test', + timestamp: Date.now(), + } as StreamChunk, + { + type: 'TEXT_MESSAGE_CONTENT', + messageId: 'msg-x', + delta: json, + content: json, + model: 'test', + timestamp: Date.now(), + } as StreamChunk, + { + type: 'CUSTOM', + name: 'structured-output.complete', + value: { object: person, raw: json, messageId: 'msg-x' }, + model: 'test', + timestamp: Date.now(), + } as StreamChunk, + { + type: 'RUN_FINISHED', + runId: 'run-x', + threadId: 'thread-x', + model: 'test', + timestamp: Date.now(), + finishReason: 'stop', + } as StreamChunk, + ] + const adapter = createMockConnectionAdapter({ chunks }) + + const { result } = renderHook(() => + useChat({ connection: adapter, outputSchema: personSchema }), + ) + + await act(async () => { + await result.current.sendMessage('Extract') + }) + await waitFor(() => { + expect(result.current.final).toEqual(person) + }) + + // Find the assistant message and the structured-output part on it. + const assistant = result.current.messages.find( + (m) => m.role === 'assistant', + ) + expect(assistant).toBeDefined() + const sop = assistant!.parts.find((p) => p.type === 'structured-output') + expect(sop).toBeDefined() + expect((sop as any).status).toBe('complete') + expect((sop as any).data).toEqual(person) + expect((sop as any).raw).toBe(json) + + // With start emitted, no text part should be on the assistant message + // (the JSON bytes were routed into the structured-output part). + const textPart = assistant!.parts.find((p) => p.type === 'text') + expect(textPart).toBeUndefined() + }) + + it("preserves each turn's structured part across multi-turn history", async () => { + const personA: Person = { + name: 'Alice', + age: 25, + email: 'alice@example.com', + } + const personB: Person = { name: 'Bob', age: 40, email: 'bob@example.com' } + + function streamFor( + p: Person, + runId: string, + messageId: string, + ): Array { + const raw = JSON.stringify(p) + return [ + { + type: 'RUN_STARTED', + runId, + threadId: `thread-${runId}`, + model: 'test', + timestamp: Date.now(), + } as StreamChunk, + { + type: 'TEXT_MESSAGE_START', + messageId, + role: 'assistant', + model: 'test', + timestamp: Date.now(), + } as StreamChunk, + { + type: 'CUSTOM', + name: 'structured-output.start', + value: { messageId }, + model: 'test', + timestamp: Date.now(), + } as StreamChunk, + { + type: 'TEXT_MESSAGE_CONTENT', + messageId, + delta: raw, + content: raw, + model: 'test', + timestamp: Date.now(), + } as StreamChunk, + { + type: 'CUSTOM', + name: 'structured-output.complete', + value: { object: p, raw, messageId }, + model: 'test', + timestamp: Date.now(), + } as StreamChunk, + { + type: 'RUN_FINISHED', + runId, + threadId: `thread-${runId}`, + model: 'test', + timestamp: Date.now(), + finishReason: 'stop', + } as StreamChunk, + ] + } + + let call = 0 + const adapter = { + async *connect() { + const chunks = + call === 0 + ? streamFor(personA, 'run-a', 'msg-a') + : streamFor(personB, 'run-b', 'msg-b') + call++ + for (const chunk of chunks) yield chunk + }, + } + + const { result } = renderHook(() => + useChat({ connection: adapter, outputSchema: personSchema }), + ) + + await act(async () => { + await result.current.sendMessage('A') + }) + await waitFor(() => { + expect(result.current.final).toEqual(personA) + }) + + await act(async () => { + await result.current.sendMessage('B') + }) + await waitFor(() => { + expect(result.current.final).toEqual(personB) + }) + + // History walk: every assistant message carries its own structured part. + const assistants = result.current.messages.filter( + (m) => m.role === 'assistant', + ) + expect(assistants.length).toBe(2) + + const partA = assistants[0]!.parts.find( + (p) => p.type === 'structured-output', + ) + const partB = assistants[1]!.parts.find( + (p) => p.type === 'structured-output', + ) + expect((partA as any)?.data).toEqual(personA) + expect((partB as any)?.data).toEqual(personB) + + // `final` reflects the latest, but the earlier turn's data is still + // recoverable via the part on the historical message. + expect(result.current.final).toEqual(personB) + }) + + it('reads `final` as null between sendMessage and the first chunk', async () => { + // Park the second connect() so no chunks ever arrive for run-b. The + // assertion is that, immediately after sendMessage('B'), the latest user + // message has no assistant message after it yet, so `final` returns null. + const personA: Person = { + name: 'Alice', + age: 25, + email: 'alice@example.com', + } + let call = 0 + let releaseSecond!: () => void + const secondStarted = new Promise((resolve) => { + releaseSecond = resolve + }) + const adapter = { + async *connect() { + if (call === 0) { + call++ + const raw = JSON.stringify(personA) + const chunks: Array = [ + { + type: 'RUN_STARTED', + runId: 'run-a', + threadId: 'thread-a', + model: 'test', + timestamp: Date.now(), + } as StreamChunk, + { + type: 'TEXT_MESSAGE_START', + messageId: 'msg-a', + role: 'assistant', + model: 'test', + timestamp: Date.now(), + } as StreamChunk, + { + type: 'CUSTOM', + name: 'structured-output.start', + value: { messageId: 'msg-a' }, + model: 'test', + timestamp: Date.now(), + } as StreamChunk, + { + type: 'TEXT_MESSAGE_CONTENT', + messageId: 'msg-a', + delta: raw, + content: raw, + model: 'test', + timestamp: Date.now(), + } as StreamChunk, + { + type: 'CUSTOM', + name: 'structured-output.complete', + value: { object: personA, raw, messageId: 'msg-a' }, + model: 'test', + timestamp: Date.now(), + } as StreamChunk, + { + type: 'RUN_FINISHED', + runId: 'run-a', + threadId: 'thread-a', + model: 'test', + timestamp: Date.now(), + finishReason: 'stop', + } as StreamChunk, + ] + for (const c of chunks) yield c + return + } + await secondStarted + }, + } + + const { result } = renderHook(() => + useChat({ connection: adapter, outputSchema: personSchema }), + ) + + await act(async () => { + await result.current.sendMessage('A') + }) + await waitFor(() => { + expect(result.current.final).toEqual(personA) + }) + + act(() => { + void result.current.sendMessage('B') + }) + + await waitFor(() => { + expect(result.current.final).toBeNull() + expect(result.current.partial).toEqual({}) + }) + + releaseSecond() + }) + + it('clears `partial` and `final` on clear()', async () => { + // `clear()` empties the messages array; the derivation then has no + // assistant message to find an active structured-output part on, so + // both views naturally read as cleared without any extra plumbing. + const chunks = buildStructuredStream(json, person) + const adapter = createMockConnectionAdapter({ chunks }) + + const { result } = renderHook(() => + useChat({ connection: adapter, outputSchema: personSchema }), + ) + + await act(async () => { + await result.current.sendMessage('Extract') + }) + await waitFor(() => { + expect(result.current.final).toEqual(person) + }) + + act(() => { + result.current.clear() + }) + + expect(result.current.partial).toEqual({}) + expect(result.current.final).toBeNull() + }) + + it("invokes the user's onChunk callback alongside internal tracking", async () => { + const chunks = buildStructuredStream(json, person) + const adapter = createMockConnectionAdapter({ chunks }) + const onChunk = vi.fn() + + const { result } = renderHook(() => + useChat({ + connection: adapter, + outputSchema: personSchema, + onChunk, + }), + ) + + await act(async () => { + await result.current.sendMessage('Extract') + }) + await waitFor(() => { + expect(result.current.final).toEqual(person) + }) + + // User callback fires for every chunk the hook sees, including the + // terminal structured-output.complete event. + const completeCalls = onChunk.mock.calls.filter( + ([c]) => c.type === 'CUSTOM' && c.name === 'structured-output.complete', + ) + expect(completeCalls.length).toBe(1) + expect(completeCalls[0]![0].value).toEqual({ object: person, raw: json }) + + const deltaCalls = onChunk.mock.calls.filter( + ([c]) => c.type === 'TEXT_MESSAGE_CONTENT', + ) + expect(deltaCalls.length).toBeGreaterThan(0) + }) +}) + +describe('useChat({ outputSchema }) — initialMessages handling', () => { + const person: Person = { + name: 'John Doe', + age: 30, + email: 'john@example.com', + } + const json = JSON.stringify(person) + + it('does not leak `final` from a stale initialMessages assistant when no user message exists yet', async () => { + // Regression: `activeStructuredPart` previously walked all assistant + // messages from the end when there was no user message — so a structured + // assistant turn carried in via `initialMessages` would leak into `final` + // before the consumer ever sent anything. With the fix, `final` reads + // null until a user message exists. + const adapter = createMockConnectionAdapter({ chunks: [] }) + + const { result } = renderHook(() => + useChat({ + connection: adapter, + outputSchema: personSchema, + initialMessages: [ + { + id: 'stale-assistant', + role: 'assistant', + parts: [ + { + type: 'structured-output', + status: 'complete', + raw: json, + data: person, + partial: person, + }, + ], + createdAt: new Date(), + }, + ], + }), + ) + + // The stale assistant message is visible in history… + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThan(0) + }) + expect(result.current.messages[0]?.id).toBe('stale-assistant') + + // …but `final` and `partial` must NOT be derived from it. + expect(result.current.final).toBeNull() + expect(result.current.partial).toEqual({}) + }) +}) + +describe('useChat() without outputSchema — runtime', () => { + it('does not break or track structured state when no schema is supplied', async () => { + const adapter = createMockConnectionAdapter({ + chunks: [ + { + type: 'RUN_STARTED', + runId: 'r', + threadId: 't', + model: 'test', + timestamp: Date.now(), + } as StreamChunk, + { + type: 'TEXT_MESSAGE_CONTENT', + messageId: 'm', + delta: 'Hello', + content: 'Hello', + model: 'test', + timestamp: Date.now(), + } as StreamChunk, + { + type: 'RUN_FINISHED', + runId: 'r', + threadId: 't', + model: 'test', + timestamp: Date.now(), + finishReason: 'stop', + } as StreamChunk, + ], + }) + + const { result } = renderHook(() => useChat({ connection: adapter })) + + await act(async () => { + await result.current.sendMessage('hi') + }) + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThan(0) + }) + // The return object doesn't expose `partial`/`final` at the type level + // (the discriminated `UseChatReturn` only adds them when `outputSchema` is + // supplied), and the runtime branch in onChunk is gated on + // `outputSchema !== undefined` so the internal state never updates. + // Runtime access is the only way to verify the no-op branch. TS cannot + // express "this object happens to also carry these fields at runtime + // even though the type says it doesn't", so a single bridge cast to + // `unknown` is the minimum legal escape. + const runtimeView: { partial?: unknown; final?: unknown } = + result.current as unknown as { + partial?: unknown + final?: unknown + } + expect(runtimeView.partial).toEqual({}) + expect(runtimeView.final).toBeNull() + }) +}) diff --git a/packages/ai-octane/tests/conformance/use-chat.test.ts b/packages/ai-octane/tests/conformance/use-chat.test.ts new file mode 100644 index 0000000000..8bc0ab7e19 --- /dev/null +++ b/packages/ai-octane/tests/conformance/use-chat.test.ts @@ -0,0 +1,2423 @@ +import type { ModelMessage, StreamChunk } from '@tanstack/ai' +import { EventType } from '@tanstack/ai' +import { ChatClient } from '@tanstack/ai-client' +import type { + ChatClientPersistence, + ConnectConnectionAdapter, + ResumableConnectConnectionAdapter, + SubscribeConnectionAdapter, +} from '@tanstack/ai-client' +import { act, renderHook, waitFor } from '@octanejs/testing-library' +import { useState } from 'octane' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { UIMessage, UseChatOptions } from '../../src/types' +import { useChat } from '../../src/use-chat.tsrx' +import { + createInterruptResumeSnapshot, + createMockConnectionAdapter, + createTextChunks, + createToolCallChunks, + renderUseChat, +} from './test-utils' + +describe('useChat', () => { + function createDeferred() { + let resolve!: (value: T) => void + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve + }) + return { promise, resolve } + } + + describe('interrupt state', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('projects one immutable snapshot with the deprecated pending alias', async () => { + const onInterruptStateChange = vi.fn() + const { result } = renderUseChat({ + connection: createMockConnectionAdapter(), + initialResumeSnapshot: createInterruptResumeSnapshot(), + onInterruptStateChange, + }) + + expect(onInterruptStateChange).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ interrupts: result.current.interrupts }), + { source: 'hydrate' }, + ) + expect(Object.isFrozen(result.current.interrupts)).toBe(true) + expect(result.current.pendingInterrupts).toBe(result.current.interrupts) + expect(result.current.interrupts[0]).toMatchObject({ + id: 'staged-interrupt', + status: 'pending', + }) + expect(result.current.interrupts[1]).toMatchObject({ + id: 'invalid-interrupt', + status: 'pending', + }) + expect(result.current.interruptErrors).toEqual([]) + expect(result.current.resuming).toBe(false) + expect(result.current.interrupts[0]).toEqual( + expect.objectContaining({ + resolveInterrupt: expect.any(Function), + cancel: expect.any(Function), + clearResolution: expect.any(Function), + }), + ) + + act(() => result.current.resolveInterrupts(false)) + await waitFor(() => { + expect(result.current.interruptErrors[0]?.code).toBe( + 'unsupported-bulk-operation', + ) + }) + expect(onInterruptStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + interrupts: result.current.interrupts, + interruptErrors: result.current.interruptErrors, + }), + { source: 'live' }, + ) + }) + + it('awaits onResponse when hydration resumes during activation', async () => { + const response = createDeferred() + const onResponse = vi.fn(() => response.promise) + const onConnect = vi.fn() + const { result } = renderUseChat({ + connection: createMockConnectionAdapter({ + chunks: createTextChunks('resumed'), + onConnect, + }), + initialResumeSnapshot: createInterruptResumeSnapshot(), + live: true, + onResponse, + onInterruptStateChange: (state, context) => { + if (context.source !== 'hydrate') return + for (const interrupt of state.interrupts) { + if (interrupt.kind === 'unbound') continue + interrupt.cancel() + } + }, + }) + + await waitFor(() => { + expect(onResponse).toHaveBeenCalledOnce() + }) + expect(onConnect).not.toHaveBeenCalled() + + await act(async () => { + response.resolve() + await response.promise + }) + + await waitFor(() => { + expect(onConnect).toHaveBeenCalledOnce() + expect(result.current.resuming).toBe(false) + }) + }) + + it('delegates every root interrupt control to ChatClient', async () => { + const resolve = vi + .spyOn(ChatClient.prototype, 'resolveInterrupts') + .mockImplementation(() => {}) + const cancel = vi + .spyOn(ChatClient.prototype, 'cancelInterrupts') + .mockImplementation(() => {}) + const retry = vi + .spyOn(ChatClient.prototype, 'retryInterrupts') + .mockImplementation(() => {}) + const unsafe = vi + .spyOn(ChatClient.prototype, 'resumeInterruptsUnsafe') + .mockResolvedValue(true) + const { result } = renderUseChat({ + connection: createMockConnectionAdapter(), + }) + const resolver = () => undefined + const resume = [{ interruptId: 'one', status: 'cancelled' as const }] + + act(() => { + result.current.resolveInterrupts(resolver) + result.current.cancelInterrupts() + result.current.retryInterrupts() + }) + await expect(result.current.resumeInterruptsUnsafe(resume)).resolves.toBe( + true, + ) + + expect(resolve).toHaveBeenCalledWith(resolver) + expect(cancel).toHaveBeenCalledOnce() + expect(retry).toHaveBeenCalledOnce() + expect(unsafe).toHaveBeenCalledWith(resume, undefined) + }) + }) + + describe('initialization', () => { + it('should initialize with default state', () => { + const adapter = createMockConnectionAdapter() + const { result } = renderUseChat({ connection: adapter }) + + expect(result.current.messages).toEqual([]) + expect(result.current.isLoading).toBe(false) + expect(result.current.error).toBeUndefined() + expect(result.current.status).toBe('ready') + expect(result.current.isSubscribed).toBe(false) + expect(result.current.connectionStatus).toBe('disconnected') + expect(result.current.sessionGenerating).toBe(false) + }) + + it('should subscribe immediately when live is true', async () => { + const adapter = createMockConnectionAdapter() + const { result } = renderUseChat({ connection: adapter, live: true }) + + await waitFor(() => { + expect(result.current.isSubscribed).toBe(true) + }) + expect(['connecting', 'connected']).toContain( + result.current.connectionStatus, + ) + }) + + it('should initialize with provided messages', () => { + const adapter = createMockConnectionAdapter() + const initialMessages: UIMessage[] = [ + { + id: 'msg-1', + role: 'user', + parts: [{ type: 'text', content: 'Hello' }], + createdAt: new Date(), + }, + ] + + const { result } = renderUseChat({ + connection: adapter, + initialMessages, + }) + + expect(result.current.messages).toEqual(initialMessages) + }) + + it('should initialize with persisted messages', async () => { + const adapter = createMockConnectionAdapter() + const persistedMessages: UIMessage[] = [ + { + id: 'persisted-1', + role: 'user', + parts: [{ type: 'text', content: 'Persisted' }], + createdAt: new Date(), + }, + ] + const persistence = { + getItem: vi.fn(() => persistedMessages), + setItem: vi.fn(), + removeItem: vi.fn(), + } + + const { result } = renderUseChat({ + connection: adapter, + threadId: 'persisted-chat', + persistence, + }) + + await waitFor(() => { + expect(result.current.messages).toEqual(persistedMessages) + }) + expect(persistence.getItem).toHaveBeenCalledWith('persisted-chat') + }) + + it('should forward synchronous persisted interrupt hydration', () => { + const onInterruptStateChange = vi.fn() + const persistence = { + getItem: vi.fn(() => ({ + messages: [], + resume: createInterruptResumeSnapshot(), + })), + setItem: vi.fn(), + removeItem: vi.fn(), + } + + const { result } = renderUseChat({ + connection: createMockConnectionAdapter(), + threadId: 'persisted-interrupt-chat', + persistence, + onInterruptStateChange, + }) + + expect(result.current.interrupts).toHaveLength(2) + expect(onInterruptStateChange).toHaveBeenCalledOnce() + expect(onInterruptStateChange).toHaveBeenCalledWith( + expect.objectContaining({ interrupts: result.current.interrupts }), + { source: 'hydrate' }, + ) + }) + + it('should preserve persisted empty messages over provided initial messages', async () => { + const adapter = createMockConnectionAdapter() + const initialMessages: UIMessage[] = [ + { + id: 'initial-1', + role: 'user', + parts: [{ type: 'text', content: 'Initial' }], + createdAt: new Date(), + }, + ] + const persistence = { + getItem: vi.fn(() => []), + setItem: vi.fn(), + removeItem: vi.fn(), + } + + const { result } = renderUseChat({ + connection: adapter, + threadId: 'persisted-empty-chat', + initialMessages, + persistence, + }) + + await waitFor(() => { + expect(persistence.getItem).toHaveBeenCalledWith('persisted-empty-chat') + }) + expect(result.current.messages).toEqual([]) + }) + + it('should ignore async persisted messages from a previous id', async () => { + const oldHydration = createDeferred>() + const oldMessages: Array = [ + { + id: 'old-persisted', + role: 'user', + parts: [{ type: 'text', content: 'Old persisted' }], + createdAt: new Date(), + }, + ] + const newMessages: Array = [ + { + id: 'new-persisted', + role: 'user', + parts: [{ type: 'text', content: 'New persisted' }], + createdAt: new Date(), + }, + ] + const persistence = { + getItem: vi.fn((id: string) => + id === 'old-chat' ? oldHydration.promise : newMessages, + ), + setItem: vi.fn(), + removeItem: vi.fn(), + } + + function useChangingChat() { + const [threadId, setId] = useState('old-chat') + const chat = useChat({ + connection: createMockConnectionAdapter(), + threadId, + persistence, + }) + + return { ...chat, setId } + } + + const { result } = renderHook(() => useChangingChat()) + + act(() => { + result.current.setId('new-chat') + }) + + await waitFor(() => { + expect(result.current.messages).toEqual(newMessages) + }) + + await act(async () => { + oldHydration.resolve(oldMessages) + await oldHydration.promise + }) + + expect(result.current.messages).toEqual(newMessages) + }) + + it('should use provided threadId', async () => { + const chunks = createTextChunks('Response') + const adapter = createMockConnectionAdapter({ chunks }) + + const { result } = renderUseChat({ + connection: adapter, + threadId: 'custom-id', + }) + + await result.current.sendMessage('Test') + + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThan(0) + }) + + // Message IDs are generated independently, not based on client ID + // Just verify messages exist and have IDs + const messageId = result.current.messages[0]!.id + expect(messageId).toBeDefined() + expect(typeof messageId).toBe('string') + }) + + it('should generate id if not provided', async () => { + const chunks = createTextChunks('Response') + const adapter = createMockConnectionAdapter({ chunks }) + + const { result } = renderUseChat({ connection: adapter }) + + await result.current.sendMessage('Test') + + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThan(0) + }) + + // Message IDs should have a generated prefix (not "custom-id-") + const messageId = result.current.messages[0]!.id + expect(messageId).toBeTruthy() + expect(messageId).not.toMatch(/^custom-id-/) + }) + + it('does not call crypto.randomUUID while rendering useChat', () => { + const randomUUID = vi.fn(() => { + throw new Error('crypto.randomUUID during render') + }) + vi.stubGlobal('crypto', { randomUUID }) + + expect(() => { + renderUseChat({ + connection: createMockConnectionAdapter(), + }) + }).not.toThrow() + + expect(randomUUID).not.toHaveBeenCalled() + vi.unstubAllGlobals() + }) + + it('sends a minted threadId that is not an Octane useId string', async () => { + const threadIds: Array = [] + const adapter: ConnectConnectionAdapter = { + async *connect(_messages, _data, abortSignal, runContext) { + if (runContext) { + threadIds.push(runContext.threadId) + } + for (const chunk of createTextChunks('Response')) { + if (abortSignal?.aborted) { + return + } + yield chunk + } + }, + } + + const { result } = renderUseChat({ connection: adapter }) + await result.current.sendMessage('Test') + + await waitFor(() => { + expect(threadIds).toHaveLength(1) + }) + expect(threadIds[0]).toMatch(/^thread-/) + expect(threadIds[0]).not.toMatch(/^[:_]/) + }) + + it('does not expose delivery-cursor resume/autoResume machinery', () => { + const adapter = createMockConnectionAdapter() + const { result } = renderUseChat({ connection: adapter }) + + expect(result.current).not.toHaveProperty('resume') + expect(result.current).not.toHaveProperty('resumeState') + expect(typeof result.current.resumeInterrupts).toBe('function') + expect(result.current.runId).toBeNull() + }) + + it('exposes the run id while a send is in flight and clears it after', async () => { + let release: (() => void) | undefined + const held = new Promise((resolve) => { + release = resolve + }) + const adapter: ConnectConnectionAdapter = { + async *connect(_messages, _data, _signal, runContext) { + yield { + type: EventType.RUN_STARTED, + runId: runContext?.runId ?? 'r1', + threadId: runContext?.threadId ?? 't1', + timestamp: 1, + } + await held + yield { + type: EventType.RUN_FINISHED, + runId: runContext?.runId ?? 'r1', + threadId: runContext?.threadId ?? 't1', + finishReason: 'stop', + timestamp: 2, + } + }, + } + const { result } = renderUseChat({ connection: adapter }) + + const send = result.current.sendMessage('hi') + await waitFor(() => { + expect(result.current.runId).toMatch(/^run-/) + }) + + await act(async () => { + release?.() + await send + }) + expect(result.current.runId).toBeNull() + }) + + it('rejoins an in-flight run on mount without aborting it (live off)', async () => { + const runChunks: Array = [ + { + type: EventType.RUN_STARTED, + runId: 'r1', + threadId: 't1', + timestamp: 1, + }, + { + type: EventType.TEXT_MESSAGE_START, + messageId: 'a1', + role: 'assistant', + timestamp: 2, + }, + { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: 'a1', + delta: 'resumed reply', + timestamp: 3, + }, + { type: EventType.TEXT_MESSAGE_END, messageId: 'a1', timestamp: 4 }, + { + type: EventType.RUN_FINISHED, + runId: 'r1', + threadId: 't1', + finishReason: 'stop', + timestamp: 5, + }, + ] + const joinRun = vi.fn(async function* (_runId: string) { + for (const chunk of runChunks) yield chunk + }) + const connection: ResumableConnectConnectionAdapter = { + connect: async function* () {}, + joinRun, + } + const persistence: ChatClientPersistence = { + getItem: () => ({ + messages: [], + resume: { + schemaVersion: 2, + resumeState: { threadId: 't1', runId: 'r1' }, + }, + }), + setItem: () => {}, + removeItem: () => {}, + } + + const { result } = renderUseChat({ + connection, + threadId: 't1', + persistence, + }) + + await waitFor(() => { + const assistant = result.current.messages.find( + (m) => m.role === 'assistant', + ) + const text = assistant?.parts.find((p) => p.type === 'text') + expect(text && 'content' in text && text.content).toBe('resumed reply') + }) + }) + + it('should maintain client instance across re-renders', () => { + const adapter = createMockConnectionAdapter() + const { result, rerender } = renderUseChat({ connection: adapter }) + + const initialMessages = result.current.messages + + rerender({ connection: adapter }) + + // Client should be the same instance, state should persist + expect(result.current.messages).toBe(initialMessages) + }) + + // OMITTED — upstream's "should auto-resume on mount and when the browser + // comes back online" case spies on `ChatClient.prototype.maybeAutoResume`, + // an API that does not exist in the pinned (and latest published) + // `@tanstack/ai-client@0.21.0` that `@tanstack/ai-react@0.17.0` depends on; + // it is a work-in-progress method present only in the upstream test file, + // not in any shipped `ai-client` nor invoked by `useChat` itself. The + // behavior is a ChatClient (dependency) concern, out of scope for this + // binding, so the case is untestable here until the dependency ships the + // method. Tracked in status.json. + }) + + describe('state synchronization', () => { + it('should update messages via onMessagesChange callback', async () => { + const chunks = createTextChunks('Hello, world!') + const adapter = createMockConnectionAdapter({ chunks }) + const { result } = renderUseChat({ connection: adapter }) + + await result.current.sendMessage('Hello') + + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThanOrEqual(2) + }) + + const userMessage = result.current.messages.find((m) => m.role === 'user') + expect(userMessage).toBeDefined() + if (userMessage) { + expect(userMessage.parts[0]).toEqual({ + type: 'text', + content: 'Hello', + }) + } + }) + + it('should update loading state via onLoadingChange callback', async () => { + const chunks = createTextChunks('Response') + const adapter = createMockConnectionAdapter({ + chunks, + chunkDelay: 50, + }) + const { result } = renderUseChat({ connection: adapter }) + + expect(result.current.isLoading).toBe(false) + + const sendPromise = result.current.sendMessage('Test') + + // Should be loading during send + await waitFor(() => { + expect(result.current.isLoading).toBe(true) + }) + + await sendPromise + + // Should not be loading after completion + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + }) + + it('should update error state via onErrorChange callback', async () => { + const error = new Error('Connection failed') + const adapter = createMockConnectionAdapter({ + shouldError: true, + error, + }) + const { result } = renderUseChat({ connection: adapter }) + + await result.current.sendMessage('Test') + + await waitFor(() => { + expect(result.current.error).toBeDefined() + }) + + expect(result.current.error?.message).toBe('Connection failed') + }) + + it('should persist state across re-renders', async () => { + const chunks = createTextChunks('Response') + const adapter = createMockConnectionAdapter({ chunks }) + const { result, rerender } = renderUseChat({ connection: adapter }) + + await result.current.sendMessage('Hello') + + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThan(0) + }) + + const messageCount = result.current.messages.length + + rerender({ connection: adapter }) + + // State should persist after re-render + expect(result.current.messages.length).toBe(messageCount) + }) + }) + + describe('sendMessage', () => { + it('should send a message and append it', async () => { + const chunks = createTextChunks('Hello, world!') + const adapter = createMockConnectionAdapter({ chunks }) + const { result } = renderUseChat({ connection: adapter }) + + await result.current.sendMessage('Hello') + + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThan(0) + }) + + const userMessage = result.current.messages.find((m) => m.role === 'user') + expect(userMessage).toBeDefined() + if (userMessage) { + expect(userMessage.parts[0]).toEqual({ + type: 'text', + content: 'Hello', + }) + } + }) + + it('should create assistant message from stream chunks', async () => { + const chunks = createTextChunks('Hello, world!') + const adapter = createMockConnectionAdapter({ chunks }) + const { result } = renderUseChat({ connection: adapter }) + + await result.current.sendMessage('Hello') + + await waitFor(() => { + const assistantMessage = result.current.messages.find( + (m) => m.role === 'assistant', + ) + expect(assistantMessage).toBeDefined() + }) + + const assistantMessage = result.current.messages.find( + (m) => m.role === 'assistant', + ) + expect(assistantMessage).toBeDefined() + if (assistantMessage) { + const textPart = assistantMessage.parts.find((p) => p.type === 'text') + expect(textPart).toBeDefined() + if (textPart && textPart.type === 'text') { + expect(textPart.content).toBe('Hello, world!') + } + } + }) + + it('should not send empty messages', async () => { + const adapter = createMockConnectionAdapter() + const { result } = renderUseChat({ connection: adapter }) + + await result.current.sendMessage('') + await result.current.sendMessage(' ') + + expect(result.current.messages.length).toBe(0) + }) + + it('should queue a message sent while loading and send it after', async () => { + const adapter = createMockConnectionAdapter({ + chunks: createTextChunks('Response'), + chunkDelay: 100, + }) + const { result } = renderUseChat({ connection: adapter }) + + const promise1 = result.current.sendMessage('First') + const promise2 = result.current.sendMessage('Second') + + await Promise.all([promise1, promise2]) + + // The second send is queued (default `whenBusy: 'queue'`) while the + // first stream is in flight, then auto-drains once it settles — both + // end up sent, in order. + const userMessages = result.current.messages.filter( + (m) => m.role === 'user', + ) + expect(userMessages.map((m) => m.parts[0])).toEqual([ + { type: 'text', content: 'First' }, + { type: 'text', content: 'Second' }, + ]) + }) + + it('honors sendMessage whenBusy: drop', async () => { + const adapter = createMockConnectionAdapter({ + chunks: createTextChunks('Response'), + chunkDelay: 100, + }) + const { result } = renderUseChat({ connection: adapter }) + + const promise1 = result.current.sendMessage('First') + const promise2 = result.current.sendMessage('Second', { + whenBusy: 'drop', + }) + + await Promise.all([promise1, promise2]) + + const userMessages = result.current.messages.filter( + (m) => m.role === 'user', + ) + expect(userMessages.map((m) => m.parts[0])).toEqual([ + { type: 'text', content: 'First' }, + ]) + }) + + it('exposes the queue and cancelQueued', async () => { + const adapter = createMockConnectionAdapter({ + chunks: createTextChunks('Response'), + chunkDelay: 100, + }) + const { result } = renderUseChat({ connection: adapter }) + + const first = result.current.sendMessage('First') + await waitFor(() => { + expect(result.current.isLoading).toBe(true) + }) + const second = result.current.sendMessage('Second') + await waitFor(() => { + expect(result.current.queue).toHaveLength(1) + }) + act(() => { + result.current.cancelQueued(result.current.queue[0]!.id) + }) + await first + await second + + const userMessages = result.current.messages.filter( + (m) => m.role === 'user', + ) + expect(userMessages.map((m) => m.parts[0])).toEqual([ + { type: 'text', content: 'First' }, + ]) + expect(result.current.queue).toEqual([]) + }) + + it('should handle errors during sendMessage', async () => { + const error = new Error('Network error') + const adapter = createMockConnectionAdapter({ + shouldError: true, + error, + }) + const { result } = renderUseChat({ connection: adapter }) + + await result.current.sendMessage('Test') + + await waitFor(() => { + expect(result.current.error).toBeDefined() + }) + + expect(result.current.error?.message).toBe('Network error') + expect(result.current.isLoading).toBe(false) + }) + }) + + describe('append', () => { + it('should append a UIMessage', async () => { + const chunks = createTextChunks('Response') + const adapter = createMockConnectionAdapter({ chunks }) + const { result } = renderUseChat({ connection: adapter }) + + const message: UIMessage = { + id: 'user-1', + role: 'user', + parts: [{ type: 'text', content: 'Hello' }], + createdAt: new Date(), + } + + await result.current.append(message) + + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThan(0) + }) + + expect(result.current.messages[0]!.id).toBe('user-1') + }) + + it('should convert and append a ModelMessage', async () => { + const chunks = createTextChunks('Response') + const adapter = createMockConnectionAdapter({ chunks }) + const { result } = renderUseChat({ connection: adapter }) + + const modelMessage: ModelMessage = { + role: 'user', + content: 'Hello from model', + } + + await result.current.append(modelMessage) + + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThan(0) + }) + + expect(result.current.messages[0]!.role).toBe('user') + expect(result.current.messages[0]!.parts[0]).toEqual({ + type: 'text', + content: 'Hello from model', + }) + }) + + it('should handle errors during append', async () => { + const error = new Error('Append failed') + const adapter = createMockConnectionAdapter({ + shouldError: true, + error, + }) + const { result } = renderUseChat({ connection: adapter }) + + const message: UIMessage = { + id: 'msg-1', + role: 'user', + parts: [{ type: 'text', content: 'Hello' }], + createdAt: new Date(), + } + + await result.current.append(message) + + await waitFor(() => { + expect(result.current.error).toBeDefined() + }) + + expect(result.current.error?.message).toBe('Append failed') + }) + }) + + describe('reload', () => { + it('should reload the last assistant message', async () => { + const chunks1 = createTextChunks('First response') + const chunks2 = createTextChunks('Second response') + let callCount = 0 + + const adapter = createMockConnectionAdapter({ + chunks: chunks1, + onConnect: () => { + callCount++ + // Return different chunks on second call + if (callCount === 2) { + return chunks2 + } + return undefined + }, + }) + + // Create a new adapter for the second call + const adapter2 = createMockConnectionAdapter({ chunks: chunks2 }) + const { result, rerender } = renderUseChat({ connection: adapter }) + + await result.current.sendMessage('Hello') + + await waitFor(() => { + const assistantMessage = result.current.messages.find( + (m) => m.role === 'assistant', + ) + expect(assistantMessage).toBeDefined() + }) + + // Reload with new adapter + rerender({ connection: adapter2 }) + await result.current.reload() + + await waitFor(() => { + const assistantMessage = result.current.messages.find( + (m) => m.role === 'assistant', + ) + expect(assistantMessage).toBeDefined() + }) + + // Should have reloaded (though content might be same if adapter doesn't change) + const messagesAfterReload = result.current.messages + expect(messagesAfterReload.length).toBeGreaterThan(0) + }) + + it('should maintain conversation history after reload', async () => { + const chunks = createTextChunks('Response') + const adapter = createMockConnectionAdapter({ chunks }) + const { result } = renderUseChat({ connection: adapter }) + + await result.current.sendMessage('First') + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThanOrEqual(2) + }) + + const messageCountBeforeReload = result.current.messages.length + + await result.current.reload() + + await waitFor(() => { + // Should still have the same number of messages (user + assistant) + expect(result.current.messages.length).toBeGreaterThanOrEqual(2) + }) + + // History should be maintained + expect(result.current.messages.length).toBeGreaterThanOrEqual( + messageCountBeforeReload, + ) + }) + + it('should handle errors during reload', async () => { + const chunks = createTextChunks('Response') + const adapter = createMockConnectionAdapter({ chunks }) + const { result } = renderUseChat({ connection: adapter }) + + await result.current.sendMessage('Hello') + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThanOrEqual(2) + }) + + // Note: We can't easily change the adapter after creation, + // so this test verifies error handling in general + // The actual error would come from the connection adapter + expect(result.current.reload).toBeDefined() + }) + }) + + describe('stop', () => { + it('should stop current generation', async () => { + const chunks = createTextChunks('Long response that will be stopped') + const adapter = createMockConnectionAdapter({ + chunks, + chunkDelay: 50, + }) + const { result } = renderUseChat({ connection: adapter }) + + const sendPromise = result.current.sendMessage('Test') + + // Wait for loading to start + await waitFor(() => { + expect(result.current.isLoading).toBe(true) + }) + + // Stop the generation + result.current.stop() + + await sendPromise + + // Should eventually stop loading + await waitFor( + () => { + expect(result.current.isLoading).toBe(false) + expect(result.current.status).toBe('ready') + }, + { timeout: 1000 }, + ) + }) + + it('should be safe to call multiple times', () => { + const adapter = createMockConnectionAdapter() + const { result } = renderUseChat({ connection: adapter }) + + // Should not throw + result.current.stop() + result.current.stop() + result.current.stop() + + expect(result.current.isLoading).toBe(false) + expect(result.current.status).toBe('ready') + }) + + it('should clear loading state when stopped', async () => { + const chunks = createTextChunks('Response') + const adapter = createMockConnectionAdapter({ + chunks, + chunkDelay: 50, + }) + const { result } = renderUseChat({ connection: adapter }) + + const sendPromise = result.current.sendMessage('Test') + + await waitFor(() => { + expect(result.current.isLoading).toBe(true) + }) + + result.current.stop() + + await waitFor( + () => { + expect(result.current.isLoading).toBe(false) + expect(result.current.status).toBe('ready') + }, + { timeout: 1000 }, + ) + + await sendPromise.catch(() => { + // Ignore errors from stopped request + }) + }) + }) + + describe('status', () => { + it('should transition through states during generation', async () => { + const chunks = createTextChunks('Response') + const adapter = createMockConnectionAdapter({ + chunks, + chunkDelay: 50, + }) + const { result } = renderUseChat({ connection: adapter }) + + const sendPromise = result.current.sendMessage('Test') + + // Should leave ready state + await waitFor(() => { + expect(result.current.status).not.toBe('ready') + }) + + // Should be submitted or streaming + expect(['submitted', 'streaming']).toContain(result.current.status) + + // Should eventually match streaming + await waitFor(() => { + expect(result.current.status).toBe('streaming') + }) + + await sendPromise + + // Should return to ready + await waitFor(() => { + expect(result.current.status).toBe('ready') + }) + }) + }) + + describe('clear', () => { + it('should clear all messages', async () => { + const chunks = createTextChunks('Response') + const adapter = createMockConnectionAdapter({ chunks }) + const { result } = renderUseChat({ connection: adapter }) + + await result.current.sendMessage('Hello') + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThan(0) + }) + + result.current.clear() + + await waitFor(() => { + expect(result.current.messages).toEqual([]) + }) + }) + + it('should reset to initial state', async () => { + const initialMessages: UIMessage[] = [ + { + id: 'msg-1', + role: 'user', + parts: [{ type: 'text', content: 'Initial' }], + createdAt: new Date(), + }, + ] + + const chunks = createTextChunks('Response') + const adapter = createMockConnectionAdapter({ chunks }) + const { result } = renderUseChat({ + connection: adapter, + initialMessages, + }) + + await result.current.sendMessage('Hello') + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThan( + initialMessages.length, + ) + }) + + result.current.clear() + + // Should clear all messages, not reset to initial + await waitFor(() => { + expect(result.current.messages).toEqual([]) + }) + }) + + it('should maintain client instance after clear', async () => { + const chunks = createTextChunks('Response') + const adapter = createMockConnectionAdapter({ chunks }) + const { result } = renderUseChat({ connection: adapter }) + + await result.current.sendMessage('Hello') + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThan(0) + }) + + result.current.clear() + + // Should still be able to send messages + await result.current.sendMessage('New message') + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThan(0) + }) + }) + }) + + describe('setMessages', () => { + it('should manually set messages', async () => { + const adapter = createMockConnectionAdapter() + const { result } = renderUseChat({ connection: adapter }) + + const newMessages: UIMessage[] = [ + { + id: 'msg-1', + role: 'user', + parts: [{ type: 'text', content: 'Manual' }], + createdAt: new Date(), + }, + ] + + result.current.setMessages(newMessages) + + await waitFor(() => { + expect(result.current.messages).toEqual(newMessages) + }) + }) + + it('should update state immediately', async () => { + const adapter = createMockConnectionAdapter() + const { result } = renderUseChat({ connection: adapter }) + + expect(result.current.messages).toEqual([]) + + const newMessages: UIMessage[] = [ + { + id: 'msg-1', + role: 'user', + parts: [{ type: 'text', content: 'Immediate' }], + createdAt: new Date(), + }, + ] + + result.current.setMessages(newMessages) + + // Wait for state to update + await waitFor(() => { + expect(result.current.messages).toEqual(newMessages) + }) + }) + + it('should replace all existing messages', async () => { + const chunks = createTextChunks('Response') + const adapter = createMockConnectionAdapter({ chunks }) + const { result } = renderUseChat({ connection: adapter }) + + await result.current.sendMessage('Hello') + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThan(0) + }) + + const originalCount = result.current.messages.length + + const newMessages: UIMessage[] = [ + { + id: 'msg-new', + role: 'user', + parts: [{ type: 'text', content: 'Replaced' }], + createdAt: new Date(), + }, + ] + + result.current.setMessages(newMessages) + + await waitFor(() => { + expect(result.current.messages).toEqual(newMessages) + expect(result.current.messages.length).toBe(1) + expect(result.current.messages.length).not.toBe(originalCount) + }) + }) + }) + + describe('callbacks', () => { + it('should call onChunk callback when chunks are received', async () => { + const chunks = createTextChunks('Hello') + const adapter = createMockConnectionAdapter({ chunks }) + const onChunk = vi.fn() + + const { result } = renderUseChat({ + connection: adapter, + onChunk, + }) + + await result.current.sendMessage('Test') + + await waitFor(() => { + expect(onChunk).toHaveBeenCalled() + }) + + // Should have been called for each chunk + expect(onChunk.mock.calls.length).toBeGreaterThan(0) + }) + + it('should call onFinish callback when response finishes', async () => { + const chunks = createTextChunks('Response') + const adapter = createMockConnectionAdapter({ chunks }) + const onFinish = vi.fn() + + const { result } = renderUseChat({ + connection: adapter, + onFinish, + }) + + await result.current.sendMessage('Test') + + await waitFor(() => { + expect(onFinish).toHaveBeenCalled() + }) + + const finishedMessage = onFinish.mock.calls[0]![0] + expect(finishedMessage).toBeDefined() + expect(finishedMessage.role).toBe('assistant') + }) + + it('should call onError callback when error occurs', async () => { + const error = new Error('Test error') + const adapter = createMockConnectionAdapter({ + shouldError: true, + error, + }) + const onError = vi.fn() + + const { result } = renderUseChat({ + connection: adapter, + onError, + }) + + await result.current.sendMessage('Test') + + await waitFor(() => { + expect(onError).toHaveBeenCalled() + }) + + expect(onError.mock.calls[0]![0].message).toBe('Test error') + }) + + it('should call onResponse callback when response is received', async () => { + const chunks = createTextChunks('Response') + const adapter = createMockConnectionAdapter({ chunks }) + const onResponse = vi.fn() + + const { result } = renderUseChat({ + connection: adapter, + onResponse, + }) + + await result.current.sendMessage('Test') + + // onResponse may or may not be called depending on adapter implementation + // This test verifies the callback is passed through + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThan(0) + }) + }) + + it('should use the latest onChunk after the parent rerenders with a new callback', async () => { + const first = vi.fn() + const second = vi.fn() + const adapter = createMockConnectionAdapter({ + chunks: createTextChunks('Hello'), + }) + + const { result, rerender } = renderHook( + (opts: UseChatOptions) => useChat(opts), + { + initialProps: { connection: adapter, onChunk: first }, + }, + ) + + // Swap in a new callback before the next sendMessage + rerender({ connection: adapter, onChunk: second }) + + await result.current.sendMessage('Test') + + // Only the newer callback should have seen this stream + await waitFor(() => { + expect(second).toHaveBeenCalled() + }) + expect(first).not.toHaveBeenCalled() + }) + + it('should ignore user callbacks from an old client after id changes', async () => { + const releaseOldStream = createDeferred() + const oldOnChunk = vi.fn() + const newOnChunk = vi.fn() + const adapter = { + async *connect() { + await releaseOldStream.promise + yield* createTextChunks('stale old client') + }, + } + + const { result, rerender } = renderHook( + (opts: UseChatOptions) => useChat(opts), + { + initialProps: { + connection: adapter, + threadId: 'old-client', + onChunk: oldOnChunk, + }, + }, + ) + + const sendPromise = result.current.sendMessage('Test') + await waitFor(() => { + expect(result.current.isLoading).toBe(true) + }) + + rerender({ + connection: adapter, + threadId: 'new-client', + onChunk: newOnChunk, + }) + + releaseOldStream.resolve() + await sendPromise + + expect(oldOnChunk).not.toHaveBeenCalled() + expect(newOnChunk).not.toHaveBeenCalled() + }) + + it('should keep callbacks live across StrictMode effect replay for the same client', async () => { + const onChunk = vi.fn() + const adapter = createMockConnectionAdapter({ + chunks: createTextChunks('strict response'), + }) + + // OCTANE DIVERGENCE: Octane has no StrictMode double-invoke + const { result } = renderHook(() => + useChat({ + connection: adapter, + onChunk, + }), + ) + + await act(async () => { + await result.current.sendMessage('Test') + }) + + expect(onChunk).toHaveBeenCalledWith( + expect.objectContaining({ type: EventType.TEXT_MESSAGE_CONTENT }), + ) + }) + }) + + describe('edge cases and error handling', () => { + describe('options changes', () => { + it('should use an updated connection without losing conversation state', async () => { + const firstConnect = vi.fn() + const adapter1 = createMockConnectionAdapter({ + chunks: createTextChunks('First response', 'first-response'), + onConnect: firstConnect, + }) + const { result, rerender } = renderUseChat({ connection: adapter1 }) + + await result.current.sendMessage('First request') + await waitFor(() => { + expect(firstConnect).toHaveBeenCalledTimes(1) + expect(result.current.messages).toHaveLength(2) + }) + + const secondConnect = vi.fn() + const adapter2 = createMockConnectionAdapter({ + chunks: createTextChunks('Second response', 'second-response'), + onConnect: secondConnect, + }) + rerender({ connection: adapter2 }) + + await result.current.sendMessage('Second request') + + await waitFor(() => { + expect(secondConnect).toHaveBeenCalledTimes(1) + expect(result.current.messages).toHaveLength(4) + }) + expect(firstConnect).toHaveBeenCalledTimes(1) + const renderedText = result.current.messages + .flatMap((message) => message.parts) + .filter((part) => part.type === 'text') + .map((part) => part.content) + expect(renderedText).toContain('First response') + expect(renderedText).toContain('Second response') + }) + + it('should handle body changes', () => { + const adapter = createMockConnectionAdapter() + const { result, rerender } = renderUseChat({ + connection: adapter, + body: { userId: '123' }, + }) + + rerender({ + connection: adapter, + body: { userId: '456' }, + }) + + // Should not throw + expect(result.current).toBeDefined() + }) + + it('should handle callback changes', () => { + const adapter = createMockConnectionAdapter() + const onChunk1 = vi.fn() + const { result, rerender } = renderUseChat({ + connection: adapter, + onChunk: onChunk1, + }) + + const onChunk2 = vi.fn() + rerender({ + connection: adapter, + onChunk: onChunk2, + }) + + // Should not throw + expect(result.current).toBeDefined() + }) + }) + + describe('client recreation', () => { + it('should not pass previous id messages to a new client id without persisted messages', async () => { + const connectSpy = vi.fn() + const chunks = createTextChunks('Reply') + const adapter = createMockConnectionAdapter({ + chunks, + onConnect: connectSpy, + }) + + // Control id via state so setMessages and setId are both React + // state updates that get batched into a single render. + const { result } = renderHook(() => { + const [threadId, setId] = useState('client-A') + const chat = useChat({ connection: adapter, threadId }) + return { ...chat, switchId: setId } + }) + + const messages: Array = [ + { + id: 'msg-1', + role: 'user', + parts: [{ type: 'text', content: 'Hello' }], + createdAt: new Date(), + }, + { + id: 'msg-2', + role: 'assistant', + parts: [{ type: 'text', content: 'Hi there!' }], + createdAt: new Date(), + }, + ] + + // Batch: set messages AND change id in one render cycle. + // With the useEffect ref pattern, the new ChatClient is created + // with stale (empty) initialMessages because messagesRef hasn't + // been updated yet. + act(() => { + result.current.setMessages(messages) + result.current.switchId('client-B') + }) + + await act(async () => { + await result.current.sendMessage('Follow-up') + }) + + await waitFor(() => { + expect(connectSpy).toHaveBeenCalled() + }) + + const sentMessages = connectSpy.mock.calls[0]![0] as Array< + ModelMessage | UIMessage + > + const sentText = sentMessages + .flatMap((message) => ('parts' in message ? message.parts : [])) + .filter((part) => part.type === 'text') + .map((part) => part.content) + .join('') + + expect(sentText).toContain('Follow-up') + expect(sentText).not.toContain('Hello') + expect(result.current.messages).not.toEqual(messages) + }) + + it('should return new client messages during the id change render', () => { + const adapter = createMockConnectionAdapter() + const oldMessages: Array = [ + { + id: 'old-message', + role: 'user', + parts: [{ type: 'text', content: 'Old client message' }], + createdAt: new Date(), + }, + ] + + const { result } = renderHook(() => { + const [threadId, setId] = useState('client-A') + const chat = useChat({ connection: adapter, threadId }) + return { ...chat, switchId: setId } + }) + + act(() => { + result.current.setMessages(oldMessages) + }) + + expect(result.current.messages).toEqual(oldMessages) + + act(() => { + result.current.switchId('client-B') + }) + + expect(result.current.messages).toEqual([]) + }) + }) + + describe('unmount behavior', () => { + it('should not update state after unmount', async () => { + const chunks = createTextChunks('Response') + const adapter = createMockConnectionAdapter({ + chunks, + chunkDelay: 100, + }) + const { result, unmount } = renderUseChat({ connection: adapter }) + + const sendPromise = result.current.sendMessage('Test') + + // Unmount before completion + unmount() + + await sendPromise.catch(() => { + // Ignore errors + }) + + // State updates after unmount should be ignored (React handles this) + // This test documents the expected behavior + expect(result.current).toBeDefined() + }) + + it('should stop loading on unmount if active', async () => { + const chunks = createTextChunks('Response') + const adapter = createMockConnectionAdapter({ + chunks, + chunkDelay: 100, + }) + const { result, unmount } = renderUseChat({ connection: adapter }) + + result.current.sendMessage('Test') + + await waitFor(() => { + expect(result.current.isLoading).toBe(true) + }) + + unmount() + + // After unmount, React will clean up + // The actual cleanup is handled by React's lifecycle + expect(result.current.isLoading).toBe(true) // Still true in test, but component is unmounted + }) + }) + + describe('concurrent operations', () => { + it('should queue and then deliver multiple sendMessage calls in order', async () => { + const adapter = createMockConnectionAdapter({ + chunks: createTextChunks('Response'), + chunkDelay: 50, + }) + const { result } = renderUseChat({ connection: adapter }) + + const promise1 = result.current.sendMessage('First') + const promise2 = result.current.sendMessage('Second') + + await Promise.all([promise1, promise2]) + + // The second call is queued while the first stream is in flight, + // then auto-sent once it settles — both land, in order. + const userMessages = result.current.messages.filter( + (m) => m.role === 'user', + ) + expect(userMessages.map((m) => m.parts[0])).toEqual([ + { type: 'text', content: 'First' }, + { type: 'text', content: 'Second' }, + ]) + }) + + it('should handle stop during sendMessage', async () => { + const chunks = createTextChunks('Long response') + const adapter = createMockConnectionAdapter({ + chunks, + chunkDelay: 50, + }) + const { result } = renderUseChat({ connection: adapter }) + + const sendPromise = result.current.sendMessage('Test') + + await waitFor(() => { + expect(result.current.isLoading).toBe(true) + }) + + result.current.stop() + + await waitFor( + () => { + expect(result.current.isLoading).toBe(false) + }, + { timeout: 1000 }, + ) + + await sendPromise.catch(() => { + // Ignore errors from stopped request + }) + }) + + it('should handle reload during active stream', async () => { + const chunks = createTextChunks('Response') + const adapter = createMockConnectionAdapter({ + chunks, + chunkDelay: 50, + }) + const { result } = renderUseChat({ connection: adapter }) + + const sendPromise = result.current.sendMessage('Test') + + await waitFor(() => { + expect(result.current.isLoading).toBe(true) + }) + + // Try to reload while sending + const reloadPromise = result.current.reload() + + await Promise.allSettled([sendPromise, reloadPromise]) + + // Should eventually complete + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + }) + }) + + describe('error scenarios', () => { + it('should handle network errors', async () => { + const error = new Error('Network request failed') + const adapter = createMockConnectionAdapter({ + shouldError: true, + error, + }) + const { result } = renderUseChat({ connection: adapter }) + + await result.current.sendMessage('Test') + + await waitFor(() => { + expect(result.current.error).toBeDefined() + }) + + expect(result.current.error?.message).toBe('Network request failed') + expect(result.current.isLoading).toBe(false) + }) + + it('should handle stream errors', async () => { + const error = new Error('Stream error') + const adapter = createMockConnectionAdapter({ + shouldError: true, + error, + }) + const { result } = renderUseChat({ connection: adapter }) + + await result.current.sendMessage('Test') + + await waitFor(() => { + expect(result.current.error).toBeDefined() + }) + + expect(result.current.error?.message).toBe('Stream error') + }) + + it('should clear error on successful operation', async () => { + const errorAdapter = createMockConnectionAdapter({ + shouldError: true, + error: new Error('Initial error'), + }) + const { result, rerender } = renderUseChat({ + connection: errorAdapter, + }) + + await result.current.sendMessage('Test') + + await waitFor(() => { + expect(result.current.error).toBeDefined() + }) + + // Switch to working adapter + const workingAdapter = createMockConnectionAdapter({ + chunks: createTextChunks('Success'), + }) + rerender({ connection: workingAdapter }) + + await result.current.sendMessage('Test') + + await waitFor(() => { + // Error should be cleared on success + expect(result.current.messages.length).toBeGreaterThan(0) + }) + }) + + // NOTE: A previous "should handle tool execution errors" test was deleted + // because it passed `onToolCall` as a useChat option. `onToolCall` is NOT + // part of the public `UseChatOptions` / `ChatClientOptions` surface — it + // is installed internally on the StreamProcessor by ChatClient (see + // packages/ai-client/src/chat-client.ts) and routes through + // the `tools` array (`AnyClientTool.execute`). The same is true of the + // deleted "should handle addToolResult" test below. Tool-execution error + // behavior is exercised in ai-client's own tests against the real + // public surface (`tools: [...]`). + }) + + describe('multiple hook instances', () => { + it('should maintain independent state per instance', async () => { + const adapter1 = createMockConnectionAdapter({ + chunks: createTextChunks('Response 1'), + }) + const adapter2 = createMockConnectionAdapter({ + chunks: createTextChunks('Response 2'), + }) + + const { result: result1 } = renderUseChat({ + connection: adapter1, + threadId: 'chat-1', + }) + const { result: result2 } = renderUseChat({ + connection: adapter2, + threadId: 'chat-2', + }) + + await result1.current.sendMessage('Hello 1') + await result2.current.sendMessage('Hello 2') + + await waitFor(() => { + expect(result1.current.messages.length).toBeGreaterThan(0) + expect(result2.current.messages.length).toBeGreaterThan(0) + }) + + // Each instance should have its own messages + expect(result1.current.messages.length).toBe( + result2.current.messages.length, + ) + expect(result1.current.messages[0]!.parts[0]).not.toEqual( + result2.current.messages[0]!.parts[0], + ) + }) + + it('should handle different IDs correctly', () => { + const adapter = createMockConnectionAdapter() + const { result: result1 } = renderUseChat({ + connection: adapter, + threadId: 'chat-1', + }) + const { result: result2 } = renderUseChat({ + connection: adapter, + threadId: 'chat-2', + }) + + // Should not interfere with each other + expect(result1.current.messages).toEqual([]) + expect(result2.current.messages).toEqual([]) + }) + + it('should not have cross-contamination', async () => { + const adapter1 = createMockConnectionAdapter({ + chunks: createTextChunks('One'), + }) + const adapter2 = createMockConnectionAdapter({ + chunks: createTextChunks('Two'), + }) + + const { result: result1 } = renderUseChat({ + connection: adapter1, + }) + const { result: result2 } = renderUseChat({ + connection: adapter2, + }) + + await result1.current.sendMessage('Message 1') + await waitFor(() => { + expect(result1.current.messages.length).toBeGreaterThan(0) + }) + + // Second instance should still be empty + expect(result2.current.messages.length).toBe(0) + + await result2.current.sendMessage('Message 2') + await waitFor(() => { + expect(result2.current.messages.length).toBeGreaterThan(0) + }) + + // Both should have messages, but different ones + expect(result1.current.messages.length).toBeGreaterThan(0) + expect(result2.current.messages.length).toBeGreaterThan(0) + expect(result1.current.messages[0]!.parts[0]).not.toEqual( + result2.current.messages[0]!.parts[0], + ) + }) + }) + + describe('tool operations', () => { + it('should handle addToolResult', async () => { + const toolCalls = createToolCallChunks([ + { id: 'tool-1', name: 'testTool', arguments: '{"param": "value"}' }, + ]) + const adapter = createMockConnectionAdapter({ chunks: toolCalls }) + // `onToolCall` is intentionally NOT passed here: it is not part of the + // public `UseChatOptions` surface (it's wired internally by ChatClient + // through the `tools` array). The previous version of this test set it + // via `as any` and then never exercised its result — `addToolResult` + // itself is the public API under test. + const { result } = renderUseChat({ + connection: adapter, + }) + + await result.current.sendMessage('Test') + + await waitFor(() => { + const assistantMessage = result.current.messages.find( + (m) => m.role === 'assistant', + ) + expect(assistantMessage).toBeDefined() + }) + + // Find tool call + const assistantMessage = result.current.messages.find( + (m) => m.role === 'assistant', + ) + const toolCallPart = assistantMessage?.parts.find( + (p) => p.type === 'tool-call', + ) + expect(toolCallPart?.input).toEqual({ param: 'value' }) + + if (toolCallPart && toolCallPart.type === 'tool-call') { + await result.current.addToolResult({ + toolCallId: toolCallPart.id, + tool: toolCallPart.name, + output: { result: 'manual' }, + }) + + // Should update the tool call + await waitFor(() => { + const updatedMessage = result.current.messages.find( + (m) => m.role === 'assistant', + ) + const updatedToolCall = updatedMessage?.parts.find( + (p) => p.type === 'tool-call' && p.id === toolCallPart.id, + ) + expect(updatedToolCall).toBeDefined() + }) + } + }) + + it('should handle addToolApprovalResponse', async () => { + const toolCalls = createToolCallChunks([ + { id: 'tool-1', name: 'testTool', arguments: '{"param": "value"}' }, + ]) + const adapter = createMockConnectionAdapter({ chunks: toolCalls }) + const { result } = renderUseChat({ + connection: adapter, + }) + + await result.current.sendMessage('Test') + + await waitFor(() => { + const assistantMessage = result.current.messages.find( + (m) => m.role === 'assistant', + ) + expect(assistantMessage).toBeDefined() + }) + + // Find tool call with approval + const assistantMessage = result.current.messages.find( + (m) => m.role === 'assistant', + ) + const toolCallPart = assistantMessage?.parts.find( + (p) => p.type === 'tool-call' && p.approval, + ) + + if ( + toolCallPart && + toolCallPart.type === 'tool-call' && + toolCallPart.approval + ) { + await result.current.addToolApprovalResponse({ + id: toolCallPart.approval.id, + approved: true, + }) + + // Should update approval state + await waitFor(() => { + const updatedMessage = result.current.messages.find( + (m) => m.role === 'assistant', + ) + const updatedToolCall = updatedMessage?.parts.find( + (p) => p.type === 'tool-call' && p.id === toolCallPart.id, + ) + if (updatedToolCall && updatedToolCall.type === 'tool-call') { + expect(updatedToolCall.approval?.approved).toBe(true) + } + }) + } + }) + }) + }) + + describe('multimodal sendMessage', () => { + it('should send a multimodal message with image URL', async () => { + const chunks = createTextChunks('I see a cat in the image') + const adapter = createMockConnectionAdapter({ chunks }) + const { result } = renderUseChat({ connection: adapter }) + + await result.current.sendMessage({ + content: [ + { type: 'text', content: 'What is in this image?' }, + { + type: 'image', + source: { type: 'url', value: 'https://example.com/cat.jpg' }, + }, + ], + }) + + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThan(0) + }) + + const userMessage = result.current.messages.find((m) => m.role === 'user') + expect(userMessage).toBeDefined() + expect(userMessage?.parts.length).toBe(2) + expect(userMessage?.parts[0]).toEqual({ + type: 'text', + content: 'What is in this image?', + }) + expect(userMessage?.parts[1]).toEqual({ + type: 'image', + source: { type: 'url', value: 'https://example.com/cat.jpg' }, + }) + }) + + it('should send a multimodal message with image data and required mimeType', async () => { + const chunks = createTextChunks('I see a cat in the image') + const adapter = createMockConnectionAdapter({ chunks }) + const { result } = renderUseChat({ connection: adapter }) + + await result.current.sendMessage({ + content: [ + { type: 'text', content: 'What is in this image?' }, + { + type: 'image', + source: { + type: 'data', + value: 'base64ImageData', + mimeType: 'image/png', + }, + }, + ], + }) + + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThan(0) + }) + + const userMessage = result.current.messages.find((m) => m.role === 'user') + expect(userMessage?.parts[1]).toEqual({ + type: 'image', + source: { + type: 'data', + value: 'base64ImageData', + mimeType: 'image/png', + }, + }) + }) + + it('should send a multimodal message with audio data and required mimeType', async () => { + const chunks = createTextChunks('The audio says hello') + const adapter = createMockConnectionAdapter({ chunks }) + const { result } = renderUseChat({ connection: adapter }) + + await result.current.sendMessage({ + content: [ + { type: 'text', content: 'Transcribe this audio' }, + { + type: 'audio', + source: { + type: 'data', + value: 'base64AudioData', + mimeType: 'audio/mp3', + }, + }, + ], + }) + + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThan(0) + }) + + const userMessage = result.current.messages.find((m) => m.role === 'user') + expect(userMessage?.parts[1]).toEqual({ + type: 'audio', + source: { + type: 'data', + value: 'base64AudioData', + mimeType: 'audio/mp3', + }, + }) + }) + + it('should send a multimodal message with video URL', async () => { + const chunks = createTextChunks('The video shows a sunset') + const adapter = createMockConnectionAdapter({ chunks }) + const { result } = renderUseChat({ connection: adapter }) + + await result.current.sendMessage({ + content: [ + { type: 'text', content: 'Describe this video' }, + { + type: 'video', + source: { type: 'url', value: 'https://example.com/video.mp4' }, + }, + ], + }) + + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThan(0) + }) + + const userMessage = result.current.messages.find((m) => m.role === 'user') + expect(userMessage?.parts[1]).toEqual({ + type: 'video', + source: { type: 'url', value: 'https://example.com/video.mp4' }, + }) + }) + + it('should send a multimodal message with video data and required mimeType', async () => { + const chunks = createTextChunks('The video shows a sunset') + const adapter = createMockConnectionAdapter({ chunks }) + const { result } = renderUseChat({ connection: adapter }) + + await result.current.sendMessage({ + content: [ + { type: 'text', content: 'Describe this video' }, + { + type: 'video', + source: { + type: 'data', + value: 'base64VideoData', + mimeType: 'video/mp4', + }, + }, + ], + }) + + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThan(0) + }) + + const userMessage = result.current.messages.find((m) => m.role === 'user') + expect(userMessage?.parts[1]).toEqual({ + type: 'video', + source: { + type: 'data', + value: 'base64VideoData', + mimeType: 'video/mp4', + }, + }) + }) + + it('should send a multimodal message with document data and required mimeType', async () => { + const chunks = createTextChunks('The document discusses AI') + const adapter = createMockConnectionAdapter({ chunks }) + const { result } = renderUseChat({ connection: adapter }) + + await result.current.sendMessage({ + content: [ + { type: 'text', content: 'Summarize this PDF' }, + { + type: 'document', + source: { + type: 'data', + value: 'base64PdfData', + mimeType: 'application/pdf', + }, + }, + ], + }) + + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThan(0) + }) + + const userMessage = result.current.messages.find((m) => m.role === 'user') + expect(userMessage?.parts[1]).toEqual({ + type: 'document', + source: { + type: 'data', + value: 'base64PdfData', + mimeType: 'application/pdf', + }, + }) + }) + + it('should send a multimodal message with document URL', async () => { + const chunks = createTextChunks('The document discusses AI') + const adapter = createMockConnectionAdapter({ chunks }) + const { result } = renderUseChat({ connection: adapter }) + + await result.current.sendMessage({ + content: [ + { type: 'text', content: 'Summarize this document' }, + { + type: 'document', + source: { + type: 'url', + value: 'https://example.com/doc.pdf', + }, + }, + ], + }) + + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThan(0) + }) + + const userMessage = result.current.messages.find((m) => m.role === 'user') + expect(userMessage?.parts[1]).toEqual({ + type: 'document', + source: { + type: 'url', + value: 'https://example.com/doc.pdf', + }, + }) + }) + + it('should send complex multimodal message with multiple content parts', async () => { + const chunks = createTextChunks('I see multiple items') + const adapter = createMockConnectionAdapter({ chunks }) + const { result } = renderUseChat({ connection: adapter }) + + await result.current.sendMessage({ + content: [ + { type: 'text', content: 'Compare these items' }, + { + type: 'image', + source: { + type: 'data', + value: 'base64Image1', + mimeType: 'image/jpeg', + }, + }, + { + type: 'image', + source: { + type: 'url', + value: 'https://example.com/image2.png', + }, + }, + { type: 'text', content: 'Which one is better?' }, + ], + }) + + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThan(0) + }) + + const userMessage = result.current.messages.find((m) => m.role === 'user') + expect(userMessage?.parts.length).toBe(4) + expect(userMessage?.parts[0]).toEqual({ + type: 'text', + content: 'Compare these items', + }) + expect(userMessage?.parts[1]).toEqual({ + type: 'image', + source: { + type: 'data', + value: 'base64Image1', + mimeType: 'image/jpeg', + }, + }) + expect(userMessage?.parts[2]).toEqual({ + type: 'image', + source: { + type: 'url', + value: 'https://example.com/image2.png', + }, + }) + expect(userMessage?.parts[3]).toEqual({ + type: 'text', + content: 'Which one is better?', + }) + }) + + it('should use custom message id when provided in multimodal message', async () => { + const chunks = createTextChunks('Response') + const adapter = createMockConnectionAdapter({ chunks }) + const { result } = renderUseChat({ connection: adapter }) + + await result.current.sendMessage({ + content: [{ type: 'text', content: 'Hello' }], + id: 'custom-multimodal-id-123', + }) + + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThan(0) + }) + + expect(result.current.messages[0]?.id).toBe('custom-multimodal-id-123') + }) + + it('should send string content as simple text message via multimodal interface', async () => { + const chunks = createTextChunks('Response') + const adapter = createMockConnectionAdapter({ chunks }) + const { result } = renderUseChat({ connection: adapter }) + + await result.current.sendMessage({ + content: 'Hello world', + }) + + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThan(0) + }) + + const userMessage = result.current.messages.find((m) => m.role === 'user') + expect(userMessage?.parts).toEqual([ + { type: 'text', content: 'Hello world' }, + ]) + }) + }) + + describe('sessionGenerating', () => { + it('should expose sessionGenerating and update from stream run events', async () => { + // Build chunks as a typed StreamChunk array so each yielded event is + // checked against the AGUI discriminated union — no inline `as any`. + const chunks: Array = [ + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + }, + { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: 'msg-1', + timestamp: Date.now(), + delta: 'Hi', + }, + { + type: EventType.RUN_FINISHED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + }, + ] + const adapter: SubscribeConnectionAdapter = { + subscribe: async function* (_signal?: AbortSignal) { + for (const chunk of chunks) yield chunk + }, + send: vi.fn(async () => {}), + } + + const { result } = renderUseChat({ connection: adapter, live: true }) + + await waitFor(() => { + expect(result.current.isSubscribed).toBe(true) + }) + + await result.current.sendMessage('Hello') + + await waitFor(() => { + expect(result.current.sessionGenerating).toBe(false) + }) + }) + + it('should integrate correctly with live subscription lifecycle', async () => { + const chunks: Array = [ + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + }, + { + type: EventType.RUN_FINISHED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + }, + ] + const adapter: SubscribeConnectionAdapter = { + subscribe: async function* () { + for (const chunk of chunks) yield chunk + }, + send: vi.fn(async () => {}), + } + + const { result } = renderUseChat({ connection: adapter, live: true }) + + await waitFor(() => { + expect(result.current.isSubscribed).toBe(true) + }) + + await result.current.sendMessage('Hello') + + await waitFor(() => { + expect(result.current.sessionGenerating).toBe(false) + expect(result.current.isLoading).toBe(false) + }) + }) + + it('should keep receiving live updates and callbacks after live toggles for the same client', async () => { + const onChunk = vi.fn() + const chunks: Array = [] + // Wrapped in an object so the assignment inside the generator closure + // is visible to TS control-flow analysis. A bare `let` would narrow to + // `null` at the call site, and `?.()` would then strip it to `never`. + const subscriberControl: { wake: (() => void) | null } = { wake: null } + const adapter: SubscribeConnectionAdapter = { + subscribe: async function* (_signal?: AbortSignal) { + while (true) { + if (chunks.length === 0) { + await new Promise((resolve) => { + subscriberControl.wake = resolve + }) + } + const chunk = chunks.shift() + if (chunk) yield chunk + } + }, + send: vi.fn(async () => {}), + } + + const { result, rerender } = renderHook( + ({ live }) => + useChat({ + connection: adapter, + live, + onChunk, + }), + { initialProps: { live: true } }, + ) + + await waitFor(() => { + expect(result.current.isSubscribed).toBe(true) + }) + + rerender({ live: false }) + await waitFor(() => { + expect(result.current.isSubscribed).toBe(false) + }) + + rerender({ live: true }) + await waitFor(() => { + expect(result.current.isSubscribed).toBe(true) + }) + + chunks.push( + { + type: EventType.RUN_STARTED, + runId: 'run-after-toggle', + threadId: 'thread-1', + timestamp: Date.now(), + }, + { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: 'msg-after-toggle', + timestamp: Date.now(), + delta: 'after toggle', + content: 'after toggle', + }, + { + type: EventType.RUN_FINISHED, + runId: 'run-after-toggle', + threadId: 'thread-1', + timestamp: Date.now(), + }, + ) + subscriberControl.wake?.() + subscriberControl.wake = null + + await waitFor(() => { + expect( + result.current.messages.some((message) => + message.parts.some( + (part) => part.type === 'text' && part.content === 'after toggle', + ), + ), + ).toBe(true) + }) + expect(onChunk).toHaveBeenCalledWith( + expect.objectContaining({ type: EventType.TEXT_MESSAGE_CONTENT }), + ) + }) + }) +}) diff --git a/packages/ai-octane/tests/conformance/use-generation.test.ts b/packages/ai-octane/tests/conformance/use-generation.test.ts new file mode 100644 index 0000000000..40cb901b80 --- /dev/null +++ b/packages/ai-octane/tests/conformance/use-generation.test.ts @@ -0,0 +1,824 @@ +import { renderHook, waitFor, act } from '@octanejs/testing-library' +import { describe, expect, expectTypeOf, it, vi } from 'vitest' +import { useGeneration } from '../../src/use-generation.tsrx' +import { useGenerateImage } from '../../src/use-generate-image.tsrx' +import { useGenerateAudio } from '../../src/use-generate-audio.tsrx' +import { useGenerateSpeech } from '../../src/use-generate-speech.tsrx' +import { useTranscription } from '../../src/use-transcription.tsrx' +import { useSummarize } from '../../src/use-summarize.tsrx' +import { useGenerateVideo } from '../../src/use-generate-video.tsrx' +import { createMockConnectionAdapter } from './test-utils' +import type { StreamChunk, TTSResult, TranscriptionResult } from '@tanstack/ai' +import { EventType } from '@tanstack/ai' + +// Helper to create generation stream chunks +function createGenerationChunks(result: unknown): Array { + return [ + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + }, + { + type: EventType.CUSTOM, + name: 'generation:result', + value: result, + timestamp: Date.now(), + }, + { + type: EventType.RUN_FINISHED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + }, + ] +} + +// Helper to create video generation stream chunks +function createVideoChunks(jobId: string, url: string): Array { + return [ + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + }, + { + type: EventType.CUSTOM, + name: 'video:job:created', + value: { jobId }, + timestamp: Date.now(), + }, + { + type: EventType.CUSTOM, + name: 'video:status', + value: { jobId, status: 'processing', progress: 50 }, + timestamp: Date.now(), + }, + { + type: EventType.CUSTOM, + name: 'generation:result', + value: { jobId, status: 'completed', url }, + timestamp: Date.now(), + }, + { + type: EventType.RUN_FINISHED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + }, + ] +} + +// Helper to create error stream chunks. +// NOTE: The AG-UI spec for RUN_ERROR carries `message` directly on the event +// (not nested under `error`). We emit BOTH shapes here because GenerationClient +// supports the legacy `chunk.error.message` fallback (see generation-client.ts: +// `chunk.message ?? chunk.error?.message`). Once that fallback is removed, the +// `error` field can drop. +function createErrorChunks(message: string): Array { + return [ + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + }, + { + type: EventType.RUN_ERROR, + message, + // Legacy shape preserved for the fallback branch in generation-client.ts. + // AGUIEventSchema is `passthrough` so unknown keys are allowed at runtime; + // the strict TS union still requires a cast on this single chunk. + error: { message }, + } as StreamChunk, + ] +} + +describe('useGeneration', () => { + describe('initialization', () => { + it('should initialize with default state', () => { + const adapter = createMockConnectionAdapter() + const { result } = renderHook(() => + useGeneration({ connection: adapter }), + ) + + expect(result.current.result).toBeNull() + expect(result.current.isLoading).toBe(false) + expect(result.current.error).toBeUndefined() + expect(result.current.status).toBe('idle') + }) + }) + + describe('fetcher mode', () => { + it('should generate a result using fetcher', async () => { + const mockResult = { id: '1', data: 'test' } + const onResult = vi.fn() + + const { result } = renderHook(() => + useGeneration({ + fetcher: async () => mockResult, + onResult, + }), + ) + + await act(async () => { + await result.current.generate({ prompt: 'test' }) + }) + + expect(result.current.result).toEqual(mockResult) + expect(result.current.status).toBe('success') + expect(result.current.isLoading).toBe(false) + expect(onResult).toHaveBeenCalledWith(mockResult) + }) + + it('should handle fetcher errors', async () => { + const onError = vi.fn() + + const { result } = renderHook(() => + useGeneration({ + fetcher: async () => { + throw new Error('fetch failed') + }, + onError, + }), + ) + + await act(async () => { + await result.current.generate({ prompt: 'test' }) + }) + + expect(result.current.status).toBe('error') + expect(result.current.error?.message).toBe('fetch failed') + expect(onError).toHaveBeenCalledWith(expect.any(Error)) + }) + }) + + describe('connection mode', () => { + it('should process stream and extract result', async () => { + const mockResult = { + id: '1', + images: [{ url: 'http://example.com/img.png' }], + } + const chunks = createGenerationChunks(mockResult) + const adapter = createMockConnectionAdapter({ chunks }) + + const { result } = renderHook(() => + useGeneration({ connection: adapter }), + ) + + await act(async () => { + await result.current.generate({ prompt: 'test' }) + }) + + expect(result.current.result).toEqual(mockResult) + expect(result.current.status).toBe('success') + }) + + it('should handle stream errors', async () => { + const chunks = createErrorChunks('Generation failed') + const adapter = createMockConnectionAdapter({ chunks }) + + const { result } = renderHook(() => + useGeneration({ connection: adapter }), + ) + + await act(async () => { + await result.current.generate({ prompt: 'test' }) + }) + + expect(result.current.status).toBe('error') + expect(result.current.error?.message).toBe('Generation failed') + }) + }) + + describe('stop and reset', () => { + it('should stop generation and return to idle', async () => { + let resolvePromise: (value: any) => void + + const { result } = renderHook(() => + useGeneration({ + fetcher: async () => + new Promise((resolve) => { + resolvePromise = resolve + }), + }), + ) + + act(() => { + result.current.generate({ prompt: 'test' }) + }) + + await waitFor(() => { + expect(result.current.isLoading).toBe(true) + }) + + act(() => { + result.current.stop() + }) + + expect(result.current.isLoading).toBe(false) + expect(result.current.status).toBe('idle') + + resolvePromise!({ id: '1' }) + }) + + it('should reset all state', async () => { + const { result } = renderHook(() => + useGeneration({ + fetcher: async () => ({ id: '1' }), + }), + ) + + await act(async () => { + await result.current.generate({ prompt: 'test' }) + }) + + expect(result.current.result).toEqual({ id: '1' }) + + act(() => { + result.current.reset() + }) + + expect(result.current.result).toBeNull() + expect(result.current.error).toBeUndefined() + expect(result.current.status).toBe('idle') + }) + }) + + describe('cleanup', () => { + it('should call stop on unmount during active generation', async () => { + let resolvePromise: (value: any) => void + + const { result, unmount } = renderHook(() => + useGeneration({ + fetcher: async () => { + return new Promise((resolve) => { + resolvePromise = resolve + }) + }, + }), + ) + + // Start generation (don't await — it's in-flight) + act(() => { + result.current.generate({ prompt: 'test' }) + }) + + await waitFor(() => { + expect(result.current.isLoading).toBe(true) + }) + + // Unmount should trigger cleanup (calls client.stop()) + unmount() + + // Resolve the promise after unmount — should not cause errors + resolvePromise!({ id: '1' }) + }) + }) +}) + +describe('useGenerateImage', () => { + it('should initialize with default state', () => { + const adapter = createMockConnectionAdapter() + const { result } = renderHook(() => + useGenerateImage({ connection: adapter }), + ) + + expect(result.current.result).toBeNull() + expect(result.current.isLoading).toBe(false) + expect(result.current.status).toBe('idle') + }) + + it('should generate images using fetcher', async () => { + const mockResult = { + id: 'img-1', + images: [{ url: 'http://example.com/img.png' }], + model: 'dall-e-3', + } + + const { result } = renderHook(() => + useGenerateImage({ + fetcher: async () => mockResult, + }), + ) + + await act(async () => { + await result.current.generate({ prompt: 'A sunset' }) + }) + + expect(result.current.result).toEqual(mockResult) + expect(result.current.status).toBe('success') + }) + + it('should generate images using connection', async () => { + const mockResult = { + images: [{ url: 'http://example.com/img.png' }], + model: 'dall-e-3', + } + const chunks = createGenerationChunks(mockResult) + const adapter = createMockConnectionAdapter({ chunks }) + + const { result } = renderHook(() => + useGenerateImage({ connection: adapter }), + ) + + await act(async () => { + await result.current.generate({ prompt: 'A sunset' }) + }) + + expect(result.current.result).toEqual(mockResult) + expect(result.current.status).toBe('success') + }) + + it('should handle errors', async () => { + const onError = vi.fn() + + const { result } = renderHook(() => + useGenerateImage({ + fetcher: async () => { + throw new Error('Image generation failed') + }, + onError, + }), + ) + + await act(async () => { + await result.current.generate({ prompt: 'test' }) + }) + + expect(result.current.status).toBe('error') + expect(result.current.error?.message).toBe('Image generation failed') + expect(onError).toHaveBeenCalled() + }) + + it('should expose stop and reset', async () => { + const adapter = createMockConnectionAdapter() + const { result } = renderHook(() => + useGenerateImage({ connection: adapter }), + ) + + expect(typeof result.current.stop).toBe('function') + expect(typeof result.current.reset).toBe('function') + }) +}) + +describe('useGenerateSpeech', () => { + it('should initialize with default state', () => { + const adapter = createMockConnectionAdapter() + const { result } = renderHook(() => + useGenerateSpeech({ connection: adapter }), + ) + + expect(result.current.result).toBeNull() + expect(result.current.isLoading).toBe(false) + expect(result.current.status).toBe('idle') + }) + + it('should generate speech using fetcher', async () => { + const mockResult = { + id: 'tts-1', + audio: 'base64data', + format: 'mp3' as const, + model: 'tts-1', + } + + const { result } = renderHook(() => + useGenerateSpeech({ + fetcher: async () => mockResult, + }), + ) + + await act(async () => { + await result.current.generate({ text: 'Hello world' }) + }) + + expect(result.current.result).toEqual(mockResult) + expect(result.current.status).toBe('success') + }) + + it('should generate speech using connection', async () => { + const mockResult = { audio: 'base64data', format: 'mp3', model: 'tts-1' } + const chunks = createGenerationChunks(mockResult) + const adapter = createMockConnectionAdapter({ chunks }) + + const { result } = renderHook(() => + useGenerateSpeech({ connection: adapter }), + ) + + await act(async () => { + await result.current.generate({ text: 'Hello world' }) + }) + + expect(result.current.result).toEqual(mockResult) + expect(result.current.status).toBe('success') + }) +}) + +describe('useGenerateAudio', () => { + const mockAudioResult = { + id: 'audio-1', + model: 'fal-ai/diffrhythm', + audio: { + url: 'https://example.com/a.mp3', + contentType: 'audio/mpeg', + duration: 10, + }, + } + + it('should initialize with default state', () => { + const adapter = createMockConnectionAdapter() + const { result } = renderHook(() => + useGenerateAudio({ connection: adapter }), + ) + + expect(result.current.result).toBeNull() + expect(result.current.isLoading).toBe(false) + expect(result.current.status).toBe('idle') + }) + + it('should generate audio using fetcher', async () => { + const { result } = renderHook(() => + useGenerateAudio({ + fetcher: async () => mockAudioResult, + }), + ) + + await act(async () => { + await result.current.generate({ prompt: 'Upbeat synths', duration: 10 }) + }) + + expect(result.current.result).toEqual(mockAudioResult) + expect(result.current.status).toBe('success') + }) + + it('should generate audio using connection', async () => { + const chunks = createGenerationChunks(mockAudioResult) + const adapter = createMockConnectionAdapter({ chunks }) + + const { result } = renderHook(() => + useGenerateAudio({ connection: adapter }), + ) + + await act(async () => { + await result.current.generate({ prompt: 'Upbeat synths', duration: 10 }) + }) + + expect(result.current.result).toEqual(mockAudioResult) + expect(result.current.status).toBe('success') + }) +}) + +describe('useTranscription', () => { + it('should initialize with default state', () => { + const adapter = createMockConnectionAdapter() + const { result } = renderHook(() => + useTranscription({ connection: adapter }), + ) + + expect(result.current.result).toBeNull() + expect(result.current.isLoading).toBe(false) + expect(result.current.status).toBe('idle') + }) + + it('should transcribe audio using fetcher', async () => { + const mockResult = { + id: 'trans-1', + text: 'Hello world', + model: 'whisper-1', + } + + const { result } = renderHook(() => + useTranscription({ + fetcher: async () => mockResult, + }), + ) + + await act(async () => { + await result.current.generate({ audio: 'base64audio' }) + }) + + expect(result.current.result).toEqual(mockResult) + expect(result.current.status).toBe('success') + }) + + it('should transcribe audio using connection', async () => { + const mockResult = { text: 'Hello world', model: 'whisper-1' } + const chunks = createGenerationChunks(mockResult) + const adapter = createMockConnectionAdapter({ chunks }) + + const { result } = renderHook(() => + useTranscription({ connection: adapter }), + ) + + await act(async () => { + await result.current.generate({ audio: 'base64audio' }) + }) + + expect(result.current.result).toEqual(mockResult) + expect(result.current.status).toBe('success') + }) +}) + +describe('useSummarize', () => { + it('should initialize with default state', () => { + const adapter = createMockConnectionAdapter() + const { result } = renderHook(() => useSummarize({ connection: adapter })) + + expect(result.current.result).toBeNull() + expect(result.current.isLoading).toBe(false) + expect(result.current.status).toBe('idle') + }) + + it('should summarize text using fetcher', async () => { + const mockResult = { + id: 'sum-1', + summary: 'A brief summary', + model: 'gpt-4', + usage: { promptTokens: 100, completionTokens: 20, totalTokens: 120 }, + } + + const { result } = renderHook(() => + useSummarize({ + fetcher: async () => mockResult, + }), + ) + + await act(async () => { + await result.current.generate({ text: 'Long text to summarize...' }) + }) + + expect(result.current.result).toEqual(mockResult) + expect(result.current.status).toBe('success') + }) + + it('should summarize text using connection', async () => { + const mockResult = { summary: 'A brief summary', model: 'gpt-4' } + const chunks = createGenerationChunks(mockResult) + const adapter = createMockConnectionAdapter({ chunks }) + + const { result } = renderHook(() => useSummarize({ connection: adapter })) + + await act(async () => { + await result.current.generate({ text: 'Long text to summarize...' }) + }) + + expect(result.current.result).toEqual(mockResult) + expect(result.current.status).toBe('success') + }) +}) + +describe('useGenerateVideo', () => { + it('should initialize with default state', () => { + const adapter = createMockConnectionAdapter() + const { result } = renderHook(() => + useGenerateVideo({ connection: adapter }), + ) + + expect(result.current.result).toBeNull() + expect(result.current.jobId).toBeNull() + expect(result.current.videoStatus).toBeNull() + expect(result.current.isLoading).toBe(false) + expect(result.current.status).toBe('idle') + }) + + it('should generate video using fetcher', async () => { + const mockResult = { + jobId: 'job-1', + status: 'completed' as const, + url: 'https://example.com/video.mp4', + } + + const { result } = renderHook(() => + useGenerateVideo({ + fetcher: async () => mockResult, + }), + ) + + await act(async () => { + await result.current.generate({ prompt: 'A flying car' }) + }) + + expect(result.current.result).toEqual(mockResult) + expect(result.current.status).toBe('success') + }) + + it('should track video job lifecycle via connection', async () => { + const chunks = createVideoChunks('job-123', 'https://example.com/video.mp4') + const adapter = createMockConnectionAdapter({ chunks }) + const onJobCreated = vi.fn() + const onStatusUpdate = vi.fn() + + const { result } = renderHook(() => + useGenerateVideo({ + connection: adapter, + onJobCreated, + onStatusUpdate, + }), + ) + + await act(async () => { + await result.current.generate({ prompt: 'A flying car' }) + }) + + expect(result.current.result).toEqual( + expect.objectContaining({ + jobId: 'job-123', + url: 'https://example.com/video.mp4', + }), + ) + expect(result.current.jobId).toBe('job-123') + expect(result.current.status).toBe('success') + expect(onJobCreated).toHaveBeenCalledWith('job-123') + expect(onStatusUpdate).toHaveBeenCalled() + }) + + it('should handle video generation errors', async () => { + const chunks = createErrorChunks('Video generation failed') + const adapter = createMockConnectionAdapter({ chunks }) + const onError = vi.fn() + + const { result } = renderHook(() => + useGenerateVideo({ + connection: adapter, + onError, + }), + ) + + await act(async () => { + await result.current.generate({ prompt: 'test' }) + }) + + expect(result.current.status).toBe('error') + expect(result.current.error?.message).toBe('Video generation failed') + expect(onError).toHaveBeenCalled() + }) + + it('should stop and reset', async () => { + const { result } = renderHook(() => + useGenerateVideo({ + fetcher: async () => ({ + jobId: 'job-1', + status: 'completed' as const, + url: 'https://example.com/video.mp4', + }), + }), + ) + + await act(async () => { + await result.current.generate({ prompt: 'test' }) + }) + + expect(result.current.result).not.toBeNull() + + act(() => { + result.current.reset() + }) + + expect(result.current.result).toBeNull() + expect(result.current.jobId).toBeNull() + expect(result.current.videoStatus).toBeNull() + expect(result.current.status).toBe('idle') + }) +}) + +describe('onResult transform', () => { + it('should transform result when onResult returns a value (fetcher)', async () => { + // Inference (issue #848): `onResult`'s parameter is contextually typed from + // the fetcher's return, and `result` narrows to the transform's return — + // no explicit type arguments needed. + const { result } = renderHook(() => + useGeneration({ + fetcher: async () => ({ id: '1', audio: 'base64data' }), + onResult: (raw) => ({ playable: raw.audio.length > 0 }), + }), + ) + expectTypeOf(result.current.result).toEqualTypeOf<{ + playable: boolean + } | null>() + + await act(async () => { + await result.current.generate({ prompt: 'test' }) + }) + + expect(result.current.result).toEqual({ playable: true }) + expect(result.current.status).toBe('success') + }) + + it('should use raw result when onResult returns void', async () => { + const onResult = vi.fn() + + const { result } = renderHook(() => + useGeneration({ + fetcher: async () => ({ id: '1', data: 'test' }), + onResult, + }), + ) + + await act(async () => { + await result.current.generate({ prompt: 'test' }) + }) + + expect(onResult).toHaveBeenCalledWith({ id: '1', data: 'test' }) + expect(result.current.result).toEqual({ id: '1', data: 'test' }) + }) + + it('should keep previous result when onResult returns null', async () => { + const { result } = renderHook(() => + useGeneration({ + fetcher: async () => ({ id: '1' }), + onResult: () => null, + }), + ) + + await act(async () => { + await result.current.generate({ prompt: 'test' }) + }) + + // null return → keep previous (which was null initially) + expect(result.current.result).toBeNull() + expect(result.current.status).toBe('success') + }) + + it('should transform result from connection stream', async () => { + type StreamResult = { id: string; images: Array } + const mockResult: StreamResult = { id: '1', images: ['img1', 'img2'] } + const chunks = createGenerationChunks(mockResult) + const adapter = createMockConnectionAdapter({ chunks }) + + // `connection` is untyped, but annotating the `onResult` parameter gives the + // base hook a site to infer `TResult` from (it appears directly in the + // callback parameter position) — no explicit type arguments needed. + const { result } = renderHook(() => + useGeneration({ + connection: adapter, + onResult: (raw: StreamResult) => ({ count: raw.images.length }), + }), + ) + expectTypeOf(result.current.result).toEqualTypeOf<{ + count: number + } | null>() + + await act(async () => { + await result.current.generate({ prompt: 'test' }) + }) + + expect(result.current.result).toEqual({ count: 2 }) + }) + + it('should work with useGenerateSpeech transform', async () => { + const mockTTSResult: TTSResult = { + id: '1', + model: 'tts-1', + audio: 'base64audio', + format: 'mp3', + contentType: 'audio/mpeg', + } + + // Inference (issue #848): the wrapper hooks infer the output type from + // `onResult` with no explicit type argument. `raw` is contextually typed + // as `TTSResult`. + const { result } = renderHook(() => + useGenerateSpeech({ + fetcher: async () => mockTTSResult, + onResult: (raw) => ({ + audioUrl: `data:${raw.contentType};base64,${raw.audio}`, + }), + }), + ) + expectTypeOf(result.current.result).toEqualTypeOf<{ + audioUrl: string + } | null>() + + await act(async () => { + await result.current.generate({ text: 'Hello' }) + }) + + expect(result.current.result).toEqual({ + audioUrl: 'data:audio/mpeg;base64,base64audio', + }) + }) + + it('infers the raw result type when no onResult is provided', () => { + const { result } = renderHook(() => + useTranscription({ + fetcher: async () => ({ id: '1', text: 'hi', model: 'whisper-1' }), + }), + ) + // Without a transform, `result` stays the raw TranscriptionResult. + expectTypeOf( + result.current.result, + ).toEqualTypeOf() + }) + + it('narrows the wrapper result type to the transform return', () => { + const { result } = renderHook(() => + useTranscription({ + fetcher: async () => ({ id: '1', text: 'hi', model: 'whisper-1' }), + onResult: (res) => res.text, + }), + ) + expectTypeOf(result.current.result).toEqualTypeOf() + }) +}) diff --git a/packages/ai-octane/tests/conformance/use-mcp-app-bridge.test.ts b/packages/ai-octane/tests/conformance/use-mcp-app-bridge.test.ts new file mode 100644 index 0000000000..af5af4166c --- /dev/null +++ b/packages/ai-octane/tests/conformance/use-mcp-app-bridge.test.ts @@ -0,0 +1,92 @@ +// @vitest-environment jsdom +import { act, renderHook } from '@octanejs/testing-library' +import { describe, expect, it, vi } from 'vitest' +import { useMcpAppBridge } from '../../src/use-mcp-app-bridge.tsrx' +import type { UseMcpAppBridgeOptions } from '../../src/use-mcp-app-bridge.tsrx' + +type SendMessage = UseMcpAppBridgeOptions['chat']['sendMessage'] + +function options( + overrides?: Partial, +): UseMcpAppBridgeOptions { + return { + threadId: 't1', + callEndpoint: '/api/mcp-apps-call', + chat: { sendMessage: vi.fn(async () => {}) }, + ...overrides, + } +} + +describe('useMcpAppBridge', () => { + it('returns a bridge exposing callTool, sendPrompt and openLink', () => { + const { result } = renderHook(() => useMcpAppBridge(options())) + expect(typeof result.current.callTool).toBe('function') + expect(typeof result.current.sendPrompt).toBe('function') + expect(typeof result.current.openLink).toBe('function') + }) + + it('returns a STABLE bridge across rerenders with a fresh options object', () => { + const { result, rerender } = renderHook( + (opts: UseMcpAppBridgeOptions) => useMcpAppBridge(opts), + { initialProps: options() }, + ) + const first = result.current + // New options object + new inline sendMessage each render must NOT churn it. + rerender(options()) + expect(result.current).toBe(first) + }) + + it('recreates the bridge when threadId changes', () => { + const { result, rerender } = renderHook( + (opts: UseMcpAppBridgeOptions) => useMcpAppBridge(opts), + { initialProps: options({ threadId: 'a' }) }, + ) + const first = result.current + rerender(options({ threadId: 'b' })) + expect(result.current).not.toBe(first) + }) + + it('invokes the LATEST chat.sendMessage even though the bridge is stable', async () => { + const first = vi.fn(async () => {}) + const second = vi.fn(async () => {}) + const { result, rerender } = renderHook( + (opts: UseMcpAppBridgeOptions) => useMcpAppBridge(opts), + { initialProps: options({ chat: { sendMessage: first } }) }, + ) + const bridge = result.current + rerender(options({ chat: { sendMessage: second } })) + expect(result.current).toBe(bridge) // identity unchanged + + await act(async () => { + await bridge.sendPrompt('hello') + }) + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledTimes(1) + expect(second.mock.calls[0]?.[0]).toBe('hello') + }) + + it('openLink forwards to the LATEST onLink handler', () => { + const first = vi.fn<(url: string) => void>() + const second = vi.fn<(url: string) => void>() + const { result, rerender } = renderHook( + (opts: UseMcpAppBridgeOptions) => useMcpAppBridge(opts), + { initialProps: options({ onLink: first }) }, + ) + rerender(options({ onLink: second })) + + expect(result.current.openLink('https://example.com')).toEqual({ + isError: false, + }) + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledWith('https://example.com') + }) + + it('openLink returns { isError: true } when no onLink is provided (display-only)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { result } = renderHook(() => useMcpAppBridge(options())) + expect(result.current.openLink('https://example.com')).toEqual({ + isError: true, + }) + warn.mockRestore() + }) +}) diff --git a/packages/ai-octane/tests/conformance/use-realtime-chat.test.ts b/packages/ai-octane/tests/conformance/use-realtime-chat.test.ts new file mode 100644 index 0000000000..5e7b9f1ccc --- /dev/null +++ b/packages/ai-octane/tests/conformance/use-realtime-chat.test.ts @@ -0,0 +1,308 @@ +import { act, renderHook } from '@octanejs/testing-library' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + RealtimeEvent, + RealtimeEventPayloads, + RealtimeToken, + UsageInfo, +} from '@tanstack/ai' +import type { RealtimeAdapter, RealtimeConnection } from '@tanstack/ai-client' +import { useRealtimeChat } from '../../src/use-realtime-chat.tsrx' +import type { UseRealtimeChatOptions } from '../../src/realtime-types' + +interface TestConnection { + connection: RealtimeConnection + updateSession: ReturnType> + emit( + event: TEvent, + payload: RealtimeEventPayloads[TEvent], + ): void +} + +function createConnection(): TestConnection { + const updateSession = vi.fn() + const listeners = new Map void>>() + const on: RealtimeConnection['on'] = (event, handler) => { + let eventListeners = listeners.get(event) + if (!eventListeners) { + eventListeners = new Set() + listeners.set(event, eventListeners) + } + const listener = handler as unknown as (payload: unknown) => void + eventListeners.add(listener) + return () => eventListeners.delete(listener) + } + function emit( + event: TEvent, + payload: RealtimeEventPayloads[TEvent], + ): void { + for (const listener of listeners.get(event) ?? []) listener(payload) + } + const connection: RealtimeConnection = { + disconnect: vi.fn(async () => {}), + startAudioCapture: vi.fn(async () => {}), + stopAudioCapture: vi.fn(), + sendText: vi.fn(), + sendImage: vi.fn(), + sendToolResult: vi.fn(), + updateSession, + interrupt: vi.fn(), + on, + getAudioVisualization: () => ({ + inputLevel: 0, + outputLevel: 0, + getInputFrequencyData: () => new Uint8Array(128), + getOutputFrequencyData: () => new Uint8Array(128), + getInputTimeDomainData: () => new Uint8Array(128), + getOutputTimeDomainData: () => new Uint8Array(128), + inputSampleRate: 48_000, + outputSampleRate: 48_000, + }), + } + return { connection, updateSession, emit } +} + +function createAdapter( + provider: string, + connections: Array, +) { + const remaining = [...connections] + const connect = vi.fn(async () => { + const connection = remaining.shift() + if (!connection) throw new Error(`No ${provider} test connection remains`) + return connection + }) + const adapter: RealtimeAdapter = { provider, connect } + return { adapter, connect } +} + +function createToken( + provider: string, + value: string, + expiresAt: number = Date.now() + 3_600_000, +): RealtimeToken { + return { provider, token: value, expiresAt, config: {} } +} + +function createOptions( + adapter: RealtimeAdapter, + getToken: UseRealtimeChatOptions['getToken'], + overrides: Partial = {}, +): UseRealtimeChatOptions { + return { adapter, getToken, autoCapture: false, ...overrides } +} + +beforeEach(() => { + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn(() => 1), + ) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) +}) + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe('useRealtimeChat', () => { + it('uses updated authentication and provider on the next connection', async () => { + const firstConnection = createConnection() + const secondConnection = createConnection() + const firstAdapter = createAdapter('first', [firstConnection.connection]) + const secondAdapter = createAdapter('second', [secondConnection.connection]) + const firstToken = createToken('first', 'first-token') + const secondToken = createToken('second', 'second-token') + const firstGetToken = vi.fn(async () => firstToken) + const secondGetToken = vi.fn(async () => secondToken) + + const { result, rerender, unmount } = renderHook( + (options: UseRealtimeChatOptions) => useRealtimeChat(options), + { initialProps: createOptions(firstAdapter.adapter, firstGetToken) }, + ) + + await act(async () => { + await result.current.connect() + await result.current.disconnect() + }) + + rerender(createOptions(secondAdapter.adapter, secondGetToken)) + await act(async () => { + await result.current.connect() + }) + + expect(firstGetToken).toHaveBeenCalledTimes(1) + expect(firstAdapter.connect).toHaveBeenCalledTimes(1) + expect(secondGetToken).toHaveBeenCalledTimes(1) + expect(secondAdapter.connect).toHaveBeenCalledWith(secondToken, undefined) + + await act(async () => { + await result.current.disconnect() + }) + unmount() + }) + + it('uses updated authentication when refreshing an active session', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-16T12:00:00Z')) + + const testConnection = createConnection() + const testAdapter = createAdapter('test', [testConnection.connection]) + const initialToken = createToken('test', 'initial', Date.now() + 60_100) + const staleRefreshToken = createToken('test', 'stale-refresh') + const currentRefreshToken = createToken('test', 'current-refresh') + const firstGetToken = vi + .fn() + .mockResolvedValueOnce(initialToken) + .mockResolvedValue(staleRefreshToken) + const secondGetToken = vi.fn(async () => currentRefreshToken) + + const { result, rerender, unmount } = renderHook( + (options: UseRealtimeChatOptions) => useRealtimeChat(options), + { initialProps: createOptions(testAdapter.adapter, firstGetToken) }, + ) + await act(async () => { + await result.current.connect() + }) + + rerender(createOptions(testAdapter.adapter, secondGetToken)) + await act(async () => { + await vi.advanceTimersByTimeAsync(101) + }) + + expect(firstGetToken).toHaveBeenCalledTimes(1) + expect(secondGetToken).toHaveBeenCalledTimes(1) + + await act(async () => { + await result.current.disconnect() + }) + unmount() + }) + + it('updates the active session and preserves changes across reconnects', async () => { + const firstConnection = createConnection() + const secondConnection = createConnection() + const testAdapter = createAdapter('test', [ + firstConnection.connection, + secondConnection.connection, + ]) + const getToken = vi.fn(async () => createToken('test', 'token')) + const { result, unmount } = renderHook(() => + useRealtimeChat(createOptions(testAdapter.adapter, getToken)), + ) + + await act(async () => { + await result.current.connect() + }) + firstConnection.updateSession.mockClear() + + act(() => { + result.current.updateSession({ vadMode: 'manual' }) + }) + expect(firstConnection.updateSession).toHaveBeenCalledWith( + expect.objectContaining({ vadMode: 'manual' }), + ) + + await act(async () => { + await result.current.disconnect() + }) + act(() => { + result.current.updateSession({ vadMode: 'semantic' }) + }) + await act(async () => { + await result.current.connect() + }) + + expect(secondConnection.updateSession).toHaveBeenCalledWith( + expect.objectContaining({ vadMode: 'semantic' }), + ) + + await act(async () => { + await result.current.disconnect() + }) + unmount() + }) + + it('forwards connection status to the latest callback', async () => { + const testConnection = createConnection() + const testAdapter = createAdapter('test', [testConnection.connection]) + const getToken = vi.fn(async () => createToken('test', 'token')) + const firstOnStatusChange = vi.fn() + const secondOnStatusChange = vi.fn() + const { result, rerender, unmount } = renderHook( + (options: UseRealtimeChatOptions) => useRealtimeChat(options), + { + initialProps: createOptions(testAdapter.adapter, getToken, { + onStatusChange: firstOnStatusChange, + }), + }, + ) + + rerender( + createOptions(testAdapter.adapter, getToken, { + onStatusChange: secondOnStatusChange, + }), + ) + await act(async () => { + await result.current.connect() + }) + + expect(firstOnStatusChange).not.toHaveBeenCalled() + expect(secondOnStatusChange).toHaveBeenCalledWith('connecting') + expect(secondOnStatusChange).toHaveBeenCalledWith('connected') + + await act(async () => { + await result.current.disconnect() + }) + unmount() + }) + + it('forwards usage and go-away events to the latest callbacks', async () => { + const testConnection = createConnection() + const testAdapter = createAdapter('test', [testConnection.connection]) + const getToken = vi.fn(async () => createToken('test', 'token')) + const firstOnUsage = vi.fn() + const firstOnGoAway = vi.fn() + const secondOnUsage = vi.fn() + const secondOnGoAway = vi.fn() + const { result, rerender, unmount } = renderHook( + (options: UseRealtimeChatOptions) => useRealtimeChat(options), + { + initialProps: createOptions(testAdapter.adapter, getToken, { + onUsage: firstOnUsage, + onGoAway: firstOnGoAway, + }), + }, + ) + + rerender( + createOptions(testAdapter.adapter, getToken, { + onUsage: secondOnUsage, + onGoAway: secondOnGoAway, + }), + ) + await act(async () => { + await result.current.connect() + }) + + const usage: UsageInfo = { + promptTokens: 3, + completionTokens: 5, + totalTokens: 8, + } + act(() => { + testConnection.emit('usage', usage) + testConnection.emit('go_away', { timeLeft: '12s' }) + }) + + expect(firstOnUsage).not.toHaveBeenCalled() + expect(firstOnGoAway).not.toHaveBeenCalled() + expect(secondOnUsage).toHaveBeenCalledWith(usage) + expect(secondOnGoAway).toHaveBeenCalledWith('12s') + + await act(async () => { + await result.current.disconnect() + }) + unmount() + }) +}) diff --git a/packages/ai-octane/tests/ssr/server.test.ts b/packages/ai-octane/tests/ssr/server.test.ts new file mode 100644 index 0000000000..087b5d71f6 --- /dev/null +++ b/packages/ai-octane/tests/ssr/server.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { renderToStaticMarkup } from 'octane/server' +import { ServerChat } from '../_fixtures/server.tsrx' + +describe('@tanstack/ai-octane SSR', () => { + it('renders the initial chat snapshot without a DOM', () => { + expect(typeof document).toBe('undefined') + + const { html, css } = renderToStaticMarkup(ServerChat) + + expect(html).toBe('
  • Hello Ada
') + expect(css).toBe('') + }) +}) diff --git a/packages/ai-octane/tsconfig.json b/packages/ai-octane/tsconfig.json new file mode 100644 index 0000000000..f0bda23c97 --- /dev/null +++ b/packages/ai-octane/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "esnext", + "lib": ["esnext", "dom", "dom.iterable"], + "target": "esnext", + "jsx": "react-jsx", + "noEmit": true, + "moduleResolution": "bundler", + "resolveJsonModule": true, + "noEmitOnError": true, + "noErrorTruncation": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "types": ["node"], + "strict": true, + "jsxImportSource": "octane" + }, + "include": ["./src/"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/packages/ai-octane/typetests/tsconfig.json b/packages/ai-octane/typetests/tsconfig.json new file mode 100644 index 0000000000..2c92e26461 --- /dev/null +++ b/packages/ai-octane/typetests/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "module": "esnext", + "target": "es2018", + "jsx": "preserve", + "moduleResolution": "bundler", + "esModuleInterop": true, + "lib": ["dom", "dom.iterable", "esnext"], + "skipLibCheck": true, + "types": ["node"], + "noEmit": true, + "noErrorTruncation": true, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["./**/*.test-d.ts", "./**/*.test-d.tsx", "./__fixtures__/**/*.ts"] +} diff --git a/packages/ai-octane/typetests/use-chat.test-d.tsx b/packages/ai-octane/typetests/use-chat.test-d.tsx new file mode 100644 index 0000000000..03b84a5639 --- /dev/null +++ b/packages/ai-octane/typetests/use-chat.test-d.tsx @@ -0,0 +1,250 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +import { useChat } from '../src/index' +import type { + DeepPartial, + QueuedMessage, + SendMessageOptions, + UseChatOptions, + UseChatReturn, +} from '../src/index' + +type Person = { name: string; age: number; email: string } + +/** + * A hand-rolled shape matching `@standard-schema/spec`'s `StandardSchemaV1` + * interface (version/vendor/validate/types), used deliberately rather than a + * concrete library: `SchemaInput`'s `StandardSchemaV1` branch is a plain + * structural interface, so this proves `outputSchema` inference works for *any* + * spec-conforming validator, not just the one we happen to depend on. Tool + * inputs below use real Zod, which is what consumers actually pass. + */ +type StandardSchemaLike = { + readonly '~standard': { + readonly version: 1 + readonly vendor: string + readonly validate: ( + value: unknown, + ) => + | { readonly value: Output; readonly issues?: undefined } + | { readonly issues: ReadonlyArray<{ readonly message: string }> } + readonly types?: { + readonly input: Input + readonly output: Output + } + } +} + +type PersonSchema = StandardSchemaLike + +describe('useChat() return type', () => { + describe('with outputSchema', () => { + it('exposes typed partial + final', () => { + type R = UseChatReturn + expectTypeOf().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf() + }) + + it('still exposes the base shape (messages, sendMessage, isLoading, …)', () => { + type R = UseChatReturn + expectTypeOf().toBeFunction() + expectTypeOf().toBeBoolean() + expectTypeOf().toBeArray() + }) + + it('options accept outputSchema with the schema type', () => { + type O = UseChatOptions + expectTypeOf().toEqualTypeOf< + PersonSchema | undefined + >() + }) + }) + + describe('without outputSchema', () => { + it('does NOT expose partial or final', () => { + type R = UseChatReturn + // The conditional resolves to Record, so accessing + // `partial` / `final` keys is a type error. + // @ts-expect-error - partial only exists when outputSchema is supplied + type _Partial = R['partial'] + // @ts-expect-error - final only exists when outputSchema is supplied + type _Final = R['final'] + }) + + it('preserves the base return shape', () => { + type R = UseChatReturn + expectTypeOf().toBeFunction() + expectTypeOf().toBeBoolean() + }) + + it('exposes queue, runId, and interrupt controls', () => { + type R = UseChatReturn + expectTypeOf().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf<(id: string) => void>() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toBeBoolean() + expectTypeOf().toBeFunction() + expectTypeOf().toBeFunction() + expectTypeOf().toBeFunction() + expectTypeOf().toBeFunction() + expectTypeOf().toBeFunction() + expectTypeOf().toMatchTypeOf< + (content: string, options?: SendMessageOptions) => Promise + >() + }) + + it('uses threadId as hook identity, not id', () => { + type O = UseChatOptions + expectTypeOf().toEqualTypeOf() + // @ts-expect-error - identity is threadId; hook-level `id` was dropped + type _Id = O['id'] + }) + }) + + describe('with a bare tools array', () => { + it('narrows tool calls from an inline array without clientTools or as const', () => { + const check = () => { + const guitarTool = toolDefinition({ + name: 'getGuitar', + description: 'Get guitar info', + }).client(() => ({ ok: true })) + const cartTool = toolDefinition({ + name: 'addToCart', + description: 'Add to cart', + }).client(() => ({ ok: true })) + + const { messages } = useChat({ + connection: { connect: async function* () {} }, + tools: [guitarTool, cartTool], + }) + + const message = messages[0] + if (message?.role === 'assistant') { + for (const part of message.parts) { + if (part.type === 'tool-call') { + expectTypeOf(part.name).toEqualTypeOf<'getGuitar' | 'addToCart'>() + } + } + } + } + void check + }) + + it('narrows tool calls from a separately declared const array', () => { + const check = () => { + const guitarTool = toolDefinition({ + name: 'getGuitar', + description: 'Get guitar info', + }).client(() => ({ ok: true })) + const cartTool = toolDefinition({ + name: 'addToCart', + description: 'Add to cart', + }).client(() => ({ ok: true })) + + const tools = [guitarTool, cartTool] + const { messages } = useChat({ + connection: { connect: async function* () {} }, + tools, + }) + + const message = messages[0] + if (message?.role === 'assistant') { + for (const part of message.parts) { + if (part.type === 'tool-call') { + expectTypeOf(part.name).toEqualTypeOf<'getGuitar' | 'addToCart'>() + } + } + } + } + void check + }) + }) + + describe('with typed tool input and approval metadata', () => { + it('narrows parsed input and gates approval by tool name', () => { + const check = () => { + const guitarTool = toolDefinition({ + name: 'getGuitar', + description: 'Get guitar info', + inputSchema: z.object({ id: z.string() }), + }).client((input) => ({ id: input.id })) + const approvalTool = toolDefinition({ + name: 'deleteAccount', + description: 'Delete an account', + inputSchema: z.object({ accountId: z.string() }), + needsApproval: true, + }).client(() => ({ deleted: true })) + + const { messages } = useChat({ + connection: { connect: async function* () {} }, + tools: [guitarTool, approvalTool], + }) + + const message = messages[0] + if (message?.role === 'assistant') { + for (const part of message.parts) { + if (part.type !== 'tool-call') continue + + if (part.name === 'getGuitar') { + if (part.input) { + expectTypeOf(part.input.id).toBeString() + // @ts-expect-error - deleteAccount-style input is not available after name narrowing + void part.input.accountId + } + // @ts-expect-error - approval is gated behind needsApproval: true + void part.approval + } + + if (part.name === 'deleteAccount') { + if (part.input) { + expectTypeOf(part.input.accountId).toBeString() + // @ts-expect-error - getGuitar-style input is not available after name narrowing + void part.input.id + } + expectTypeOf(part.approval).toMatchTypeOf< + | { id: string; needsApproval: boolean; approved?: boolean } + | undefined + >() + } + } + } + } + void check + }) + + it('supports a generic approval handler through in-operator narrowing', () => { + const check = () => { + const regularTool = toolDefinition({ + name: 'readAccount', + description: 'Read an account', + }).client(() => ({ ok: true })) + const approvalTool = toolDefinition({ + name: 'deleteAccount', + description: 'Delete an account', + needsApproval: true, + }).client(() => ({ deleted: true })) + + const { messages } = useChat({ + connection: { connect: async function* () {} }, + tools: [regularTool, approvalTool], + }) + + const message = messages[0] + if (message?.role === 'assistant') { + for (const part of message.parts) { + if ( + part.type === 'tool-call' && + 'approval' in part && + part.approval + ) { + expectTypeOf(part.approval.id).toEqualTypeOf() + } + } + } + } + void check + }) + }) +}) diff --git a/packages/ai-octane/typetests/use-generation.test-d.ts b/packages/ai-octane/typetests/use-generation.test-d.ts new file mode 100644 index 0000000000..15590d9402 --- /dev/null +++ b/packages/ai-octane/typetests/use-generation.test-d.ts @@ -0,0 +1,88 @@ +import { describe, expectTypeOf, it } from 'vitest' + +import { useAudioRecorder, useGeneration } from '../src/index' +import type { UseGenerationReturn } from '../src/index' + +/** + * These lock in two divergences from `@tanstack/ai-react` where this binding + * deliberately types more precisely than upstream. Both are documented in + * status.json. If a future parity pass re-widens either signature, these fail. + */ +describe('useGeneration', () => { + type CustomInput = { prompt: string; steps: number } + + it('types generate() with TInput rather than Record', () => { + const check = () => { + const { generate } = useGeneration({ + connection: { connect: async function* () {} }, + }) + + expectTypeOf(generate).parameter(0).toEqualTypeOf() + + void generate({ prompt: 'a guitar', steps: 4 }) + + // @ts-expect-error - `steps` is required by CustomInput + void generate({ prompt: 'a guitar' }) + + // @ts-expect-error - `steps` must be a number + void generate({ prompt: 'a guitar', steps: 'four' }) + + // @ts-expect-error - unknown keys are not part of CustomInput + void generate({ prompt: 'a guitar', steps: 4, seed: 1 }) + } + void check + }) + + it('carries TInput through the exported return type', () => { + expectTypeOf< + UseGenerationReturn['generate'] + >() + .parameter(0) + .toEqualTypeOf() + }) +}) + +describe('useAudioRecorder', () => { + it('keeps AudioRecording when options carry no onComplete', () => { + const check = () => { + // Only an unrelated option: this must select the untransformed overload + // rather than inferring `TOnComplete` as `unknown` and collapsing + // `recording`/`stop()` to `unknown`. + const { recording, stop } = useAudioRecorder({ + onError: (_error: Error) => {}, + }) + + expectTypeOf(recording).not.toBeUnknown() + expectTypeOf(recording).not.toBeNull() + expectTypeOf(stop).returns.not.toBeUnknown() + + // The `.part` accessor only exists if the AudioRecording type survived. + if (recording) { + expectTypeOf(recording.base64).toBeString() + } + } + void check + }) + + it('still infers the transform when onComplete is supplied', () => { + const check = () => { + const { recording, stop } = useAudioRecorder({ + onComplete: () => 42, + }) + + expectTypeOf(recording).toEqualTypeOf() + expectTypeOf(stop).returns.toEqualTypeOf>() + } + void check + }) + + it('takes no options at all', () => { + const check = () => { + const { recording } = useAudioRecorder() + if (recording) { + expectTypeOf(recording.base64).toBeString() + } + } + void check + }) +}) diff --git a/packages/ai-octane/typetests/use-realtime-chat.test-d.ts b/packages/ai-octane/typetests/use-realtime-chat.test-d.ts new file mode 100644 index 0000000000..0c0653a0bb --- /dev/null +++ b/packages/ai-octane/typetests/use-realtime-chat.test-d.ts @@ -0,0 +1,15 @@ +import { expectTypeOf } from 'vitest' +import type { RealtimeSessionConfig } from '@tanstack/ai' + +import type { UseRealtimeChatReturn } from '../src/index' + +declare const realtime: UseRealtimeChatReturn + +expectTypeOf(realtime.updateSession) + .parameter(0) + .toEqualTypeOf() + +// @ts-expect-error - upstream 0.17 replaced the local VAD snapshot with updateSession +void realtime.vadMode +// @ts-expect-error - upstream 0.17 replaced setVADMode with updateSession +void realtime.setVADMode diff --git a/packages/ai-octane/vite.config.ts b/packages/ai-octane/vite.config.ts new file mode 100644 index 0000000000..3e8a7ae5f7 --- /dev/null +++ b/packages/ai-octane/vite.config.ts @@ -0,0 +1,85 @@ +import { createRequire } from 'node:module' +import { dirname, resolve } from 'node:path' +import { defineConfig } from 'vitest/config' +import { octane } from 'octane/compiler/vite' +import packageJson from './package.json' + +const SELF = resolve(import.meta.dirname, 'src/index.ts') + +// `@octanejs/testing-library` publishes uncompiled TypeScript (`main: +// src/index.ts`), so Vitest has to transform it instead of externalizing it +// the way it would a built dependency. +const inlineDeps = ['@octanejs/testing-library'] + +// `act-environment` ships in the tarball but is missing from the package's +// `exports`, so it can only be reached by path. Derive that path from the +// resolved `.` entry rather than assuming a node_modules layout — pnpm +// symlinks this package and the store path is not stable. (The upstream +// Octane repo aliases the same subpath; worth exporting there instead.) +const testingLibrarySrc = dirname( + createRequire(import.meta.url).resolve('@octanejs/testing-library'), +) + +const octaneAliases = [ + { find: /^@tanstack\/ai-octane$/, replacement: SELF }, + { + find: /^@octanejs\/testing-library\/(.*)$/, + replacement: `${testingLibrarySrc}/$1.ts`, + }, +] + +export default defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + projects: [ + { + // The hook modules are .tsrx, so both projects need the Octane + // compiler — this one with the default client renderer. + plugins: [octane()], + resolve: { alias: octaneAliases }, + test: { + name: 'conformance', + // happy-dom, not the jsdom the upstream Octane repo used: this repo + // pins jsdom ^27, whose `Blob` predates `arrayBuffer()` (Octane + // pins ^29, which has it), and the recorder tests need it. Bumping + // jsdom for one package would break sherif's cross-package version + // consistency, and happy-dom is this repo's DOM-testing default. + environment: 'happy-dom', + globals: false, + include: [ + 'tests/conformance/**/*.test.ts', + 'tests/conformance/**/*.test.tsx', + ], + setupFiles: ['./tests/conformance/test-setup.ts'], + server: { deps: { inline: inlineDeps } }, + }, + }, + { + plugins: [octane({ ssr: true })], + resolve: { + alias: [ + ...octaneAliases, + // The compiled .tsrx output imports its runtime from bare + // `octane`; under SSR that has to resolve to the server runtime. + { find: /^octane$/, replacement: 'octane/server' }, + ], + }, + test: { + name: 'ssr', + environment: 'node', + globals: false, + include: ['tests/ssr/**/*.test.ts'], + server: { deps: { inline: inlineDeps } }, + }, + }, + ], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + include: ['src/**'], + exclude: ['src/types.ts', 'src/realtime-types.ts', 'src/**/*.d.ts'], + }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d88c56aa37..8f202eae95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,7 +96,7 @@ importers: version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) codemods: devDependencies: @@ -117,7 +117,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) examples/ag-ui: dependencies: @@ -636,7 +636,7 @@ importers: version: 5.2.0(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) jsdom: specifier: ^27.4.0 - version: 27.4.0(postcss@8.5.26) + version: 27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26) typescript: specifier: 5.9.3 version: 5.9.3 @@ -645,7 +645,7 @@ importers: version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -856,7 +856,7 @@ importers: version: 9.2.1 jsdom: specifier: ^27.4.0 - version: 27.4.0(postcss@8.5.26) + version: 27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26) typescript: specifier: 5.9.3 version: 5.9.3 @@ -865,7 +865,7 @@ importers: version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -1151,7 +1151,7 @@ importers: version: 5.2.0(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) jsdom: specifier: ^27.4.0 - version: 27.4.0(postcss@8.5.26) + version: 27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26) typescript: specifier: 5.9.3 version: 5.9.3 @@ -1160,7 +1160,7 @@ importers: version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -1199,7 +1199,7 @@ importers: version: link:../../packages/ai-solid-ui '@tanstack/nitro-v2-vite-plugin': specifier: ^1.155.0 - version: 1.155.0(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3)(rolldown@1.1.5)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 1.155.0(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3)(rolldown@1.1.5)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: ^1.158.4 version: 1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(supports-color@7.2.0)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) @@ -1263,7 +1263,7 @@ importers: version: 24.10.3 jsdom: specifier: ^27.4.0 - version: 27.4.0(postcss@8.5.26) + version: 27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26) typescript: specifier: 5.9.3 version: 5.9.3 @@ -1275,7 +1275,7 @@ importers: version: 2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -1527,7 +1527,7 @@ importers: version: 24.10.3 jsdom: specifier: ^27.4.0 - version: 27.4.0(postcss@8.5.26) + version: 27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26) ng-packagr: specifier: ^21.2.0 version: 21.2.5(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.20)(typescript@5.9.3))(tailwindcss@4.1.18)(tslib@2.8.1)(typescript@5.9.3) @@ -1536,7 +1536,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) zod: specifier: ^4.2.0 version: 4.3.6 @@ -1821,7 +1821,7 @@ importers: version: 4.1.10(vitest@4.1.10) jsdom: specifier: ^27.4.0 - version: 27.4.0(postcss@8.5.26) + version: 27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26) tsup: specifier: ^8.5.1 version: 8.5.1(@microsoft/api-extractor@7.47.7(@types/node@24.10.3))(jiti@2.7.0)(postcss@8.5.26)(supports-color@7.2.0)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0) @@ -2168,6 +2168,40 @@ importers: specifier: ^8.2.1 version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + packages/ai-octane: + dependencies: + '@tanstack/ai-client': + specifier: workspace:* + version: link:../ai-client + devDependencies: + '@octanejs/testing-library': + specifier: 0.1.14 + version: 0.1.14(octane@0.1.17(@typescript-eslint/types@8.59.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))) + '@standard-schema/spec': + specifier: ^1.1.0 + version: 1.1.0 + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@types/node': + specifier: ^24.10.1 + version: 24.10.3 + '@vitest/coverage-v8': + specifier: 4.1.10 + version: 4.1.10(vitest@4.1.10) + happy-dom: + specifier: ^20.11.2 + version: 20.11.2 + octane: + specifier: 0.1.17 + version: 0.1.17(@typescript-eslint/types@8.59.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + vite: + specifier: ^8.2.1 + version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + zod: + specifier: ^4.2.0 + version: 4.3.6 + packages/ai-ollama: dependencies: '@tanstack/ai-utils': @@ -2280,7 +2314,7 @@ importers: version: 4.1.10(vitest@4.1.10) vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) packages/ai-preact: dependencies: @@ -2302,7 +2336,7 @@ importers: version: 4.1.10(vitest@4.1.10) jsdom: specifier: ^27.4.0 - version: 27.4.0(postcss@8.5.26) + version: 27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26) preact: specifier: ^10.26.9 version: 10.28.2 @@ -2336,7 +2370,7 @@ importers: version: 4.1.10(vitest@4.1.10) jsdom: specifier: ^27.4.0 - version: 27.4.0(postcss@8.5.26) + version: 27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26) react: specifier: ^19.2.3 version: 19.2.3 @@ -2404,7 +2438,7 @@ importers: version: 4.1.10(vitest@4.1.10) vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) packages/ai-sandbox-cloudflare: dependencies: @@ -2520,7 +2554,7 @@ importers: version: 4.1.10(vitest@4.1.10) jsdom: specifier: ^27.4.0 - version: 27.4.0(postcss@8.5.26) + version: 27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26) solid-js: specifier: ^1.9.10 version: 1.9.10 @@ -2532,7 +2566,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) packages/ai-solid-ui: dependencies: @@ -2600,7 +2634,7 @@ importers: version: 4.1.10(vitest@4.1.10) jsdom: specifier: ^27.4.0 - version: 27.4.0(postcss@8.5.26) + version: 27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26) svelte: specifier: ^5.56.9 version: 5.56.9(@typescript-eslint/types@8.59.4) @@ -2693,7 +2727,7 @@ importers: version: 2.4.6 jsdom: specifier: ^27.4.0 - version: 27.4.0(postcss@8.5.26) + version: 27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26) tsdown: specifier: ^0.17.0-beta.6 version: 0.17.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(oxc-resolver@11.21.3)(publint@0.3.16)(typescript@5.9.3) @@ -2702,7 +2736,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) vue: specifier: ^3.5.25 version: 3.5.25(typescript@5.9.3) @@ -5814,6 +5848,10 @@ packages: resolution: {integrity: sha512-P06o9TpxrJbiRbHQkiwy/rUrlXRupc+Z8KT4MiJfmcdWxvIdzjCaJOdnNkcOTs6DMyzIOefG5tvk/HLdtjqr0g==} engines: {node: '>= 10'} + '@noble/hashes@2.3.0': + resolution: {integrity: sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -5883,6 +5921,12 @@ packages: cpu: [x64] os: [win32] + '@octanejs/testing-library@0.1.14': + resolution: {integrity: sha512-ECPL7+uNGRDlT3ncQsO2qbA0pTcpBgaZJ1gpWnpbMzr8FnVz01sBaoIFyQPU6BbUX3XypLbCgx4xybPQu+5veQ==} + engines: {node: '>=22'} + peerDependencies: + octane: 0.1.17 + '@one-ini/wasm@0.1.1': resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} @@ -9419,6 +9463,12 @@ packages: '@ts-morph/common@0.22.0': resolution: {integrity: sha512-HqNBuV/oIlMKdkLshXd1zKBqNQCsuPEsgQOkfFQ/eUKjRlwndXW1AjN9LVkBEIukm00gGXSRmfkl0Wv5VXLnlw==} + '@tsrx/core@0.1.60': + resolution: {integrity: sha512-t/lb8wWrJUsiwGTz3NI2GqF5be9kojmemfur6SdJqhbrMccx8TkHtZh6N8mE9gh2PTiGPuNf6HTZIomOpamA7Q==} + + '@tsrx/runtime@0.1.1': + resolution: {integrity: sha512-R7n2forc2XQvqCYpYMqGij1tCwoPILBubRczQ2Kbjj3d6naiseEq6qfMmljEkKp14ERYHstx7ISrw2wY7Ml0DA==} + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -9591,6 +9641,9 @@ packages: peerDependencies: '@types/react': ^19.2.0 + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + '@types/react@19.2.7': resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} @@ -14054,6 +14107,21 @@ packages: ocache@0.1.5: resolution: {integrity: sha512-kNNnkkVQup/QDvmTz8Q84wc2ntiyoVHDxa6eHWKt5qdGAmFRBIxy83rxgCYEjW0x06UJ9E3P6VgM2yY4rOBH4w==} + octane@0.1.17: + resolution: {integrity: sha512-SP/DX7WPjAuZHobJiWy7UFp1IllsufOmfwIlfCKKoPV96sKnM9BwUCK8ueguqdOMri5E7giVBjZBWF0g9fmCZw==} + engines: {node: '>=22'} + peerDependencies: + react: ^19.0.0 + react-dom: ^19.0.0 + vite: ^8.0.16 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + vite: + optional: true + ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} @@ -17033,7 +17101,7 @@ snapshots: ng-packagr: 21.2.5(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.20)(typescript@5.9.3))(tailwindcss@4.1.18)(tslib@2.8.1)(typescript@5.9.3) postcss: 8.5.19 tailwindcss: 4.1.18 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(postcss@8.5.19))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0)(postcss@8.5.19))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -17090,7 +17158,7 @@ snapshots: ng-packagr: 21.2.5(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.20)(typescript@5.9.3))(tailwindcss@4.1.18)(tslib@2.8.1)(typescript@5.9.3) postcss: 8.5.26 tailwindcss: 4.1.18 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -18747,7 +18815,7 @@ snapshots: '@copilotkit/aimock@1.34.0(vitest@4.1.10)': optionalDependencies: - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@crazydos/vue-markdown@1.1.4(vue@3.5.25(typescript@5.9.3))': dependencies: @@ -19265,7 +19333,9 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@exodus/bytes@1.15.1': {} + '@exodus/bytes@1.15.1(@noble/hashes@2.3.0)': + optionalDependencies: + '@noble/hashes': 2.3.0 '@expo/cli@56.1.12(@expo/dom-webview@56.0.5)(expo-constants@56.0.16(expo@56.0.5)(react-native@0.85.3(@babel/core@7.29.7)(@types/react@19.2.7)(react@19.2.3)(supports-color@7.2.0)))(expo-font@56.0.5(expo@56.0.5)(react-native@0.85.3(@babel/core@7.29.7)(@types/react@19.2.7)(react@19.2.3)(supports-color@7.2.0))(react@19.2.3))(expo@56.0.5)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@types/react@19.2.7)(react@19.2.3)(supports-color@7.2.0))(react@19.2.3)(supports-color@7.2.0)(typescript@5.9.3)': dependencies: @@ -20392,6 +20462,8 @@ snapshots: '@ngrok/ngrok-win32-ia32-msvc': 1.7.0 '@ngrok/ngrok-win32-x64-msvc': 1.7.0 + '@noble/hashes@2.3.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -20436,6 +20508,11 @@ snapshots: '@nx/nx-win32-x64-msvc@23.1.1': optional: true + '@octanejs/testing-library@0.1.14(octane@0.1.17(@typescript-eslint/types@8.59.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))': + dependencies: + '@testing-library/dom': 10.4.1 + octane: 0.1.17(@typescript-eslint/types@8.59.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + '@one-ini/wasm@0.1.1': {} '@oozcitak/dom@1.15.10': @@ -21174,7 +21251,7 @@ snapshots: '@prisma/client-runtime-utils@7.9.0': optional: true - '@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)': + '@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)': dependencies: '@prisma/client-runtime-utils': 7.9.0 optionalDependencies: @@ -21182,6 +21259,14 @@ snapshots: typescript: 5.9.3 optional: true + '@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)': + dependencies: + '@prisma/client-runtime-utils': 7.9.0 + optionalDependencies: + prisma: 7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) + typescript: 5.9.3 + optional: true + '@prisma/config@7.9.0(magicast@0.5.2)': dependencies: c12: 3.3.4(magicast@0.5.2) @@ -21278,6 +21363,26 @@ snapshots: - '@types/react-dom' optional: true + '@prisma/studio-core@0.33.0(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-toggle': 1.1.10(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@types/react': 19.2.18 + '@visx/curve': 4.0.1-alpha.0 + '@visx/event': 4.0.1-alpha.0 + '@visx/grid': 4.0.1-alpha.0(react@19.2.3) + '@visx/group': 4.0.1-alpha.0(react@19.2.3) + '@visx/responsive': 4.0.1-alpha.0(react@19.2.3) + '@visx/scale': 4.0.1-alpha.0 + '@visx/shape': 4.0.1-alpha.0(react@19.2.3) + d3-array: 3.2.4 + d3-shape: 3.2.0 + elkjs: 0.11.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - '@types/react-dom' + optional: true + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -21440,6 +21545,13 @@ snapshots: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.18)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.18 + optional: true + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.7)(react@19.2.3)': dependencies: react: 19.2.3 @@ -21758,6 +21870,15 @@ snapshots: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@radix-ui/react-primitive@2.1.3(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.18)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.18 + optional: true + '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3) @@ -21877,6 +21998,14 @@ snapshots: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@radix-ui/react-slot@1.2.3(@types/react@19.2.18)(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.18)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.18 + optional: true + '@radix-ui/react-slot@1.2.3(@types/react@19.2.7)(react@19.2.3)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) @@ -21968,6 +22097,17 @@ snapshots: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@radix-ui/react-toggle@1.1.10(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.18)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.18 + optional: true + '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -22009,6 +22149,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.18)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.18)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.18 + optional: true + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.7)(react@19.2.3)': dependencies: '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.7)(react@19.2.3) @@ -22017,6 +22166,14 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.18)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.18)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.18 + optional: true + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.7)(react@19.2.3)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3) @@ -22038,6 +22195,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.18)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.18 + optional: true + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.7)(react@19.2.3)': dependencies: react: 19.2.3 @@ -23158,9 +23322,9 @@ snapshots: - uploadthing - xml2js - '@tanstack/nitro-v2-vite-plugin@1.155.0(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3)(rolldown@1.1.5)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': + '@tanstack/nitro-v2-vite-plugin@1.155.0(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3)(rolldown@1.1.5)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': dependencies: - nitropack: 2.13.1(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3)(rolldown@1.1.5) + nitropack: 2.13.1(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3)(rolldown@1.1.5) pathe: 2.0.3 vite: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) transitivePeerDependencies: @@ -24272,6 +24436,24 @@ snapshots: mkdirp: 3.0.1 path-browserify: 1.0.1 + '@tsrx/core@0.1.60(@typescript-eslint/types@8.59.4)': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@noble/hashes': 2.3.0 + '@sveltejs/acorn-typescript': 1.0.13(acorn@8.18.0) + '@tsrx/runtime': 0.1.1 + '@types/estree': 1.0.9 + '@types/estree-jsx': 1.0.5 + acorn: 8.18.0 + esrap: 2.3.5(@typescript-eslint/types@8.59.4) + is-reference: 3.0.3 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + transitivePeerDependencies: + - '@typescript-eslint/types' + + '@tsrx/runtime@0.1.1': {} + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 @@ -24465,6 +24647,10 @@ snapshots: dependencies: '@types/react': 19.2.7 + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + '@types/react@19.2.7': dependencies: csstype: 3.2.3 @@ -24723,8 +24909,8 @@ snapshots: dependencies: '@mapbox/node-pre-gyp': 2.0.3 '@rollup/pluginutils': 5.3.0(rollup@4.60.1) - acorn: 8.15.0 - acorn-import-attributes: 1.9.5(acorn@8.15.0) + acorn: 8.18.0 + acorn-import-attributes: 1.9.5(acorn@8.18.0) async-sema: 3.1.1 bindings: 1.5.0 estree-walker: 2.0.2 @@ -24784,13 +24970,13 @@ snapshots: '@visx/event@4.0.1-alpha.0': dependencies: - '@types/react': 19.2.7 + '@types/react': 19.2.18 '@visx/point': 4.0.1-alpha.0 optional: true '@visx/grid@4.0.1-alpha.0(react@19.2.3)': dependencies: - '@types/react': 19.2.7 + '@types/react': 19.2.18 '@visx/curve': 4.0.1-alpha.0 '@visx/group': 4.0.1-alpha.0(react@19.2.3) '@visx/point': 4.0.1-alpha.0 @@ -24802,7 +24988,7 @@ snapshots: '@visx/group@4.0.1-alpha.0(react@19.2.3)': dependencies: - '@types/react': 19.2.7 + '@types/react': 19.2.18 classnames: 2.5.1 react: 19.2.3 optional: true @@ -24813,7 +24999,7 @@ snapshots: '@visx/responsive@4.0.1-alpha.0(react@19.2.3)': dependencies: '@types/lodash': 4.17.24 - '@types/react': 19.2.7 + '@types/react': 19.2.18 lodash: 4.17.21 react: 19.2.3 optional: true @@ -24826,7 +25012,7 @@ snapshots: '@visx/shape@4.0.1-alpha.0(react@19.2.3)': dependencies: '@types/lodash': 4.17.24 - '@types/react': 19.2.7 + '@types/react': 19.2.18 '@visx/curve': 4.0.1-alpha.0 '@visx/group': 4.0.1-alpha.0(react@19.2.3) '@visx/scale': 4.0.1-alpha.0 @@ -24909,7 +25095,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@vitest/expect@4.1.10': dependencies: @@ -25099,9 +25285,9 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - acorn-import-attributes@1.9.5(acorn@8.15.0): + acorn-import-attributes@1.9.5(acorn@8.18.0): dependencies: - acorn: 8.15.0 + acorn: 8.18.0 acorn-jsx@5.3.2(acorn@8.15.0): dependencies: @@ -26311,11 +26497,11 @@ snapshots: dayjs@1.11.19: {} - db0@0.3.4(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3): + db0@0.3.4(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3): optionalDependencies: '@electric-sql/pglite': 0.4.3 better-sqlite3: 12.11.1 - drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)) + drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)) mysql2: 3.15.3 db0@0.3.4(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3): @@ -26537,7 +26723,7 @@ snapshots: '@cloudflare/workers-types': 4.20260317.1 '@electric-sql/pglite': 0.4.3 '@opentelemetry/api': 1.9.1 - '@prisma/client': 7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3) + '@prisma/client': 7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3) better-sqlite3: 12.11.1 bun-types: 1.3.14 mysql2: 3.15.3 @@ -26545,17 +26731,17 @@ snapshots: prisma: 7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) optional: true - drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)): + drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)): optionalDependencies: '@cloudflare/workers-types': 4.20260317.1 '@electric-sql/pglite': 0.4.3 '@opentelemetry/api': 1.9.1 - '@prisma/client': 7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3) + '@prisma/client': 7.9.0(prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3) better-sqlite3: 12.11.1 bun-types: 1.3.14 mysql2: 3.15.3 postgres: 3.4.7 - prisma: 7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) + prisma: 7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) optional: true dts-resolver@2.1.3(oxc-resolver@11.21.3): @@ -27861,9 +28047,9 @@ snapshots: dependencies: lru-cache: 10.4.3 - html-encoding-sniffer@6.0.0: + html-encoding-sniffer@6.0.0(@noble/hashes@2.3.0): dependencies: - '@exodus/bytes': 1.15.1 + '@exodus/bytes': 1.15.1(@noble/hashes@2.3.0) transitivePeerDependencies: - '@noble/hashes' @@ -27973,8 +28159,8 @@ snapshots: import-in-the-middle@3.2.0: dependencies: - acorn: 8.15.0 - acorn-import-attributes: 1.9.5(acorn@8.15.0) + acorn: 8.18.0 + acorn-import-attributes: 1.9.5(acorn@8.18.0) cjs-module-lexer: 2.2.0 module-details-from-path: 1.0.4 @@ -28358,15 +28544,15 @@ snapshots: transitivePeerDependencies: - supports-color - jsdom@27.4.0(postcss@8.5.19): + jsdom@27.4.0(@noble/hashes@2.3.0)(postcss@8.5.19): dependencies: '@acemir/cssom': 0.9.29 '@asamuzakjp/dom-selector': 6.7.6 - '@exodus/bytes': 1.15.1 + '@exodus/bytes': 1.15.1(@noble/hashes@2.3.0) cssstyle: 5.3.4(postcss@8.5.19) data-urls: 6.0.0 decimal.js: 10.6.0 - html-encoding-sniffer: 6.0.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.3.0) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 @@ -28388,15 +28574,15 @@ snapshots: - utf-8-validate optional: true - jsdom@27.4.0(postcss@8.5.26): + jsdom@27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26): dependencies: '@acemir/cssom': 0.9.29 '@asamuzakjp/dom-selector': 6.7.6 - '@exodus/bytes': 1.15.1 + '@exodus/bytes': 1.15.1(@noble/hashes@2.3.0) cssstyle: 5.3.4(postcss@8.5.26) data-urls: 6.0.0 decimal.js: 10.6.0 - html-encoding-sniffer: 6.0.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.3.0) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 @@ -29553,7 +29739,7 @@ snapshots: mlly@1.8.0: dependencies: - acorn: 8.15.0 + acorn: 8.18.0 pathe: 2.0.3 pkg-types: 1.3.1 ufo: 1.6.3 @@ -29895,7 +30081,7 @@ snapshots: - supports-color - uploadthing - nitropack@2.13.1(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3)(rolldown@1.1.5): + nitropack@2.13.1(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3)(rolldown@1.1.5): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@rollup/plugin-alias': 6.0.0(rollup@4.60.1) @@ -29916,7 +30102,7 @@ snapshots: cookie-es: 2.0.1 croner: 9.1.0 crossws: 0.3.5 - db0: 0.3.4(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3) + db0: 0.3.4(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3) defu: 6.1.4 destr: 2.0.5 dot-prop: 10.1.0 @@ -29962,7 +30148,7 @@ snapshots: unenv: 2.0.0-rc.24 unimport: 5.6.0 unplugin-utils: 0.3.1 - unstorage: 1.17.4(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3))(ioredis@5.9.2) + unstorage: 1.17.4(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3))(ioredis@5.9.2) untyped: 2.0.0 unwasm: 0.5.3 youch: 4.1.0-beta.13 @@ -30241,6 +30427,19 @@ snapshots: dependencies: ohash: 2.0.11 + octane@0.1.17(@typescript-eslint/types@8.59.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)): + dependencies: + '@tsrx/core': 0.1.60(@typescript-eslint/types@8.59.4) + '@types/react': 19.2.18 + devalue: 5.9.0 + esrap: 2.3.5(@typescript-eslint/types@8.59.4) + optionalDependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + vite: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + transitivePeerDependencies: + - '@typescript-eslint/types' + ofetch@1.5.1: dependencies: destr: 2.0.5 @@ -30810,6 +31009,25 @@ snapshots: - react-dom optional: true + prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3): + dependencies: + '@prisma/config': 7.9.0(magicast@0.5.2) + '@prisma/dev': 0.24.14(typescript@5.9.3) + '@prisma/engines': 7.9.0 + '@prisma/studio-core': 0.33.0(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + mysql2: 3.15.3 + postgres: 3.4.7 + optionalDependencies: + better-sqlite3: 12.11.1 + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - magicast + - react + - react-dom + optional: true + proc-log@4.2.0: {} process-nextick-args@2.0.1: {} @@ -32758,14 +32976,14 @@ snapshots: unctx@2.4.1: dependencies: - acorn: 8.15.0 + acorn: 8.18.0 estree-walker: 3.0.3 magic-string: 0.30.21 unplugin: 2.3.11 unctx@2.5.0: dependencies: - acorn: 8.15.0 + acorn: 8.18.0 estree-walker: 3.0.3 magic-string: 0.30.21 unplugin: 2.3.11 @@ -32819,7 +33037,7 @@ snapshots: unimport@5.6.0: dependencies: - acorn: 8.15.0 + acorn: 8.18.0 escape-string-regexp: 5.0.0 estree-walker: 3.0.3 local-pkg: 1.1.2 @@ -32935,7 +33153,7 @@ snapshots: db0: 0.3.4(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3) ioredis: 5.9.2 - unstorage@1.17.4(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3))(ioredis@5.9.2): + unstorage@1.17.4(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3))(ioredis@5.9.2): dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -32947,7 +33165,7 @@ snapshots: ufo: 1.6.3 optionalDependencies: aws4fetch: 1.0.20 - db0: 0.3.4(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3) + db0: 0.3.4(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3) ioredis: 5.9.2 unstorage@2.0.0-alpha.7(aws4fetch@1.0.20)(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3))(ofetch@2.0.0-alpha.3): @@ -33284,7 +33502,7 @@ snapshots: optionalDependencies: vite: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(postcss@8.5.19))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0)(postcss@8.5.19))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) @@ -33311,12 +33529,12 @@ snapshots: '@types/node': 24.10.3 '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) happy-dom: 20.11.2 - jsdom: 27.4.0(postcss@8.5.19) + jsdom: 27.4.0(@noble/hashes@2.3.0)(postcss@8.5.19) transitivePeerDependencies: - msw optional: true - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) @@ -33343,7 +33561,7 @@ snapshots: '@types/node': 24.10.3 '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) happy-dom: 20.11.2 - jsdom: 27.4.0(postcss@8.5.26) + jsdom: 27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26) transitivePeerDependencies: - msw diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d52dc37729..8e292988d4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,6 +13,13 @@ minimumReleaseAgeExclude: # Build-time devDep; 0.6.0 is the release carrying the dts-5/Vite-8 fix # (TanStack/config#403) this toolchain migration depends on. - '@tanstack/vite-config@0.6.0' + # @tanstack/ai-octane's framework peer + its testing library. Octane + # publishes these two in lockstep — @octanejs/testing-library pins an exact + # `octane` peer — so they must be excluded together or neither resolves. + # Octane releases roughly daily, so the newest pair is essentially always + # inside the 24h window. + - 'octane@0.1.17' + - '@octanejs/testing-library@0.1.14' trustPolicyExclude: - 'chokidar@4.0.3' # existing transitive from sass/nitro; latest v4 with v5 available