diff --git a/.changeset/ai-octane-port.md b/.changeset/ai-octane-port.md new file mode 100644 index 000000000..f9b1e2ae3 --- /dev/null +++ b/.changeset/ai-octane-port.md @@ -0,0 +1,48 @@ +--- +'@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. +- It is baselined against `@tanstack/ai-react@0.17.0`, while this repo is at + 0.18.1. The interrupts overhaul (#970) and server-persistence / browser-refresh + durability work (#984) are **not** yet reflected in the Octane hooks; the + `./mcp-apps` subpath is intentionally not ported (it renders a React-only + component). See `packages/ai-octane/status.json` for the full scope, + divergence list, and the exact type-surface gap. Catching up to current parity + is follow-up work. diff --git a/packages/ai-octane/README.md b/packages/ai-octane/README.md new file mode 100644 index 000000000..db34dec09 --- /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 + .map((part) => (part.type === 'text' ? part.content : '')) + .join('')) as string} +

+ } + 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 0.17.0 currently drops the external callback. +- Changing `useChat`'s connection or fetcher updates the active `ChatClient` in + place and preserves conversation state; upstream 0.17.0 captures the initial + transport. + +### 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`. + +It is baselined against `@tanstack/ai-react@0.17.0`. Current scope, divergences, +and verification state are tracked in [`status.json`](./status.json) — including +the upstream changes not yet reflected here. + +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 000000000..d26adaee1 --- /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.0.14", + "happy-dom": "^20.0.10", + "octane": "0.1.17", + "vite": "^8.1.4", + "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 000000000..c5bae6b28 --- /dev/null +++ b/packages/ai-octane/src/index.ts @@ -0,0 +1,89 @@ +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, +} 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 000000000..4bf2e45a9 --- /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 000000000..107b08701 --- /dev/null +++ b/packages/ai-octane/src/types.ts @@ -0,0 +1,219 @@ +import type { + AnyClientTool, + InferSchemaType, + ModelMessage, + SchemaInput, +} from '@tanstack/ai/client' +import type { + AIDevtoolsDisplayOptions, + ChatClientOptions, + ChatClientState, + ChatRequestBody, + ClientContextOptionFromTools, + ConnectionStatus, + DistributedOmit, + InferredClientContext, + MultimodalContent, + UIMessage, +} from '@tanstack/ai-client' + +// Re-export types from ai-client +export type { ChatRequestBody, MultimodalContent, UIMessage } + +/** + * 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 `id` creates a fresh client. + */ +export type UseChatOptions< + TTools extends ReadonlyArray = any, + TSchema extends SchemaInput | undefined = undefined, + TContext = InferredClientContext, +> = DistributedOmit< + ChatClientOptions, + | 'onMessagesChange' + | 'onLoadingChange' + | 'onErrorChange' + | 'onStatusChange' + | 'onSubscriptionChange' + | 'onConnectionStatusChange' + | 'onSessionGeneratingChange' + | '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, +> = BaseUseChatReturn< + TTools, + TSchema extends SchemaInput ? InferSchemaType : unknown +> & + (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, +> { + /** + * 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. + */ + sendMessage: (content: string | MultimodalContent) => Promise + + /** + * 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 + + /** + * 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 000000000..2a470aa09 --- /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 000000000..7fa8d9139 --- /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 000000000..60c33c818 --- /dev/null +++ b/packages/ai-octane/src/use-chat.tsrx @@ -0,0 +1,363 @@ +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, + ModelMessage, + SchemaInput, + StreamChunk, +} from '@tanstack/ai/client' +import type { + ChatClientState, + ConnectionStatus, + InferredClientContext, + StructuredOutputPart, +} from '@tanstack/ai-client' + +import type { + DeepPartial, + MultimodalContent, + UIMessage, + UseChatOptions, + UseChatReturn, +} from './types' + +export function useChat< + const TTools extends ReadonlyArray = any, + TSchema extends SchemaInput | undefined = undefined, + TContext = InferredClientContext, +>( + options: UseChatOptions, +): UseChatReturn { + const hookId = useId() + const clientId = options.id || 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) + + 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 activeClientRef = useRef(null) + const cleanupInvalidationRef = useRef | null>( + 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 + + // Create ChatClient instance with callbacks to sync state + const client = 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 instance = new ChatClient({ + devtoolsBridgeFactory: createChatDevtoolsBridge, + ...transport, + id: clientId, + initialMessages: messagesToUse, + ...(initialOptions.body !== undefined && { body: initialOptions.body }), + ...(initialOptions.threadId !== undefined && { + threadId: initialOptions.threadId, + }), + ...(initialOptions.forwardedProps !== undefined && { + forwardedProps: initialOptions.forwardedProps, + }), + ...(initialOptions.persistence !== undefined && { + persistence: initialOptions.persistence, + }), + ...(initialOptions.context !== undefined && { + context: initialOptions.context, + }), + devtools: { + ...initialOptions.devtools, + // OCTANE DIVERGENCE: identify this binding as Octane (not 'react') in TanStack AI Devtools. + framework: 'octane', + hookName: 'useChat', + outputKind: initialOptions.outputSchema ? 'structured' : 'chat', + }, + onResponse: (response) => { + if (activeClientRef.current !== instance) return + void optionsRef.current.onResponse?.(response) + }, + onChunk: (chunk: StreamChunk) => { + if (activeClientRef.current !== instance) return + optionsRef.current.onChunk?.(chunk) + }, + onFinish: (message: UIMessage) => { + if (activeClientRef.current !== instance) return + optionsRef.current.onFinish?.(message) + }, + onError: (error: Error) => { + if (activeClientRef.current !== instance) return + optionsRef.current.onError?.(error) + }, + ...(initialOptions.tools !== undefined && { + tools: initialOptions.tools, + }), + onCustomEvent: (eventType, data, context) => { + if (activeClientRef.current !== instance) return + optionsRef.current.onCustomEvent?.(eventType, data, context) + }, + ...(options.streamProcessor !== undefined && { + streamProcessor: options.streamProcessor, + }), + onMessagesChange: (newMessages: Array>) => { + if (activeClientRef.current !== instance) return + setMessages(newMessages) + }, + onLoadingChange: (newIsLoading: boolean) => { + if (activeClientRef.current !== instance) return + setIsLoading(newIsLoading) + }, + onErrorChange: (newError: Error | undefined) => { + if (activeClientRef.current !== instance) return + setError(newError) + }, + onStatusChange: (status: ChatClientState) => { + if (activeClientRef.current !== instance) return + setStatus(status) + }, + onSubscriptionChange: (nextIsSubscribed: boolean) => { + if (activeClientRef.current !== instance) return + setIsSubscribed(nextIsSubscribed) + }, + onConnectionStatusChange: (nextStatus: ConnectionStatus) => { + if (activeClientRef.current !== instance) return + setConnectionStatus(nextStatus) + }, + onSessionGeneratingChange: (isGenerating: boolean) => { + if (activeClientRef.current !== instance) return + setSessionGenerating(isGenerating) + }, + }) + activeClientRef.current = instance + return instance + }, [clientId]) + + 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. + useEffect(() => { + 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.live) { + client.subscribe() + } else { + client.unsubscribe() + } + }, [client, options.live]) + + useEffect(() => { + if (cleanupInvalidationRef.current) { + clearTimeout(cleanupInvalidationRef.current) + cleanupInvalidationRef.current = null + } + activeClientRef.current = client + client.mountDevtools() + + return () => { + cleanupInvalidationRef.current = setTimeout(() => { + if (activeClientRef.current === client) { + activeClientRef.current = null + } + cleanupInvalidationRef.current = null + }, 0) + // Subscribe/unsubscribe on `options.live` is owned by the dedicated + // effect above. This cleanup only fires on unmount or client swap, + // so read `live` through the ref to avoid disposing the client every + // time `live` toggles. + if (optionsRef.current.live) { + client.unsubscribe() + } else { + client.stop() + } + client.dispose() + } + }, [client]) + + const sendMessage = useCallback( + async (content: string | MultimodalContent) => { + await client.sendMessage(content) + }, + [client], + ) + + const append = useCallback( + async (message: ModelMessage | UIMessage) => { + await client.append(message) + }, + [client], + ) + + const reload = useCallback(async () => { + await client.reload() + }, [client]) + + const stop = useCallback(() => { + client.stop() + }, [client]) + + const clear = useCallback(() => { + client.clear() + }, [client]) + + 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) + }, + [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, + 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 000000000..6ab55c89d --- /dev/null +++ b/packages/ai-octane/src/use-chat.tsrx.d.ts @@ -0,0 +1,11 @@ +// Declaration companion generated from use-chat.tsrx. +import type { AnyClientTool, 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, +>( + 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 000000000..285714692 --- /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 000000000..d5948e531 --- /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 000000000..9aa4eb5f3 --- /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 000000000..a28a37cef --- /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 000000000..bf20857ce --- /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 000000000..62410ea79 --- /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 000000000..5772a9732 --- /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 000000000..e11103962 --- /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 000000000..e3ca19c08 --- /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 000000000..a371d6abd --- /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 000000000..d347c836d --- /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 000000000..a10953096 --- /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 000000000..b486b8cc1 --- /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 000000000..384bd3f0b --- /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 000000000..d974f4f52 --- /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 000000000..d90a11146 --- /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 000000000..79cc79f02 --- /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 000000000..2806df273 --- /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 000000000..2d6ea7a52 --- /dev/null +++ b/packages/ai-octane/status.json @@ -0,0 +1,34 @@ +{ + "upstream": { + "package": "@tanstack/ai-react", + "version": "0.17.0" + }, + "surface": "Ports the @tanstack/ai-react 0.17.0 hook surface (useChat, useRealtimeChat, useGeneration, useGenerateImage/Audio/Speech/Video, useTranscription, useSummarize, useAudioRecorder, useMcpAppBridge) while reusing @tanstack/ai and @tanstack/ai-client unchanged and mirroring all 30 @tanstack/ai-client convenience re-exports from the upstream index.", + "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.", + "The TanStack AI Devtools bridge is tagged `framework: 'octane'` (upstream `@tanstack/ai-react` 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 @tanstack/ai-react 0.17.0 captures the first render's callbacks.", + "The declared realtime `onStatusChange` callback is invoked alongside the hook's state update; upstream @tanstack/ai-react 0.17.0 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 0.17.0 captures the initial transport.", + "One upstream `useChat` test case (\"auto-resume on mount / when the browser comes back online\") is omitted: it targets `ChatClient.prototype.maybeAutoResume`, an API absent from @tanstack/ai-client at the time of the port and never invoked by `useChat`. It is untestable in this binding until that dependency ships the method.", + "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; the runtime surface is unchanged.", + "147 tests pass with no skipped, todo, or expected-failure cases: 142 behavioral cases across 9 files (the upstream @tanstack/ai-react tests run against Octane), 4 new devtools-identification cases, and the 1 SSR case. The compile-time type tests run separately under `test:types`. 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.", + "Baselined against @tanstack/ai-react 0.17.0. This repo is at 0.18.1: #970 (interrupts lifecycle overhaul) and #984 (server persistence + client browser-refresh durability) both changed use-chat.ts and types.ts upstream and are NOT yet reflected here. Concretely, src/types.ts is missing the queue types (QueueConfig, QueuedMessage, QueueStrategy, WhenBusy, SendMessageOptions), the interrupt types (BoundInterrupts, ChatInterrupt, ChatInterruptState, ChatResumeState), RunAgentResumeItem, and the threadId-as-identity change that drops the `id` option. Catching up to current parity is tracked as follow-up work.", + "Two conformance cases were updated from their 0.17.0 form rather than ported verbatim: 'should not send message while loading' and 'should handle multiple sendMessage calls' asserted that a second concurrent sendMessage is dropped. #900 made client-side queueing (`whenBusy: 'queue'`) the default, so both now assert that the second message is queued and delivered in order, mirroring the current @tanstack/ai-react tests. The hook itself needed no change — queueing lives in ChatClient.", + "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`. The upstream 'narrows parsed input and gates approval by tool name' case originally failed here: its hand-rolled StandardJSONSchemaLike stand-in does not thread through @tanstack/ai's tool-input inference (the same pattern fails against @tanstack/ai-react too). The stand-in existed only because @standard-schema/spec was not directly resolvable in the Octane repo; it is resolvable here, so tool inputs now use real Zod — matching how @tanstack/ai-client's own type tests do it — and the case passes. 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." + ], + "verified": "2026-07-27" +} diff --git a/packages/ai-octane/tests/_fixtures/server.tsrx b/packages/ai-octane/tests/_fixtures/server.tsrx new file mode 100644 index 000000000..d364ca81e --- /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 000000000..ad3debe04 --- /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 000000000..ccc25792e --- /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 000000000..e6a0bc6b6 --- /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 000000000..3a0ca4164 --- /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 000000000..66a733fab --- /dev/null +++ b/packages/ai-octane/tests/conformance/test-utils.ts @@ -0,0 +1,36 @@ +// 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' + +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 000000000..30ee29221 --- /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 000000000..a55281f2a --- /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 000000000..34eb9a536 --- /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 000000000..cfd63c0fc --- /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 000000000..ba4d1918f --- /dev/null +++ b/packages/ai-octane/tests/conformance/use-chat.test.ts @@ -0,0 +1,2061 @@ +import type { ModelMessage, StreamChunk } from '@tanstack/ai' +import { EventType } from '@tanstack/ai' +import type { SubscribeConnectionAdapter } from '@tanstack/ai-client' +import { act, renderHook, waitFor } from '@octanejs/testing-library' +import { useState } from 'octane' +import { describe, expect, it, vi } from 'vitest' +import type { UIMessage, UseChatOptions } from '../../src/types' +import { useChat } from '../../src/use-chat.tsrx' +import { + 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('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, + id: 'persisted-chat', + persistence, + }) + + await waitFor(() => { + expect(result.current.messages).toEqual(persistedMessages) + }) + expect(persistence.getItem).toHaveBeenCalledWith('persisted-chat') + }) + + 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, + id: '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 [id, setId] = useState('old-chat') + const chat = useChat({ + connection: createMockConnectionAdapter(), + id, + 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 id', async () => { + const chunks = createTextChunks('Response') + const adapter = createMockConnectionAdapter({ chunks }) + + const { result } = renderUseChat({ + connection: adapter, + id: '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('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('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, + id: 'old-client', + onChunk: oldOnChunk, + }, + }, + ) + + const sendPromise = result.current.sendMessage('Test') + await waitFor(() => { + expect(result.current.isLoading).toBe(true) + }) + + rerender({ + connection: adapter, + id: '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 [id, setId] = useState('client-A') + const chat = useChat({ connection: adapter, id }) + 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 [id, setId] = useState('client-A') + const chat = useChat({ connection: adapter, id }) + 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, + id: 'chat-1', + }) + const { result: result2 } = renderUseChat({ + connection: adapter2, + id: '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, + id: 'chat-1', + }) + const { result: result2 } = renderUseChat({ + connection: adapter, + id: '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 000000000..40cb901b8 --- /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 000000000..af5af4166 --- /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 000000000..5e7b9f1cc --- /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 000000000..087b5d71f --- /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 000000000..f0bda23c9 --- /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 000000000..2c92e2646 --- /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 000000000..ff4a69964 --- /dev/null +++ b/packages/ai-octane/typetests/use-chat.test-d.tsx @@ -0,0 +1,221 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +import { useChat } from '../src/index' +import type { DeepPartial, 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() + }) + }) + + 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 000000000..15590d940 --- /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 000000000..0c0653a0b --- /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 000000000..3e8a7ae5f --- /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 caec892a8..6d934f8de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,7 +63,7 @@ importers: version: 23.1.0 oxfmt: specifier: ^0.59.0 - version: 0.59.0(svelte@5.45.10) + version: 0.59.0(svelte@5.45.10(@typescript-eslint/types@8.59.4)) oxlint: specifier: ^1.74.0 version: 1.74.0(oxlint-tsgolint@0.24.0) @@ -1135,7 +1135,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)(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.1.4(@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.21.0)(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.17)(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)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.17)(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.1.4(@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.21.0)(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))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@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.21.0)(yaml@2.9.0)))(vite@8.1.4(@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.21.0)(yaml@2.9.0)) @@ -1244,7 +1244,7 @@ importers: version: 11.11.1 lucide-svelte: specifier: ^0.468.0 - version: 0.468.0(svelte@5.45.10) + version: 0.468.0(svelte@5.45.10(@typescript-eslint/types@8.59.4)) marked: specifier: ^15.0.6 version: 15.0.12 @@ -1257,13 +1257,13 @@ importers: devDependencies: '@sveltejs/adapter-auto': specifier: ^3.3.1 - version: 3.3.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@5.1.1(supports-color@7.2.0)(svelte@5.45.10)(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(svelte@5.45.10)(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))) + version: 3.3.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@5.1.1(supports-color@7.2.0)(svelte@5.45.10(@typescript-eslint/types@8.59.4))(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(svelte@5.45.10(@typescript-eslint/types@8.59.4))(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))) '@sveltejs/kit': specifier: ^2.15.10 - version: 2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@5.1.1(supports-color@7.2.0)(svelte@5.45.10)(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(svelte@5.45.10)(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@5.1.1(supports-color@7.2.0)(svelte@5.45.10(@typescript-eslint/types@8.59.4))(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(svelte@5.45.10(@typescript-eslint/types@8.59.4))(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) '@sveltejs/vite-plugin-svelte': specifier: ^5.1.1 - version: 5.1.1(supports-color@7.2.0)(svelte@5.45.10)(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 5.1.1(supports-color@7.2.0)(svelte@5.45.10(@typescript-eslint/types@8.59.4))(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) @@ -1272,10 +1272,10 @@ importers: version: 24.10.3 svelte: specifier: ^5.20.0 - version: 5.45.10 + version: 5.45.10(@typescript-eslint/types@8.59.4) svelte-check: specifier: ^4.2.0 - version: 4.3.4(picomatch@4.0.5)(svelte@5.45.10)(typescript@5.9.3) + version: 4.3.4(picomatch@4.0.5)(svelte@5.45.10(@typescript-eslint/types@8.59.4))(typescript@5.9.3) tailwindcss: specifier: ^4.1.18 version: 4.1.18 @@ -1992,6 +1992,40 @@ importers: specifier: ^8.1.4 version: 8.1.4(@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.21.0)(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.1.4(@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.21.0)(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.0.14 + version: 4.0.14(supports-color@7.2.0)(vitest@4.1.10) + happy-dom: + specifier: ^20.0.10 + version: 20.0.11 + 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.1.4(@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.21.0)(yaml@2.9.0)) + vite: + specifier: ^8.1.4 + version: 8.1.4(@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.21.0)(yaml@2.9.0) + zod: + specifier: ^4.2.0 + version: 4.3.6 + packages/ai-ollama: dependencies: '@tanstack/ai-utils': @@ -2383,10 +2417,10 @@ importers: version: 1.1.0 '@sveltejs/package': specifier: ^2.3.10 - version: 2.5.7(svelte@5.45.10) + version: 2.5.7(svelte@5.45.10(@typescript-eslint/types@8.59.4)) '@sveltejs/vite-plugin-svelte': specifier: ^5.1.1 - version: 5.1.1(svelte@5.45.10)(vite@8.1.4(@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.21.0)(yaml@2.9.0)) + version: 5.1.1(svelte@5.45.10(@typescript-eslint/types@8.59.4))(vite@8.1.4(@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.21.0)(yaml@2.9.0)) '@tanstack/ai': specifier: workspace:* version: link:../ai @@ -2401,10 +2435,10 @@ importers: version: 27.3.0(postcss@8.5.19) svelte: specifier: ^5.20.0 - version: 5.45.10 + version: 5.45.10(@typescript-eslint/types@8.59.4) svelte-check: specifier: ^4.2.0 - version: 4.3.4(picomatch@4.0.5)(svelte@5.45.10)(typescript@5.9.3) + version: 4.3.4(picomatch@4.0.5)(svelte@5.45.10(@typescript-eslint/types@8.59.4))(typescript@5.9.3) typescript: specifier: 5.9.3 version: 5.9.3 @@ -5671,6 +5705,10 @@ packages: resolution: {integrity: sha512-P06o9TpxrJbiRbHQkiwy/rUrlXRupc+Z8KT4MiJfmcdWxvIdzjCaJOdnNkcOTs6DMyzIOefG5tvk/HLdtjqr0g==} engines: {node: '>= 10'} + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -5740,6 +5778,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==} @@ -8439,6 +8483,11 @@ packages: peerDependencies: eslint: ^9.0.0 || ^10.0.0 + '@sveltejs/acorn-typescript@1.0.11': + resolution: {integrity: sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==} + peerDependencies: + acorn: ^8.9.0 + '@sveltejs/acorn-typescript@1.0.8': resolution: {integrity: sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==} peerDependencies: @@ -9188,6 +9237,9 @@ packages: '@ts-morph/common@0.22.0': resolution: {integrity: sha512-HqNBuV/oIlMKdkLshXd1zKBqNQCsuPEsgQOkfFQ/eUKjRlwndXW1AjN9LVkBEIukm00gGXSRmfkl0Wv5VXLnlw==} + '@tsrx/core@0.1.50': + resolution: {integrity: sha512-s5oZWG9FQpyMGbnoM5or9/OBb0etUDzkVQuq4lIPFp8aYAb/mEA2m9GYRGq8nlsyAztVvqku3huQXwlXZT0m7g==} + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -9360,6 +9412,9 @@ packages: peerDependencies: '@types/react': ^19.2.0 + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/react@19.2.7': resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} @@ -9853,6 +9908,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -11007,6 +11067,9 @@ packages: devalue@5.6.1: resolution: {integrity: sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==} + devalue@5.8.2: + resolution: {integrity: sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -11443,8 +11506,13 @@ packages: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} - esrap@2.2.1: - resolution: {integrity: sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==} + esrap@2.3.0: + resolution: {integrity: sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng==} + peerDependencies: + '@typescript-eslint/types': ^8.2.0 + peerDependenciesMeta: + '@typescript-eslint/types': + optional: true esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} @@ -13795,6 +13863,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==} @@ -20116,6 +20199,8 @@ snapshots: '@ngrok/ngrok-win32-ia32-msvc': 1.7.0 '@ngrok/ngrok-win32-x64-msvc': 1.7.0 + '@noble/hashes@2.2.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -20160,6 +20245,11 @@ snapshots: '@nx/nx-win32-x64-msvc@23.1.0': 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.1.4(@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.21.0)(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.1.4(@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.21.0)(yaml@2.9.0)) + '@one-ini/wasm@0.1.1': {} '@oozcitak/dom@1.15.10': @@ -20896,7 +20986,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: @@ -20904,6 +20994,14 @@ snapshots: typescript: 5.9.3 optional: true + '@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.17)(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.17)(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) @@ -21000,6 +21098,26 @@ snapshots: - '@types/react-dom' optional: true + '@prisma/studio-core@0.33.0(@types/react@19.2.17)(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.17)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@types/react': 19.2.17 + '@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': {} @@ -21162,6 +21280,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.17)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.17 + optional: true + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.7)(react@19.2.3)': dependencies: react: 19.2.3 @@ -21480,6 +21605,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.17)(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.17)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.17 + 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) @@ -21599,6 +21733,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.17)(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.17 + 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) @@ -21690,6 +21832,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.17)(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.17)(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.17)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.17 + 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 @@ -21731,6 +21884,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.17)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.17)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.17 + 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) @@ -21739,6 +21901,14 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.17)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.17 + 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) @@ -21760,6 +21930,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.17)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.17 + 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 @@ -22514,20 +22691,24 @@ snapshots: estraverse: 5.3.0 picomatch: 4.0.4 + '@sveltejs/acorn-typescript@1.0.11(acorn@8.17.0)': + dependencies: + acorn: 8.17.0 + '@sveltejs/acorn-typescript@1.0.8(acorn@8.15.0)': dependencies: acorn: 8.15.0 - '@sveltejs/adapter-auto@3.3.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@5.1.1(supports-color@7.2.0)(svelte@5.45.10)(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(svelte@5.45.10)(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))': + '@sveltejs/adapter-auto@3.3.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@5.1.1(supports-color@7.2.0)(svelte@5.45.10(@typescript-eslint/types@8.59.4))(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(svelte@5.45.10(@typescript-eslint/types@8.59.4))(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))': dependencies: - '@sveltejs/kit': 2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@5.1.1(supports-color@7.2.0)(svelte@5.45.10)(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(svelte@5.45.10)(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) + '@sveltejs/kit': 2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@5.1.1(supports-color@7.2.0)(svelte@5.45.10(@typescript-eslint/types@8.59.4))(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(svelte@5.45.10(@typescript-eslint/types@8.59.4))(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) import-meta-resolve: 4.2.0 - '@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@5.1.1(supports-color@7.2.0)(svelte@5.45.10)(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(svelte@5.45.10)(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))': + '@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@5.1.1(supports-color@7.2.0)(svelte@5.45.10(@typescript-eslint/types@8.59.4))(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(svelte@5.45.10(@typescript-eslint/types@8.59.4))(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@standard-schema/spec': 1.0.0 '@sveltejs/acorn-typescript': 1.0.8(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 5.1.1(supports-color@7.2.0)(svelte@5.45.10)(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) + '@sveltejs/vite-plugin-svelte': 5.1.1(supports-color@7.2.0)(svelte@5.45.10(@typescript-eslint/types@8.59.4))(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 @@ -22539,60 +22720,60 @@ snapshots: sade: 1.8.1 set-cookie-parser: 2.7.2 sirv: 3.0.2 - svelte: 5.45.10 + svelte: 5.45.10(@typescript-eslint/types@8.59.4) vite: 7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) optionalDependencies: '@opentelemetry/api': 1.9.1 - '@sveltejs/package@2.5.7(svelte@5.45.10)': + '@sveltejs/package@2.5.7(svelte@5.45.10(@typescript-eslint/types@8.59.4))': dependencies: chokidar: 5.0.0 kleur: 4.1.5 sade: 1.8.1 semver: 7.7.3 - svelte: 5.45.10 - svelte2tsx: 0.7.45(@typescript/typescript6@6.0.2)(svelte@5.45.10) + svelte: 5.45.10(@typescript-eslint/types@8.59.4) + svelte2tsx: 0.7.45(@typescript/typescript6@6.0.2)(svelte@5.45.10(@typescript-eslint/types@8.59.4)) typescript: '@typescript/typescript6@6.0.2' - '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(supports-color@7.2.0)(svelte@5.45.10)(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(supports-color@7.2.0)(svelte@5.45.10)(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))': + '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(supports-color@7.2.0)(svelte@5.45.10(@typescript-eslint/types@8.59.4))(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(supports-color@7.2.0)(svelte@5.45.10(@typescript-eslint/types@8.59.4))(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))': dependencies: - '@sveltejs/vite-plugin-svelte': 5.1.1(supports-color@7.2.0)(svelte@5.45.10)(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) + '@sveltejs/vite-plugin-svelte': 5.1.1(supports-color@7.2.0)(svelte@5.45.10(@typescript-eslint/types@8.59.4))(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) debug: 4.4.3(supports-color@7.2.0) - svelte: 5.45.10 + svelte: 5.45.10(@typescript-eslint/types@8.59.4) vite: 7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.45.10)(vite@8.1.4(@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.21.0)(yaml@2.9.0)))(svelte@5.45.10)(vite@8.1.4(@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.21.0)(yaml@2.9.0))': + '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.45.10(@typescript-eslint/types@8.59.4))(vite@8.1.4(@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.21.0)(yaml@2.9.0)))(svelte@5.45.10(@typescript-eslint/types@8.59.4))(vite@8.1.4(@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.21.0)(yaml@2.9.0))': dependencies: - '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.45.10)(vite@8.1.4(@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.21.0)(yaml@2.9.0)) + '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.45.10(@typescript-eslint/types@8.59.4))(vite@8.1.4(@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.21.0)(yaml@2.9.0)) debug: 4.4.3(supports-color@7.2.0) - svelte: 5.45.10 + svelte: 5.45.10(@typescript-eslint/types@8.59.4) vite: 8.1.4(@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.21.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@5.1.1(supports-color@7.2.0)(svelte@5.45.10)(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))': + '@sveltejs/vite-plugin-svelte@5.1.1(supports-color@7.2.0)(svelte@5.45.10(@typescript-eslint/types@8.59.4))(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(supports-color@7.2.0)(svelte@5.45.10)(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(supports-color@7.2.0)(svelte@5.45.10)(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) + '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(supports-color@7.2.0)(svelte@5.45.10(@typescript-eslint/types@8.59.4))(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(supports-color@7.2.0)(svelte@5.45.10(@typescript-eslint/types@8.59.4))(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) debug: 4.4.3(supports-color@7.2.0) deepmerge: 4.3.1 kleur: 4.1.5 magic-string: 0.30.21 - svelte: 5.45.10 + svelte: 5.45.10(@typescript-eslint/types@8.59.4) vite: 7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) vitefu: 1.1.1(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.45.10)(vite@8.1.4(@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.21.0)(yaml@2.9.0))': + '@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.45.10(@typescript-eslint/types@8.59.4))(vite@8.1.4(@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.21.0)(yaml@2.9.0))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.45.10)(vite@8.1.4(@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.21.0)(yaml@2.9.0)))(svelte@5.45.10)(vite@8.1.4(@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.21.0)(yaml@2.9.0)) + '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.45.10(@typescript-eslint/types@8.59.4))(vite@8.1.4(@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.21.0)(yaml@2.9.0)))(svelte@5.45.10(@typescript-eslint/types@8.59.4))(vite@8.1.4(@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.21.0)(yaml@2.9.0)) debug: 4.4.3(supports-color@7.2.0) deepmerge: 4.3.1 kleur: 4.1.5 magic-string: 0.30.21 - svelte: 5.45.10 + svelte: 5.45.10(@typescript-eslint/types@8.59.4) vite: 8.1.4(@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.21.0)(yaml@2.9.0) vitefu: 1.1.1(vite@8.1.4(@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.21.0)(yaml@2.9.0)) transitivePeerDependencies: @@ -22898,9 +23079,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)(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.1.4(@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.21.0)(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.17)(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)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.17)(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.1.4(@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.21.0)(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)(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.17)(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)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.17)(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.1.4(@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.21.0)(yaml@2.9.0) transitivePeerDependencies: @@ -24085,6 +24266,21 @@ snapshots: mkdirp: 3.0.1 path-browserify: 1.0.1 + '@tsrx/core@0.1.50(@typescript-eslint/types@8.59.4)': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@noble/hashes': 2.2.0 + '@sveltejs/acorn-typescript': 1.0.11(acorn@8.17.0) + '@types/estree': 1.0.9 + '@types/estree-jsx': 1.0.5 + acorn: 8.17.0 + esrap: 2.3.0(@typescript-eslint/types@8.59.4) + is-reference: 3.0.3 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + transitivePeerDependencies: + - '@typescript-eslint/types' + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 @@ -24278,6 +24474,10 @@ snapshots: dependencies: '@types/react': 19.2.7 + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + '@types/react@19.2.7': dependencies: csstype: 3.2.3 @@ -24595,13 +24795,13 @@ snapshots: '@visx/event@4.0.1-alpha.0': dependencies: - '@types/react': 19.2.7 + '@types/react': 19.2.17 '@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.17 '@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 @@ -24613,7 +24813,7 @@ snapshots: '@visx/group@4.0.1-alpha.0(react@19.2.3)': dependencies: - '@types/react': 19.2.7 + '@types/react': 19.2.17 classnames: 2.5.1 react: 19.2.3 optional: true @@ -24624,7 +24824,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.17 lodash: 4.17.21 react: 19.2.3 optional: true @@ -24637,7 +24837,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.17 '@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 @@ -24958,6 +25158,8 @@ snapshots: acorn@8.15.0: {} + acorn@8.17.0: {} + agent-base@6.0.2(supports-color@7.2.0): dependencies: debug: 4.4.3(supports-color@7.2.0) @@ -26127,11 +26329,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)(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.17)(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)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.17)(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)(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.17)(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)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.17)(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)(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): @@ -26261,6 +26463,8 @@ snapshots: devalue@5.6.1: {} + devalue@5.8.2: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -26344,23 +26548,23 @@ 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 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) 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)(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.17)(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)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.17)(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.17)(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 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.17)(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): @@ -26761,9 +26965,11 @@ snapshots: dependencies: estraverse: 5.3.0 - esrap@2.2.1: + esrap@2.3.0(@typescript-eslint/types@8.59.4): dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + optionalDependencies: + '@typescript-eslint/types': 8.59.4 esrecurse@4.3.0: dependencies: @@ -28645,9 +28851,9 @@ snapshots: dependencies: solid-js: 1.9.10 - lucide-svelte@0.468.0(svelte@5.45.10): + lucide-svelte@0.468.0(svelte@5.45.10(@typescript-eslint/types@8.59.4)): dependencies: - svelte: 5.45.10 + svelte: 5.45.10(@typescript-eslint/types@8.59.4) lunr@2.3.9: {} @@ -29703,7 +29909,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)(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.17)(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)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.17)(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) @@ -29724,7 +29930,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)(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.17)(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)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.17)(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 @@ -29770,7 +29976,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)(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.17)(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)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.17)(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 @@ -30044,6 +30250,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.1.4(@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.21.0)(yaml@2.9.0)): + dependencies: + '@tsrx/core': 0.1.50(@typescript-eslint/types@8.59.4) + '@types/react': 19.2.17 + devalue: 5.8.2 + esrap: 2.3.0(@typescript-eslint/types@8.59.4) + optionalDependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + vite: 8.1.4(@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.21.0)(yaml@2.9.0) + transitivePeerDependencies: + - '@typescript-eslint/types' + ofetch@1.5.1: dependencies: destr: 2.0.5 @@ -30229,7 +30448,7 @@ snapshots: '@oxc-resolver/binding-win32-arm64-msvc': 11.21.3 '@oxc-resolver/binding-win32-x64-msvc': 11.21.3 - oxfmt@0.59.0(svelte@5.45.10): + oxfmt@0.59.0(svelte@5.45.10(@typescript-eslint/types@8.59.4)): dependencies: tinypool: 2.1.0 optionalDependencies: @@ -30252,7 +30471,7 @@ snapshots: '@oxfmt/binding-win32-arm64-msvc': 0.59.0 '@oxfmt/binding-win32-ia32-msvc': 0.59.0 '@oxfmt/binding-win32-x64-msvc': 0.59.0 - svelte: 5.45.10 + svelte: 5.45.10(@typescript-eslint/types@8.59.4) oxlint-plugin-eslint@1.74.0: {} @@ -30595,6 +30814,25 @@ snapshots: - react-dom optional: true + prisma@7.9.0(@types/react@19.2.17)(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.17)(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: {} @@ -32068,42 +32306,44 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-check@4.3.4(picomatch@4.0.5)(svelte@5.45.10)(typescript@5.9.3): + svelte-check@4.3.4(picomatch@4.0.5)(svelte@5.45.10(@typescript-eslint/types@8.59.4))(typescript@5.9.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 chokidar: 4.0.3 fdir: 6.5.0(picomatch@4.0.5) picocolors: 1.1.1 sade: 1.8.1 - svelte: 5.45.10 + svelte: 5.45.10(@typescript-eslint/types@8.59.4) typescript: 5.9.3 transitivePeerDependencies: - picomatch - svelte2tsx@0.7.45(@typescript/typescript6@6.0.2)(svelte@5.45.10): + svelte2tsx@0.7.45(@typescript/typescript6@6.0.2)(svelte@5.45.10(@typescript-eslint/types@8.59.4)): dependencies: dedent-js: 1.0.1 scule: 1.3.0 - svelte: 5.45.10 + svelte: 5.45.10(@typescript-eslint/types@8.59.4) typescript: '@typescript/typescript6@6.0.2' - svelte@5.45.10: + svelte@5.45.10(@typescript-eslint/types@8.59.4): dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 - '@sveltejs/acorn-typescript': 1.0.8(acorn@8.15.0) + '@sveltejs/acorn-typescript': 1.0.11(acorn@8.17.0) '@types/estree': 1.0.9 - acorn: 8.15.0 + acorn: 8.17.0 aria-query: 5.3.2 axobject-query: 4.1.0 clsx: 2.1.1 - devalue: 5.6.1 + devalue: 5.8.2 esm-env: 1.2.2 - esrap: 2.2.1 + esrap: 2.3.0(@typescript-eslint/types@8.59.4) is-reference: 3.0.3 locate-character: 3.0.0 magic-string: 0.30.21 zimmerframe: 1.1.4 + transitivePeerDependencies: + - '@typescript-eslint/types' symbol-tree@3.2.4: {} @@ -32179,7 +32419,7 @@ snapshots: terser@5.44.1: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.15.0 + acorn: 8.17.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -32659,7 +32899,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)(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)(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.17)(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)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.17)(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 @@ -32671,7 +32911,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)(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.17)(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)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.17)(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)(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): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0c87a0383..ed8a75a5e 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