@tanstack/ai-client 0.33.2 → 0.35.1

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.
@@ -74,7 +74,98 @@ async function registerWebMCPTools(tools, options) {
74
74
  throw error;
75
75
  }
76
76
  }
77
+ function isWebMCPToolReader(value) {
78
+ return value !== null && typeof value === "object" && "getTools" in value && typeof value.getTools === "function" && "executeTool" in value && typeof value.executeTool === "function" && "addEventListener" in value && typeof value.addEventListener === "function" && "removeEventListener" in value && typeof value.removeEventListener === "function";
79
+ }
80
+ function getWebMCPToolReader() {
81
+ if (typeof document === "undefined" || typeof isSecureContext !== "undefined" && !isSecureContext || !("modelContext" in document) || !isWebMCPToolReader(document.modelContext)) return;
82
+ return document.modelContext;
83
+ }
84
+ function parseToolResult(result) {
85
+ try {
86
+ return JSON.parse(result);
87
+ } catch {
88
+ return result;
89
+ }
90
+ }
91
+ async function readWebMCPTools(reader, options) {
92
+ const pageTools = await reader.getTools();
93
+ const names = /* @__PURE__ */ new Set();
94
+ return pageTools.filter((tool) => options?.filter?.(tool) ?? true).map((tool) => {
95
+ if (names.has(tool.name)) throw new Error(`Duplicate WebMCP tool name "${tool.name}". Use a filter or register tools with unique names.`);
96
+ names.add(tool.name);
97
+ return {
98
+ __toolSide: "client",
99
+ name: tool.name,
100
+ description: tool.description,
101
+ inputSchema: tool.inputSchema ?? { type: "object" },
102
+ async execute(input, context) {
103
+ return parseToolResult(await reader.executeTool(tool, input, context?.abortSignal ? { signal: context.abortSignal } : {}));
104
+ }
105
+ };
106
+ });
107
+ }
108
+ /**
109
+ * Reads the WebMCP tools on the page and returns them as client tools.
110
+ *
111
+ * Pass the result to a chat as `tools`. Each tool runs through the WebMCP
112
+ * `executeTool()` call. Unsupported browsers and server environments return
113
+ * an empty array.
114
+ *
115
+ * @param options - A filter that skips tools.
116
+ *
117
+ * @example
118
+ * ```ts
119
+ * const tools = await getWebMCPTools({
120
+ * filter: (tool) => tool.origin === location.origin,
121
+ * })
122
+ * ```
123
+ */
124
+ async function getWebMCPTools(options) {
125
+ const reader = getWebMCPToolReader();
126
+ return reader ? readWebMCPTools(reader, options) : [];
127
+ }
128
+ /**
129
+ * Calls `listener` with the page WebMCP tools now and after each
130
+ * `toolchange` event, until `options.signal` aborts.
131
+ *
132
+ * Unsupported browsers and server environments call `listener` once with an
133
+ * empty array. When a read fails, `options.onError` gets the error and the
134
+ * listener keeps the last list.
135
+ *
136
+ * @param listener - Receives the current client tools.
137
+ * @param options - The subscription signal, a filter, and an error callback.
138
+ *
139
+ * @example
140
+ * ```ts
141
+ * const controller = new AbortController()
142
+ * subscribeWebMCPTools((tools) => client.updateOptions({ tools }), {
143
+ * signal: controller.signal,
144
+ * })
145
+ * ```
146
+ */
147
+ function subscribeWebMCPTools(listener, options) {
148
+ if (options.signal.aborted) return;
149
+ const reader = getWebMCPToolReader();
150
+ if (!reader) {
151
+ listener([]);
152
+ return;
153
+ }
154
+ let latestRead = 0;
155
+ const refresh = () => {
156
+ const read = ++latestRead;
157
+ const isCurrent = () => read === latestRead && !options.signal.aborted;
158
+ readWebMCPTools(reader, options).then((tools) => {
159
+ if (isCurrent()) listener(tools);
160
+ }, (error) => {
161
+ if (isCurrent()) options.onError?.(error);
162
+ });
163
+ };
164
+ reader.addEventListener("toolchange", refresh);
165
+ options.signal.addEventListener("abort", () => reader.removeEventListener("toolchange", refresh), { once: true });
166
+ refresh();
167
+ }
77
168
  //#endregion
78
- export { registerWebMCPTools };
169
+ export { getWebMCPTools, registerWebMCPTools, subscribeWebMCPTools };
79
170
 
