@tanstack/ai-client 0.24.0 → 0.25.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","names":[],"sources":["../../src/types.ts"],"sourcesContent":["import type {\n AnyClientTool,\n ApprovalCapabilityOf,\n ApprovalSchemaOf,\n AudioPart,\n BatchInterruptError,\n ChunkStrategy,\n ContentPart,\n DocumentPart,\n ImagePart,\n InferSchemaType,\n InferToolInput,\n InferToolOutput,\n InputSchemaOf,\n Interrupt,\n InterruptBinding,\n ItemInterruptError,\n ModelMessage,\n NoSchema,\n RunAgentResumeItem,\n SchemaInput,\n StreamChunk,\n StructuredOutputPart,\n UIResourcePart,\n VideoPart,\n} from '@tanstack/ai/client'\nimport type { ConnectionAdapter } from './connection-adapters'\nimport type { AIDevtoolsClientMetadata } from './devtools'\nimport type { ChatDevtoolsBridgeFactory } from './devtools-noop'\n\nexport type { StructuredOutputPart }\n\nexport interface ChatResumeState {\n threadId: string\n runId: string\n}\n\nexport type ChatPendingInterrupt = Interrupt\n\n/**\n * The durable pointer a chat keeps for the run it may need to rejoin, plus any\n * interrupt that run is waiting on.\n *\n * @internal\n */\nexport interface ChatResumeSnapshot {\n resumeState: ChatResumeState\n pendingInterrupts?: Array<ChatPendingInterrupt>\n}\n\nexport type InterruptItemStatus =\n | 'pending'\n | 'validating'\n | 'staged'\n | 'submitting'\n | 'error'\n\nexport interface BoundInterruptBase {\n readonly id: string\n readonly interruptId: string\n readonly reason: string\n readonly message?: string\n readonly responseSchema?: Readonly<Record<string, unknown>>\n readonly expiresAt?: string\n readonly metadata?: Readonly<Record<string, unknown>>\n readonly threadId: string\n readonly interruptedRunId: string\n readonly generation: number\n readonly status: InterruptItemStatus\n readonly errors: ReadonlyArray<ItemInterruptError>\n /** @deprecated Use `errors[0]`. */\n readonly error?: ItemInterruptError\n /**\n * Whether the binding/schema allows resolution at hydrate time.\n * Does not flip on submit/expiry — gate UI on `status`, `resuming`, and\n * `errors` for those lifecycle states.\n */\n readonly canResolve: boolean\n cancel: () => void\n clearResolution: () => void\n}\n\nexport interface GenericAGUIInterrupt extends BoundInterruptBase {\n readonly kind: 'generic'\n readonly binding: Readonly<Extract<InterruptBinding, { kind: 'generic' }>>\n resolveInterrupt: (payload: unknown) => void\n}\n\n/**\n * An interrupt that arrived on the stream carrying no resume binding this\n * client understands — no `tanstack:interruptBinding`, or one written at a\n * protocol version we don't recognise.\n *\n * These are surfaced rather than hidden so a UI can show that the run is\n * paused, but they are never resolvable here: something else owns them. A\n * workflow engine's durable approval projected into the same AG-UI stream\n * lands in this bucket, and resolving it through the chat resume path would\n * send an answer no one is waiting for. Render it, or route it to whatever\n * actually owns the pause.\n */\nexport interface UnboundInterrupt extends BoundInterruptBase {\n readonly kind: 'unbound'\n readonly binding?: undefined\n readonly canResolve: false\n}\n\ntype ApprovalBranchSchema<TTool, TBranch extends 'approve' | 'reject'> =\n ApprovalSchemaOf<TTool> extends infer TApproval\n ? TApproval extends { approve?: SchemaInput; reject?: SchemaInput }\n ? Exclude<TApproval[TBranch], undefined>\n : TApproval extends SchemaInput\n ? TApproval\n : never\n : never\n\ntype ApprovalEdits<TTool> =\n InputSchemaOf<TTool> extends NoSchema\n ? { editedArgs?: never }\n : { editedArgs?: InferToolInput<TTool> }\n\ntype ApprovalPayload<TSchema> = [TSchema] extends [never]\n ? { payload?: never }\n : TSchema extends SchemaInput\n ? { payload: InferSchemaType<TSchema> }\n : { payload?: never }\n\ntype ApproveArguments<TTool> = [\n ApprovalBranchSchema<TTool, 'approve'>,\n] extends [never]\n ? InputSchemaOf<TTool> extends NoSchema\n ? [options?: never]\n : [options?: ApprovalEdits<TTool> & { payload?: never }]\n : [\n options: ApprovalEdits<TTool> &\n ApprovalPayload<ApprovalBranchSchema<TTool, 'approve'>>,\n ]\n\ntype RejectArguments<TTool> = [ApprovalBranchSchema<TTool, 'reject'>] extends [\n never,\n]\n ? [options?: never]\n : [\n options: { editedArgs?: never } & ApprovalPayload<\n ApprovalBranchSchema<TTool, 'reject'>\n >,\n ]\n\nexport type ToolApprovalInterrupt<TTool extends AnyClientTool = AnyClientTool> =\n TTool extends AnyClientTool\n ? BoundInterruptBase & {\n readonly kind: 'tool-approval'\n readonly binding: Readonly<\n Extract<InterruptBinding, { kind: 'tool-approval' }>\n >\n readonly toolName: TTool['name']\n readonly toolCallId: string\n readonly originalArgs: InferToolInput<TTool>\n // A single generic call signature — not two overloads. Overloads break\n // editor autocomplete: a half-typed options literal (e.g.\n // `resolveInterrupt(true, { payload: {` ) satisfies neither overload,\n // so TS resolves no signature and offers no contextual completions.\n // Making `approved` a generic discriminant lets TS infer it from the\n // first argument and pick the matching branch for the rest params, so\n // `payload` / `editedArgs` / the correct schema's fields complete\n // per-branch (a plain union-of-tuples would offer both branches'\n // fields) while still enforcing the right shape.\n resolveInterrupt: <TApproved extends boolean>(\n approved: TApproved,\n ...args: TApproved extends true\n ? ApproveArguments<TTool>\n : RejectArguments<TTool>\n ) => void\n }\n : never\n\ntype ApprovalInterrupts<TTools extends ReadonlyArray<AnyClientTool>> =\n TTools[number] extends infer TTool\n ? TTool extends AnyClientTool\n ? ApprovalCapabilityOf<TTool> extends true\n ? ToolApprovalInterrupt<TTool>\n : never\n : never\n : never\n\n// Client tools resolve through their `.client()` implementation (auto-run) or\n// `addToolResult` — never as a bound interrupt. The `client-tool-execution`\n// pause is handled internally and is intentionally absent from this public\n// union.\nexport type ChatInterrupt<\n TTools extends ReadonlyArray<AnyClientTool> = ReadonlyArray<AnyClientTool>,\n> = GenericAGUIInterrupt | UnboundInterrupt | ApprovalInterrupts<TTools>\n\nexport type BoundInterrupts<\n TTools extends ReadonlyArray<AnyClientTool> = ReadonlyArray<AnyClientTool>,\n> = ReadonlyArray<ChatInterrupt<TTools>>\n\nexport interface ChatInterruptState<\n TTools extends ReadonlyArray<AnyClientTool> = ReadonlyArray<AnyClientTool>,\n> {\n readonly interrupts: BoundInterrupts<TTools>\n /** @deprecated Use `interrupts`. Same snapshot today. */\n readonly pendingInterrupts: BoundInterrupts<TTools>\n readonly interruptErrors: ReadonlyArray<BatchInterruptError>\n readonly resuming: boolean\n}\n\n/**\n * `messages` is the full UIMessage history (not a delta). `data` is the\n * merged body — `ChatClientOptions.body` plus any per-call data passed to\n * `sendMessage(...)`. `threadId` / `runId` are the AG-UI correlation ids\n * the chat client uses to track this turn — forward them to your server\n * if it needs to correlate requests.\n */\nexport interface ChatFetcherInput {\n messages: Array<UIMessage>\n data?: Record<string, unknown>\n threadId: string\n runId: string\n parentRunId?: string\n resume?: Array<RunAgentResumeItem>\n}\n\nexport interface ChatFetcherOptions {\n /** Fires when `stop()` is called or the request is superseded. */\n signal: AbortSignal\n}\n\n/**\n * Direct function that performs a chat request. Mirrors\n * `GenerationFetcher`. Returns either a `Response` (SSE body parsed by the\n * chat client) or an `AsyncIterable<StreamChunk>` (yielded directly). May\n * return the value synchronously, as a `Promise`, or as an async generator\n * (`async function*`) — the chat client awaits whichever shape is returned.\n *\n * @example\n * ```ts\n * useChat({\n * fetcher: ({ messages }, { signal }) =>\n * chatFn({ data: { messages }, signal }),\n * })\n * ```\n */\nexport type ChatFetcher = (\n input: ChatFetcherInput,\n options: ChatFetcherOptions,\n) =>\n | Response\n | AsyncIterable<StreamChunk>\n | Promise<Response | AsyncIterable<StreamChunk>>\n\n/**\n * Distributive `Omit` — applies `Omit<O, K>` per branch of a union so\n * discriminated unions survive omission. Plain `Omit` collapses unions\n * into a single object shape, which would erase the `ChatTransport` XOR\n * when framework hooks omit React-managed callbacks from\n * `ChatClientOptions`.\n */\nexport type DistributedOmit<\n TObject,\n TKeys extends keyof any,\n> = TObject extends unknown ? Omit<TObject, TKeys> : never\n\n/**\n * Discriminated union enforcing that exactly one of `connection` or\n * `fetcher` is provided. Mirrors `GenerationTransport`.\n */\nexport type ChatTransport =\n | { connection: ConnectionAdapter; fetcher?: never }\n | { fetcher: ChatFetcher; connection?: never }\n\n/**\n * Tool call states - track the lifecycle of a tool call\n */\nexport type ToolCallState =\n | 'awaiting-input' // Received start but no arguments yet\n | 'input-streaming' // Partial arguments received\n | 'input-complete' // All arguments received\n | 'approval-requested' // Waiting for user approval\n | 'approval-responded' // User has approved/denied\n | 'complete' // Result is complete\n | 'error' // Tool execution failed (terminal)\n\n/**\n * Tool result states - track the lifecycle of a tool result\n */\nexport type ToolResultState =\n | 'streaming' // Placeholder for future streamed output\n | 'complete' // Result is complete\n | 'error' // Error occurred\n\n/**\n * ChatClient state - track the lifecycle of a chat\n */\nexport type ChatClientState = 'ready' | 'submitted' | 'streaming' | 'error'\n\n/**\n * Connection lifecycle state for the subscription loop.\n */\nexport type ConnectionStatus =\n | 'disconnected'\n | 'connecting'\n | 'connected'\n | 'error'\n\n/**\n * Multimodal content input for sending messages with rich media.\n * Allows sending text, images, audio, video, and documents to the LLM.\n *\n * @example\n * ```ts\n * // Send an image with a question\n * client.sendMessage({\n * content: [\n * { type: 'text', content: 'What is in this image?' },\n * { type: 'image', source: { type: 'url', value: 'https://example.com/photo.jpg' } }\n * ],\n * id: 'custom-message-id' // optional\n * })\n * ```\n */\nexport interface MultimodalContent {\n /**\n * The content of the message.\n * Can be a simple string or an array of content parts for multimodal messages.\n */\n content: string | Array<ContentPart>\n /**\n * Optional custom ID for the message.\n * If not provided, a unique ID will be generated.\n */\n id?: string\n}\n\n/**\n * Action taken when `sendMessage` is called while the client is busy\n * (streaming, claiming a send, or draining the queue).\n * - `queue`: hold the message; it auto-sends when the current run settles\n * **successfully**.\n * - `drop`: ignore the send (promise still resolves; does not throw).\n * - `interrupt`: abort the current stream and send immediately. Unlike\n * `stop()`, does **not** flush already-queued messages — they still drain\n * after the interrupting send settles successfully.\n */\nexport type WhenBusy = 'queue' | 'drop' | 'interrupt'\n\n/**\n * Why the client is busy when a {@link QueueStrategy} runs.\n * - `streaming` — an LLM stream is active (`isLoading`).\n * - `sendInFlight` — a send has claimed the client but is not yet loading.\n * - `draining` — the queue drain loop is delivering pending messages.\n */\nexport type QueueBusyReason = 'streaming' | 'sendInFlight' | 'draining'\n\n/**\n * A user message held in the send queue while a stream is active.\n * Rendered separately from `messages`; cancellable via `cancelQueued(id)`\n * until it drains.\n */\nexport interface QueuedMessage {\n id: string\n content: string | MultimodalContent\n createdAt: number\n}\n\n/**\n * Declarative queue policy.\n */\nexport interface QueueConfig {\n /**\n * Action when the client is busy (streaming, claiming a send, or draining).\n * Default `'queue'`.\n */\n whenBusy?: WhenBusy\n /**\n * How queued items leave the queue.\n * - `'fifo'`: one at a time, in order (default).\n * - `'batch'`: merge all queued items into one send when the run settles\n * successfully.\n */\n drain?: 'fifo' | 'batch'\n /** Max queued items. Unlimited when omitted. `0` means never queue. */\n maxSize?: number\n /**\n * Behavior when `maxSize` is reached. Default `'reject'`.\n * `'reject'` silently discards the new send (does not throw);\n * `'drop-oldest'` evicts the oldest queued item to make room.\n * Only meaningful when `maxSize` is set.\n */\n onOverflow?: 'reject' | 'drop-oldest'\n}\n\n/**\n * Escape hatch: decide the action for a single send. Drain stays FIFO for the\n * function form (no `batch` via function). Per-call `sendOptions.whenBusy`\n * overrides the strategy for that send.\n *\n * Actions match {@link WhenBusy}: `'queue' | 'drop' | 'interrupt'`. Concurrent\n * streams are not supported. `pending.id` is the id that will be stored if the\n * action is `'queue'` (safe to pass to `cancelQueued`).\n */\nexport type QueueStrategy = (ctx: {\n pending: QueuedMessage\n busyReason: QueueBusyReason\n queued: ReadonlyArray<QueuedMessage>\n}) => { action: WhenBusy }\n\n/** A `WhenBusy` shorthand, a full config, or a strategy function. */\nexport type QueueOption = WhenBusy | QueueConfig | QueueStrategy\n\n/** Per-call overrides for `sendMessage`. */\nexport interface SendMessageOptions {\n /** Overrides the configured `whenBusy` for this one send. */\n whenBusy?: WhenBusy\n}\n\n/**\n * Message parts - building blocks of UIMessage\n */\nexport interface TextPart {\n type: 'text'\n content: string\n}\n\n/**\n * Helper type that creates a tool-call part for a specific tool.\n * This is a conditional type to enable proper distribution over union types,\n * creating a discriminated union where `name` is the discriminant.\n */\ntype ToolCallPartForTool<T> = T extends AnyClientTool\n ? {\n type: 'tool-call'\n id: string\n name: T['name']\n arguments: string // JSON string (may be incomplete)\n /** Parsed tool input (typed from inputSchema) */\n input?: InferToolInput<T>\n state: ToolCallState\n /** Tool execution output (for client tools or after approval) */\n output?: InferToolOutput<T>\n } & (NonNullable<T['needsApproval']> extends true\n ? {\n /**\n * Approval metadata — present only on tools defined with\n * `needsApproval: true`. Populated once the call reaches\n * `state: 'approval-requested'`. `needsApproval` is an optional\n * property on the tool, so we index into it (rather than\n * `T extends { needsApproval: true }`, which an optional property\n * never satisfies) and strip `undefined` before comparing to `true`.\n */\n approval?: {\n id: string // Unique approval ID\n needsApproval: boolean // Always true if present\n approved?: boolean // User's decision (undefined until responded)\n }\n }\n : // Tools without `needsApproval: true` never carry an approval field.\n // `& unknown` is a no-op intersection (adds nothing).\n unknown)\n : never\n\n/**\n * Fallback tool-call part type when tools are not typed\n */\ntype UntypedToolCallPart = {\n type: 'tool-call'\n id: string\n name: string\n arguments: string\n input?: any\n state: ToolCallState\n approval?: {\n id: string\n needsApproval: boolean\n approved?: boolean\n }\n output?: any\n}\n\n/**\n * Tool call part that creates a proper discriminated union.\n * When TTools is typed, checking `part.name === 'toolName'` will narrow\n * `part.output` to the correct type for that tool.\n *\n * The discriminant is `name`, so code like:\n * ```ts\n * if (part.name === 'recommendGuitar') {\n * // part.output is now typed to the recommendGuitar tool's output\n * }\n * ```\n */\nexport type ToolCallPart<TTools extends ReadonlyArray<AnyClientTool> = any> =\n // Check if we have a concrete tools array (not 'any' or 'never')\n [TTools] extends [never]\n ? UntypedToolCallPart\n : unknown extends TTools\n ? UntypedToolCallPart\n : TTools extends ReadonlyArray<infer Tool>\n ? Tool extends AnyClientTool\n ? ToolCallPartForTool<Tool>\n : UntypedToolCallPart\n : UntypedToolCallPart\n\nexport interface ToolResultPart {\n type: 'tool-result'\n toolCallId: string\n content: string | Array<ContentPart>\n state: ToolResultState\n error?: string // Error message if state is \"error\"\n}\n\nexport interface ThinkingPart {\n type: 'thinking'\n content: string\n}\n\nexport type MessagePart<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n TData = unknown,\n> =\n | TextPart\n | ImagePart\n | AudioPart\n | VideoPart\n | DocumentPart\n | ToolCallPart<TTools>\n | ToolResultPart\n | ThinkingPart\n | StructuredOutputPart<TData>\n | UIResourcePart\n\n/**\n * UIMessage - Domain-specific message format optimized for building chat UIs\n * Contains parts that can be text, tool calls, or tool results.\n *\n * `TTools` narrows the tool-call/result part types based on the registered\n * tools. `TData` is the schema-inferred type for any `structured-output` part\n * on the message — defaulted to `unknown` so untyped consumers (the core\n * stream processor, the wire converter) don't need to thread a schema generic\n * everywhere; the hook layer (`useChat({ outputSchema })`) substitutes it on\n * the public return so `m.parts.find(p => p.type === 'structured-output').data`\n * is typed without manual casts.\n */\nexport interface UIMessage<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n TData = unknown,\n> {\n id: string\n role: 'system' | 'user' | 'assistant'\n parts: Array<MessagePart<TTools, TData>>\n createdAt?: Date\n}\n\n/**\n * A generic key/value storage adapter. `getItem` may be sync or async; the\n * chat persistence layer treats every call as best-effort. The provided\n * `localStoragePersistence` / `sessionStoragePersistence` / `indexedDBPersistence`\n * factories return one of these, and `ChatStorageAdapter<ChatPersistedState>`\n * is assignable to {@link ChatClientPersistence}.\n */\nexport interface ChatStorageAdapter<TValue> {\n getItem: (\n id: string,\n ) => TValue | null | undefined | Promise<TValue | null | undefined>\n setItem: (id: string, value: TValue) => void | Promise<void>\n removeItem: (id: string) => void | Promise<void>\n}\n\n/**\n * The single record a `ChatClientPersistence` adapter stores per chat. It folds\n * the two things that must survive a full page reload into one blob under one\n * key: the message transcript and the optional resume snapshot (which run to\n * rejoin / which interrupts to rehydrate). One adapter, one key — see\n * {@link ChatClientPersistence}.\n */\nexport interface ChatPersistedState<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n> {\n messages: Array<UIMessage<TTools>>\n /** Present while a run is in flight or paused on an interrupt; absent otherwise. */\n resume?: ChatResumeSnapshot\n}\n\n/**\n * Storage adapter for durable chat state. A single adapter persists both the\n * message transcript and the resume snapshot as one {@link ChatPersistedState}\n * record, so a full page reload restores the conversation AND can rejoin an\n * in-flight run / rehydrate pending interrupts.\n *\n * For backward compatibility `getItem` may also return a bare `UIMessage[]`\n * (the legacy messages-only format); the client normalizes it to\n * `{ messages }`. `setItem` always writes the combined record.\n */\nexport interface ChatClientPersistence<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n> {\n getItem: (\n id: string,\n ) =>\n | ChatPersistedState<TTools>\n | Array<UIMessage<TTools>>\n | null\n | undefined\n | Promise<\n ChatPersistedState<TTools> | Array<UIMessage<TTools>> | null | undefined\n >\n setItem: (\n id: string,\n state: ChatPersistedState<TTools>,\n ) => void | Promise<void>\n removeItem: (id: string) => void | Promise<void>\n}\n\n/**\n * The `persistence` option for a chat.\n *\n * - `false` (default): ephemeral. Messages live in memory only; a reload starts\n * from empty.\n * - `true`: server-authoritative. Nothing is cached in the browser. On mount the\n * client hydrates the thread from the server by its `threadId` (paints the\n * stored transcript and tails any run still generating), so a reload or the\n * same thread opened on another device both just resume. Requires a connection\n * with a `hydrate` handler.\n * - a {@link ChatClientPersistence} adapter: client-authoritative. The combined\n * {@link ChatPersistedState} record (transcript plus resume pointer) is cached\n * in the browser and restored on reload with no network.\n */\nexport type ChatPersistenceOption<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n> = boolean | ChatClientPersistence<TTools>\n\n/**\n * The `persistence` / `threadId` pairing for `ChatClient` and the chat hooks.\n *\n * Persistence that is on (`true` or a storage adapter) requires a `threadId`.\n * A minted id changes every reload, so nothing would restore. The compiler\n * asks for the conversation id instead.\n *\n * Omit `persistence`, or set it to `false`, and `threadId` stays optional.\n * The client then mints one after mount for the wire and DevTools.\n *\n * Intersect this onto `ChatClientOptions`. Do not apply a later plain `Omit`\n * to that type: it collapses the union and the requirement disappears. Use\n * {@link DistributedOmit}.\n */\nexport type ChatPersistenceOptions<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n> =\n | {\n persistence: true\n threadId: string\n }\n | {\n persistence: ChatClientPersistence<TTools>\n threadId: string\n }\n | {\n persistence?: false | undefined\n threadId?: string\n }\n\ntype IsUnknown<T> = unknown extends T\n ? [T] extends [unknown]\n ? true\n : false\n : false\n\ntype KnownContext<T> = IsUnknown<T> extends true ? never : T\n\ntype MergeContext<TLeft, TRight> = [TLeft] extends [never]\n ? TRight\n : [TRight] extends [never]\n ? TLeft\n : TLeft & TRight\n\ntype UnionToIntersection<T> = [T] extends [never]\n ? never\n : (T extends unknown ? (value: T) => void : never) extends (\n value: infer TIntersection,\n ) => void\n ? TIntersection\n : never\n\ntype DefinedContext<T> = Exclude<T, undefined>\n\ntype ContextFromExecute<T> = T extends (...args: any) => any\n ? NonNullable<Parameters<T>[1]> extends { context: infer TContext }\n ? KnownContext<TContext>\n : never\n : never\n\ntype ContextFromClientTool<T> = T extends AnyClientTool\n ? T extends { execute?: infer TExecute }\n ? ContextFromExecute<TExecute>\n : never\n : never\n\ntype RequiredContextFromClientToolUnion<T> = T extends unknown\n ? undefined extends ContextFromClientTool<T>\n ? never\n : ContextFromClientTool<T>\n : never\n\ntype ContextFromClientToolUnion<T> = [\n UnionToIntersection<DefinedContext<ContextFromClientTool<T>>>,\n] extends [never]\n ? never\n : [RequiredContextFromClientToolUnion<T>] extends [never]\n ? UnionToIntersection<DefinedContext<ContextFromClientTool<T>>> | undefined\n : UnionToIntersection<DefinedContext<ContextFromClientTool<T>>>\n\ntype ContextFromClientTools<TTools> =\n IsUnknown<TTools> extends true\n ? never\n : TTools extends readonly [infer THead, ...infer TTail]\n ? MergeContext<\n ContextFromClientTool<THead>,\n ContextFromClientTools<TTail>\n >\n : TTools extends ReadonlyArray<infer TItem>\n ? ContextFromClientToolUnion<TItem>\n : never\n\nexport type InferredClientContext<TTools> = [\n ContextFromClientTools<TTools>,\n] extends [never]\n ? unknown\n : ContextFromClientTools<TTools>\n\nexport type ClientContextOptionFromTools<TTools, TContext> = [\n ContextFromClientTools<TTools>,\n] extends [never]\n ? { context?: TContext }\n : undefined extends ContextFromClientTools<TTools>\n ? { context?: TContext & ContextFromClientTools<TTools> }\n : { context: TContext & ContextFromClientTools<TTools> }\n\n/**\n * Base options for `ChatClient`, excluding the transport (`connection` or\n * `fetcher`) which is supplied separately via `ChatTransport` so the XOR\n * is preserved when composing the final `ChatClientOptions` type.\n */\nexport interface ChatClientBaseOptions<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n TContext = unknown,\n> {\n /**\n * Initial messages to populate the chat\n */\n initialMessages?: Array<UIMessage<TTools>>\n\n /**\n * Initial resumable run state, useful when rehydrating a persisted client\n * after a full page reload. This restores the client-side interrupt\n * descriptors needed to send AG-UI resume entries.\n */\n initialResumeSnapshot?: ChatResumeSnapshot\n\n /**\n * Arbitrary client-controlled JSON forwarded to the server in the\n * AG-UI `RunAgentInput.forwardedProps` field. Use this for per-session\n * options like provider/model selection or feature flags that the\n * server endpoint should read.\n *\n * Replaces the legacy `body` option. If both are provided,\n * `forwardedProps` wins on key collision.\n */\n forwardedProps?: Record<string, any>\n\n /**\n * @deprecated Use `forwardedProps` instead. `body` continues to work\n * unchanged — its values are merged into the AG-UI\n * `RunAgentInput.forwardedProps` field on the wire and are also\n * mirrored under the legacy `data` field for servers that have not\n * migrated yet. Will be removed in a future major release.\n */\n body?: Record<string, any>\n\n /**\n * Client-local runtime context passed to client tool implementations.\n *\n * This value is not serialized to the server. Use `forwardedProps` for\n * explicit client-to-server handoff of serializable values.\n */\n context?: TContext\n\n /**\n * Callback when a response is received\n */\n onResponse?: (response?: Response) => void | Promise<void>\n\n /**\n * Callback when a stream chunk is received\n */\n onChunk?: (chunk: StreamChunk) => void\n\n /**\n * Callback when the response is finished\n */\n onFinish?: (message: UIMessage<TTools>) => void\n\n /**\n * Callback when an error occurs\n */\n onError?: (error: Error) => void\n\n /**\n * Callback when messages change\n */\n onMessagesChange?: (messages: Array<UIMessage<TTools>>) => void\n\n /**\n * Callback when loading state changes\n */\n onLoadingChange?: (isLoading: boolean) => void\n\n /**\n * Callback when error state changes\n */\n onErrorChange?: (error: Error | undefined) => void\n\n /**\n * Callback when chat status changes\n */\n onStatusChange?: (status: ChatClientState) => void\n\n /**\n * Callback when subscription lifecycle changes.\n * This is independent from request lifecycle (`isLoading`, `status`).\n */\n onSubscriptionChange?: (isSubscribed: boolean) => void\n\n /**\n * Callback when connection lifecycle changes.\n */\n onConnectionStatusChange?: (status: ConnectionStatus) => void\n\n /**\n * Callback when session generation activity changes.\n * Derived from stream run events (RUN_STARTED / RUN_FINISHED / RUN_ERROR).\n * Unlike `onLoadingChange` (request-local), this reflects shared generation\n * activity visible to all subscribers (e.g. across tabs/devices).\n */\n onSessionGeneratingChange?: (isGenerating: boolean) => void\n\n /**\n * Policy for messages sent while the client is busy (streaming, claiming\n * a send, or draining the queue). Accepts a `WhenBusy` string, a\n * `QueueConfig`, or a `QueueStrategy` function.\n * Default: `{ whenBusy: 'queue', drain: 'fifo' }`.\n * Queued items auto-send only after a **successful** settle; they are\n * discarded on error/abort, `stop()`, `clear()`, `unsubscribe()`, and\n * `reload()`.\n */\n queue?: QueueOption\n\n /**\n * Callback when the pending send queue changes (enqueue, cancel, drain,\n * or flush).\n */\n onQueueChange?: (queue: Array<QueuedMessage>) => void\n\n /**\n * Callback when resumable run state or pending interrupts change.\n */\n onResumeStateChange?: (\n resumeState: ChatResumeState | null,\n pendingInterrupts: BoundInterrupts<TTools>,\n ) => void\n\n /**\n * Callback when the id of the run this client has in flight changes: the new\n * id when a run starts (a send, or a `joinRun` rejoin), `null` when it settles.\n */\n onRunIdChange?: (runId: string | null) => void\n\n /** Callback when the immutable interrupt state snapshot changes. */\n onInterruptStateChange?: (state: ChatInterruptState<TTools>) => void\n\n /**\n * Callback when a custom event is received from a server-side tool.\n * Custom events are emitted by tools using `context.emitCustomEvent()` during execution.\n *\n * @param eventType - The name of the custom event\n * @param data - The event payload data\n * @param context - Additional context including the toolCallId that emitted the event\n */\n onCustomEvent?: (\n eventType: string,\n data: unknown,\n context: { toolCallId?: string },\n ) => void\n\n /**\n * Client-side tools with execution logic\n * When provided, tools with execute functions will be called automatically\n */\n tools?: TTools\n\n /**\n * Devtools hook metadata for this client instance.\n */\n devtools?: Partial<AIDevtoolsClientMetadata>\n\n /**\n * Factory that constructs the devtools bridge. Default is a no-op\n * factory, which keeps `@tanstack/ai-client/devtools` (the heavy\n * bridge implementation) out of the main entry's bundle. Frameworks\n * that need live devtools should pass the real factory from\n * `@tanstack/ai-client/devtools`.\n */\n devtoolsBridgeFactory?: ChatDevtoolsBridgeFactory\n\n /**\n * Stream processing options (optional)\n * Configure chunking strategy\n */\n streamProcessor?: {\n /**\n * Strategy for when to emit text updates\n * Defaults to ImmediateStrategy (every chunk)\n */\n chunkStrategy?: ChunkStrategy\n }\n}\n\n/**\n * Options for `ChatClient`. Exactly one of `connection` or `fetcher` must be\n * provided — the type-level XOR is enforced via `ChatTransport`. Persistence\n * that is on requires a `threadId` via {@link ChatPersistenceOptions}.\n */\nexport type ChatClientOptions<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n TContext = InferredClientContext<TTools>,\n> = DistributedOmit<ChatClientBaseOptions<TTools, TContext>, 'context'> &\n ClientContextOptionFromTools<TTools, TContext> &\n ChatTransport &\n ChatPersistenceOptions<TTools>\n\nexport interface ChatRequestBody {\n messages: Array<ModelMessage>\n data?: Record<string, any>\n}\n\n/**\n * Create a typed array of client tools with proper type inference.\n * This eliminates the need for `as const` when defining tool arrays.\n *\n * @example\n * ```ts\n * const tools = clientTools(\n * myTool1.client(() => result1),\n * myTool2.client(() => result2),\n * )\n *\n * // tools is now properly typed as a tuple with literal tool names\n * // This enables type narrowing when checking part.name === 'toolName'\n * ```\n */\nexport function clientTools<const T extends Array<AnyClientTool>>(\n ...tools: T\n): T {\n return tools\n}\n\n/**\n * Helper to create typed chat client options\n * Use this to get proper type inference for messages\n *\n * @example\n * ```ts\n * const tools = clientTools(myTool1, myTool2)\n *\n * const chatOptions = createChatClientOptions({\n * connection: fetchServerSentEvents('/api/chat'),\n * tools,\n * })\n *\n * type MyMessages = InferChatMessages<typeof chatOptions>\n * ```\n */\nexport function createChatClientOptions<\n const TTools extends ReadonlyArray<AnyClientTool>,\n TContext = InferredClientContext<TTools>,\n>(\n options: ChatClientOptions<TTools, TContext>,\n): ChatClientOptions<TTools, TContext> {\n return options\n}\n\n/**\n * Extract the message type from chat options\n *\n * @example\n * ```ts\n * const chatOptions = createChatClientOptions({\n * connection: fetchServerSentEvents('/api/chat'),\n * tools: [myTool1, myTool2],\n * })\n *\n * type MyMessages = InferChatMessages<typeof chatOptions>\n * // MyMessages is now Array<UIMessage<[typeof myTool1, typeof myTool2]>>\n * ```\n */\nexport type InferChatMessages<T> =\n T extends ChatClientOptions<infer TTools, any>\n ? Array<UIMessage<TTools>>\n : never\n"],"mappings":";;;;;;;;;;;;;;;;AA87BA,SAAgB,YACd,GAAG,OACA;CACH,OAAO;AACT;;;;;;;;;;;;;;;;;AAkBA,SAAgB,wBAId,SACqC;CACrC,OAAO;AACT"}
1
+ {"version":3,"file":"types.js","names":[],"sources":["../../src/types.ts"],"sourcesContent":["import type {\n AnyClientTool,\n ApprovalCapabilityOf,\n ApprovalSchemaOf,\n AudioPart,\n BatchInterruptError,\n ChunkStrategy,\n ContentPart,\n DocumentPart,\n ImagePart,\n InferSchemaType,\n InterruptDefinition,\n InferToolInput,\n InferToolOutput,\n InputSchemaOf,\n Interrupt,\n InterruptBinding,\n ItemInterruptError,\n ModelMessage,\n NoSchema,\n RunAgentResumeItem,\n SchemaInput,\n StreamChunk,\n StructuredOutputPart,\n UIResourcePart,\n VideoPart,\n} from '@tanstack/ai/client'\nimport type { ConnectionAdapter } from './connection-adapters'\nimport type { AIDevtoolsClientMetadata } from './devtools'\nimport type { ChatDevtoolsBridgeFactory } from './devtools-noop'\n\nexport type { StructuredOutputPart }\n\nexport interface ChatResumeState {\n threadId: string\n runId: string\n}\n\nexport type ChatPendingInterrupt = Interrupt\n\n/**\n * The durable pointer a chat keeps for the run it may need to rejoin, plus any\n * interrupt that run is waiting on.\n *\n * @internal\n */\nexport interface ChatResumeSnapshot {\n resumeState: ChatResumeState\n pendingInterrupts?: Array<ChatPendingInterrupt>\n}\n\nexport type InterruptItemStatus =\n | 'pending'\n | 'validating'\n | 'staged'\n | 'submitting'\n | 'error'\n\nexport interface BoundInterruptBase {\n readonly id: string\n readonly interruptId: string\n readonly reason: string\n readonly message?: string\n readonly responseSchema?: Readonly<Record<string, unknown>>\n readonly expiresAt?: string\n readonly metadata?: Readonly<Record<string, unknown>>\n readonly threadId: string\n readonly interruptedRunId: string\n readonly generation: number\n readonly status: InterruptItemStatus\n readonly errors: ReadonlyArray<ItemInterruptError>\n /** @deprecated Use `errors[0]`. */\n readonly error?: ItemInterruptError\n /**\n * Whether the binding/schema allows resolution at hydrate time.\n * Does not flip on submit/expiry — gate UI on `status`, `resuming`, and\n * `errors` for those lifecycle states.\n */\n readonly canResolve: boolean\n cancel: () => void\n clearResolution: () => void\n}\n\nexport interface GenericAGUIInterrupt extends BoundInterruptBase {\n readonly kind: 'generic'\n readonly binding: Readonly<Extract<InterruptBinding, { kind: 'generic' }>>\n resolveInterrupt: (payload: unknown) => void\n}\n\ntype InterruptResponseInput<TDefinition> =\n TDefinition extends InterruptDefinition<any, any, infer TResponseSchema, any>\n ? InferSchemaType<TResponseSchema>\n : never\n\ntype RegisteredGenericInterruptFor<\n TDefinition extends InterruptDefinition<any, any, any, any>,\n> =\n TDefinition extends InterruptDefinition<\n infer TDefinitionId,\n any,\n any,\n infer TPayload\n >\n ? BoundInterruptBase & {\n readonly kind: 'generic'\n readonly definitionId: TDefinitionId\n readonly key: string\n readonly payload: TPayload | undefined\n readonly binding: Readonly<\n Extract<InterruptBinding, { kind: 'generic' }> & {\n definitionId: TDefinitionId\n key: string\n batchIndex: number\n }\n >\n resolveInterrupt: (\n response: InterruptResponseInput<TDefinition>,\n ) => void\n }\n : never\n\nexport type RegisteredGenericInterrupt<\n TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>>,\n> = TInterrupts[number] extends infer TDefinition\n ? TDefinition extends InterruptDefinition<any, any, any, any>\n ? RegisteredGenericInterruptFor<TDefinition>\n : never\n : never\n\n/** A bound generic interrupt for one `defineInterrupt()` definition. */\nexport type GenericInterrupt<\n TDefinition extends InterruptDefinition<any, any, any, any>,\n> = RegisteredGenericInterruptFor<TDefinition>\n\n/**\n * An interrupt that arrived on the stream carrying no resume binding this\n * client understands — no `tanstack:interruptBinding`, or one written at a\n * protocol version we don't recognise.\n *\n * These are surfaced rather than hidden so a UI can show that the run is\n * paused, but they are never resolvable here: something else owns them. A\n * workflow engine's durable approval projected into the same AG-UI stream\n * lands in this bucket, and resolving it through the chat resume path would\n * send an answer no one is waiting for. Render it, or route it to whatever\n * actually owns the pause.\n */\nexport interface UnboundInterrupt extends Omit<\n BoundInterruptBase,\n 'cancel' | 'clearResolution'\n> {\n readonly kind: 'unbound'\n readonly binding?: undefined\n readonly canResolve: false\n}\n\ntype ApprovalBranchSchema<TTool, TBranch extends 'approve' | 'reject'> =\n ApprovalSchemaOf<TTool> extends infer TApproval\n ? TApproval extends { approve?: SchemaInput; reject?: SchemaInput }\n ? Exclude<TApproval[TBranch], undefined>\n : TApproval extends SchemaInput\n ? TApproval\n : never\n : never\n\ntype ApprovalEdits<TTool> =\n InputSchemaOf<TTool> extends NoSchema\n ? { editedArgs?: never }\n : { editedArgs?: InferToolInput<TTool> }\n\ntype ApprovalPayload<TSchema> = [TSchema] extends [never]\n ? { payload?: never }\n : TSchema extends SchemaInput\n ? { payload: InferSchemaType<TSchema> }\n : { payload?: never }\n\ntype ApproveArguments<TTool> = [\n ApprovalBranchSchema<TTool, 'approve'>,\n] extends [never]\n ? InputSchemaOf<TTool> extends NoSchema\n ? [options?: never]\n : [options?: ApprovalEdits<TTool> & { payload?: never }]\n : [\n options: ApprovalEdits<TTool> &\n ApprovalPayload<ApprovalBranchSchema<TTool, 'approve'>>,\n ]\n\ntype RejectArguments<TTool> = [ApprovalBranchSchema<TTool, 'reject'>] extends [\n never,\n]\n ? [options?: never]\n : [\n options: { editedArgs?: never } & ApprovalPayload<\n ApprovalBranchSchema<TTool, 'reject'>\n >,\n ]\n\nexport type ToolApprovalInterrupt<TTool extends AnyClientTool = AnyClientTool> =\n TTool extends AnyClientTool\n ? BoundInterruptBase & {\n readonly kind: 'tool-approval'\n readonly binding: Readonly<\n Extract<InterruptBinding, { kind: 'tool-approval' }>\n >\n readonly toolName: TTool['name']\n readonly toolCallId: string\n readonly originalArgs: InferToolInput<TTool>\n // A single generic call signature — not two overloads. Overloads break\n // editor autocomplete: a half-typed options literal (e.g.\n // `resolveInterrupt(true, { payload: {` ) satisfies neither overload,\n // so TS resolves no signature and offers no contextual completions.\n // Making `approved` a generic discriminant lets TS infer it from the\n // first argument and pick the matching branch for the rest params, so\n // `payload` / `editedArgs` / the correct schema's fields complete\n // per-branch (a plain union-of-tuples would offer both branches'\n // fields) while still enforcing the right shape.\n resolveInterrupt: <TApproved extends boolean>(\n approved: TApproved,\n ...args: TApproved extends true\n ? ApproveArguments<TTool>\n : RejectArguments<TTool>\n ) => void\n }\n : never\n\ntype ApprovalInterrupts<TTools extends ReadonlyArray<AnyClientTool>> =\n TTools[number] extends infer TTool\n ? TTool extends AnyClientTool\n ? ApprovalCapabilityOf<TTool> extends true\n ? ToolApprovalInterrupt<TTool>\n : never\n : never\n : never\n\n// Client tools resolve through their `.client()` implementation (auto-run) or\n// `addToolResult` — never as a bound interrupt. The `client-tool-execution`\n// pause is handled internally and is intentionally absent from this public\n// union.\nexport type ChatInterrupt<\n TTools extends ReadonlyArray<AnyClientTool> = ReadonlyArray<AnyClientTool>,\n TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> =\n readonly [],\n> =\n | GenericAGUIInterrupt\n | RegisteredGenericInterrupt<TInterrupts>\n | UnboundInterrupt\n | ApprovalInterrupts<TTools>\n\nexport type ResolvableChatInterrupt<\n TTools extends ReadonlyArray<AnyClientTool> = ReadonlyArray<AnyClientTool>,\n TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> =\n readonly [],\n> =\n | GenericAGUIInterrupt\n | RegisteredGenericInterrupt<TInterrupts>\n | ApprovalInterrupts<TTools>\n\nexport type BoundInterrupts<\n TTools extends ReadonlyArray<AnyClientTool> = ReadonlyArray<AnyClientTool>,\n TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> =\n readonly [],\n> = ReadonlyArray<ChatInterrupt<TTools, TInterrupts>>\n\nexport interface ChatInterruptState<\n TTools extends ReadonlyArray<AnyClientTool> = ReadonlyArray<AnyClientTool>,\n TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> =\n readonly [],\n> {\n readonly interrupts: BoundInterrupts<TTools, TInterrupts>\n /** @deprecated Use `interrupts`. Same snapshot today. */\n readonly pendingInterrupts: BoundInterrupts<TTools, TInterrupts>\n readonly interruptErrors: ReadonlyArray<BatchInterruptError>\n readonly resuming: boolean\n}\n\n/**\n * `messages` is the full UIMessage history (not a delta). `data` is the\n * merged body — `ChatClientOptions.body` plus any per-call data passed to\n * `sendMessage(...)`. `threadId` / `runId` are the AG-UI correlation ids\n * the chat client uses to track this turn — forward them to your server\n * if it needs to correlate requests.\n */\nexport interface ChatFetcherInput {\n messages: Array<UIMessage>\n data?: Record<string, unknown>\n threadId: string\n runId: string\n parentRunId?: string\n resume?: Array<RunAgentResumeItem>\n}\n\nexport interface ChatFetcherOptions {\n /** Fires when `stop()` is called or the request is superseded. */\n signal: AbortSignal\n}\n\n/**\n * Direct function that performs a chat request. Mirrors\n * `GenerationFetcher`. Returns either a `Response` (SSE body parsed by the\n * chat client) or an `AsyncIterable<StreamChunk>` (yielded directly). May\n * return the value synchronously, as a `Promise`, or as an async generator\n * (`async function*`) — the chat client awaits whichever shape is returned.\n *\n * @example\n * ```ts\n * useChat({\n * fetcher: ({ messages }, { signal }) =>\n * chatFn({ data: { messages }, signal }),\n * })\n * ```\n */\nexport type ChatFetcher = (\n input: ChatFetcherInput,\n options: ChatFetcherOptions,\n) =>\n | Response\n | AsyncIterable<StreamChunk>\n | Promise<Response | AsyncIterable<StreamChunk>>\n\n/**\n * Distributive `Omit` — applies `Omit<O, K>` per branch of a union so\n * discriminated unions survive omission. Plain `Omit` collapses unions\n * into a single object shape, which would erase the `ChatTransport` XOR\n * when framework hooks omit React-managed callbacks from\n * `ChatClientOptions`.\n */\nexport type DistributedOmit<\n TObject,\n TKeys extends keyof any,\n> = TObject extends unknown ? Omit<TObject, TKeys> : never\n\n/**\n * Discriminated union enforcing that exactly one of `connection` or\n * `fetcher` is provided. Mirrors `GenerationTransport`.\n */\nexport type ChatTransport =\n | { connection: ConnectionAdapter; fetcher?: never }\n | { fetcher: ChatFetcher; connection?: never }\n\n/**\n * Tool call states - track the lifecycle of a tool call\n */\nexport type ToolCallState =\n | 'awaiting-input' // Received start but no arguments yet\n | 'input-streaming' // Partial arguments received\n | 'input-complete' // All arguments received\n | 'approval-requested' // Waiting for user approval\n | 'approval-responded' // User has approved/denied\n | 'complete' // Result is complete\n | 'error' // Tool execution failed (terminal)\n\n/**\n * Tool result states - track the lifecycle of a tool result\n */\nexport type ToolResultState =\n | 'streaming' // Placeholder for future streamed output\n | 'complete' // Result is complete\n | 'error' // Error occurred\n\n/**\n * ChatClient state - track the lifecycle of a chat\n */\nexport type ChatClientState = 'ready' | 'submitted' | 'streaming' | 'error'\n\n/**\n * Connection lifecycle state for the subscription loop.\n */\nexport type ConnectionStatus =\n | 'disconnected'\n | 'connecting'\n | 'connected'\n | 'error'\n\n/**\n * Multimodal content input for sending messages with rich media.\n * Allows sending text, images, audio, video, and documents to the LLM.\n *\n * @example\n * ```ts\n * // Send an image with a question\n * client.sendMessage({\n * content: [\n * { type: 'text', content: 'What is in this image?' },\n * { type: 'image', source: { type: 'url', value: 'https://example.com/photo.jpg' } }\n * ],\n * id: 'custom-message-id' // optional\n * })\n * ```\n */\nexport interface MultimodalContent {\n /**\n * The content of the message.\n * Can be a simple string or an array of content parts for multimodal messages.\n */\n content: string | Array<ContentPart>\n /**\n * Optional custom ID for the message.\n * If not provided, a unique ID will be generated.\n */\n id?: string\n}\n\n/**\n * Action taken when `sendMessage` is called while the client is busy\n * (streaming, claiming a send, or draining the queue).\n * - `queue`: hold the message; it auto-sends when the current run settles\n * **successfully**.\n * - `drop`: ignore the send (promise still resolves; does not throw).\n * - `interrupt`: abort the current stream and send immediately. Unlike\n * `stop()`, does **not** flush already-queued messages — they still drain\n * after the interrupting send settles successfully.\n */\nexport type WhenBusy = 'queue' | 'drop' | 'interrupt'\n\n/**\n * Why the client is busy when a {@link QueueStrategy} runs.\n * - `streaming` — an LLM stream is active (`isLoading`).\n * - `sendInFlight` — a send has claimed the client but is not yet loading.\n * - `draining` — the queue drain loop is delivering pending messages.\n */\nexport type QueueBusyReason = 'streaming' | 'sendInFlight' | 'draining'\n\n/**\n * A user message held in the send queue while a stream is active.\n * Rendered separately from `messages`; cancellable via `cancelQueued(id)`\n * until it drains.\n */\nexport interface QueuedMessage {\n id: string\n content: string | MultimodalContent\n createdAt: number\n}\n\n/**\n * Declarative queue policy.\n */\nexport interface QueueConfig {\n /**\n * Action when the client is busy (streaming, claiming a send, or draining).\n * Default `'queue'`.\n */\n whenBusy?: WhenBusy\n /**\n * How queued items leave the queue.\n * - `'fifo'`: one at a time, in order (default).\n * - `'batch'`: merge all queued items into one send when the run settles\n * successfully.\n */\n drain?: 'fifo' | 'batch'\n /** Max queued items. Unlimited when omitted. `0` means never queue. */\n maxSize?: number\n /**\n * Behavior when `maxSize` is reached. Default `'reject'`.\n * `'reject'` silently discards the new send (does not throw);\n * `'drop-oldest'` evicts the oldest queued item to make room.\n * Only meaningful when `maxSize` is set.\n */\n onOverflow?: 'reject' | 'drop-oldest'\n}\n\n/**\n * Escape hatch: decide the action for a single send. Drain stays FIFO for the\n * function form (no `batch` via function). Per-call `sendOptions.whenBusy`\n * overrides the strategy for that send.\n *\n * Actions match {@link WhenBusy}: `'queue' | 'drop' | 'interrupt'`. Concurrent\n * streams are not supported. `pending.id` is the id that will be stored if the\n * action is `'queue'` (safe to pass to `cancelQueued`).\n */\nexport type QueueStrategy = (ctx: {\n pending: QueuedMessage\n busyReason: QueueBusyReason\n queued: ReadonlyArray<QueuedMessage>\n}) => { action: WhenBusy }\n\n/** A `WhenBusy` shorthand, a full config, or a strategy function. */\nexport type QueueOption = WhenBusy | QueueConfig | QueueStrategy\n\n/** Per-call overrides for `sendMessage`. */\nexport interface SendMessageOptions {\n /** Overrides the configured `whenBusy` for this one send. */\n whenBusy?: WhenBusy\n}\n\n/**\n * Message parts - building blocks of UIMessage\n */\nexport interface TextPart {\n type: 'text'\n content: string\n}\n\n/**\n * Helper type that creates a tool-call part for a specific tool.\n * This is a conditional type to enable proper distribution over union types,\n * creating a discriminated union where `name` is the discriminant.\n */\ntype ToolCallPartForTool<T> = T extends AnyClientTool\n ? {\n type: 'tool-call'\n id: string\n name: T['name']\n arguments: string // JSON string (may be incomplete)\n /** Parsed tool input (typed from inputSchema) */\n input?: InferToolInput<T>\n state: ToolCallState\n /** Tool execution output (for client tools or after approval) */\n output?: InferToolOutput<T>\n } & (NonNullable<T['needsApproval']> extends true\n ? {\n /**\n * Approval metadata — present only on tools defined with\n * `needsApproval: true`. Populated once the call reaches\n * `state: 'approval-requested'`. `needsApproval` is an optional\n * property on the tool, so we index into it (rather than\n * `T extends { needsApproval: true }`, which an optional property\n * never satisfies) and strip `undefined` before comparing to `true`.\n */\n approval?: {\n id: string // Unique approval ID\n needsApproval: boolean // Always true if present\n approved?: boolean // User's decision (undefined until responded)\n }\n }\n : // Tools without `needsApproval: true` never carry an approval field.\n // `& unknown` is a no-op intersection (adds nothing).\n unknown)\n : never\n\n/**\n * Fallback tool-call part type when tools are not typed\n */\ntype UntypedToolCallPart = {\n type: 'tool-call'\n id: string\n name: string\n arguments: string\n input?: any\n state: ToolCallState\n approval?: {\n id: string\n needsApproval: boolean\n approved?: boolean\n }\n output?: any\n}\n\n/**\n * Tool call part that creates a proper discriminated union.\n * When TTools is typed, checking `part.name === 'toolName'` will narrow\n * `part.output` to the correct type for that tool.\n *\n * The discriminant is `name`, so code like:\n * ```ts\n * if (part.name === 'recommendGuitar') {\n * // part.output is now typed to the recommendGuitar tool's output\n * }\n * ```\n */\nexport type ToolCallPart<TTools extends ReadonlyArray<AnyClientTool> = any> =\n // Check if we have a concrete tools array (not 'any' or 'never')\n [TTools] extends [never]\n ? UntypedToolCallPart\n : unknown extends TTools\n ? UntypedToolCallPart\n : TTools extends ReadonlyArray<infer Tool>\n ? Tool extends AnyClientTool\n ? ToolCallPartForTool<Tool>\n : UntypedToolCallPart\n : UntypedToolCallPart\n\nexport interface ToolResultPart {\n type: 'tool-result'\n toolCallId: string\n content: string | Array<ContentPart>\n state: ToolResultState\n error?: string // Error message if state is \"error\"\n}\n\nexport interface ThinkingPart {\n type: 'thinking'\n content: string\n}\n\nexport type MessagePart<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n TData = unknown,\n> =\n | TextPart\n | ImagePart\n | AudioPart\n | VideoPart\n | DocumentPart\n | ToolCallPart<TTools>\n | ToolResultPart\n | ThinkingPart\n | StructuredOutputPart<TData>\n | UIResourcePart\n\n/**\n * UIMessage - Domain-specific message format optimized for building chat UIs\n * Contains parts that can be text, tool calls, or tool results.\n *\n * `TTools` narrows the tool-call/result part types based on the registered\n * tools. `TData` is the schema-inferred type for any `structured-output` part\n * on the message — defaulted to `unknown` so untyped consumers (the core\n * stream processor, the wire converter) don't need to thread a schema generic\n * everywhere; the hook layer (`useChat({ outputSchema })`) substitutes it on\n * the public return so `m.parts.find(p => p.type === 'structured-output').data`\n * is typed without manual casts.\n */\nexport interface UIMessage<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n TData = unknown,\n> {\n id: string\n role: 'system' | 'user' | 'assistant'\n parts: Array<MessagePart<TTools, TData>>\n createdAt?: Date\n}\n\n/**\n * A generic key/value storage adapter. `getItem` may be sync or async; the\n * chat persistence layer treats every call as best-effort. The provided\n * `localStoragePersistence` / `sessionStoragePersistence` / `indexedDBPersistence`\n * factories return one of these, and `ChatStorageAdapter<ChatPersistedState>`\n * is assignable to {@link ChatClientPersistence}.\n */\nexport interface ChatStorageAdapter<TValue> {\n getItem: (\n id: string,\n ) => TValue | null | undefined | Promise<TValue | null | undefined>\n setItem: (id: string, value: TValue) => void | Promise<void>\n removeItem: (id: string) => void | Promise<void>\n}\n\n/**\n * The single record a `ChatClientPersistence` adapter stores per chat. It folds\n * the two things that must survive a full page reload into one blob under one\n * key: the message transcript and the optional resume snapshot (which run to\n * rejoin / which interrupts to rehydrate). One adapter, one key — see\n * {@link ChatClientPersistence}.\n */\nexport interface ChatPersistedState<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n> {\n messages: Array<UIMessage<TTools>>\n /** Present while a run is in flight or paused on an interrupt; absent otherwise. */\n resume?: ChatResumeSnapshot\n}\n\n/**\n * Storage adapter for durable chat state. A single adapter persists both the\n * message transcript and the resume snapshot as one {@link ChatPersistedState}\n * record, so a full page reload restores the conversation AND can rejoin an\n * in-flight run / rehydrate pending interrupts.\n *\n * For backward compatibility `getItem` may also return a bare `UIMessage[]`\n * (the legacy messages-only format); the client normalizes it to\n * `{ messages }`. `setItem` always writes the combined record.\n */\nexport interface ChatClientPersistence<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n> {\n getItem: (\n id: string,\n ) =>\n | ChatPersistedState<TTools>\n | Array<UIMessage<TTools>>\n | null\n | undefined\n | Promise<\n ChatPersistedState<TTools> | Array<UIMessage<TTools>> | null | undefined\n >\n setItem: (\n id: string,\n state: ChatPersistedState<TTools>,\n ) => void | Promise<void>\n removeItem: (id: string) => void | Promise<void>\n}\n\n/**\n * The `persistence` option for a chat.\n *\n * - `false` (default): ephemeral. Messages live in memory only; a reload starts\n * from empty.\n * - `true`: server-authoritative. Nothing is cached in the browser. On mount the\n * client hydrates the thread from the server by its `threadId` (paints the\n * stored transcript and tails any run still generating), so a reload or the\n * same thread opened on another device both just resume. Requires a connection\n * with a `hydrate` handler.\n * - a {@link ChatClientPersistence} adapter: client-authoritative. The combined\n * {@link ChatPersistedState} record (transcript plus resume pointer) is cached\n * in the browser and restored on reload with no network.\n */\nexport type ChatPersistenceOption<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n> = boolean | ChatClientPersistence<TTools>\n\n/**\n * The `persistence` / `threadId` pairing for `ChatClient` and the chat hooks.\n *\n * Persistence that is on (`true` or a storage adapter) requires a `threadId`.\n * A minted id changes every reload, so nothing would restore. The compiler\n * asks for the conversation id instead.\n *\n * Omit `persistence`, or set it to `false`, and `threadId` stays optional.\n * The client then mints one after mount for the wire and DevTools.\n *\n * Intersect this onto `ChatClientOptions`. Do not apply a later plain `Omit`\n * to that type: it collapses the union and the requirement disappears. Use\n * {@link DistributedOmit}.\n */\nexport type ChatPersistenceOptions<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n> =\n | {\n persistence: true\n threadId: string\n }\n | {\n persistence: ChatClientPersistence<TTools>\n threadId: string\n }\n | {\n persistence?: false | undefined\n threadId?: string\n }\n\ntype IsUnknown<T> = unknown extends T\n ? [T] extends [unknown]\n ? true\n : false\n : false\n\ntype KnownContext<T> = IsUnknown<T> extends true ? never : T\n\ntype MergeContext<TLeft, TRight> = [TLeft] extends [never]\n ? TRight\n : [TRight] extends [never]\n ? TLeft\n : TLeft & TRight\n\ntype UnionToIntersection<T> = [T] extends [never]\n ? never\n : (T extends unknown ? (value: T) => void : never) extends (\n value: infer TIntersection,\n ) => void\n ? TIntersection\n : never\n\ntype DefinedContext<T> = Exclude<T, undefined>\n\ntype ContextFromExecute<T> = T extends (...args: any) => any\n ? NonNullable<Parameters<T>[1]> extends { context: infer TContext }\n ? KnownContext<TContext>\n : never\n : never\n\ntype ContextFromClientTool<T> = T extends AnyClientTool\n ? T extends { execute?: infer TExecute }\n ? ContextFromExecute<TExecute>\n : never\n : never\n\ntype RequiredContextFromClientToolUnion<T> = T extends unknown\n ? undefined extends ContextFromClientTool<T>\n ? never\n : ContextFromClientTool<T>\n : never\n\ntype ContextFromClientToolUnion<T> = [\n UnionToIntersection<DefinedContext<ContextFromClientTool<T>>>,\n] extends [never]\n ? never\n : [RequiredContextFromClientToolUnion<T>] extends [never]\n ? UnionToIntersection<DefinedContext<ContextFromClientTool<T>>> | undefined\n : UnionToIntersection<DefinedContext<ContextFromClientTool<T>>>\n\ntype ContextFromClientTools<TTools> =\n IsUnknown<TTools> extends true\n ? never\n : TTools extends readonly [infer THead, ...infer TTail]\n ? MergeContext<\n ContextFromClientTool<THead>,\n ContextFromClientTools<TTail>\n >\n : TTools extends ReadonlyArray<infer TItem>\n ? ContextFromClientToolUnion<TItem>\n : never\n\nexport type InferredClientContext<TTools> = [\n ContextFromClientTools<TTools>,\n] extends [never]\n ? unknown\n : ContextFromClientTools<TTools>\n\nexport type ClientContextOptionFromTools<TTools, TContext> = [\n ContextFromClientTools<TTools>,\n] extends [never]\n ? { context?: TContext }\n : undefined extends ContextFromClientTools<TTools>\n ? { context?: TContext & ContextFromClientTools<TTools> }\n : { context: TContext & ContextFromClientTools<TTools> }\n\n/**\n * Base options for `ChatClient`, excluding the transport (`connection` or\n * `fetcher`) which is supplied separately via `ChatTransport` so the XOR\n * is preserved when composing the final `ChatClientOptions` type.\n */\nexport interface ChatClientBaseOptions<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n TContext = unknown,\n TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> =\n readonly [],\n> {\n /**\n * Initial messages to populate the chat\n */\n initialMessages?: Array<UIMessage<TTools>>\n\n /**\n * Initial resumable run state, useful when rehydrating a persisted client\n * after a full page reload. This restores the client-side interrupt\n * descriptors needed to send AG-UI resume entries.\n */\n initialResumeSnapshot?: ChatResumeSnapshot\n\n /**\n * Arbitrary client-controlled JSON forwarded to the server in the\n * AG-UI `RunAgentInput.forwardedProps` field. Use this for per-session\n * options like provider/model selection or feature flags that the\n * server endpoint should read.\n *\n * Replaces the legacy `body` option. If both are provided,\n * `forwardedProps` wins on key collision.\n */\n forwardedProps?: Record<string, any>\n\n /**\n * @deprecated Use `forwardedProps` instead. `body` continues to work\n * unchanged — its values are merged into the AG-UI\n * `RunAgentInput.forwardedProps` field on the wire and are also\n * mirrored under the legacy `data` field for servers that have not\n * migrated yet. Will be removed in a future major release.\n */\n body?: Record<string, any>\n\n /**\n * Client-local runtime context passed to client tool implementations.\n *\n * This value is not serialized to the server. Use `forwardedProps` for\n * explicit client-to-server handoff of serializable values.\n */\n context?: TContext\n\n /**\n * Callback when a response is received\n */\n onResponse?: (response?: Response) => void | Promise<void>\n\n /**\n * Callback when a stream chunk is received\n */\n onChunk?: (chunk: StreamChunk) => void\n\n /**\n * Callback when the response is finished\n */\n onFinish?: (message: UIMessage<TTools>) => void\n\n /**\n * Callback when an error occurs\n */\n onError?: (error: Error) => void\n\n /**\n * Callback when messages change\n */\n onMessagesChange?: (messages: Array<UIMessage<TTools>>) => void\n\n /**\n * Callback when loading state changes\n */\n onLoadingChange?: (isLoading: boolean) => void\n\n /**\n * Callback when error state changes\n */\n onErrorChange?: (error: Error | undefined) => void\n\n /**\n * Callback when chat status changes\n */\n onStatusChange?: (status: ChatClientState) => void\n\n /**\n * Callback when subscription lifecycle changes.\n * This is independent from request lifecycle (`isLoading`, `status`).\n */\n onSubscriptionChange?: (isSubscribed: boolean) => void\n\n /**\n * Callback when connection lifecycle changes.\n */\n onConnectionStatusChange?: (status: ConnectionStatus) => void\n\n /**\n * Callback when session generation activity changes.\n * Derived from stream run events (RUN_STARTED / RUN_FINISHED / RUN_ERROR).\n * Unlike `onLoadingChange` (request-local), this reflects shared generation\n * activity visible to all subscribers (e.g. across tabs/devices).\n */\n onSessionGeneratingChange?: (isGenerating: boolean) => void\n\n /**\n * Policy for messages sent while the client is busy (streaming, claiming\n * a send, or draining the queue). Accepts a `WhenBusy` string, a\n * `QueueConfig`, or a `QueueStrategy` function.\n * Default: `{ whenBusy: 'queue', drain: 'fifo' }`.\n * Queued items auto-send only after a **successful** settle; they are\n * discarded on error/abort, `stop()`, `clear()`, `unsubscribe()`, and\n * `reload()`.\n */\n queue?: QueueOption\n\n /**\n * Callback when the pending send queue changes (enqueue, cancel, drain,\n * or flush).\n */\n onQueueChange?: (queue: Array<QueuedMessage>) => void\n\n /**\n * Callback when resumable run state or pending interrupts change.\n */\n onResumeStateChange?: (\n resumeState: ChatResumeState | null,\n pendingInterrupts: BoundInterrupts<TTools, TInterrupts>,\n ) => void\n\n /**\n * Callback when the id of the run this client has in flight changes: the new\n * id when a run starts (a send, or a `joinRun` rejoin), `null` when it settles.\n */\n onRunIdChange?: (runId: string | null) => void\n\n /**\n * Callback when the immutable interrupt state snapshot changes.\n * Snapshot restoration passes `{ source: 'hydrate' }`; streamed and\n * client-initiated updates pass `{ source: 'live' }`.\n */\n onInterruptStateChange?: (\n state: ChatInterruptState<TTools, TInterrupts>,\n context: { source: 'hydrate' | 'live' },\n ) => void\n\n /**\n * Callback when a custom event is received from a server-side tool.\n * Custom events are emitted by tools using `context.emitCustomEvent()` during execution.\n *\n * @param eventType - The name of the custom event\n * @param data - The event payload data\n * @param context - Additional context including the toolCallId that emitted the event\n */\n onCustomEvent?: (\n eventType: string,\n data: unknown,\n context: { toolCallId?: string },\n ) => void\n\n /**\n * Client-side tools with execution logic\n * When provided, tools with execute functions will be called automatically\n */\n tools?: TTools\n\n /** First-party generic interrupts this client can type and resolve. */\n interrupts?: TInterrupts\n\n /**\n * Devtools hook metadata for this client instance.\n */\n devtools?: Partial<AIDevtoolsClientMetadata>\n\n /**\n * Factory that constructs the devtools bridge. Default is a no-op\n * factory, which keeps `@tanstack/ai-client/devtools` (the heavy\n * bridge implementation) out of the main entry's bundle. Frameworks\n * that need live devtools should pass the real factory from\n * `@tanstack/ai-client/devtools`.\n */\n devtoolsBridgeFactory?: ChatDevtoolsBridgeFactory\n\n /**\n * Stream processing options (optional)\n * Configure chunking strategy\n */\n streamProcessor?: {\n /**\n * Strategy for when to emit text updates\n * Defaults to ImmediateStrategy (every chunk)\n */\n chunkStrategy?: ChunkStrategy\n }\n}\n\n/**\n * Options for `ChatClient`. Exactly one of `connection` or `fetcher` must be\n * provided — the type-level XOR is enforced via `ChatTransport`. Persistence\n * that is on requires a `threadId` via {@link ChatPersistenceOptions}.\n */\nexport type ChatClientOptions<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n TContext = InferredClientContext<TTools>,\n TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> =\n readonly [],\n> = DistributedOmit<\n ChatClientBaseOptions<TTools, TContext, TInterrupts>,\n 'context'\n> &\n ClientContextOptionFromTools<TTools, TContext> &\n ChatTransport &\n ChatPersistenceOptions<TTools>\n\nexport interface ChatRequestBody {\n messages: Array<ModelMessage>\n data?: Record<string, any>\n}\n\n/**\n * Create a typed array of client tools with proper type inference.\n * This eliminates the need for `as const` when defining tool arrays.\n *\n * @example\n * ```ts\n * const tools = clientTools(\n * myTool1.client(() => result1),\n * myTool2.client(() => result2),\n * )\n *\n * // tools is now properly typed as a tuple with literal tool names\n * // This enables type narrowing when checking part.name === 'toolName'\n * ```\n */\nexport function clientTools<const T extends Array<AnyClientTool>>(\n ...tools: T\n): T {\n return tools\n}\n\n/**\n * Helper to create typed chat client options\n * Use this to get proper type inference for messages\n *\n * @example\n * ```ts\n * const tools = clientTools(myTool1, myTool2)\n *\n * const chatOptions = createChatClientOptions({\n * connection: fetchServerSentEvents('/api/chat'),\n * tools,\n * })\n *\n * type MyMessages = InferChatMessages<typeof chatOptions>\n * ```\n */\nexport function createChatClientOptions<\n const TTools extends ReadonlyArray<AnyClientTool>,\n TContext = InferredClientContext<TTools>,\n const TInterrupts extends ReadonlyArray<\n InterruptDefinition<any, any, any, any>\n > = readonly [],\n>(\n options: ChatClientOptions<TTools, TContext, TInterrupts>,\n): ChatClientOptions<TTools, TContext, TInterrupts> {\n return options\n}\n\n/**\n * Extract the message type from chat options\n *\n * @example\n * ```ts\n * const chatOptions = createChatClientOptions({\n * connection: fetchServerSentEvents('/api/chat'),\n * tools: [myTool1, myTool2],\n * })\n *\n * type MyMessages = InferChatMessages<typeof chatOptions>\n * // MyMessages is now Array<UIMessage<[typeof myTool1, typeof myTool2]>>\n * ```\n */\nexport type InferChatMessages<T> =\n T extends ChatClientOptions<infer TTools, any>\n ? Array<UIMessage<TTools>>\n : never\n"],"mappings":";;;;;;;;;;;;;;;;AAmhCA,SAAgB,YACd,GAAG,OACA;CACH,OAAO;AACT;;;;;;;;;;;;;;;;;AAkBA,SAAgB,wBAOd,SACkD;CAClD,OAAO;AACT"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-client",
3
- "version": "0.24.0",
3
+ "version": "0.25.2",
4
4
  "description": "Framework-agnostic headless client for TanStack AI chat, realtime sessions, streaming transports, and media generations.",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -49,7 +49,7 @@
49
49
  "src"
50
50
  ],
51
51
  "dependencies": {
52
- "@tanstack/ai": "^0.46.0",
52
+ "@tanstack/ai": "^0.47.3",
53
53
  "@tanstack/ai-event-client": "^0.9.0",
54
54
  "@tanstack/ai-utils": "^0.4.0"
55
55
  },
@@ -18,6 +18,7 @@ import { InterruptManager } from './interrupt-manager'
18
18
  import type {
19
19
  AnyClientTool,
20
20
  ContentPart,
21
+ InterruptDefinition,
21
22
  InterruptSubmissionError,
22
23
  ModelMessage,
23
24
  RunAgentResumeItem,
@@ -42,8 +43,8 @@ import type {
42
43
  ChatClientOptions,
43
44
  ChatClientState,
44
45
  ChatFetcher,
45
- ChatInterrupt,
46
46
  ChatInterruptState,
47
+ ResolvableChatInterrupt,
47
48
  ChatPendingInterrupt,
48
49
  ChatResumeSnapshot,
49
50
  ChatResumeState,
@@ -59,15 +60,34 @@ import type {
59
60
  UIMessage,
60
61
  WhenBusy,
61
62
  } from './types'
62
- import type { InterruptManagerSubmission } from './interrupt-manager'
63
+ import type {
64
+ InterruptManagerChangeSource,
65
+ InterruptManagerSubmission,
66
+ } from './interrupt-manager'
63
67
 
64
68
  /** Internal queue entry — public {@link QueuedMessage} plus optional per-send body. */
65
69
  interface InternalQueuedMessage extends QueuedMessage {
66
70
  body?: Record<string, any>
67
71
  }
68
72
 
73
+ function assertUniqueInterruptDefinitions(
74
+ interrupts:
75
+ | ReadonlyArray<InterruptDefinition<any, any, any, any>>
76
+ | undefined,
77
+ ): void {
78
+ const ids = new Set<string>()
79
+ for (const interrupt of interrupts ?? []) {
80
+ if (ids.has(interrupt.id)) {
81
+ throw new Error(`Duplicate interrupt definition id: ${interrupt.id}`)
82
+ }
83
+ ids.add(interrupt.id)
84
+ }
85
+ }
86
+
69
87
  type ChatClientUpdateOptionsWithoutContext<
70
88
  TTools extends ReadonlyArray<AnyClientTool>,
89
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> =
90
+ readonly [],
71
91
  > = {
72
92
  connection?: ConnectionAdapter
73
93
  fetcher?: ChatFetcher
@@ -75,6 +95,7 @@ type ChatClientUpdateOptionsWithoutContext<
75
95
  body?: Record<string, any>
76
96
  forwardedProps?: Record<string, any>
77
97
  tools?: TTools
98
+ interrupts?: TInterrupts
78
99
  queue?: QueueOption
79
100
  onResponse?: (response?: Response) => void | Promise<void>
80
101
  onChunk?: (chunk: StreamChunk) => void
@@ -86,14 +107,17 @@ type ChatClientUpdateOptionsWithoutContext<
86
107
  onQueueChange?: (queue: Array<QueuedMessage>) => void
87
108
  onResumeStateChange?: (
88
109
  resumeState: ChatResumeState | null,
89
- pendingInterrupts: BoundInterrupts<TTools>,
110
+ pendingInterrupts: BoundInterrupts<TTools, TInterrupts>,
90
111
  ) => void
91
112
  /**
92
113
  * Fires whenever the id of the run in flight changes: the new id when a run
93
114
  * starts (including a rejoin), `null` when it settles.
94
115
  */
95
116
  onRunIdChange?: (runId: string | null) => void
96
- onInterruptStateChange?: (state: ChatInterruptState<TTools>) => void
117
+ onInterruptStateChange?: (
118
+ state: ChatInterruptState<TTools, TInterrupts>,
119
+ context: { source: 'hydrate' | 'live' },
120
+ ) => void
97
121
  onCustomEvent?: (
98
122
  eventType: string,
99
123
  data: unknown,
@@ -271,6 +295,8 @@ const REJOIN_REBUILD_TRIGGERS = new Set<string>([
271
295
  export class ChatClient<
272
296
  TTools extends ReadonlyArray<AnyClientTool> = any,
273
297
  TContext = unknown,
298
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> =
299
+ any,
274
300
  > {
275
301
  private readonly processor: StreamProcessor
276
302
  private connection: SubscribeConnectionAdapter
@@ -292,7 +318,7 @@ export class ChatClient<
292
318
  // run is rejoined at most once even when both the sync read and the async
293
319
  // hydrate surface the same resume pointer.
294
320
  private rejoinedRunId: string | null = null
295
- private readonly interruptManager: InterruptManager<TTools>
321
+ private readonly interruptManager: InterruptManager<TTools, TInterrupts>
296
322
  private activeInterruptSubmission: InterruptManagerSubmission | undefined
297
323
  private interruptSubmissionFailure:
298
324
  | { errors: ReadonlyArray<InterruptSubmissionError> }
@@ -397,10 +423,13 @@ export class ChatClient<
397
423
  onQueueChange: (queue: Array<QueuedMessage>) => void
398
424
  onResumeStateChange: (
399
425
  resumeState: ChatResumeState | null,
400
- pendingInterrupts: BoundInterrupts<TTools>,
426
+ pendingInterrupts: BoundInterrupts<TTools, TInterrupts>,
401
427
  ) => void
402
428
  onRunIdChange: (runId: string | null) => void
403
- onInterruptStateChange: (state: ChatInterruptState<TTools>) => void
429
+ onInterruptStateChange: (
430
+ state: ChatInterruptState<TTools, TInterrupts>,
431
+ context: { source: 'hydrate' | 'live' },
432
+ ) => void
404
433
  onCustomEvent: (
405
434
  eventType: string,
406
435
  data: unknown,
@@ -409,7 +438,8 @@ export class ChatClient<
409
438
  }
410
439
  }
411
440
 
412
- constructor(options: ChatClientOptions<TTools, TContext>) {
441
+ constructor(options: ChatClientOptions<TTools, TContext, TInterrupts>) {
442
+ assertUniqueInterruptDefinitions(options.interrupts)
413
443
  // Do not mint a random thread id during construct. Framework hooks build
414
444
  // this client during render (SSR included). The wire/devtools identity is
415
445
  // `threadId`; it is assigned here when the caller passed one, or later in
@@ -493,10 +523,13 @@ export class ChatClient<
493
523
  },
494
524
  }
495
525
 
496
- this.interruptManager = new InterruptManager({
526
+ this.interruptManager = new InterruptManager<TTools, TInterrupts>({
497
527
  ...(options.tools !== undefined ? { tools: options.tools } : {}),
528
+ ...(options.interrupts !== undefined
529
+ ? { interrupts: options.interrupts }
530
+ : {}),
498
531
  submit: (submission) => this.submitInterruptBatch(submission),
499
- onChange: () => this.notifyResumeStateChange(),
532
+ onChange: (source) => this.notifyResumeStateChange(source),
500
533
  })
501
534
 
502
535
  // In-memory rehydrate of interrupt descriptors (e.g. after a page reload
@@ -856,7 +889,7 @@ export class ChatClient<
856
889
  private applyResumeSnapshot(snapshot: ChatResumeSnapshot): void {
857
890
  const resumeState = readResumeState(snapshot)
858
891
  if (resumeState === undefined) {
859
- this.interruptManager.reset()
892
+ this.interruptManager.reset({ source: 'hydrate' })
860
893
  return
861
894
  }
862
895
  this.lastResume = resumeState
@@ -864,16 +897,19 @@ export class ChatClient<
864
897
  ? snapshot.pendingInterrupts
865
898
  : []
866
899
  if (pendingInterrupts.length === 0) {
867
- this.interruptManager.reset()
900
+ this.interruptManager.reset({ source: 'hydrate' })
868
901
  return
869
902
  }
870
903
  const generation = this.interruptGeneration(pendingInterrupts)
871
- this.interruptManager.hydrate({
872
- threadId: resumeState.threadId,
873
- interruptedRunId: resumeState.runId,
874
- generation,
875
- interrupts: pendingInterrupts,
876
- })
904
+ this.interruptManager.hydrate(
905
+ {
906
+ threadId: resumeState.threadId,
907
+ interruptedRunId: resumeState.runId,
908
+ generation,
909
+ interrupts: pendingInterrupts,
910
+ },
911
+ 'hydrate',
912
+ )
877
913
  }
878
914
 
879
915
  /**
@@ -1102,12 +1138,15 @@ export class ChatClient<
1102
1138
  threadId: threadId ?? this.threadId,
1103
1139
  runId: interruptedRunId,
1104
1140
  }
1105
- this.interruptManager.hydrate({
1106
- threadId: this.lastResume.threadId,
1107
- interruptedRunId,
1108
- generation: this.interruptGeneration(chunk.outcome.interrupts),
1109
- interrupts: chunk.outcome.interrupts,
1110
- })
1141
+ this.interruptManager.hydrate(
1142
+ {
1143
+ threadId: this.lastResume.threadId,
1144
+ interruptedRunId,
1145
+ generation: this.interruptGeneration(chunk.outcome.interrupts),
1146
+ interrupts: chunk.outcome.interrupts,
1147
+ },
1148
+ 'live',
1149
+ )
1111
1150
  return
1112
1151
  }
1113
1152
 
@@ -1154,7 +1193,7 @@ export class ChatClient<
1154
1193
  this.interruptManager.reset()
1155
1194
  return
1156
1195
  }
1157
- this.notifyResumeStateChange()
1196
+ this.notifyResumeStateChange('live')
1158
1197
  }
1159
1198
 
1160
1199
  /**
@@ -1183,25 +1222,37 @@ export class ChatClient<
1183
1222
  this.callbacksRef.current.onRunIdChange(runId)
1184
1223
  }
1185
1224
 
1186
- getInterruptState(): ChatInterruptState<TTools> {
1225
+ getInterruptState(): ChatInterruptState<TTools, TInterrupts> {
1187
1226
  return this.interruptManager.getState()
1188
1227
  }
1189
1228
 
1190
- getInterrupts(): BoundInterrupts<TTools> {
1191
- return this.interruptManager.getInterrupts()
1229
+ getInterrupts(): BoundInterrupts<TTools, TInterrupts> {
1230
+ return this.interruptManager.getInterrupts() as BoundInterrupts<
1231
+ TTools,
1232
+ TInterrupts
1233
+ >
1192
1234
  }
1193
1235
 
1194
1236
  /** @deprecated Use getInterrupts(). */
1195
- getPendingInterrupts(): BoundInterrupts<TTools> {
1196
- return this.interruptManager.getInterrupts()
1237
+ getPendingInterrupts(): BoundInterrupts<TTools, TInterrupts> {
1238
+ return this.interruptManager.getInterrupts() as BoundInterrupts<
1239
+ TTools,
1240
+ TInterrupts
1241
+ >
1197
1242
  }
1198
1243
 
1199
1244
  resolveInterrupts(approved: boolean): void
1200
1245
  resolveInterrupts(
1201
- resolver: (interrupt: ChatInterrupt<TTools>) => undefined,
1246
+ resolver: (
1247
+ interrupt: ResolvableChatInterrupt<TTools, TInterrupts>,
1248
+ ) => undefined,
1202
1249
  ): void
1203
1250
  resolveInterrupts(
1204
- resolution: boolean | ((interrupt: ChatInterrupt<TTools>) => undefined),
1251
+ resolution:
1252
+ | boolean
1253
+ | ((
1254
+ interrupt: ResolvableChatInterrupt<TTools, TInterrupts>,
1255
+ ) => undefined),
1205
1256
  ): void {
1206
1257
  // Branch so TypeScript can select the InterruptManager.resolve overloads.
1207
1258
  if (typeof resolution === 'boolean') {
@@ -1362,19 +1413,20 @@ export class ChatClient<
1362
1413
  this.devtoolsBridge.emitSnapshot()
1363
1414
  }
1364
1415
 
1365
- private notifyResumeStateChange(): void {
1416
+ private notifyResumeStateChange(source: InterruptManagerChangeSource): void {
1366
1417
  const resumeState = this.getResumeState()
1418
+ // Capture state before invoking callbacks so a synchronous nested change
1419
+ // cannot pair this publication's source with a later manager snapshot.
1420
+ const interruptState = this.interruptManager.getState()
1367
1421
  // Persist (or clear) the durable resume snapshot so a full page reload can
1368
1422
  // rehydrate pending interrupts and rejoin the run. Folded into the same
1369
1423
  // persistence adapter that stores messages (one record per chat).
1370
1424
  this.persistResumeSnapshot(resumeState)
1371
1425
  this.callbacksRef.current.onResumeStateChange(
1372
1426
  resumeState,
1373
- this.interruptManager.getInterrupts(),
1374
- )
1375
- this.callbacksRef.current.onInterruptStateChange(
1376
- this.interruptManager.getState(),
1427
+ interruptState.interrupts,
1377
1428
  )
1429
+ this.callbacksRef.current.onInterruptStateChange(interruptState, { source })
1378
1430
  }
1379
1431
 
1380
1432
  /**
@@ -1624,6 +1676,12 @@ export class ChatClient<
1624
1676
  }
1625
1677
  await this.processIncomingChunk(chunk, { defer: false })
1626
1678
  }
1679
+ // Same contract as `streamResponse`: client tools may finish (and
1680
+ // queue a resume) while `isLoading` is still true. Wait for them
1681
+ // before teardown so `drainPostStreamActions` below sees the queue.
1682
+ if (this.pendingToolExecutions.size > 0) {
1683
+ await Promise.all(this.pendingToolExecutions.values())
1684
+ }
1627
1685
  } catch (error) {
1628
1686
  // Pre-attach failures (unknown/evicted run, connect deadline abort)
1629
1687
  // stay soft: keep the restored transcript. Post-attach transport/parser
@@ -1665,6 +1723,7 @@ export class ChatClient<
1665
1723
  this.abortController = null
1666
1724
  this.setIsLoading(false)
1667
1725
  if (this.status === 'streaming') this.setStatus('ready')
1726
+ await this.drainPostStreamActions()
1668
1727
  }
1669
1728
  }
1670
1729
  })()
@@ -1717,11 +1776,13 @@ export class ChatClient<
1717
1776
  this.processor.processChunk(chunk)
1718
1777
  this.updateRunLifecycle(chunk)
1719
1778
  this.observeInterruptState(chunk)
1720
- // The live path yields a macrotask between chunks so React can paint each
1721
- // delta progressively. A resume replay passes `defer: false` to skip it, so
1722
- // the buffered backlog applies in one batch (instant catch-up) instead of
1723
- // re-typing the whole reply.
1724
- if (options?.defer !== false) {
1779
+ // Live path: yield a macrotask so the UI can paint. Skip when the page is
1780
+ // hidden. Browsers clamp setTimeout there, and that wait paces stream pull.
1781
+ // Replay passes defer: false so a backlog applies in one batch.
1782
+ if (
1783
+ options?.defer !== false &&
1784
+ (typeof document === 'undefined' || !document.hidden)
1785
+ ) {
1725
1786
  await new Promise((resolve) => setTimeout(resolve, 0))
1726
1787
  }
1727
1788
  this.resolveJoinedRun(chunk)
@@ -2631,6 +2692,18 @@ export class ChatClient<
2631
2692
  * a text-only response has nothing to auto-send.
2632
2693
  */
2633
2694
  private shouldAutoSend(): boolean {
2695
+ // A pending interrupt owns the next send. Auto-continuing after a
2696
+ // completed server tool would start a sibling run and hide the card.
2697
+ if (this.lastResume) return false
2698
+ // Ownership follows the descriptors, not the submission handle. Generic
2699
+ // interrupts settle the resume stream through a post-stream action that
2700
+ // runs before `submitInterruptBatch`'s `finally` clears the handle, so
2701
+ // gating on the handle alone would strand a legacy client tool that the
2702
+ // native resume itself emitted (#1106).
2703
+ if (this.activeInterruptSubmission && this.hasPendingInterrupts()) {
2704
+ return false
2705
+ }
2706
+ if (this.interruptManager.getInterrupts().length > 0) return false
2634
2707
  const messages = this.processor.getMessages()
2635
2708
  const lastAssistant = messages.findLast(
2636
2709
  (m: UIMessage) => m.role === 'assistant',
@@ -508,7 +508,10 @@ async function fetchThreadHydration(
508
508
  const data = (await response.json()) as {
509
509
  messages?: Array<UIMessage>
510
510
  activeRun?: { runId?: unknown } | null
511
- interrupts?: { runId?: unknown; pending?: unknown } | null
511
+ interrupts?: {
512
+ runId?: unknown
513
+ pending?: unknown
514
+ } | null
512
515
  }
513
516
  const activeRun =
514
517
  data.activeRun && typeof data.activeRun.runId === 'string'
@@ -872,7 +875,10 @@ export interface ChatHydrationResult {
872
875
  * so a reload (or another device) re-prompts the approval from the server. The
873
876
  * client restores them exactly as a persisted resume snapshot would.
874
877
  */
875
- interrupts: { runId: string; pending: Array<ChatPendingInterrupt> } | null
878
+ interrupts: {
879
+ runId: string
880
+ pending: Array<ChatPendingInterrupt>
881
+ } | null
876
882
  }
877
883
 
878
884
  /**
@@ -2455,6 +2461,12 @@ export function fetcherToConnectionAdapter(
2455
2461
  data,
2456
2462
  threadId: runContext.threadId,
2457
2463
  runId: runContext.runId,
2464
+ ...(runContext.parentRunId !== undefined
2465
+ ? { parentRunId: runContext.parentRunId }
2466
+ : {}),
2467
+ ...(runContext.resume !== undefined
2468
+ ? { resume: runContext.resume }
2469
+ : {}),
2458
2470
  },
2459
2471
  { signal: abortSignal },
2460
2472
  )
package/src/index.ts CHANGED
@@ -39,6 +39,9 @@ export type {
39
39
  ChatInterrupt,
40
40
  ChatInterruptState,
41
41
  GenericAGUIInterrupt,
42
+ GenericInterrupt,
43
+ RegisteredGenericInterrupt,
44
+ ResolvableChatInterrupt,
42
45
  UnboundInterrupt,
43
46
  InterruptItemStatus,
44
47
  ToolApprovalInterrupt,