80
171
  //# sourceMappingURL=web-mcp-tools.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"web-mcp-tools.js","names":[],"sources":["../../src/web-mcp-tools.ts"],"sourcesContent":["import {\n convertSchemaToJsonSchema,\n validateWithStandardSchema,\n} from '@tanstack/ai/client'\nimport type { AnyClientTool } from '@tanstack/ai/client'\nimport type {\n ClientContextOptionFromTools,\n InferredClientContext,\n} from './types'\n\ninterface WebMCPTool {\n name: string\n title?: string\n description: string\n inputSchema?: object\n annotations?: WebMCPToolAnnotations\n execute: (input: object, options: { signal: AbortSignal }) => Promise<unknown>\n}\n\ninterface WebMCPModelContext {\n registerTool: (\n tool: WebMCPTool,\n options: { signal: AbortSignal },\n ) => Promise<void>\n}\n\n/** WebMCP behavior hints for one registered tool. */\nexport interface WebMCPToolAnnotations {\n /** Indicates that the tool does not modify state. */\n readOnlyHint?: boolean\n /** Indicates that the tool can return content that the application does not trust. */\n untrustedContentHint?: boolean\n}\n\n/** Display and behavior options for one WebMCP tool. */\nexport interface WebMCPToolOptions {\n /** A human-readable title for browser user interfaces. */\n title?: string\n /** Optional behavior hints for browser agents. */\n annotations?: WebMCPToolAnnotations\n}\n\n/** WebMCP options keyed by the inferred names in a client tool list. */\nexport type WebMCPToolOptionsByName<\n TTools extends ReadonlyArray<AnyClientTool>,\n> = Partial<{\n [TName in TTools[number]['name']]: WebMCPToolOptions\n}>\n\n/**\n * Options for {@link registerWebMCPTools}.\n *\n * The signal controls the registration lifetime. Context is required when a\n * client tool declares a required runtime context.\n */\nexport type RegisterWebMCPToolsOptions<\n TTools extends ReadonlyArray<AnyClientTool>,\n TContext = InferredClientContext<TTools>,\n> = {\n /** Removes all tools from this call when the signal aborts. */\n signal: AbortSignal\n /** Per-tool display and behavior options. */\n toolOptions?: WebMCPToolOptionsByName<TTools>\n} & ClientContextOptionFromTools<TTools, TContext>\n\nfunction isWebMCPModelContext(value: unknown): value is WebMCPModelContext {\n return (\n value !== null &&\n typeof value === 'object' &&\n 'registerTool' in value &&\n typeof value.registerTool === 'function'\n )\n}\n\nfunction getToolOptions<TName extends string>(\n toolOptions: Partial<Record<TName, WebMCPToolOptions>> | undefined,\n name: TName,\n) {\n return toolOptions?.[name]\n}\n\nasync function validateSchemaValue(schema: unknown, value: unknown) {\n const result = await validateWithStandardSchema(schema, value)\n if (result.success) {\n return result.data\n }\n\n throw new Error(\n `Validation failed: ${result.issues.map((issue) => issue.message).join(', ')}`,\n )\n}\n\n/**\n * Registers executable TanStack client tools with the browser WebMCP API.\n *\n * Unsupported browsers and server environments resolve without registration.\n * Abort `options.signal` to remove every tool registered by this call.\n *\n * @param tools - The executable client tools to expose through WebMCP.\n * @param options - The registration signal, runtime context, and per-tool options.\n *\n * @example\n * ```ts\n * const controller = new AbortController()\n * await registerWebMCPTools(tools, { signal: controller.signal })\n * controller.abort()\n * ```\n */\nexport async function registerWebMCPTools<\n const TTools extends ReadonlyArray<AnyClientTool>,\n TContext = InferredClientContext<TTools>,\n>(tools: TTools, options: RegisterWebMCPToolsOptions<TTools, TContext>) {\n if (\n typeof document === 'undefined' ||\n (typeof isSecureContext !== 'undefined' && !isSecureContext) ||\n !('modelContext' in document) ||\n !isWebMCPModelContext(document.modelContext)\n ) {\n return\n }\n if (tools.length === 0) {\n return\n }\n\n const names = new Set<string>()\n const webMCPTools = tools.map((tool) => {\n if (!/^[A-Za-z0-9_.-]{1,128}$/.test(tool.name)) {\n throw new Error(\n `WebMCP tool name \"${tool.name}\" must contain 1 to 128 ASCII letters, numbers, underscores, hyphens, or periods.`,\n )\n }\n if (names.has(tool.name)) {\n throw new Error(`Duplicate WebMCP tool name \"${tool.name}\".`)\n }\n if (tool.description.trim() === '') {\n throw new Error(`WebMCP tool \"${tool.name}\" must have a description.`)\n }\n if (typeof tool.execute !== 'function') {\n throw new Error(\n `WebMCP tool \"${tool.name}\" must have an execute handler.`,\n )\n }\n if (tool.needsApproval === true) {\n throw new Error(\n `WebMCP tool \"${tool.name}\" cannot use needsApproval: true.`,\n )\n }\n\n names.add(tool.name)\n const toolOptions = getToolOptions(options.toolOptions, tool.name)\n const inputSchema = tool.inputSchema\n const outputSchema = tool.outputSchema\n const convertedInputSchema = convertSchemaToJsonSchema(inputSchema)\n const inputSchemaType = convertedInputSchema?.type\n const requiresNonObjectInput =\n (typeof inputSchemaType === 'string' && inputSchemaType !== 'object') ||\n (Array.isArray(inputSchemaType) && !inputSchemaType.includes('object'))\n if (requiresNonObjectInput) {\n throw new Error(\n `WebMCP tool \"${tool.name}\" input schema must accept an object.`,\n )\n }\n const execute = tool.execute\n\n return {\n name: tool.name,\n description: tool.description,\n ...(toolOptions?.title !== undefined ? { title: toolOptions.title } : {}),\n ...(convertedInputSchema !== undefined\n ? { inputSchema: convertedInputSchema }\n : {}),\n ...(toolOptions?.annotations !== undefined\n ? { annotations: toolOptions.annotations }\n : {}),\n async execute(\n input: object,\n executionOptions?: { signal?: AbortSignal },\n ) {\n const validatedInput = await validateSchemaValue(inputSchema, input)\n const output = await execute(validatedInput, {\n abortSignal: executionOptions?.signal,\n context: options.context,\n emitCustomEvent() {},\n })\n return validateSchemaValue(outputSchema, output)\n },\n }\n })\n\n const registrationController = new AbortController()\n const abortRegistration = () =>\n registrationController.abort(options.signal.reason)\n\n if (options.signal.aborted) {\n abortRegistration()\n } else {\n options.signal.addEventListener('abort', abortRegistration, { once: true })\n }\n\n try {\n for (const tool of webMCPTools) {\n await document.modelContext.registerTool(tool, {\n signal: registrationController.signal,\n })\n }\n } catch (error) {\n registrationController.abort(error)\n options.signal.removeEventListener('abort', abortRegistration)\n throw error\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"web-mcp-tools.js","names":[],"sources":["../../src/web-mcp-tools.ts"],"sourcesContent":["import {\n convertSchemaToJsonSchema,\n validateWithStandardSchema,\n} from '@tanstack/ai/client'\nimport type { AnyClientTool } from '@tanstack/ai/client'\nimport type {\n ClientContextOptionFromTools,\n InferredClientContext,\n} from './types'\n\ninterface WebMCPTool {\n name: string\n title?: string\n description: string\n inputSchema?: object\n annotations?: WebMCPToolAnnotations\n execute: (input: object, options: { signal: AbortSignal }) => Promise<unknown>\n}\n\ninterface WebMCPModelContext {\n registerTool: (\n tool: WebMCPTool,\n options: { signal: AbortSignal },\n ) => Promise<void>\n}\n\n/** WebMCP behavior hints for one registered tool. */\nexport interface WebMCPToolAnnotations {\n /** Indicates that the tool does not modify state. */\n readOnlyHint?: boolean\n /** Indicates that the tool can return content that the application does not trust. */\n untrustedContentHint?: boolean\n}\n\n/** Display and behavior options for one WebMCP tool. */\nexport interface WebMCPToolOptions {\n /** A human-readable title for browser user interfaces. */\n title?: string\n /** Optional behavior hints for browser agents. */\n annotations?: WebMCPToolAnnotations\n}\n\n/** WebMCP options keyed by the inferred names in a client tool list. */\nexport type WebMCPToolOptionsByName<\n TTools extends ReadonlyArray<AnyClientTool>,\n> = Partial<{\n [TName in TTools[number]['name']]: WebMCPToolOptions\n}>\n\n/**\n * Options for {@link registerWebMCPTools}.\n *\n * The signal controls the registration lifetime. Context is required when a\n * client tool declares a required runtime context.\n */\nexport type RegisterWebMCPToolsOptions<\n TTools extends ReadonlyArray<AnyClientTool>,\n TContext = InferredClientContext<TTools>,\n> = {\n /** Removes all tools from this call when the signal aborts. */\n signal: AbortSignal\n /** Per-tool display and behavior options. */\n toolOptions?: WebMCPToolOptionsByName<TTools>\n} & ClientContextOptionFromTools<TTools, TContext>\n\nfunction isWebMCPModelContext(value: unknown): value is WebMCPModelContext {\n return (\n value !== null &&\n typeof value === 'object' &&\n 'registerTool' in value &&\n typeof value.registerTool === 'function'\n )\n}\n\nfunction getToolOptions<TName extends string>(\n toolOptions: Partial<Record<TName, WebMCPToolOptions>> | undefined,\n name: TName,\n) {\n return toolOptions?.[name]\n}\n\nasync function validateSchemaValue(schema: unknown, value: unknown) {\n const result = await validateWithStandardSchema(schema, value)\n if (result.success) {\n return result.data\n }\n\n throw new Error(\n `Validation failed: ${result.issues.map((issue) => issue.message).join(', ')}`,\n )\n}\n\n/**\n * Registers executable TanStack client tools with the browser WebMCP API.\n *\n * Unsupported browsers and server environments resolve without registration.\n * Abort `options.signal` to remove every tool registered by this call.\n *\n * @param tools - The executable client tools to expose through WebMCP.\n * @param options - The registration signal, runtime context, and per-tool options.\n *\n * @example\n * ```ts\n * const controller = new AbortController()\n * await registerWebMCPTools(tools, { signal: controller.signal })\n * controller.abort()\n * ```\n */\nexport async function registerWebMCPTools<\n const TTools extends ReadonlyArray<AnyClientTool>,\n TContext = InferredClientContext<TTools>,\n>(tools: TTools, options: RegisterWebMCPToolsOptions<TTools, TContext>) {\n if (\n typeof document === 'undefined' ||\n (typeof isSecureContext !== 'undefined' && !isSecureContext) ||\n !('modelContext' in document) ||\n !isWebMCPModelContext(document.modelContext)\n ) {\n return\n }\n if (tools.length === 0) {\n return\n }\n\n const names = new Set<string>()\n const webMCPTools = tools.map((tool) => {\n if (!/^[A-Za-z0-9_.-]{1,128}$/.test(tool.name)) {\n throw new Error(\n `WebMCP tool name \"${tool.name}\" must contain 1 to 128 ASCII letters, numbers, underscores, hyphens, or periods.`,\n )\n }\n if (names.has(tool.name)) {\n throw new Error(`Duplicate WebMCP tool name \"${tool.name}\".`)\n }\n if (tool.description.trim() === '') {\n throw new Error(`WebMCP tool \"${tool.name}\" must have a description.`)\n }\n if (typeof tool.execute !== 'function') {\n throw new Error(\n `WebMCP tool \"${tool.name}\" must have an execute handler.`,\n )\n }\n if (tool.needsApproval === true) {\n throw new Error(\n `WebMCP tool \"${tool.name}\" cannot use needsApproval: true.`,\n )\n }\n\n names.add(tool.name)\n const toolOptions = getToolOptions(options.toolOptions, tool.name)\n const inputSchema = tool.inputSchema\n const outputSchema = tool.outputSchema\n const convertedInputSchema = convertSchemaToJsonSchema(inputSchema)\n const inputSchemaType = convertedInputSchema?.type\n const requiresNonObjectInput =\n (typeof inputSchemaType === 'string' && inputSchemaType !== 'object') ||\n (Array.isArray(inputSchemaType) && !inputSchemaType.includes('object'))\n if (requiresNonObjectInput) {\n throw new Error(\n `WebMCP tool \"${tool.name}\" input schema must accept an object.`,\n )\n }\n const execute = tool.execute\n\n return {\n name: tool.name,\n description: tool.description,\n ...(toolOptions?.title !== undefined ? { title: toolOptions.title } : {}),\n ...(convertedInputSchema !== undefined\n ? { inputSchema: convertedInputSchema }\n : {}),\n ...(toolOptions?.annotations !== undefined\n ? { annotations: toolOptions.annotations }\n : {}),\n async execute(\n input: object,\n executionOptions?: { signal?: AbortSignal },\n ) {\n const validatedInput = await validateSchemaValue(inputSchema, input)\n const output = await execute(validatedInput, {\n abortSignal: executionOptions?.signal,\n context: options.context,\n emitCustomEvent() {},\n })\n return validateSchemaValue(outputSchema, output)\n },\n }\n })\n\n const registrationController = new AbortController()\n const abortRegistration = () =>\n registrationController.abort(options.signal.reason)\n\n if (options.signal.aborted) {\n abortRegistration()\n } else {\n options.signal.addEventListener('abort', abortRegistration, { once: true })\n }\n\n try {\n for (const tool of webMCPTools) {\n await document.modelContext.registerTool(tool, {\n signal: registrationController.signal,\n })\n }\n } catch (error) {\n registrationController.abort(error)\n options.signal.removeEventListener('abort', abortRegistration)\n throw error\n }\n}\n\n/** A tool that a page registered with WebMCP, as `getTools()` returns it. */\nexport interface WebMCPPageTool {\n name: string\n title?: string\n description: string\n /** A JSON Schema object for the tool input. */\n inputSchema?: object\n /** The origin of the document that registered the tool. */\n origin: string\n annotations?: WebMCPToolAnnotations\n}\n\ninterface WebMCPToolReader {\n getTools: () => Promise<Array<WebMCPPageTool>>\n executeTool: (\n tool: WebMCPPageTool,\n input: unknown,\n options: { signal?: AbortSignal },\n ) => Promise<string>\n addEventListener: EventTarget['addEventListener']\n removeEventListener: EventTarget['removeEventListener']\n}\n\n/** Options for {@link getWebMCPTools}. */\nexport interface GetWebMCPToolsOptions {\n /** Return `false` to skip a tool. */\n filter?: (tool: WebMCPPageTool) => boolean\n}\n\n/** Options for {@link subscribeWebMCPTools}. */\nexport interface SubscribeWebMCPToolsOptions extends GetWebMCPToolsOptions {\n /** Stops the subscription when it aborts. */\n signal: AbortSignal\n /** Receives a failure from the WebMCP `getTools()` call. */\n onError?: (error: unknown) => void\n}\n\nfunction isWebMCPToolReader(value: unknown): value is WebMCPToolReader {\n return (\n value !== null &&\n typeof value === 'object' &&\n 'getTools' in value &&\n typeof value.getTools === 'function' &&\n 'executeTool' in value &&\n typeof value.executeTool === 'function' &&\n 'addEventListener' in value &&\n typeof value.addEventListener === 'function' &&\n 'removeEventListener' in value &&\n typeof value.removeEventListener === 'function'\n )\n}\n\nfunction getWebMCPToolReader() {\n if (\n typeof document === 'undefined' ||\n (typeof isSecureContext !== 'undefined' && !isSecureContext) ||\n !('modelContext' in document) ||\n !isWebMCPToolReader(document.modelContext)\n ) {\n return undefined\n }\n return document.modelContext\n}\n\nfunction parseToolResult(result: string): unknown {\n try {\n return JSON.parse(result)\n } catch {\n return result\n }\n}\n\nasync function readWebMCPTools(\n reader: WebMCPToolReader,\n options: GetWebMCPToolsOptions | undefined,\n): Promise<Array<AnyClientTool>> {\n const pageTools = await reader.getTools()\n const names = new Set<string>()\n return pageTools\n .filter((tool) => options?.filter?.(tool) ?? true)\n .map((tool) => {\n if (names.has(tool.name)) {\n throw new Error(\n `Duplicate WebMCP tool name \"${tool.name}\". Use a filter or register tools with unique names.`,\n )\n }\n names.add(tool.name)\n return {\n __toolSide: 'client' as const,\n name: tool.name,\n description: tool.description,\n inputSchema: tool.inputSchema ?? { type: 'object' },\n async execute(input: unknown, context?: { abortSignal?: AbortSignal }) {\n const result = await reader.executeTool(\n tool,\n input,\n context?.abortSignal ? { signal: context.abortSignal } : {},\n )\n return parseToolResult(result)\n },\n }\n })\n}\n\n/**\n * Reads the WebMCP tools on the page and returns them as client tools.\n *\n * Pass the result to a chat as `tools`. Each tool runs through the WebMCP\n * `executeTool()` call. Unsupported browsers and server environments return\n * an empty array.\n *\n * @param options - A filter that skips tools.\n *\n * @example\n * ```ts\n * const tools = await getWebMCPTools({\n * filter: (tool) => tool.origin === location.origin,\n * })\n * ```\n */\nexport async function getWebMCPTools(\n options?: GetWebMCPToolsOptions,\n): Promise<Array<AnyClientTool>> {\n const reader = getWebMCPToolReader()\n return reader ? readWebMCPTools(reader, options) : []\n}\n\n/**\n * Calls `listener` with the page WebMCP tools now and after each\n * `toolchange` event, until `options.signal` aborts.\n *\n * Unsupported browsers and server environments call `listener` once with an\n * empty array. When a read fails, `options.onError` gets the error and the\n * listener keeps the last list.\n *\n * @param listener - Receives the current client tools.\n * @param options - The subscription signal, a filter, and an error callback.\n *\n * @example\n * ```ts\n * const controller = new AbortController()\n * subscribeWebMCPTools((tools) => client.updateOptions({ tools }), {\n * signal: controller.signal,\n * })\n * ```\n */\nexport function subscribeWebMCPTools(\n listener: (tools: Array<AnyClientTool>) => void,\n options: SubscribeWebMCPToolsOptions,\n): void {\n if (options.signal.aborted) return\n const reader = getWebMCPToolReader()\n if (!reader) {\n listener([])\n return\n }\n\n let latestRead = 0\n const refresh = () => {\n const read = ++latestRead\n const isCurrent = () => read === latestRead && !options.signal.aborted\n readWebMCPTools(reader, options).then(\n (tools) => {\n if (isCurrent()) listener(tools)\n },\n (error: unknown) => {\n if (isCurrent()) options.onError?.(error)\n },\n )\n }\n\n // Remove the listener by hand: Zone.js breaks the `signal` listener option.\n reader.addEventListener('toolchange', refresh)\n options.signal.addEventListener(\n 'abort',\n () => reader.removeEventListener('toolchange', refresh),\n { once: true },\n )\n refresh()\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-client",
3
- "version": "0.33.2",
3
+ "version": "0.35.1",
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",
@@ -57,8 +57,8 @@
57
57
  "src"
58
58
  ],
59
59
  "dependencies": {
60
- "@tanstack/ai": "^0.58.0",
61
- "@tanstack/ai-event-client": "^0.12.1",
60
+ "@tanstack/ai": "^0.61.0",
61
+ "@tanstack/ai-event-client": "^0.13.0",
62
62
  "@tanstack/ai-utils": "^0.4.1"
63
63
  },
64
64
  "devDependencies": {
@@ -1,4 +1,5 @@
1
1
  import {
2
+ EventType,
2
3
  StreamProcessor,
3
4
  convertSchemaToJsonSchema,
4
5
  generateMessageId,
@@ -72,6 +73,7 @@ import type {
72
73
  QueueStrategy,
73
74
  QueuedMessage,
74
75
  SendMessageOptions,
76
+ SubagentHandle,
75
77
  ToolCallPart,
76
78
  UIMessage,
77
79
  WhenBusy,
@@ -328,13 +330,71 @@ const REJOIN_CONNECT_DEADLINE_MS = 2000
328
330
  const REJOIN_REBUILD_TRIGGERS = new Set<string>([
329
331
  'TEXT_MESSAGE_START',
330
332
  'TEXT_MESSAGE_CONTENT',
333
+ 'REASONING_MESSAGE_CONTENT',
331
334
  'TOOL_CALL_START',
332
335
  'MESSAGES_SNAPSHOT',
336
+ // Drop the hydrated card before this chunk creates it again. A subagent
337
+ // turn may have no parent text, so the text triggers arrive too late.
338
+ 'SUBAGENT_STARTED',
333
339
  ])
334
340
 
341
+ function rebuildsAssistantMessage(chunk: StreamChunk): boolean {
342
+ if (chunk.type === 'REASONING_ENCRYPTED_VALUE') {
343
+ return (
344
+ chunk.subtype === 'message' &&
345
+ typeof chunk.encryptedValue === 'string' &&
346
+ chunk.encryptedValue.length > 0
347
+ )
348
+ }
349
+ if (chunk.type === 'STEP_FINISHED') {
350
+ return 'signature' in chunk && Boolean(chunk.signature)
351
+ }
352
+ return REJOIN_REBUILD_TRIGGERS.has(chunk.type)
353
+ }
354
+
355
+ type SubagentCard = Extract<UIMessage['parts'][number], { type: 'subagent' }>
356
+
357
+ /** Every subagent card in the messages, nested cards included. */
358
+ function collectSubagentParts(
359
+ messages: ReadonlyArray<UIMessage>,
360
+ ): Array<SubagentCard> {
361
+ const cards: Array<SubagentCard> = []
362
+ for (const message of messages) {
363
+ for (const part of message.parts) {
364
+ if (part.type !== 'subagent') continue
365
+ cards.push(part)
366
+ cards.push(...collectSubagentParts(part.subagent.messages))
367
+ }
368
+ }
369
+ return cards
370
+ }
371
+
372
+ function readSubagentRunId(chunk: StreamChunk) {
373
+ if ('subagentRunId' in chunk && typeof chunk.subagentRunId === 'string') {
374
+ return chunk.subagentRunId
375
+ }
376
+ return undefined
377
+ }
378
+
335
379
  // Cap parent-chain walks so a cyclic replayed parentRunId cannot loop.
336
380
  const MAX_RUN_LINEAGE_DEPTH = 64
337
381
 
382
+ /**
383
+ * A deep copy of plain data. Dates stay Dates. Function-valued properties are
384
+ * dropped.
385
+ */
386
+ function copyForDevtools<T>(value: T): T
387
+ function copyForDevtools(value: unknown): unknown {
388
+ if (Array.isArray(value)) return value.map(copyForDevtools)
389
+ if (value instanceof Date) return new Date(value.getTime())
390
+ if (value === null || typeof value !== 'object') return value
391
+ const out: Record<string, unknown> = {}
392
+ for (const [key, item] of Object.entries(value)) {
393
+ if (typeof item !== 'function') out[key] = copyForDevtools(item)
394
+ }
395
+ return out
396
+ }
397
+
338
398
  export class ChatClient<
339
399
  TTools extends ReadonlyArray<AnyClientTool> = any,
340
400
  TContext = unknown,
@@ -342,6 +402,8 @@ export class ChatClient<
342
402
  any,
343
403
  > {
344
404
  private readonly processor: StreamProcessor
405
+ private readonly subagentHandles = new Map<string, SubagentHandle>()
406
+ private readonly stoppedSubagentIds = new Set<string>()
345
407
  private connection: SubscribeConnectionAdapter
346
408
  private uniqueId: string
347
409
  private threadId: string
@@ -459,6 +521,8 @@ export class ChatClient<
459
521
  private readonly activeRunIds = new Set<string>()
460
522
  /** Latched by `dispose()`; stops any late async callback starting new work. */
461
523
  private disposed = false
524
+ /** The error a failed mount hydration set, cleared by the next successful one. */
525
+ private hydrationError: Error | undefined
462
526
  /** Whether a view is currently watching. See `attach` / `detach`. */
463
527
  private tailing = false
464
528
  /** Constructor inputs `attach()` needs on every re-attach, not just the first. */
@@ -669,9 +733,16 @@ export class ChatClient<
669
733
  : {}),
670
734
  ...(initialMessages ? { initialMessages } : {}),
671
735
  events: {
672
- onMessagesChange: (messages: Array<UIMessage>) => {
736
+ onMessagesChange: (messages) => {
737
+ // Restored or replaced messages bring their own cards. Give each one
738
+ // its live handle before anyone reads the messages.
739
+ this.syncSubagentHandles()
673
740
  this.persistor?.notifyMessagesChanged(messages)
674
741
  this.callbacksRef.current.onMessagesChange(messages)
742
+ // The bridge only snapshots on status changes, and the panel draws a
743
+ // child card from the snapshot, so without this the card would update
744
+ // only when the parent run ends.
745
+ this.queueDevtoolsSnapshot()
675
746
  },
676
747
  onStreamStart: () => {
677
748
  this.setStatus('streaming')
@@ -887,6 +958,8 @@ export class ChatClient<
887
958
  },
888
959
  },
889
960
  })
961
+ // `initialMessages` do not fire a change event. Give their cards handles.
962
+ this.syncSubagentHandles()
890
963
 
891
964
  this.persistor?.hydrateAsync(persistedState)
892
965
 
@@ -1068,7 +1141,10 @@ export class ChatClient<
1068
1141
  const generation = this.historyGeneration
1069
1142
  try {
1070
1143
  result = await hydrate(this.threadId, hydrateOptions)
1071
- } catch {
1144
+ } catch (cause) {
1145
+ // Same staleness guard as the success path below: a failure from an
1146
+ // older attempt must not touch the state of a newer one.
1147
+ if (generation === this.historyGeneration) this.failHydration(cause)
1072
1148
  return
1073
1149
  }
1074
1150
  if (generation !== this.historyGeneration) return
@@ -1084,6 +1160,13 @@ export class ChatClient<
1084
1160
  if (this.disposed || !this.tailing) return
1085
1161
  // A send may have started while the fetch was in flight — don't stomp it.
1086
1162
  if (this.isLoading || this.abortController) return
1163
+ // A retry after a failed load succeeded: drop that failure, but not an
1164
+ // error something else set since.
1165
+ if (this.hydrationError && this.error === this.hydrationError) {
1166
+ this.setError(undefined)
1167
+ this.setStatus('ready')
1168
+ }
1169
+ this.hydrationError = undefined
1087
1170
  this.applyHydrationPage(result.page)
1088
1171
  if (result.messages.length > 0) {
1089
1172
  const windowMessages = normalizeMessagesDates(result.messages)
@@ -1114,6 +1197,36 @@ export class ChatClient<
1114
1197
  })()
1115
1198
  }
1116
1199
 
1200
+ /**
1201
+ * Surface a mount-hydration failure (`persistence: true`) on the observable
1202
+ * fields, mirroring `GenerationClient.failHydration`, so "this thread failed
1203
+ * to load" is distinguishable from "this thread has no messages" and the app
1204
+ * can show an error / offer a retry. A genuine miss — the server having no
1205
+ * record for a fresh thread — resolves normally and never reaches here; only a
1206
+ * thrown transport / authorize-gate error does.
1207
+ *
1208
+ * Skipped when the view unmounted (`!tailing`) or a `sendMessage` took
1209
+ * ownership while the hydrate GET was in flight, so a live run's state always
1210
+ * wins over a stale mount-time failure — same guard as the success path above.
1211
+ */
1212
+ private failHydration(cause: unknown): void {
1213
+ if (this.disposed || !this.tailing) return
1214
+ if (this.isLoading || this.abortController) return
1215
+ const error = cause instanceof Error ? cause : new Error(String(cause))
1216
+ // Mirror the send path: a BYOK key that is missing / locked must still
1217
+ // trigger the key-request flow on thread load, not just be reported.
1218
+ if (error instanceof ByokMissingError) {
1219
+ this.byok?.request(error.provider, 'missing')
1220
+ }
1221
+ if (error instanceof ByokBlockedError && error.reason === 'locked') {
1222
+ this.byok?.request(error.provider, 'locked')
1223
+ }
1224
+ this.hydrationError = error
1225
+ this.setStatus('error')
1226
+ this.setError(error)
1227
+ this.callbacksRef.current.onError(error)
1228
+ }
1229
+
1117
1230
  mountDevtools(): void {
1118
1231
  this.ensureThreadId()
1119
1232
  if (this.devtoolsMounted) {
@@ -1693,7 +1806,9 @@ export class ChatClient<
1693
1806
 
1694
1807
  private getDevtoolsSnapshot(): AIDevtoolsChatSnapshot {
1695
1808
  return {
1696
- messages: this.processor.getMessages(),
1809
+ // The devtools store keeps the objects it gets, and a subagent handle
1810
+ // changes in place. A copy lets the panel see each new status and part.
1811
+ messages: copyForDevtools(this.processor.getMessages()),
1697
1812
  status: this.status,
1698
1813
  isLoading: this.isLoading,
1699
1814
  isSubscribed: this.isSubscribed,
@@ -1865,7 +1980,7 @@ export class ChatClient<
1865
1980
  attached = true
1866
1981
  clearTimeout(connectTimer)
1867
1982
  }
1868
- if (!rebuilt && REJOIN_REBUILD_TRIGGERS.has(chunk.type)) {
1983
+ if (!rebuilt && rebuildsAssistantMessage(chunk)) {
1869
1984
  rebuilt = true
1870
1985
  this.dropTrailingInFlightAssistant()
1871
1986
  }
@@ -1970,7 +2085,18 @@ export class ChatClient<
1970
2085
  }
1971
2086
  this.callbacksRef.current.onChunk(chunk)
1972
2087
  this.devtoolsBridge.observeChunk(chunk)
2088
+ const attributedId = readSubagentRunId(chunk)
2089
+ // A resumed child starts again under the same id, so its start lifts the stop.
2090
+ if (attributedId && chunk.type === EventType.SUBAGENT_STARTED) {
2091
+ this.stoppedSubagentIds.delete(attributedId)
2092
+ }
2093
+ if (attributedId && this.stoppedSubagentIds.has(attributedId)) {
2094
+ this.updateRunLifecycle(chunk)
2095
+ this.resolveJoinedRun(chunk)
2096
+ return
2097
+ }
1973
2098
  this.processor.processChunk(chunk)
2099
+ this.syncSubagentHandles()
1974
2100
  this.updateRunLifecycle(chunk)
1975
2101
  this.observeInterruptState(chunk)
1976
2102
  // Live path: yield a macrotask so the UI can paint. Skip when the page is
@@ -2678,6 +2804,7 @@ export class ChatClient<
2678
2804
  */
2679
2805
  stop(): void {
2680
2806
  // Invalidate deferred work from the stopped continuation.
2807
+ // This aborts the local request. A durable server run keeps going.
2681
2808
  this.continuationGeneration++
2682
2809
  const hadLocalStream = this.abortController !== null
2683
2810
  this.cancelInFlightStream({ setReadyStatus: true })
@@ -2715,6 +2842,7 @@ export class ChatClient<
2715
2842
  this.resetHistoryPaging()
2716
2843
  this.discardPendingSends()
2717
2844
  this.persistor?.remove()
2845
+ this.stoppedSubagentIds.clear()
2718
2846
  this.lastResume = null
2719
2847
  this.interruptManager.reset()
2720
2848
  this.pendingResumeThreadId = null
@@ -2974,6 +3102,72 @@ export class ChatClient<
2974
3102
  return this.processor.getMessages() as Array<UIMessage<TTools>>
2975
3103
  }
2976
3104
 
3105
+ getSubagents() {
3106
+ return [...this.subagentHandles.values()]
3107
+ }
3108
+
3109
+ private devtoolsSnapshotQueued = false
3110
+
3111
+ /** One devtools snapshot per microtask, however many chunks arrive. */
3112
+ private queueDevtoolsSnapshot(): void {
3113
+ if (this.devtoolsSnapshotQueued) return
3114
+ this.devtoolsSnapshotQueued = true
3115
+ queueMicrotask(() => {
3116
+ this.devtoolsSnapshotQueued = false
3117
+ this.devtoolsBridge.emitSnapshot()
3118
+ })
3119
+ }
3120
+
3121
+ private syncSubagentHandles(): void {
3122
+ // Every card, nested ones included, gets a handle.
3123
+ const cards = collectSubagentParts(this.processor.getMessages())
3124
+ const present = new Set(cards.map((part) => part.subagent.id))
3125
+ // A card that left the messages (clear, reload) loses its handle.
3126
+ for (const id of this.subagentHandles.keys()) {
3127
+ if (!present.has(id)) this.subagentHandles.delete(id)
3128
+ }
3129
+ for (const part of cards) {
3130
+ const id = part.subagent.id
3131
+ const existing = this.subagentHandles.get(id)
3132
+ if (existing) {
3133
+ if (part.subagent === existing) continue
3134
+ // Keep the same live object, with exactly the card's fields. The
3135
+ // wire reads this object, so a field the card dropped goes too.
3136
+ const { stop } = existing
3137
+ for (const key of Object.keys(existing)) {
3138
+ Reflect.deleteProperty(existing, key)
3139
+ }
3140
+ Object.assign(existing, part.subagent, { stop })
3141
+ part.subagent = existing
3142
+ continue
3143
+ }
3144
+ const handle: SubagentHandle = {
3145
+ ...part.subagent,
3146
+ stop: () => {
3147
+ this.stopSubagent(id)
3148
+ },
3149
+ }
3150
+ this.subagentHandles.set(id, handle)
3151
+ part.subagent = handle
3152
+ }
3153
+ }
3154
+
3155
+ private stopSubagent(id: string): void {
3156
+ const handle = this.subagentHandles.get(id)
3157
+ if (!handle || handle.status !== 'running') return
3158
+ this.stoppedSubagentIds.add(id)
3159
+ this.processor.processChunk({
3160
+ type: EventType.SUBAGENT_ERROR,
3161
+ subagentRunId: id,
3162
+ message: 'Stopped',
3163
+ timestamp: Date.now(),
3164
+ })
3165
+ this.syncSubagentHandles()
3166
+ handle.status = 'error'
3167
+ handle.error = { message: 'Stopped' }
3168
+ this.abortController?.abort()
3169
+ }
3170
+
2977
3171
  /**
2978
3172
  * True when the last hydrate or older-page response said more messages exist.
2979
3173
  */
package/src/index.ts CHANGED
@@ -14,9 +14,16 @@ export type {
14
14
  } from './interrupt-manager'
15
15
  export { createMcpAppBridge } from './mcp-app-bridge'
16
16
  export type { McpAppBridge, CreateMcpAppBridgeOptions } from './mcp-app-bridge'
17
- export { registerWebMCPTools } from './web-mcp-tools'
17
+ export {
18
+ getWebMCPTools,
19
+ registerWebMCPTools,
20
+ subscribeWebMCPTools,
21
+ } from './web-mcp-tools'
18
22
  export type {
23
+ GetWebMCPToolsOptions,
19
24
  RegisterWebMCPToolsOptions,
25
+ SubscribeWebMCPToolsOptions,
26
+ WebMCPPageTool,
20
27
  WebMCPToolAnnotations,
21
28
  WebMCPToolOptions,
22
29
  WebMCPToolOptionsByName,
@@ -33,6 +40,13 @@ export type {
33
40
  ToolResultPart,
34
41
  ThinkingPart,
35
42
  StructuredOutputPart,
43
+ SubagentPart,
44
+ SubagentPartOf,
45
+ SubagentHandle,
46
+ SubagentHandleOf,
47
+ SubagentHandles,
48
+ SubagentClientAgent,
49
+ SubagentStatus,
36
50
  // Client configuration types
37
51
  ChatClientPersistence,
38
52
  ChatPersistedState,