From 3a746a6cd3782e782e5d255bb1974b512194ad61 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 3 Jul 2026 23:48:03 +0900 Subject: [PATCH 01/19] Add AI Gateway tool calling support --- .changeset/ai-gateway-tools.md | 7 + docs/api/create-ai-gateway-client.md | 50 ++- docs/api/use-ai-chat.md | 50 ++- packages/core/src/ai/client.test.ts | 99 ++++++ packages/core/src/ai/client.ts | 230 +++++++++++-- packages/core/src/ai/tools.test.ts | 70 ++++ packages/core/src/ai/tools.ts | 374 ++++++++++++++++++++++ packages/core/src/ai/use-ai-chat.test.tsx | 144 ++++++++- packages/core/src/ai/use-ai-chat.ts | 337 ++++++++++++++++--- packages/core/src/index.ts | 16 + 10 files changed, 1309 insertions(+), 68 deletions(-) create mode 100644 .changeset/ai-gateway-tools.md create mode 100644 packages/core/src/ai/tools.test.ts create mode 100644 packages/core/src/ai/tools.ts diff --git a/.changeset/ai-gateway-tools.md b/.changeset/ai-gateway-tools.md new file mode 100644 index 000000000..d033a25b3 --- /dev/null +++ b/.changeset/ai-gateway-tools.md @@ -0,0 +1,7 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add tool support to `useAIChat()` and the AI Gateway transport layer. + +You can now register local tools with `defineAIChatTool(...)` and provider tools such as `aiProviderTool.openai.webSearch(...)`, while keeping the public chat message model focused on user/assistant messages. diff --git a/docs/api/create-ai-gateway-client.md b/docs/api/create-ai-gateway-client.md index d59f2da3e..0544e6e41 100644 --- a/docs/api/create-ai-gateway-client.md +++ b/docs/api/create-ai-gateway-client.md @@ -5,7 +5,7 @@ description: Create a low-level AI Gateway client that reuses AppShell authentic # createAIGatewayClient -Creates a small AI Gateway transport client for text-only chat completions. +Creates a small AI Gateway transport client for chat completions, tool calls, and optional sources. ## Signature @@ -41,7 +41,8 @@ interface AIGatewayClient { The iterable yields completion events: - `text-delta` — append `event.text` to build the assistant response -- `done` — terminal event with an optional `finishReason` +- `tool-call` — a local function tool call requested by the model +- `done` — terminal event with an optional `finishReason` and optional `sources` ## Related Types @@ -54,11 +55,40 @@ type AIGatewayChatMessage = | { role: "assistant"; content?: string; + toolCalls?: AIGatewayToolCall[]; + } + | { + role: "tool"; + toolCallId: string; + content: string; + }; + +interface AIGatewayToolCall { + id: string; + name: string; + argumentsText: string; +} + +type AIGatewayTool = + | { + type: "function"; + function: { + name: string; + description?: string; + parameters: Record; + }; + } + | { + type: "provider"; + provider: "openai"; + name: "web_search"; + options?: unknown; }; interface AIGatewayChatRequest { model: string; messages: AIGatewayChatMessage[]; + tools?: AIGatewayTool[]; signal?: AbortSignal; } @@ -67,10 +97,23 @@ type AIChatCompletionEvent = type: "text-delta"; text: string; } + | { + type: "tool-call"; + toolCallId: string; + toolName: string; + argumentsText: string; + } | { type: "done"; finishReason?: string; + sources?: AIChatSource[]; }; + +interface AIChatSource { + type: "url"; + url: string; + title?: string; +} ``` ## Usage @@ -104,7 +147,8 @@ console.log(text); ## Notes - AppShell chooses the appropriate AI Gateway transport automatically -- The low-level API is intentionally narrow: text deltas plus completion metadata +- Local tools are sent as normalized `type: "function"` definitions; provider tools are passed through as normalized `type: "provider"` definitions +- The low-level API stays event-based so hooks can layer streaming UI and tool loops on top - `request.signal` is passed through so callers can abort in-flight work ## Related diff --git a/docs/api/use-ai-chat.md b/docs/api/use-ai-chat.md index 506176abb..d3aa7a671 100644 --- a/docs/api/use-ai-chat.md +++ b/docs/api/use-ai-chat.md @@ -1,16 +1,20 @@ --- title: useAIChat -description: Simple text-only chat hook for AI Gateway +description: AI Gateway chat hook with optional local and provider tools --- # useAIChat -React hook for simple text-only chat on top of `createAIGatewayClient`. +React hook for AI Gateway chat on top of `createAIGatewayClient`, with optional local and provider tool support. ## Signature ```typescript -const useAIChat: (config: { client: AIGatewayClient; model: string }) => { +const useAIChat: (config: { + client: AIGatewayClient; + model: string; + tools?: Record; +}) => { messages: AIChatMessage[]; status: "ready" | "submitted" | "streaming" | "error"; error?: Error; @@ -28,6 +32,13 @@ interface AIChatMessage { id: string; role: "user" | "assistant"; content: string; + sources?: AIChatSource[]; +} + +interface AIChatSource { + type: "url"; + url: string; + title?: string; } ``` @@ -52,10 +63,24 @@ interface AIChatMessage { - **Type:** `() => void` - **Description:** Aborts the current request if one is in progress +### `tools` + +Register tools under a single object: + +- local tools created with `defineAIChatTool(...)` +- provider tools such as `aiProviderTool.openai.webSearch(...)` + ## Usage ```tsx -import { createAuthClient, createAIGatewayClient, useAIChat } from "@tailor-platform/app-shell"; +import { + aiProviderTool, + aiToolSchema, + createAuthClient, + createAIGatewayClient, + defineAIChatTool, + useAIChat, +} from "@tailor-platform/app-shell"; const authClient = createAuthClient({ clientId: "your-client-id", @@ -67,10 +92,24 @@ const aiClient = createAIGatewayClient({ authClient, }); +const lookupCustomer = defineAIChatTool({ + description: "Look up a customer in the current workspace", + schema: aiToolSchema.object({ + customerId: aiToolSchema.string(), + }), + async execute({ customerId }) { + return { customerId, name: "Acme Corp" }; + }, +}); + export function ChatScreen() { const { messages, sendMessage, status, stop, error } = useAIChat({ client: aiClient, model: "gpt-5-mini", + tools: { + lookupCustomer, + web_search: aiProviderTool.openai.webSearch({ searchContextSize: "high" }), + }, }); return ( @@ -99,7 +138,8 @@ export function ChatScreen() { ## Notes - AppShell chooses the appropriate AI Gateway transport automatically -- The hook is intentionally text-only +- Public messages stay user/assistant text-first; internal tool messages remain private to the hook +- Provider tools can attach optional `sources` to assistant messages - System prompts and custom history shaping should use the low-level client directly - `stop()` keeps any already-streamed assistant text and ignores late chunks from the stopped request diff --git a/packages/core/src/ai/client.test.ts b/packages/core/src/ai/client.test.ts index f747297cb..752e57f63 100644 --- a/packages/core/src/ai/client.test.ts +++ b/packages/core/src/ai/client.test.ts @@ -178,6 +178,105 @@ describe("createAIGatewayClient", () => { }); }); + it("emits tool calls from streaming responses", async () => { + const authClient = createMockAuthClient( + new Response( + createSSEStream([ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"lookupCustomer","arguments":"{\\"customerId\\":\\""}}]}}]}\n\n', + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"cust-1\\"}"}}]}}]}\n\n', + 'data: {"choices":[{"finish_reason":"tool_calls"}]}\n\n', + "data: [DONE]\n\n", + ]), + { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }, + ), + ); + const client = createAIGatewayClient({ + gatewayUri: "https://gateway.example.com", + authClient, + }); + + await expect( + collectEvents( + client, + createRequest({ + tools: [ + { + type: "function", + function: { + name: "lookupCustomer", + parameters: { type: "object", properties: { customerId: { type: "string" } } }, + }, + }, + ], + }), + ), + ).resolves.toEqual([ + { + type: "tool-call", + toolCallId: "call_1", + toolName: "lookupCustomer", + argumentsText: '{"customerId":"cust-1"}', + }, + { type: "done", finishReason: "tool_calls" }, + ]); + + const [input, init] = getRequestCall(authClient); + expect(JSON.parse(String(await readRequestBody(input, init)))).toEqual({ + model: "gpt-5-mini", + messages: [{ role: "user", content: "Hello" }], + tools: [ + { + type: "function", + function: { + name: "lookupCustomer", + parameters: { type: "object", properties: { customerId: { type: "string" } } }, + }, + }, + ], + stream: true, + }); + }); + + it("emits sources from json responses", async () => { + const authClient = createMockAuthClient( + new Response( + JSON.stringify({ + choices: [ + { + message: { + content: "Grounded answer", + sources: [{ type: "url", url: "https://example.com", title: "Example" }], + }, + finish_reason: "stop", + }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + const client = createAIGatewayClient({ + gatewayUri: "https://gateway.example.com", + authClient, + }); + + await expect( + collectEvents(client, createRequest({ model: "gemini-2.5-flash" })), + ).resolves.toEqual([ + { type: "text-delta", text: "Grounded answer" }, + { + type: "done", + finishReason: "stop", + sources: [{ type: "url", url: "https://example.com", title: "Example" }], + }, + ]); + }); + it("emits done even when json responses do not include assistant text", async () => { const authClient = createMockAuthClient( new Response( diff --git a/packages/core/src/ai/client.ts b/packages/core/src/ai/client.ts index 5f9fca4e9..2a7148416 100644 --- a/packages/core/src/ai/client.ts +++ b/packages/core/src/ai/client.ts @@ -6,6 +6,18 @@ import type { } from "openai/resources/chat/completions"; import type { AuthClient } from "@tailor-platform/auth-public-client"; +export interface AIChatSource { + type: "url"; + url: string; + title?: string; +} + +export interface AIGatewayToolCall { + id: string; + name: string; + argumentsText: string; +} + export type AIGatewayChatMessage = | { role: "system" | "user"; @@ -14,11 +26,36 @@ export type AIGatewayChatMessage = | { role: "assistant"; content?: string; + toolCalls?: AIGatewayToolCall[]; + } + | { + role: "tool"; + toolCallId: string; + content: string; }; +export interface AIGatewayFunctionTool { + type: "function"; + function: { + name: string; + description?: string; + parameters: Record; + }; +} + +export interface AIGatewayProviderTool { + type: "provider"; + provider: "openai"; + name: "web_search"; + options?: unknown; +} + +export type AIGatewayTool = AIGatewayFunctionTool | AIGatewayProviderTool; + export interface AIGatewayChatRequest { model: string; messages: AIGatewayChatMessage[]; + tools?: AIGatewayTool[]; signal?: AbortSignal; } @@ -27,9 +64,16 @@ export type AIChatCompletionEvent = type: "text-delta"; text: string; } + | { + type: "tool-call"; + toolCallId: string; + toolName: string; + argumentsText: string; + } | { type: "done"; finishReason?: string; + sources?: AIChatSource[]; }; export interface AIGatewayClient { @@ -103,30 +147,39 @@ async function* streamOpenAICompatibleResponse(input: { request: AIGatewayChatRequest; }): AsyncGenerator { try { - const stream = await input.client.chat.completions.create( - { - model: input.request.model, - messages: input.request.messages.map(toOpenAIMessage), - stream: true, - }, + const stream = (await input.client.chat.completions.create( + buildChatRequestBody(input.request, true), { signal: input.request.signal, }, - ); + )) as AsyncIterable; let finishReason: string | undefined; + let sources: AIChatSource[] | undefined; + const toolCalls = new Map>(); for await (const chunk of stream) { const choice = chunk.choices[0]; const delta = extractStreamingText(choice); finishReason = extractFinishReason(choice?.finish_reason) ?? finishReason; + sources = extractSources(choice) ?? sources; + collectStreamingToolCalls(toolCalls, choice); if (delta) { yield { type: "text-delta", text: delta }; } } - yield createDoneEvent(finishReason); + for (const toolCall of finalizeStreamingToolCalls(toolCalls)) { + yield { + type: "tool-call", + toolCallId: toolCall.id, + toolName: toolCall.name, + argumentsText: toolCall.argumentsText, + }; + } + + yield createDoneEvent(finishReason, sources); } catch (error) { throw normalizeGatewayError(error, input.request.signal); } @@ -137,16 +190,12 @@ async function* streamJSONResponse(input: { request: AIGatewayChatRequest; }): AsyncGenerator { try { - const completion = await input.client.chat.completions.create( - { - model: input.request.model, - messages: input.request.messages.map(toOpenAIMessage), - stream: false, - }, + const completion = (await input.client.chat.completions.create( + buildChatRequestBody(input.request, false), { signal: input.request.signal, }, - ); + )) as ChatCompletion; const choice = completion.choices[0]; const text = extractFinalText(choice); @@ -155,21 +204,63 @@ async function* streamJSONResponse(input: { yield { type: "text-delta", text }; } - yield createDoneEvent(extractFinishReason(choice?.finish_reason)); + for (const toolCall of extractToolCalls(choice)) { + yield { + type: "tool-call", + toolCallId: toolCall.id, + toolName: toolCall.name, + argumentsText: toolCall.argumentsText, + }; + } + + yield createDoneEvent(extractFinishReason(choice?.finish_reason), extractSources(choice)); } catch (error) { throw normalizeGatewayError(error, input.request.signal); } } +function buildChatRequestBody( + request: AIGatewayChatRequest, + stream: boolean, +): Parameters[0] { + return { + model: request.model, + messages: request.messages.map(toOpenAIMessage), + ...(request.tools?.length ? { tools: request.tools } : {}), + stream, + } as Parameters[0]; +} + function withTrailingSlash(value: string): string { return value.endsWith("/") ? value : `${value}/`; } function toOpenAIMessage(message: AIGatewayChatMessage): ChatCompletionMessageParam { if (message.role === "assistant") { - return message.content === undefined - ? { role: "assistant" } - : { role: "assistant", content: message.content }; + return { + role: "assistant", + ...(message.content !== undefined ? { content: message.content } : { content: null }), + ...(message.toolCalls?.length + ? { + tool_calls: message.toolCalls.map((toolCall) => ({ + id: toolCall.id, + type: "function", + function: { + name: toolCall.name, + arguments: toolCall.argumentsText, + }, + })), + } + : {}), + } as ChatCompletionMessageParam; + } + + if (message.role === "tool") { + return { + role: "tool", + tool_call_id: message.toolCallId, + content: message.content, + } as ChatCompletionMessageParam; } return { @@ -178,6 +269,55 @@ function toOpenAIMessage(message: AIGatewayChatMessage): ChatCompletionMessagePa }; } +function collectStreamingToolCalls( + toolCalls: Map>, + choice: ChatCompletionChunk["choices"][number] | undefined, +): void { + for (const toolCallDelta of choice?.delta?.tool_calls ?? []) { + const existing = toolCalls.get(toolCallDelta.index) ?? { argumentsText: "" }; + + if (toolCallDelta.id) { + existing.id = toolCallDelta.id; + } + + if (toolCallDelta.function?.name) { + existing.name = toolCallDelta.function.name; + } + + if (toolCallDelta.function?.arguments) { + existing.argumentsText = `${existing.argumentsText ?? ""}${toolCallDelta.function.arguments}`; + } + + toolCalls.set(toolCallDelta.index, existing); + } +} + +function finalizeStreamingToolCalls( + toolCalls: Map>, +): AIGatewayToolCall[] { + return [...toolCalls.entries()] + .toSorted(([leftIndex], [rightIndex]) => leftIndex - rightIndex) + .map(([, toolCall]) => ({ + id: toolCall.id ?? crypto.randomUUID(), + name: toolCall.name ?? "", + argumentsText: toolCall.argumentsText ?? "", + })) + .filter((toolCall) => toolCall.name.length > 0); +} + +function extractToolCalls( + choice: ChatCompletion["choices"][number] | undefined, +): AIGatewayToolCall[] { + return (choice?.message?.tool_calls ?? []) + .filter((toolCall) => toolCall.type === "function") + .map((toolCall) => ({ + id: toolCall.id ?? crypto.randomUUID(), + name: toolCall.function.name, + argumentsText: toolCall.function.arguments, + })) + .filter((toolCall) => toolCall.name.length > 0); +} + function extractStreamingText(choice: ChatCompletionChunk["choices"][number] | undefined): string { return extractText(choice?.delta?.content); } @@ -211,12 +351,60 @@ function extractText(content: unknown): string { .join(""); } +function extractSources(value: unknown): AIChatSource[] | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + + if ("message" in value) { + return extractSources((value as { message?: unknown }).message); + } + + if ("delta" in value) { + return extractSources((value as { delta?: unknown }).delta); + } + + if (!("sources" in value) || !Array.isArray(value.sources)) { + return undefined; + } + + const sources = value.sources + .map((source) => { + if (!source || typeof source !== "object") { + return null; + } + + if ((source as { type?: unknown }).type !== "url") { + return null; + } + + const url = (source as { url?: unknown }).url; + if (typeof url !== "string" || url.length === 0) { + return null; + } + + const title = (source as { title?: unknown }).title; + return { + type: "url" as const, + url, + ...(typeof title === "string" && title.length > 0 ? { title } : {}), + }; + }) + .filter((source) => source !== null); + + return sources.length > 0 ? sources : undefined; +} + function extractFinishReason(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } -function createDoneEvent(finishReason?: string): AIChatCompletionEvent { - return finishReason ? { type: "done", finishReason } : { type: "done" }; +function createDoneEvent(finishReason?: string, sources?: AIChatSource[]): AIChatCompletionEvent { + return { + type: "done", + ...(finishReason ? { finishReason } : {}), + ...(sources?.length ? { sources } : {}), + }; } function normalizeGatewayError(error: unknown, signal?: AbortSignal): Error { diff --git a/packages/core/src/ai/tools.test.ts b/packages/core/src/ai/tools.test.ts new file mode 100644 index 000000000..d7eb880cc --- /dev/null +++ b/packages/core/src/ai/tools.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { aiProviderTool, aiToolSchema, defineAIChatTool } from "./tools"; + +describe("aiToolSchema", () => { + it("validates nested objects and generates json schema", async () => { + const schema = aiToolSchema.object({ + city: aiToolSchema.string({ description: "City name" }), + unit: aiToolSchema.optional(aiToolSchema.enum(["c", "f"])), + tags: aiToolSchema.array(aiToolSchema.string()), + }); + + await expect( + schema["~standard"].validate({ city: "Tokyo", unit: "c", tags: ["capital"] }), + ).resolves.toEqual({ + value: { + city: "Tokyo", + unit: "c", + tags: ["capital"], + }, + }); + + await expect( + schema["~standard"].validate({ city: "Tokyo", unit: "k", tags: [] }), + ).resolves.toEqual({ + issues: [{ message: "Must be one of: c, f", path: ["unit"] }], + }); + + expect(schema["~standard"].jsonSchema.input({ target: "draft-07" })).toEqual({ + type: "object", + properties: { + city: { type: "string", description: "City name" }, + unit: { type: "string", enum: ["c", "f"] }, + tags: { type: "array", items: { type: "string" } }, + }, + required: ["city", "tags"], + additionalProperties: false, + }); + }); +}); + +describe("AI chat tool helpers", () => { + it("creates local and provider tool definitions", async () => { + const lookupCustomer = defineAIChatTool({ + description: "Look up a customer", + schema: aiToolSchema.object({ + customerId: aiToolSchema.string(), + }), + async execute({ customerId }) { + return { customerId, name: "Acme" }; + }, + }); + + const webSearch = aiProviderTool.openai.webSearch({ searchContextSize: "high" }); + + expect(lookupCustomer.kind).toBe("local"); + await expect( + lookupCustomer.execute( + { customerId: "cust-1" }, + { signal: new AbortController().signal, messages: [] }, + ), + ).resolves.toEqual({ customerId: "cust-1", name: "Acme" }); + + expect(webSearch).toEqual({ + kind: "provider", + provider: "openai", + tool: "webSearch", + options: { searchContextSize: "high" }, + }); + }); +}); diff --git a/packages/core/src/ai/tools.ts b/packages/core/src/ai/tools.ts new file mode 100644 index 000000000..a657c32fe --- /dev/null +++ b/packages/core/src/ai/tools.ts @@ -0,0 +1,374 @@ +import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"; +import type { AIChatMessage } from "./use-ai-chat"; + +export type AIChatToolSchema = StandardSchemaV1 & + StandardJSONSchemaV1; + +type AnyAIChatToolSchema = AIChatToolSchema; +const OPTIONAL_TOOL_SCHEMA = Symbol("optional-tool-schema"); + +type AIOptionalToolSchema = + AIChatToolSchema< + StandardSchemaV1.InferInput | undefined, + StandardSchemaV1.InferOutput | undefined + > & { + [OPTIONAL_TOOL_SCHEMA]: TSchema; + }; + +type AISchemaShape = Record; + +type UnwrapOptionalSchema = + TSchema extends AIOptionalToolSchema + ? TInner + : TSchema extends AnyAIChatToolSchema + ? TSchema + : never; + +type SchemaInput = + StandardSchemaV1.InferInput>; + +type SchemaOutput = + StandardSchemaV1.InferOutput>; + +type ObjectInput = { + [K in keyof TShape as TShape[K] extends AIOptionalToolSchema ? never : K]: SchemaInput; +} & { + [K in keyof TShape as TShape[K] extends AIOptionalToolSchema ? K : never]?: SchemaInput< + TShape[K] + >; +}; + +type ObjectOutput = { + [K in keyof TShape as TShape[K] extends AIOptionalToolSchema ? never : K]: SchemaOutput< + TShape[K] + >; +} & { + [K in keyof TShape as TShape[K] extends AIOptionalToolSchema ? K : never]?: SchemaOutput< + TShape[K] + >; +}; + +function success(value: T): StandardSchemaV1.SuccessResult { + return { value }; +} + +function failure(message: string): StandardSchemaV1.FailureResult { + return { issues: [{ message }] }; +} + +function prefixIssues( + issues: readonly StandardSchemaV1.Issue[], + key: PropertyKey, +): StandardSchemaV1.FailureResult { + return { + issues: issues.map((issue) => ({ + ...issue, + path: [key, ...(issue.path ?? [])], + })), + }; +} + +function createToolSchema(config: { + validate: ( + value: unknown, + ) => StandardSchemaV1.Result | Promise>; + jsonSchemaInput: (options: StandardJSONSchemaV1.Options) => Record; + jsonSchemaOutput?: (options: StandardJSONSchemaV1.Options) => Record; +}): AIChatToolSchema { + return { + "~standard": { + version: 1, + vendor: "app-shell", + validate: config.validate, + jsonSchema: { + input: config.jsonSchemaInput, + output: config.jsonSchemaOutput ?? config.jsonSchemaInput, + }, + }, + } as AIChatToolSchema; +} + +function isOptionalSchema( + schema: AnyAIChatToolSchema | AIOptionalToolSchema, +): schema is AIOptionalToolSchema { + return OPTIONAL_TOOL_SCHEMA in schema; +} + +function unwrapOptionalSchema( + schema: TSchema, +): UnwrapOptionalSchema { + return ( + isOptionalSchema(schema) ? schema[OPTIONAL_TOOL_SCHEMA] : schema + ) as UnwrapOptionalSchema; +} + +export const aiToolSchema = { + string(options?: { + description?: string; + minLength?: number; + maxLength?: number; + }): AIChatToolSchema { + return createToolSchema({ + validate: (value) => { + if (typeof value !== "string") { + return failure("Must be a string"); + } + if (options?.minLength !== undefined && value.length < options.minLength) { + return failure(`Must be at least ${options.minLength} character(s)`); + } + if (options?.maxLength !== undefined && value.length > options.maxLength) { + return failure(`Must be at most ${options.maxLength} character(s)`); + } + return success(value); + }, + jsonSchemaInput: () => ({ + type: "string", + ...(options?.description ? { description: options.description } : {}), + ...(options?.minLength !== undefined ? { minLength: options.minLength } : {}), + ...(options?.maxLength !== undefined ? { maxLength: options.maxLength } : {}), + }), + }); + }, + + number(options?: { + description?: string; + minimum?: number; + maximum?: number; + integer?: boolean; + }): AIChatToolSchema { + return createToolSchema({ + validate: (value) => { + if (typeof value !== "number" || Number.isNaN(value)) { + return failure("Must be a number"); + } + if (options?.integer && !Number.isInteger(value)) { + return failure("Must be an integer"); + } + if (options?.minimum !== undefined && value < options.minimum) { + return failure(`Must be at least ${options.minimum}`); + } + if (options?.maximum !== undefined && value > options.maximum) { + return failure(`Must be at most ${options.maximum}`); + } + return success(value); + }, + jsonSchemaInput: () => ({ + type: options?.integer ? "integer" : "number", + ...(options?.description ? { description: options.description } : {}), + ...(options?.minimum !== undefined ? { minimum: options.minimum } : {}), + ...(options?.maximum !== undefined ? { maximum: options.maximum } : {}), + }), + }); + }, + + boolean(options?: { description?: string }): AIChatToolSchema { + return createToolSchema({ + validate: (value) => + typeof value === "boolean" ? success(value) : failure("Must be a boolean"), + jsonSchemaInput: () => ({ + type: "boolean", + ...(options?.description ? { description: options.description } : {}), + }), + }); + }, + + enum( + values: TValues, + options?: { description?: string }, + ): AIChatToolSchema { + return createToolSchema({ + validate: (value) => + typeof value === "string" && values.includes(value) + ? success(value as TValues[number]) + : failure(`Must be one of: ${values.join(", ")}`), + jsonSchemaInput: () => ({ + type: "string", + enum: [...values], + ...(options?.description ? { description: options.description } : {}), + }), + }); + }, + + array( + schema: TSchema, + options?: { description?: string }, + ): AIChatToolSchema< + StandardSchemaV1.InferInput[], + StandardSchemaV1.InferOutput[] + > { + return createToolSchema({ + validate: async (value) => { + if (!Array.isArray(value)) { + return failure("Must be an array"); + } + + const output: StandardSchemaV1.InferOutput[] = []; + for (const [index, entry] of value.entries()) { + const result = await schema["~standard"].validate(entry); + if (result.issues) { + return prefixIssues(result.issues, index); + } + output.push(result.value); + } + + return success(output); + }, + jsonSchemaInput: (jsonSchemaOptions) => ({ + type: "array", + items: schema["~standard"].jsonSchema.input(jsonSchemaOptions), + ...(options?.description ? { description: options.description } : {}), + }), + jsonSchemaOutput: (jsonSchemaOptions) => ({ + type: "array", + items: schema["~standard"].jsonSchema.output(jsonSchemaOptions), + ...(options?.description ? { description: options.description } : {}), + }), + }); + }, + + object( + shape: TShape, + options?: { description?: string }, + ): AIChatToolSchema, ObjectOutput> { + return createToolSchema({ + validate: async (value) => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return failure("Must be an object"); + } + + const output: Record = {}; + + for (const [key, rawSchema] of Object.entries(shape)) { + const schema = unwrapOptionalSchema(rawSchema); + const isOptional = isOptionalSchema(rawSchema); + const inputValue = (value as Record)[key]; + + if (inputValue === undefined) { + if (!isOptional) { + return prefixIssues(failure("Required").issues, key); + } + continue; + } + + const result = await schema["~standard"].validate(inputValue); + if (result.issues) { + return prefixIssues(result.issues, key); + } + output[key] = result.value; + } + + return success(output as ObjectOutput); + }, + jsonSchemaInput: (jsonSchemaOptions) => ({ + type: "object", + properties: Object.fromEntries( + Object.entries(shape).map(([key, rawSchema]) => [ + key, + unwrapOptionalSchema(rawSchema)["~standard"].jsonSchema.input(jsonSchemaOptions), + ]), + ), + required: Object.entries(shape) + .filter(([, rawSchema]) => !isOptionalSchema(rawSchema)) + .map(([key]) => key), + additionalProperties: false, + ...(options?.description ? { description: options.description } : {}), + }), + jsonSchemaOutput: (jsonSchemaOptions) => ({ + type: "object", + properties: Object.fromEntries( + Object.entries(shape).map(([key, rawSchema]) => [ + key, + unwrapOptionalSchema(rawSchema)["~standard"].jsonSchema.output(jsonSchemaOptions), + ]), + ), + required: Object.entries(shape) + .filter(([, rawSchema]) => !isOptionalSchema(rawSchema)) + .map(([key]) => key), + additionalProperties: false, + ...(options?.description ? { description: options.description } : {}), + }), + }); + }, + + optional(schema: TSchema): AIOptionalToolSchema { + return { + ...createToolSchema({ + validate: async (value) => + value === undefined ? success(undefined) : await schema["~standard"].validate(value), + jsonSchemaInput: (jsonSchemaOptions) => + schema["~standard"].jsonSchema.input(jsonSchemaOptions), + jsonSchemaOutput: (jsonSchemaOptions) => + schema["~standard"].jsonSchema.output(jsonSchemaOptions), + }), + [OPTIONAL_TOOL_SCHEMA]: schema, + } as AIOptionalToolSchema; + }, +}; + +export interface AIChatToolContext { + signal: AbortSignal; + messages: AIChatMessage[]; +} + +export interface AILocalTool< + TSchema extends AIChatToolSchema = AIChatToolSchema, +> { + kind: "local"; + description?: string; + schema: TSchema; + execute: ( + args: StandardSchemaV1.InferOutput, + context: AIChatToolContext, + ) => unknown | Promise; +} + +export function defineAIChatTool>(tool: { + description?: string; + schema: TSchema; + execute: ( + args: StandardSchemaV1.InferOutput, + context: AIChatToolContext, + ) => unknown | Promise; +}): AILocalTool { + return { + kind: "local", + ...tool, + }; +} + +export interface OpenAIWebSearchToolOptions { + externalWebAccess?: boolean; + searchContextSize?: "low" | "medium" | "high"; + userLocation?: { + type: "approximate"; + country?: string; + city?: string; + region?: string; + timezone?: string; + }; + filters?: { + allowedDomains?: string[]; + }; +} + +export interface AIOpenAIWebSearchTool { + kind: "provider"; + provider: "openai"; + tool: "webSearch"; + options?: OpenAIWebSearchToolOptions; +} + +export type AIChatConfiguredTool = AILocalTool | AIOpenAIWebSearchTool; + +export const aiProviderTool = { + openai: { + webSearch(options?: OpenAIWebSearchToolOptions): AIOpenAIWebSearchTool { + return { + kind: "provider", + provider: "openai", + tool: "webSearch", + options, + }; + }, + }, +}; diff --git a/packages/core/src/ai/use-ai-chat.test.tsx b/packages/core/src/ai/use-ai-chat.test.tsx index 54c96f22f..628c0d0f6 100644 --- a/packages/core/src/ai/use-ai-chat.test.tsx +++ b/packages/core/src/ai/use-ai-chat.test.tsx @@ -1,7 +1,8 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useAIChat } from "./use-ai-chat"; -import type { AIGatewayClient } from "./client"; +import type { AIGatewayClient, AIChatCompletionEvent } from "./client"; +import { aiProviderTool, aiToolSchema, defineAIChatTool } from "./tools"; function createAbortError(): Error { if (typeof DOMException !== "undefined") { @@ -250,6 +251,147 @@ describe("useAIChat", () => { }); }); + it("executes local tools and continues the conversation", async () => { + const lookupCustomer = defineAIChatTool({ + description: "Look up a customer", + schema: aiToolSchema.object({ + customerId: aiToolSchema.string(), + }), + async execute({ customerId }) { + return { customerId, name: "Acme Corp" }; + }, + }); + + const client = { + streamChatCompletion: vi.fn(async function* ({ messages, tools }) { + const lastMessage = messages.at(-1); + + if (lastMessage?.role === "user") { + expect(tools).toEqual([ + { + type: "function", + function: { + name: "lookupCustomer", + description: "Look up a customer", + parameters: { + type: "object", + properties: { + customerId: { type: "string" }, + }, + required: ["customerId"], + additionalProperties: false, + }, + }, + }, + ]); + + yield { + type: "tool-call", + toolCallId: "call-1", + toolName: "lookupCustomer", + argumentsText: '{"customerId":"cust-1"}', + } as const; + yield { type: "done", finishReason: "tool_calls" } as const; + return; + } + + expect(messages).toEqual([ + { role: "user", content: "Find customer" }, + { + role: "assistant", + toolCalls: [ + { + id: "call-1", + name: "lookupCustomer", + argumentsText: '{"customerId":"cust-1"}', + }, + ], + }, + { + role: "tool", + toolCallId: "call-1", + content: '{"customerId":"cust-1","name":"Acme Corp"}', + }, + ]); + + yield { type: "text-delta", text: "Customer: Acme Corp" } as const; + yield { type: "done", finishReason: "stop" } as const; + }), + } satisfies AIGatewayClient; + + const { result } = renderHook(() => + useAIChat({ + client, + model: "gpt-5-mini", + tools: { + lookupCustomer, + }, + }), + ); + + await act(async () => { + await expect(result.current.sendMessage("Find customer")).resolves.toBe(true); + }); + + await waitFor(() => { + expect(result.current.status).toBe("ready"); + expect(result.current.messages).toEqual([ + { id: "id-1", role: "user", content: "Find customer" }, + { id: "id-2", role: "assistant", content: "Customer: Acme Corp" }, + ]); + }); + + expect(client.streamChatCompletion).toHaveBeenCalledTimes(2); + }); + + it("passes provider tools through and exposes sources", async () => { + const client = { + streamChatCompletion: vi.fn(async function* ({ tools }) { + expect(tools).toEqual([ + { + type: "provider", + provider: "openai", + name: "web_search", + options: { searchContextSize: "high" }, + }, + ]); + yield { type: "text-delta", text: "Latest market news" } as const; + const doneEvent: AIChatCompletionEvent = { + type: "done", + finishReason: "stop", + sources: [{ type: "url", url: "https://example.com", title: "Example" }], + }; + yield doneEvent; + }), + } satisfies AIGatewayClient; + + const { result } = renderHook(() => + useAIChat({ + client, + model: "gpt-5-mini", + tools: { + web_search: aiProviderTool.openai.webSearch({ searchContextSize: "high" }), + }, + }), + ); + + await act(async () => { + await expect(result.current.sendMessage("Search the web")).resolves.toBe(true); + }); + + await waitFor(() => { + expect(result.current.messages).toEqual([ + { id: "id-1", role: "user", content: "Search the web" }, + { + id: "id-2", + role: "assistant", + content: "Latest market news", + sources: [{ type: "url", url: "https://example.com", title: "Example" }], + }, + ]); + }); + }); + it("ignores blank and concurrent sends", async () => { let releaseRequest!: () => void; const requestGate = new Promise((resolve) => { diff --git a/packages/core/src/ai/use-ai-chat.ts b/packages/core/src/ai/use-ai-chat.ts index c551b89a0..4525871a2 100644 --- a/packages/core/src/ai/use-ai-chat.ts +++ b/packages/core/src/ai/use-ai-chat.ts @@ -1,16 +1,32 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { isAbortError } from "./client"; -import type { AIGatewayChatMessage, AIGatewayClient } from "./client"; +import type { + AIGatewayChatMessage, + AIGatewayClient, + AIGatewayFunctionTool, + AIGatewayProviderTool, + AIGatewayTool, + AIGatewayToolCall, + AIChatSource, +} from "./client"; +import type { AIChatConfiguredTool, AIChatToolContext, AILocalTool } from "./tools"; + +const MAX_TOOL_ROUNDS = 8; export interface AIChatMessage { id: string; role: "user" | "assistant"; content: string; + sources?: AIChatSource[]; } export type AIChatStatus = "ready" | "submitted" | "streaming" | "error"; -export function useAIChat(config: { client: AIGatewayClient; model: string }): { +export function useAIChat(config: { + client: AIGatewayClient; + model: string; + tools?: Record; +}): { messages: AIChatMessage[]; status: AIChatStatus; error?: Error; @@ -25,6 +41,7 @@ export function useAIChat(config: { client: AIGatewayClient; model: string }): { const [status, setStatus] = useState("ready"); const [error, setError] = useState(undefined); const messagesRef = useRef([]); + const gatewayMessagesRef = useRef([]); const activeRequestRef = useRef(null); const abortControllerRef = useRef(null); @@ -79,58 +96,70 @@ export function useAIChat(config: { client: AIGatewayClient; model: string }): { setMessages(nextMessages); setError(undefined); setStatus("submitted"); + gatewayMessagesRef.current = [...gatewayMessagesRef.current, toGatewayMessage(userMessage)]; const controller = new AbortController(); const requestId = Symbol(); activeRequestRef.current = requestId; abortControllerRef.current = controller; - let assistantMessageId: string | null = null; const isActive = () => activeRequestRef.current === requestId; try { - for await (const event of config.client.streamChatCompletion({ - model: config.model, - messages: nextMessages.map(toGatewayMessage), - signal: controller.signal, - })) { + const { gatewayTools, localTools } = normalizeTools(config.tools); + + for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) { if (!isActive()) { return false; } - if (event.type !== "text-delta" || !event.text) { - continue; + const turnResult = await runAssistantTurn({ + client: config.client, + model: config.model, + messages: gatewayMessagesRef.current, + tools: gatewayTools, + signal: controller.signal, + isActive, + setStatus, + updateMessages, + }); + + if (!isActive()) { + return false; } - setStatus("streaming"); - - if (!assistantMessageId) { - assistantMessageId = crypto.randomUUID(); - updateMessages((previous) => [ - ...previous, - { - id: assistantMessageId!, - role: "assistant", - content: event.text, - }, - ]); - continue; + if (turnResult.publicAssistantMessage) { + gatewayMessagesRef.current = [ + ...gatewayMessagesRef.current, + turnResult.gatewayAssistantMessage, + ]; + } else if (turnResult.toolCalls.length > 0) { + gatewayMessagesRef.current = [ + ...gatewayMessagesRef.current, + turnResult.gatewayAssistantMessage, + ]; } - updateMessages((previous) => - previous.map((entry) => - entry.id === assistantMessageId - ? { ...entry, content: `${entry.content}${event.text}` } - : entry, - ), - ); - } + if (turnResult.toolCalls.length === 0) { + setStatus("ready"); + return true; + } - if (!isActive()) { - return false; + setStatus("submitted"); + const toolMessages = await executeToolCalls({ + toolCalls: turnResult.toolCalls, + localTools, + signal: controller.signal, + visibleMessages: messagesRef.current, + }); + + if (!isActive()) { + return false; + } + + gatewayMessagesRef.current = [...gatewayMessagesRef.current, ...toolMessages]; } - setStatus("ready"); - return true; + throw new Error(`AI chat exceeded the maximum number of tool rounds (${MAX_TOOL_ROUNDS}).`); } catch (caughtError) { if (!isActive()) { return false; @@ -154,7 +183,7 @@ export function useAIChat(config: { client: AIGatewayClient; model: string }): { } } }, - [config.client, config.model, updateMessages], + [config.client, config.model, config.tools, updateMessages], ); return { @@ -166,13 +195,245 @@ export function useAIChat(config: { client: AIGatewayClient; model: string }): { }; } -function toGatewayMessage(message: AIChatMessage): AIGatewayChatMessage { +async function runAssistantTurn(input: { + client: AIGatewayClient; + model: string; + messages: AIGatewayChatMessage[]; + tools: AIGatewayTool[]; + signal: AbortSignal; + isActive: () => boolean; + setStatus: (status: AIChatStatus) => void; + updateMessages: (updater: (previous: AIChatMessage[]) => AIChatMessage[]) => void; +}): Promise<{ + gatewayAssistantMessage: Extract; + publicAssistantMessage?: AIChatMessage; + toolCalls: AIGatewayToolCall[]; +}> { + let assistantMessageId: string | null = null; + let assistantContent = ""; + let assistantSources: AIChatSource[] | undefined; + const toolCalls: AIGatewayToolCall[] = []; + + for await (const event of input.client.streamChatCompletion({ + model: input.model, + messages: input.messages, + ...(input.tools.length > 0 ? { tools: input.tools } : {}), + signal: input.signal, + })) { + if (!input.isActive()) { + break; + } + + if (event.type === "text-delta") { + assistantContent = `${assistantContent}${event.text}`; + input.setStatus("streaming"); + + if (!assistantMessageId) { + assistantMessageId = crypto.randomUUID(); + input.updateMessages((previous) => [ + ...previous, + { + id: assistantMessageId!, + role: "assistant", + content: event.text, + }, + ]); + continue; + } + + input.updateMessages((previous) => + previous.map((entry) => + entry.id === assistantMessageId ? { ...entry, content: assistantContent } : entry, + ), + ); + continue; + } + + if (event.type === "tool-call") { + toolCalls.push({ + id: event.toolCallId, + name: event.toolName, + argumentsText: event.argumentsText, + }); + continue; + } + + assistantSources = event.sources; + } + + if (assistantMessageId && assistantSources?.length) { + input.updateMessages((previous) => + previous.map((entry) => + entry.id === assistantMessageId ? { ...entry, sources: assistantSources } : entry, + ), + ); + } + + const gatewayAssistantMessage: Extract = { + role: "assistant", + ...(assistantContent ? { content: assistantContent } : {}), + ...(toolCalls.length > 0 ? { toolCalls } : {}), + }; + return { - role: message.role, + gatewayAssistantMessage, + ...(assistantMessageId + ? { + publicAssistantMessage: { + id: assistantMessageId, + role: "assistant", + content: assistantContent, + ...(assistantSources?.length ? { sources: assistantSources } : {}), + }, + } + : {}), + toolCalls, + }; +} + +function normalizeTools(tools: Record | undefined): { + gatewayTools: AIGatewayTool[]; + localTools: Map; +} { + if (!tools) { + return { + gatewayTools: [], + localTools: new Map(), + }; + } + + const localTools = new Map(); + const gatewayTools = Object.entries(tools).map(([name, tool]) => { + if (tool.kind === "local") { + localTools.set(name, tool); + return { + type: "function", + function: { + name, + ...(tool.description ? { description: tool.description } : {}), + parameters: tool.schema["~standard"].jsonSchema.input({ target: "draft-07" }), + }, + } satisfies AIGatewayFunctionTool; + } + + return { + type: "provider", + provider: "openai", + name: "web_search", + ...(tool.options ? { options: tool.options } : {}), + } satisfies AIGatewayProviderTool; + }); + + return { + gatewayTools, + localTools, + }; +} + +async function executeToolCalls(input: { + toolCalls: AIGatewayToolCall[]; + localTools: Map; + signal: AbortSignal; + visibleMessages: AIChatMessage[]; +}): Promise[]> { + const messages: Extract[] = []; + + for (const toolCall of input.toolCalls) { + if (input.signal.aborted) { + throw createAbortError(); + } + + messages.push( + await executeToolCall({ + toolCall, + localTools: input.localTools, + context: { + signal: input.signal, + messages: input.visibleMessages, + }, + }), + ); + } + + return messages; +} + +async function executeToolCall(input: { + toolCall: AIGatewayToolCall; + localTools: Map; + context: AIChatToolContext; +}): Promise> { + const tool = input.localTools.get(input.toolCall.name); + + if (!tool) { + return { + role: "tool", + toolCallId: input.toolCall.id, + content: JSON.stringify({ error: `Unknown tool: ${input.toolCall.name}` }), + }; + } + + try { + const rawArguments = parseToolArguments(input.toolCall.argumentsText); + const result = await tool.schema["~standard"].validate(rawArguments); + + if (result.issues) { + return { + role: "tool", + toolCallId: input.toolCall.id, + content: JSON.stringify({ error: result.issues.map((issue) => issue.message).join("; ") }), + }; + } + + const output = await tool.execute(result.value, input.context); + return { + role: "tool", + toolCallId: input.toolCall.id, + content: stringifyToolResult(output), + }; + } catch (error) { + return { + role: "tool", + toolCallId: input.toolCall.id, + content: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }), + }; + } +} + +function parseToolArguments(argumentsText: string): unknown { + const text = argumentsText.trim(); + return text ? JSON.parse(text) : {}; +} + +function stringifyToolResult(value: unknown): string { + if (typeof value === "string") { + return value; + } + + if (value === undefined) { + return "null"; + } + + return JSON.stringify(value); +} + +function toGatewayMessage(message: AIChatMessage): { role: "user"; content: string } { + return { + role: "user", content: message.content, }; } +function createAbortError(): Error { + if (typeof DOMException !== "undefined") { + return new DOMException("The operation was aborted.", "AbortError"); + } + + const error = new Error("The operation was aborted."); + error.name = "AbortError"; + return error; +} + function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9148a70f1..f5b3ee3df 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -69,9 +69,25 @@ export { type AIGatewayClient, type AIGatewayChatMessage, type AIGatewayChatRequest, + type AIGatewayTool, + type AIGatewayToolCall, + type AIGatewayFunctionTool, + type AIGatewayProviderTool, type AIChatCompletionEvent, + type AIChatSource, } from "./ai/client"; export { useAIChat, type AIChatMessage, type AIChatStatus } from "./ai/use-ai-chat"; +export { + defineAIChatTool, + aiToolSchema, + aiProviderTool, + type AIChatConfiguredTool, + type AIChatToolContext, + type AIChatToolSchema, + type AILocalTool, + type AIOpenAIWebSearchTool, + type OpenAIWebSearchToolOptions, +} from "./ai/tools"; export { AIChat, type AIChatProps, From d05386b52df9a5c8513913ab38ea92434f52e06f Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Sat, 4 Jul 2026 00:03:52 +0900 Subject: [PATCH 02/19] Document AI tool calling internals and APIs --- packages/core/src/ai/client.ts | 24 +++++ packages/core/src/ai/tools.ts | 137 ++++++++++++++++++++++++++++ packages/core/src/ai/use-ai-chat.ts | 35 +++++++ 3 files changed, 196 insertions(+) diff --git a/packages/core/src/ai/client.ts b/packages/core/src/ai/client.ts index 2a7148416..3ca22dfeb 100644 --- a/packages/core/src/ai/client.ts +++ b/packages/core/src/ai/client.ts @@ -6,18 +6,21 @@ import type { } from "openai/resources/chat/completions"; import type { AuthClient } from "@tailor-platform/auth-public-client"; +/** Source/citation metadata attached to assistant messages when available. */ export interface AIChatSource { type: "url"; url: string; title?: string; } +/** Normalized local function tool call emitted by the AI Gateway transport. */ export interface AIGatewayToolCall { id: string; name: string; argumentsText: string; } +/** Internal transcript message shape sent to the AI Gateway transport layer. */ export type AIGatewayChatMessage = | { role: "system" | "user"; @@ -34,6 +37,7 @@ export type AIGatewayChatMessage = content: string; }; +/** Normalized local function tool definition understood by the AI Gateway. */ export interface AIGatewayFunctionTool { type: "function"; function: { @@ -43,6 +47,7 @@ export interface AIGatewayFunctionTool { }; } +/** Normalized provider tool definition passed through to the AI Gateway. */ export interface AIGatewayProviderTool { type: "provider"; provider: "openai"; @@ -59,6 +64,7 @@ export interface AIGatewayChatRequest { signal?: AbortSignal; } +/** Low-level event stream consumed by `useAIChat()`. */ export type AIChatCompletionEvent = | { type: "text-delta"; @@ -219,6 +225,10 @@ async function* streamJSONResponse(input: { } } +/** + * Builds the narrow OpenAI-compatible request body that Envoy AI Gateway + * expects, while preserving AppShell's normalized tool contract internally. + */ function buildChatRequestBody( request: AIGatewayChatRequest, stream: boolean, @@ -269,6 +279,12 @@ function toOpenAIMessage(message: AIGatewayChatMessage): ChatCompletionMessagePa }; } +/** + * Accumulates streamed tool-call deltas into complete normalized tool calls. + * + * OpenAI-compatible streams may split ids, names, and JSON arguments across + * many chunks, so this keeps a per-index assembly buffer until the stream ends. + */ function collectStreamingToolCalls( toolCalls: Map>, choice: ChatCompletionChunk["choices"][number] | undefined, @@ -292,6 +308,7 @@ function collectStreamingToolCalls( } } +/** Finalizes buffered streamed tool calls into stable ordered results. */ function finalizeStreamingToolCalls( toolCalls: Map>, ): AIGatewayToolCall[] { @@ -305,6 +322,7 @@ function finalizeStreamingToolCalls( .filter((toolCall) => toolCall.name.length > 0); } +/** Extracts complete function tool calls from a non-streaming completion. */ function extractToolCalls( choice: ChatCompletion["choices"][number] | undefined, ): AIGatewayToolCall[] { @@ -351,6 +369,12 @@ function extractText(content: unknown): string { .join(""); } +/** + * Best-effort parser for normalized provider sources/citations. + * + * This intentionally tolerates response-shape differences so AppShell can + * surface sources without depending on one provider-specific payload format. + */ function extractSources(value: unknown): AIChatSource[] | undefined { if (!value || typeof value !== "object") { return undefined; diff --git a/packages/core/src/ai/tools.ts b/packages/core/src/ai/tools.ts index a657c32fe..1fe445d0b 100644 --- a/packages/core/src/ai/tools.ts +++ b/packages/core/src/ai/tools.ts @@ -1,6 +1,22 @@ import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"; import type { AIChatMessage } from "./use-ai-chat"; +/** + * Public schema contract for local AI chat tools. + * + * A schema must both validate runtime inputs and generate JSON Schema for the + * AI Gateway request payload. + * + * @example + * ```ts + * import { aiToolSchema } from "@tailor-platform/app-shell"; + * + * const schema = aiToolSchema.object({ + * customerId: aiToolSchema.string(), + * includeInactive: aiToolSchema.optional(aiToolSchema.boolean()), + * }); + * ``` + */ export type AIChatToolSchema = StandardSchemaV1 & StandardJSONSchemaV1; @@ -68,6 +84,12 @@ function prefixIssues( }; } +/** + * Creates a Standard Schema + Standard JSON Schema compatible tool schema. + * + * This keeps local tool validation and AI Gateway JSON Schema generation bound + * to the same declaration. + */ function createToolSchema(config: { validate: ( value: unknown, @@ -88,12 +110,14 @@ function createToolSchema(config: { } as AIChatToolSchema; } +/** Marks wrapper schemas created by `aiToolSchema.optional(...)`. */ function isOptionalSchema( schema: AnyAIChatToolSchema | AIOptionalToolSchema, ): schema is AIOptionalToolSchema { return OPTIONAL_TOOL_SCHEMA in schema; } +/** Returns the underlying schema for optional and non-optional schema entries. */ function unwrapOptionalSchema( schema: TSchema, ): UnwrapOptionalSchema { @@ -102,6 +126,24 @@ function unwrapOptionalSchema; } +/** + * Minimal Standard Schema helpers for common local tool inputs. + * + * These helpers intentionally cover the narrow set of JSON-schema-friendly + * shapes AppShell needs for tool calling today. + * + * @example + * ```ts + * import { aiToolSchema } from "@tailor-platform/app-shell"; + * + * const lookupCustomerSchema = aiToolSchema.object({ + * customerId: aiToolSchema.string({ description: "Workspace customer id" }), + * fields: aiToolSchema.optional( + * aiToolSchema.array(aiToolSchema.enum(["name", "email", "status"])), + * ), + * }); + * ``` + */ export const aiToolSchema = { string(options?: { description?: string; @@ -305,11 +347,21 @@ export const aiToolSchema = { }, }; +/** Context passed to local tool executors. */ export interface AIChatToolContext { + /** Abort signal for the in-flight chat request. */ signal: AbortSignal; + /** Public user/assistant transcript visible to the current chat turn. */ messages: AIChatMessage[]; } +/** + * A tool executed locally inside AppShell. + * + * Local tools are validated with Standard Schema, executed in-process, and then + * written back into the internal AI Gateway transcript as `role: "tool"` + * messages for the next model round. + */ export interface AILocalTool< TSchema extends AIChatToolSchema = AIChatToolSchema, > { @@ -322,6 +374,32 @@ export interface AILocalTool< ) => unknown | Promise; } +/** + * Small typed helper for defining local AI chat tools. + * + * This is mostly an identity function, but it keeps `execute(args)` inferred + * from the provided schema when tools are declared inline. + * + * @example + * ```ts + * import { defineAIChatTool, aiToolSchema } from "@tailor-platform/app-shell"; + * + * const lookupCustomer = defineAIChatTool({ + * description: "Look up a customer in the current workspace", + * schema: aiToolSchema.object({ + * customerId: aiToolSchema.string(), + * }), + * async execute({ customerId }, { signal }) { + * const customer = await fetchCustomer(customerId, { signal }); + * return { + * id: customer.id, + * name: customer.name, + * status: customer.status, + * }; + * }, + * }); + * ``` + */ export function defineAIChatTool>(tool: { description?: string; schema: TSchema; @@ -336,6 +414,7 @@ export function defineAIChatTool("ready"); const [error, setError] = useState(undefined); const messagesRef = useRef([]); + // Internal transcript used for the next AI Gateway request. This can contain + // assistant tool calls and tool result messages that are intentionally hidden + // from the public `messages` array. const gatewayMessagesRef = useRef([]); const activeRequestRef = useRef(null); const abortControllerRef = useRef(null); @@ -195,6 +206,13 @@ export function useAIChat(config: { }; } +/** + * Runs a single assistant turn against the AI Gateway. + * + * Visible assistant text is streamed into the public `messages` state, while + * requested tool calls are collected into the returned internal transcript + * payload for the next loop step. + */ async function runAssistantTurn(input: { client: AIGatewayClient; model: string; @@ -291,6 +309,11 @@ async function runAssistantTurn(input: { }; } +/** + * Splits the public tools object into: + * - normalized AI Gateway tool definitions sent over the wire + * - local tool executors kept for in-process validation and execution + */ function normalizeTools(tools: Record | undefined): { gatewayTools: AIGatewayTool[]; localTools: Map; @@ -330,6 +353,10 @@ function normalizeTools(tools: Record | undefined) }; } +/** + * Executes requested local tools in call order and converts their outputs into + * internal `role: "tool"` transcript messages for the next model round. + */ async function executeToolCalls(input: { toolCalls: AIGatewayToolCall[]; localTools: Map; @@ -358,6 +385,12 @@ async function executeToolCalls(input: { return messages; } +/** + * Validates, executes, and stringifies one local tool call. + * + * Tool failures are converted into tool result payloads so the model can + * recover in-band instead of failing the entire chat request. + */ async function executeToolCall(input: { toolCall: AIGatewayToolCall; localTools: Map; @@ -400,11 +433,13 @@ async function executeToolCall(input: { } } +/** Parses the model-produced JSON argument payload for one tool call. */ function parseToolArguments(argumentsText: string): unknown { const text = argumentsText.trim(); return text ? JSON.parse(text) : {}; } +/** Normalizes local tool results to the string payload expected by tool messages. */ function stringifyToolResult(value: unknown): string { if (typeof value === "string") { return value; From ce7b518db0a607d697b7e445206ee4909d0e81d0 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Sat, 4 Jul 2026 00:08:07 +0900 Subject: [PATCH 03/19] Document AI tool schema helpers --- packages/core/src/ai/tools.ts | 95 +++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/packages/core/src/ai/tools.ts b/packages/core/src/ai/tools.ts index 1fe445d0b..ca9fb4bc7 100644 --- a/packages/core/src/ai/tools.ts +++ b/packages/core/src/ai/tools.ts @@ -145,6 +145,16 @@ function unwrapOptionalSchema { return createToolSchema({ validate: (value) => @@ -214,6 +244,16 @@ export const aiToolSchema = { }); }, + /** + * Declares a string enum input. + * + * Use this when the model should choose from a fixed set of string literals. + * + * @example + * ```ts + * aiToolSchema.enum(["draft", "published", "archived"]) + * ``` + */ enum( values: TValues, options?: { description?: string }, @@ -231,6 +271,16 @@ export const aiToolSchema = { }); }, + /** + * Declares an array input whose items must match another tool schema. + * + * @example + * ```ts + * aiToolSchema.array(aiToolSchema.string(), { + * description: "Fields to include in the response", + * }) + * ``` + */ array( schema: TSchema, options?: { description?: string }, @@ -268,6 +318,21 @@ export const aiToolSchema = { }); }, + /** + * Declares an object input composed from named child schemas. + * + * All properties are required unless wrapped in `aiToolSchema.optional(...)`. + * + * @example + * ```ts + * aiToolSchema.object({ + * customerId: aiToolSchema.string(), + * fields: aiToolSchema.optional( + * aiToolSchema.array(aiToolSchema.enum(["name", "email", "status"])), + * ), + * }) + * ``` + */ object( shape: TShape, options?: { description?: string }, @@ -332,6 +397,17 @@ export const aiToolSchema = { }); }, + /** + * Marks a child schema as optional inside `aiToolSchema.object(...)`. + * + * @example + * ```ts + * aiToolSchema.object({ + * customerId: aiToolSchema.string(), + * fields: aiToolSchema.optional(aiToolSchema.array(aiToolSchema.string())), + * }) + * ``` + */ optional(schema: TSchema): AIOptionalToolSchema { return { ...createToolSchema({ @@ -499,6 +575,25 @@ export type AIChatConfiguredTool = AILocalTool | AIOpenAIWebSearchTool; */ export const aiProviderTool = { openai: { + /** + * Registers the OpenAI web search provider tool. + * + * AppShell passes this through to the AI Gateway and does not execute it + * locally. + * + * @example + * ```ts + * aiProviderTool.openai.webSearch({ + * searchContextSize: "high", + * userLocation: { + * type: "approximate", + * country: "JP", + * city: "Tokyo", + * timezone: "Asia/Tokyo", + * }, + * }) + * ``` + */ webSearch(options?: OpenAIWebSearchToolOptions): AIOpenAIWebSearchTool { return { kind: "provider", From 4bd438e5765b193174a42a23947cdab64c9bb183 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Mon, 6 Jul 2026 16:55:02 +0900 Subject: [PATCH 04/19] refactor(ai): restructure use-ai-chat and tool runtime into clear responsibilities - Extract runAssistantLoop as a pure async generator (assistant-loop.ts) that yields events instead of calling session callbacks - Extract tool execution logic into tool-execution.ts - Introduce ChatRequestContext class to encapsulate per-request state (transcript staging + streaming turn buffer) - Reduce useAIChat hook to thin React orchestrator with single applyEvent dispatch point for all state transitions - Remove the 12-method AIChatSessionRequest god-interface --- packages/core/src/ai/assistant-loop.ts | 175 ++++++++ packages/core/src/ai/tool-execution.ts | 129 ++++++ packages/core/src/ai/use-ai-chat.test.tsx | 45 ++ packages/core/src/ai/use-ai-chat.ts | 498 ++++++---------------- 4 files changed, 479 insertions(+), 368 deletions(-) create mode 100644 packages/core/src/ai/assistant-loop.ts create mode 100644 packages/core/src/ai/tool-execution.ts diff --git a/packages/core/src/ai/assistant-loop.ts b/packages/core/src/ai/assistant-loop.ts new file mode 100644 index 000000000..5c40441f6 --- /dev/null +++ b/packages/core/src/ai/assistant-loop.ts @@ -0,0 +1,175 @@ +import type { + AIGatewayChatMessage, + AIGatewayClient, + AIGatewayFunctionTool, + AIGatewayProviderTool, + AIGatewayTool, + AIGatewayToolCall, + AIChatSource, +} from "./client"; +import type { AIChatConfiguredTool, AILocalTool } from "./tools"; +import { deriveVisibleMessages, resolveToolCalls } from "./tool-execution"; + +const MAX_TOOL_ROUNDS = 8; + +/** + * Splits the public tools object into: + * - normalized AI Gateway tool definitions sent over the wire + * - local tool executors kept for in-process validation and execution + */ +function normalizeConfiguredTools(tools: Record | undefined): { + gatewayTools: AIGatewayTool[]; + localTools: Map; +} { + if (!tools) { + return { + gatewayTools: [], + localTools: new Map(), + }; + } + + const localTools = new Map(); + const gatewayTools = Object.entries(tools).map(([name, tool]) => { + if (tool.kind === "local") { + localTools.set(name, tool); + return { + type: "function", + function: { + name, + ...(tool.description ? { description: tool.description } : {}), + parameters: tool.schema["~standard"].jsonSchema.input({ target: "draft-07" }), + }, + } satisfies AIGatewayFunctionTool; + } + + return { + type: "provider", + provider: "openai", + name: "web_search", + ...(tool.options ? { options: tool.options } : {}), + } satisfies AIGatewayProviderTool; + }); + + return { + gatewayTools, + localTools, + }; +} + +export interface AIChatAssistantTurnResult { + gatewayAssistantMessage: Extract; + toolCalls: AIGatewayToolCall[]; +} + +export type AssistantLoopEvent = + | { type: "text-delta"; delta: string } + | { type: "sources"; sources: AIChatSource[] } + | { type: "turn-end"; turn: AIChatAssistantTurnResult } + | { type: "tool-resolution-start" } + | { + type: "tool-results"; + messages: Extract[]; + } + | { type: "complete" }; + +/** + * Pure async generator that runs the multi-turn assistant loop. + * Yields events for the consumer to translate into state changes. + * Manages transcript internally — the caller only provides the initial transcript. + */ +export async function* runAssistantLoop(input: { + client: AIGatewayClient; + model: string; + tools: Record | undefined; + transcript: AIGatewayChatMessage[]; + signal: AbortSignal; +}): AsyncGenerator { + const { gatewayTools, localTools } = normalizeConfiguredTools(input.tools); + const transcript = [...input.transcript]; + + for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) { + const turn: AIChatAssistantTurnResult = yield* streamAssistantTurn({ + client: input.client, + model: input.model, + tools: gatewayTools, + transcript, + signal: input.signal, + }); + + if (turn.gatewayAssistantMessage.content || turn.toolCalls.length > 0) { + transcript.push(turn.gatewayAssistantMessage); + } + + yield { type: "turn-end", turn }; + + if (turn.toolCalls.length === 0) { + yield { type: "complete" }; + return; + } + + yield { type: "tool-resolution-start" }; + + const toolMessages = await resolveToolCalls({ + toolCalls: turn.toolCalls, + localTools, + signal: input.signal, + visibleMessages: deriveVisibleMessages(transcript), + }); + + transcript.push(...toolMessages); + yield { type: "tool-results", messages: toolMessages }; + } + + throw new Error(`AI chat exceeded the maximum number of tool rounds (${MAX_TOOL_ROUNDS}).`); +} + +/** + * Streams one assistant turn from the AI Gateway. + * Yields text-delta and sources events as they arrive. + * Returns the collected turn result (content + tool calls) via generator return. + */ +async function* streamAssistantTurn(input: { + client: AIGatewayClient; + model: string; + tools: AIGatewayTool[]; + transcript: AIGatewayChatMessage[]; + signal: AbortSignal; +}): AsyncGenerator { + let content = ""; + const toolCalls: AIGatewayToolCall[] = []; + + for await (const event of input.client.streamChatCompletion({ + model: input.model, + messages: input.transcript, + ...(input.tools.length > 0 ? { tools: input.tools } : {}), + signal: input.signal, + })) { + if (event.type === "text-delta") { + content = `${content}${event.text}`; + yield { type: "text-delta", delta: event.text }; + continue; + } + + if (event.type === "tool-call") { + toolCalls.push({ + id: event.toolCallId, + name: event.toolName, + argumentsText: event.argumentsText, + }); + continue; + } + + if (event.sources?.length) { + yield { type: "sources", sources: event.sources }; + } + } + + return { + gatewayAssistantMessage: { + role: "assistant", + ...(content ? { content } : {}), + ...(toolCalls.length > 0 ? { toolCalls } : {}), + }, + toolCalls, + }; +} diff --git a/packages/core/src/ai/tool-execution.ts b/packages/core/src/ai/tool-execution.ts new file mode 100644 index 000000000..af74c6697 --- /dev/null +++ b/packages/core/src/ai/tool-execution.ts @@ -0,0 +1,129 @@ +import type { AIGatewayChatMessage, AIGatewayToolCall } from "./client"; +import type { AIChatMessage } from "./use-ai-chat"; +import type { AIChatToolContext, AILocalTool } from "./tools"; + +/** + * Executes requested local tools in call order and converts their outputs into + * internal `role: "tool"` transcript messages for the next model round. + */ +export async function resolveToolCalls(input: { + toolCalls: AIGatewayToolCall[]; + localTools: Map; + signal: AbortSignal; + visibleMessages: AIChatToolContext["messages"]; +}): Promise[]> { + const messages: Extract[] = []; + + for (const toolCall of input.toolCalls) { + if (input.signal.aborted) { + throw createAbortError(); + } + + messages.push( + await resolveToolCall({ + toolCall, + localTools: input.localTools, + context: { + signal: input.signal, + messages: input.visibleMessages, + }, + }), + ); + } + + return messages; +} + +/** + * Validates, executes, and stringifies one local tool call. + * + * Tool failures are converted into tool result payloads so the model can + * recover in-band instead of failing the entire chat request. + */ +async function resolveToolCall(input: { + toolCall: AIGatewayToolCall; + localTools: Map; + context: AIChatToolContext; +}): Promise> { + const tool = input.localTools.get(input.toolCall.name); + + if (!tool) { + return { + role: "tool", + toolCallId: input.toolCall.id, + content: JSON.stringify({ error: `Unknown tool: ${input.toolCall.name}` }), + }; + } + + try { + const rawArguments = parseToolArguments(input.toolCall.argumentsText); + const result = await tool.schema["~standard"].validate(rawArguments); + + if (result.issues) { + return { + role: "tool", + toolCallId: input.toolCall.id, + content: JSON.stringify({ error: result.issues.map((issue) => issue.message).join("; ") }), + }; + } + + const output = await tool.execute(result.value, input.context); + return { + role: "tool", + toolCallId: input.toolCall.id, + content: stringifyToolResult(output), + }; + } catch (error) { + return { + role: "tool", + toolCallId: input.toolCall.id, + content: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }), + }; + } +} + +/** + * Derives the user/assistant visible messages from the full transcript. + * Used to provide conversation context to tool executors. + */ +export function deriveVisibleMessages(transcript: AIGatewayChatMessage[]): AIChatMessage[] { + const result: AIChatMessage[] = []; + + for (const message of transcript) { + if (message.role === "user") { + // ponytail: ids are placeholders — tool executors only read content, not ids + result.push({ id: "", role: "user", content: message.content }); + } else if (message.role === "assistant" && message.content) { + result.push({ id: "", role: "assistant", content: message.content }); + } + } + + return result; +} + +function parseToolArguments(argumentsText: string): unknown { + const text = argumentsText.trim(); + return text ? JSON.parse(text) : {}; +} + +function stringifyToolResult(value: unknown): string { + if (typeof value === "string") { + return value; + } + + if (value === undefined) { + return "null"; + } + + return JSON.stringify(value); +} + +function createAbortError(): Error { + if (typeof DOMException !== "undefined") { + return new DOMException("The operation was aborted.", "AbortError"); + } + + const error = new Error("The operation was aborted."); + error.name = "AbortError"; + return error; +} diff --git a/packages/core/src/ai/use-ai-chat.test.tsx b/packages/core/src/ai/use-ai-chat.test.tsx index 628c0d0f6..2608103be 100644 --- a/packages/core/src/ai/use-ai-chat.test.tsx +++ b/packages/core/src/ai/use-ai-chat.test.tsx @@ -344,6 +344,51 @@ describe("useAIChat", () => { expect(client.streamChatCompletion).toHaveBeenCalledTimes(2); }); + it("fails after too many tool rounds", async () => { + const loop = defineAIChatTool({ + description: "Loop forever", + schema: aiToolSchema.object({ + value: aiToolSchema.string(), + }), + async execute({ value }) { + return { value }; + }, + }); + + let callCount = 0; + const client = { + streamChatCompletion: vi.fn(async function* () { + callCount += 1; + yield { + type: "tool-call", + toolCallId: `call-${callCount}`, + toolName: "loop", + argumentsText: '{"value":"again"}', + } as const; + yield { type: "done", finishReason: "tool_calls" } as const; + }), + } satisfies AIGatewayClient; + + const { result } = renderHook(() => + useAIChat({ + client, + model: "gpt-5-mini", + tools: { + loop, + }, + }), + ); + + await act(async () => { + await expect(result.current.sendMessage("Start looping")).resolves.toBe(false); + }); + + await waitFor(() => { + expect(result.current.status).toBe("error"); + expect(result.current.error?.message).toContain("maximum number of tool rounds"); + }); + }); + it("passes provider tools through and exposes sources", async () => { const client = { streamChatCompletion: vi.fn(async function* ({ tools }) { diff --git a/packages/core/src/ai/use-ai-chat.ts b/packages/core/src/ai/use-ai-chat.ts index a9ee2a89d..303701228 100644 --- a/packages/core/src/ai/use-ai-chat.ts +++ b/packages/core/src/ai/use-ai-chat.ts @@ -1,18 +1,12 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { isAbortError } from "./client"; -import type { - AIGatewayChatMessage, - AIGatewayClient, - AIGatewayFunctionTool, - AIGatewayProviderTool, - AIGatewayTool, - AIGatewayToolCall, - AIChatSource, -} from "./client"; -import type { AIChatConfiguredTool, AIChatToolContext, AILocalTool } from "./tools"; - -// Simple loop guard so a bad model/tool interaction cannot recurse forever. -const MAX_TOOL_ROUNDS = 8; +import type { AIGatewayChatMessage, AIGatewayClient, AIChatSource } from "./client"; +import { + runAssistantLoop, + type AIChatAssistantTurnResult, + type AssistantLoopEvent, +} from "./assistant-loop"; +import type { AIChatConfiguredTool } from "./tools"; /** Public chat message shape exposed by `useAIChat()`. */ export interface AIChatMessage { @@ -24,6 +18,54 @@ export interface AIChatMessage { export type AIChatStatus = "ready" | "submitted" | "streaming" | "error"; +/** Active request handle stored in the ref for staleness checks + cancellation. */ +interface ActiveHandle { + readonly controller: AbortController; + readonly ctx: ChatRequestContext; +} + +/** + * Encapsulates per-request mutable state: transcript staging + streaming turn buffer. + * + * Transcript is "staged" here and only committed to the persistent ref on success, + * so failed/aborted requests don't pollute conversation history. + */ +class ChatRequestContext { + transcript: AIGatewayChatMessage[]; + private turnMessageId: string | null = null; + private turnContent = ""; + + constructor(initialTranscript: AIGatewayChatMessage[]) { + this.transcript = [...initialTranscript]; + } + + /** Appends a text delta to the current turn buffer, lazily creating a message id. */ + appendDelta(delta: string): { messageId: string; content: string; isNew: boolean } { + this.turnContent = `${this.turnContent}${delta}`; + const isNew = !this.turnMessageId; + if (isNew) { + this.turnMessageId = crypto.randomUUID(); + } + return { messageId: this.turnMessageId!, content: this.turnContent, isNew }; + } + + get currentMessageId(): string | null { + return this.turnMessageId; + } + + commitTurn(turn: AIChatAssistantTurnResult): void { + if (turn.gatewayAssistantMessage.content || turn.toolCalls.length > 0) { + this.transcript.push(turn.gatewayAssistantMessage); + } + this.turnMessageId = null; + this.turnContent = ""; + } + + commitToolResults(messages: Extract[]): void { + this.transcript.push(...messages); + } +} + /** * React hook for AI Gateway chat with optional local and provider tools. * @@ -48,53 +90,35 @@ export function useAIChat(config: { const [messages, setMessages] = useState([]); const [status, setStatus] = useState("ready"); const [error, setError] = useState(undefined); - const messagesRef = useRef([]); - // Internal transcript used for the next AI Gateway request. This can contain - // assistant tool calls and tool result messages that are intentionally hidden - // from the public `messages` array. - const gatewayMessagesRef = useRef([]); - const activeRequestRef = useRef(null); - const abortControllerRef = useRef(null); + const transcriptRef = useRef([]); + const activeRef = useRef(null); const updateMessages = useCallback((updater: (previous: AIChatMessage[]) => AIChatMessage[]) => { - setMessages((previous) => { - const next = updater(previous); - messagesRef.current = next; - return next; - }); + setMessages((previous) => updater(previous)); }, []); const stop = useCallback(() => { - const controller = abortControllerRef.current; - - if (!controller) { - return; - } + const active = activeRef.current; + if (!active) return; - activeRequestRef.current = null; - abortControllerRef.current = null; - controller.abort(); + activeRef.current = null; + active.controller.abort(); setStatus("ready"); }, []); useEffect(() => { return () => { - activeRequestRef.current = null; - abortControllerRef.current?.abort(); - abortControllerRef.current = null; + activeRef.current?.controller.abort(); + activeRef.current = null; }; }, []); const sendMessage = useCallback( - async (message: string) => { - if (activeRequestRef.current) { - return false; - } + async (message: string): Promise => { + if (activeRef.current) return false; const text = message.trim(); - if (!text) { - return false; - } + if (!text) return false; const userMessage: AIChatMessage = { id: crypto.randomUUID(), @@ -102,79 +126,40 @@ export function useAIChat(config: { content: text, }; - const nextMessages = [...messagesRef.current, userMessage]; - messagesRef.current = nextMessages; - setMessages(nextMessages); + updateMessages((previous) => [...previous, userMessage]); setError(undefined); setStatus("submitted"); - gatewayMessagesRef.current = [...gatewayMessagesRef.current, toGatewayMessage(userMessage)]; + const ctx = new ChatRequestContext([ + ...transcriptRef.current, + { role: "user", content: text }, + ]); const controller = new AbortController(); - const requestId = Symbol(); - activeRequestRef.current = requestId; - abortControllerRef.current = controller; - const isActive = () => activeRequestRef.current === requestId; + const handle: ActiveHandle = { controller, ctx }; + activeRef.current = handle; try { - const { gatewayTools, localTools } = normalizeTools(config.tools); - - for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) { - if (!isActive()) { - return false; - } - - const turnResult = await runAssistantTurn({ - client: config.client, - model: config.model, - messages: gatewayMessagesRef.current, - tools: gatewayTools, - signal: controller.signal, - isActive, - setStatus, - updateMessages, - }); - - if (!isActive()) { - return false; - } - - if (turnResult.publicAssistantMessage) { - gatewayMessagesRef.current = [ - ...gatewayMessagesRef.current, - turnResult.gatewayAssistantMessage, - ]; - } else if (turnResult.toolCalls.length > 0) { - gatewayMessagesRef.current = [ - ...gatewayMessagesRef.current, - turnResult.gatewayAssistantMessage, - ]; - } - - if (turnResult.toolCalls.length === 0) { - setStatus("ready"); - return true; - } - - setStatus("submitted"); - const toolMessages = await executeToolCalls({ - toolCalls: turnResult.toolCalls, - localTools, - signal: controller.signal, - visibleMessages: messagesRef.current, - }); - - if (!isActive()) { - return false; - } - - gatewayMessagesRef.current = [...gatewayMessagesRef.current, ...toolMessages]; + for await (const event of runAssistantLoop({ + client: config.client, + model: config.model, + tools: config.tools, + transcript: ctx.transcript, + signal: controller.signal, + })) { + if (activeRef.current !== handle) break; + applyEvent(event, ctx, updateMessages, setStatus); } - throw new Error(`AI chat exceeded the maximum number of tool rounds (${MAX_TOOL_ROUNDS}).`); + if (activeRef.current !== handle) return false; + + // Commit staged transcript to persistent history + transcriptRef.current = ctx.transcript; + activeRef.current = null; + setStatus("ready"); + return true; } catch (caughtError) { - if (!isActive()) { - return false; - } + if (activeRef.current !== handle) return false; + activeRef.current = null; if (isAbortError(caughtError)) { setStatus("ready"); @@ -184,290 +169,67 @@ export function useAIChat(config: { setError(toError(caughtError)); setStatus("error"); return false; - } finally { - if (activeRequestRef.current === requestId) { - activeRequestRef.current = null; - } - - if (abortControllerRef.current === controller) { - abortControllerRef.current = null; - } } }, [config.client, config.model, config.tools, updateMessages], ); - return { - messages, - status, - error, - sendMessage, - stop, - }; + return { messages, status, error, sendMessage, stop }; } -/** - * Runs a single assistant turn against the AI Gateway. - * - * Visible assistant text is streamed into the public `messages` state, while - * requested tool calls are collected into the returned internal transcript - * payload for the next loop step. - */ -async function runAssistantTurn(input: { - client: AIGatewayClient; - model: string; - messages: AIGatewayChatMessage[]; - tools: AIGatewayTool[]; - signal: AbortSignal; - isActive: () => boolean; - setStatus: (status: AIChatStatus) => void; - updateMessages: (updater: (previous: AIChatMessage[]) => AIChatMessage[]) => void; -}): Promise<{ - gatewayAssistantMessage: Extract; - publicAssistantMessage?: AIChatMessage; - toolCalls: AIGatewayToolCall[]; -}> { - let assistantMessageId: string | null = null; - let assistantContent = ""; - let assistantSources: AIChatSource[] | undefined; - const toolCalls: AIGatewayToolCall[] = []; - - for await (const event of input.client.streamChatCompletion({ - model: input.model, - messages: input.messages, - ...(input.tools.length > 0 ? { tools: input.tools } : {}), - signal: input.signal, - })) { - if (!input.isActive()) { +/** Single dispatch point: loop events → React state updates + context mutations. */ +function applyEvent( + event: AssistantLoopEvent, + ctx: ChatRequestContext, + updateMessages: (updater: (previous: AIChatMessage[]) => AIChatMessage[]) => void, + setStatus: (status: AIChatStatus) => void, +): void { + switch (event.type) { + case "text-delta": { + const { messageId, content, isNew } = ctx.appendDelta(event.delta); + setStatus("streaming"); + + if (isNew) { + updateMessages((previous) => [...previous, { id: messageId, role: "assistant", content }]); + } else { + updateMessages((previous) => + previous.map((entry) => (entry.id === messageId ? { ...entry, content } : entry)), + ); + } break; } - if (event.type === "text-delta") { - assistantContent = `${assistantContent}${event.text}`; - input.setStatus("streaming"); - - if (!assistantMessageId) { - assistantMessageId = crypto.randomUUID(); - input.updateMessages((previous) => [ - ...previous, - { - id: assistantMessageId!, - role: "assistant", - content: event.text, - }, - ]); - continue; - } - - input.updateMessages((previous) => - previous.map((entry) => - entry.id === assistantMessageId ? { ...entry, content: assistantContent } : entry, - ), + case "sources": { + const id = ctx.currentMessageId; + if (!id) break; + const { sources } = event; + updateMessages((previous) => + previous.map((entry) => (entry.id === id ? { ...entry, sources } : entry)), ); - continue; + break; } - if (event.type === "tool-call") { - toolCalls.push({ - id: event.toolCallId, - name: event.toolName, - argumentsText: event.argumentsText, - }); - continue; + case "turn-end": { + ctx.commitTurn(event.turn); + break; } - assistantSources = event.sources; - } - - if (assistantMessageId && assistantSources?.length) { - input.updateMessages((previous) => - previous.map((entry) => - entry.id === assistantMessageId ? { ...entry, sources: assistantSources } : entry, - ), - ); - } - - const gatewayAssistantMessage: Extract = { - role: "assistant", - ...(assistantContent ? { content: assistantContent } : {}), - ...(toolCalls.length > 0 ? { toolCalls } : {}), - }; - - return { - gatewayAssistantMessage, - ...(assistantMessageId - ? { - publicAssistantMessage: { - id: assistantMessageId, - role: "assistant", - content: assistantContent, - ...(assistantSources?.length ? { sources: assistantSources } : {}), - }, - } - : {}), - toolCalls, - }; -} - -/** - * Splits the public tools object into: - * - normalized AI Gateway tool definitions sent over the wire - * - local tool executors kept for in-process validation and execution - */ -function normalizeTools(tools: Record | undefined): { - gatewayTools: AIGatewayTool[]; - localTools: Map; -} { - if (!tools) { - return { - gatewayTools: [], - localTools: new Map(), - }; - } - - const localTools = new Map(); - const gatewayTools = Object.entries(tools).map(([name, tool]) => { - if (tool.kind === "local") { - localTools.set(name, tool); - return { - type: "function", - function: { - name, - ...(tool.description ? { description: tool.description } : {}), - parameters: tool.schema["~standard"].jsonSchema.input({ target: "draft-07" }), - }, - } satisfies AIGatewayFunctionTool; + case "tool-resolution-start": { + setStatus("submitted"); + break; } - return { - type: "provider", - provider: "openai", - name: "web_search", - ...(tool.options ? { options: tool.options } : {}), - } satisfies AIGatewayProviderTool; - }); - - return { - gatewayTools, - localTools, - }; -} - -/** - * Executes requested local tools in call order and converts their outputs into - * internal `role: "tool"` transcript messages for the next model round. - */ -async function executeToolCalls(input: { - toolCalls: AIGatewayToolCall[]; - localTools: Map; - signal: AbortSignal; - visibleMessages: AIChatMessage[]; -}): Promise[]> { - const messages: Extract[] = []; - - for (const toolCall of input.toolCalls) { - if (input.signal.aborted) { - throw createAbortError(); + case "tool-results": { + ctx.commitToolResults(event.messages); + break; } - messages.push( - await executeToolCall({ - toolCall, - localTools: input.localTools, - context: { - signal: input.signal, - messages: input.visibleMessages, - }, - }), - ); - } - - return messages; -} - -/** - * Validates, executes, and stringifies one local tool call. - * - * Tool failures are converted into tool result payloads so the model can - * recover in-band instead of failing the entire chat request. - */ -async function executeToolCall(input: { - toolCall: AIGatewayToolCall; - localTools: Map; - context: AIChatToolContext; -}): Promise> { - const tool = input.localTools.get(input.toolCall.name); - - if (!tool) { - return { - role: "tool", - toolCallId: input.toolCall.id, - content: JSON.stringify({ error: `Unknown tool: ${input.toolCall.name}` }), - }; - } - - try { - const rawArguments = parseToolArguments(input.toolCall.argumentsText); - const result = await tool.schema["~standard"].validate(rawArguments); - - if (result.issues) { - return { - role: "tool", - toolCallId: input.toolCall.id, - content: JSON.stringify({ error: result.issues.map((issue) => issue.message).join("; ") }), - }; + case "complete": { + break; } - - const output = await tool.execute(result.value, input.context); - return { - role: "tool", - toolCallId: input.toolCall.id, - content: stringifyToolResult(output), - }; - } catch (error) { - return { - role: "tool", - toolCallId: input.toolCall.id, - content: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }), - }; - } -} - -/** Parses the model-produced JSON argument payload for one tool call. */ -function parseToolArguments(argumentsText: string): unknown { - const text = argumentsText.trim(); - return text ? JSON.parse(text) : {}; -} - -/** Normalizes local tool results to the string payload expected by tool messages. */ -function stringifyToolResult(value: unknown): string { - if (typeof value === "string") { - return value; } - - if (value === undefined) { - return "null"; - } - - return JSON.stringify(value); } -function toGatewayMessage(message: AIChatMessage): { role: "user"; content: string } { - return { - role: "user", - content: message.content, - }; -} - -function createAbortError(): Error { - if (typeof DOMException !== "undefined") { - return new DOMException("The operation was aborted.", "AbortError"); - } - - const error = new Error("The operation was aborted."); - error.name = "AbortError"; - return error; -} function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); From 126a5506e5397dc9105a8d5f311411ea8553e629 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Mon, 6 Jul 2026 17:25:26 +0900 Subject: [PATCH 05/19] docs: enrich ai-gateway-tools changeset with examples and API reference --- .changeset/ai-gateway-tools.md | 103 ++++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/.changeset/ai-gateway-tools.md b/.changeset/ai-gateway-tools.md index d033a25b3..af7ee72c1 100644 --- a/.changeset/ai-gateway-tools.md +++ b/.changeset/ai-gateway-tools.md @@ -4,4 +4,105 @@ Add tool support to `useAIChat()` and the AI Gateway transport layer. -You can now register local tools with `defineAIChatTool(...)` and provider tools such as `aiProviderTool.openai.webSearch(...)`, while keeping the public chat message model focused on user/assistant messages. +You can now register **local tools** and **provider tools** via the `tools` option on `useAIChat()`. Tool-call and tool-result protocol messages stay internal to the hook — the public `messages` array remains user/assistant text only. + +## Local tools + +Define tools that run inside AppShell using `defineAIChatTool`. The model decides when to call them, AppShell validates arguments with the schema, executes the handler, and feeds results back into the next model turn automatically. + +```ts +import { defineAIChatTool, aiToolSchema } from "@tailor-platform/app-shell"; + +const lookupCustomer = defineAIChatTool({ + description: "Look up a customer by ID and return their profile", + schema: aiToolSchema.object({ + customerId: aiToolSchema.string({ description: "Customer ID" }), + includeInactive: aiToolSchema.optional(aiToolSchema.boolean()), + }), + async execute({ customerId, includeInactive }, { signal }) { + const res = await fetch(`/api/customers/${customerId}?inactive=${includeInactive ?? false}`, { + signal, + }); + return res.json(); + }, +}); +``` + +### Schema primitives (`aiToolSchema`) + +| Helper | Description | +| --------------------------------------- | --------------------------------------------------------------- | +| `aiToolSchema.string(opts?)` | String input (supports `minLength`, `maxLength`, `description`) | +| `aiToolSchema.number(opts?)` | Numeric input (supports `minimum`, `maximum`, `integer`) | +| `aiToolSchema.boolean(opts?)` | Boolean input | +| `aiToolSchema.enum(values, opts?)` | Fixed set of string literals | +| `aiToolSchema.array(itemSchema, opts?)` | Array of a given schema | +| `aiToolSchema.object(shape)` | Object with named fields | +| `aiToolSchema.optional(schema)` | Marks a field as optional | + +### Tool context + +The `execute` function receives a second argument with: + +- `signal` — the `AbortSignal` for the in-flight chat request +- `messages` — the public user/assistant message history at the time of execution + +## Provider tools + +Provider tools are not executed locally — they are passed through to the AI Gateway and handled upstream by the model provider. + +```ts +import { aiProviderTool } from "@tailor-platform/app-shell"; + +const webSearch = aiProviderTool.openai.webSearch({ + searchContextSize: "high", + userLocation: { + type: "approximate", + country: "JP", + city: "Tokyo", + timezone: "Asia/Tokyo", + }, + filters: { + allowedDomains: ["nikkei.com", "reuters.com"], + }, +}); +``` + +## Registering tools with `useAIChat` + +Pass tools as a record to the `tools` option. The key becomes the tool name sent to the model. + +```tsx +import { + useAIChat, + defineAIChatTool, + aiToolSchema, + aiProviderTool, +} from "@tailor-platform/app-shell"; + +const lookupCustomer = defineAIChatTool({ + description: "Look up a customer by ID", + schema: aiToolSchema.object({ + customerId: aiToolSchema.string(), + }), + async execute({ customerId }) { + return { customerId, name: "Acme Corp", plan: "enterprise" }; + }, +}); + +function ChatPanel({ client }) { + const { messages, status, sendMessage } = useAIChat({ + client, + model: "gpt-5-mini", + tools: { + lookupCustomer, + web_search: aiProviderTool.openai.webSearch({ searchContextSize: "high" }), + }, + }); + + // messages only contains user/assistant text — tool calls are handled internally + // status transitions: "submitted" → "streaming" → ("submitted" during tool rounds) → "ready" +} +``` + +The hook runs up to 8 tool rounds per user message. If the model keeps requesting tools beyond that limit, the request fails with an error. From e03b3e13059023700d853fe157c6906ee68f6a47 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Wed, 8 Jul 2026 15:08:08 +0900 Subject: [PATCH 06/19] Format files --- packages/core/src/ai/use-ai-chat.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/ai/use-ai-chat.ts b/packages/core/src/ai/use-ai-chat.ts index 303701228..0f94a2782 100644 --- a/packages/core/src/ai/use-ai-chat.ts +++ b/packages/core/src/ai/use-ai-chat.ts @@ -230,7 +230,6 @@ function applyEvent( } } - function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } From 6b2195811c5054ee42088b68c70791700a85c45f Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Thu, 17 Sep 2026 15:19:41 +0900 Subject: [PATCH 07/19] refactor(ai): remove built-in tool schema helpers --- .changeset/ai-gateway-tools.md | 33 +- docs/api/use-ai-chat.md | 8 +- packages/core/package.json | 3 +- packages/core/src/ai/tools.test.ts | 69 ++-- packages/core/src/ai/tools.ts | 430 +--------------------- packages/core/src/ai/use-ai-chat.test.tsx | 13 +- packages/core/src/index.ts | 1 - pnpm-lock.yaml | 3 + 8 files changed, 63 insertions(+), 497 deletions(-) diff --git a/.changeset/ai-gateway-tools.md b/.changeset/ai-gateway-tools.md index af7ee72c1..835647cc6 100644 --- a/.changeset/ai-gateway-tools.md +++ b/.changeset/ai-gateway-tools.md @@ -11,13 +11,14 @@ You can now register **local tools** and **provider tools** via the `tools` opti Define tools that run inside AppShell using `defineAIChatTool`. The model decides when to call them, AppShell validates arguments with the schema, executes the handler, and feeds results back into the next model turn automatically. ```ts -import { defineAIChatTool, aiToolSchema } from "@tailor-platform/app-shell"; +import { defineAIChatTool } from "@tailor-platform/app-shell"; +import { z } from "zod/v4"; const lookupCustomer = defineAIChatTool({ description: "Look up a customer by ID and return their profile", - schema: aiToolSchema.object({ - customerId: aiToolSchema.string({ description: "Customer ID" }), - includeInactive: aiToolSchema.optional(aiToolSchema.boolean()), + schema: z.object({ + customerId: z.string().describe("Customer ID"), + includeInactive: z.boolean().optional(), }), async execute({ customerId, includeInactive }, { signal }) { const res = await fetch(`/api/customers/${customerId}?inactive=${includeInactive ?? false}`, { @@ -28,17 +29,7 @@ const lookupCustomer = defineAIChatTool({ }); ``` -### Schema primitives (`aiToolSchema`) - -| Helper | Description | -| --------------------------------------- | --------------------------------------------------------------- | -| `aiToolSchema.string(opts?)` | String input (supports `minLength`, `maxLength`, `description`) | -| `aiToolSchema.number(opts?)` | Numeric input (supports `minimum`, `maximum`, `integer`) | -| `aiToolSchema.boolean(opts?)` | Boolean input | -| `aiToolSchema.enum(values, opts?)` | Fixed set of string literals | -| `aiToolSchema.array(itemSchema, opts?)` | Array of a given schema | -| `aiToolSchema.object(shape)` | Object with named fields | -| `aiToolSchema.optional(schema)` | Marks a field as optional | +Tool schemas must implement both [Standard Schema](https://standardschema.dev/) validation and Standard JSON Schema generation; Zod 4 does this directly. ### Tool context @@ -73,17 +64,13 @@ const webSearch = aiProviderTool.openai.webSearch({ Pass tools as a record to the `tools` option. The key becomes the tool name sent to the model. ```tsx -import { - useAIChat, - defineAIChatTool, - aiToolSchema, - aiProviderTool, -} from "@tailor-platform/app-shell"; +import { useAIChat, defineAIChatTool, aiProviderTool } from "@tailor-platform/app-shell"; +import { z } from "zod/v4"; const lookupCustomer = defineAIChatTool({ description: "Look up a customer by ID", - schema: aiToolSchema.object({ - customerId: aiToolSchema.string(), + schema: z.object({ + customerId: z.string(), }), async execute({ customerId }) { return { customerId, name: "Acme Corp", plan: "enterprise" }; diff --git a/docs/api/use-ai-chat.md b/docs/api/use-ai-chat.md index d3aa7a671..cd3e2e510 100644 --- a/docs/api/use-ai-chat.md +++ b/docs/api/use-ai-chat.md @@ -67,7 +67,7 @@ interface AIChatSource { Register tools under a single object: -- local tools created with `defineAIChatTool(...)` +- local tools created with `defineAIChatTool(...)`; their schemas must implement both [Standard Schema](https://standardschema.dev/) validation and Standard JSON Schema generation (for example, Zod 4) - provider tools such as `aiProviderTool.openai.webSearch(...)` ## Usage @@ -75,12 +75,12 @@ Register tools under a single object: ```tsx import { aiProviderTool, - aiToolSchema, createAuthClient, createAIGatewayClient, defineAIChatTool, useAIChat, } from "@tailor-platform/app-shell"; +import { z } from "zod/v4"; const authClient = createAuthClient({ clientId: "your-client-id", @@ -94,8 +94,8 @@ const aiClient = createAIGatewayClient({ const lookupCustomer = defineAIChatTool({ description: "Look up a customer in the current workspace", - schema: aiToolSchema.object({ - customerId: aiToolSchema.string(), + schema: z.object({ + customerId: z.string(), }), async execute({ customerId }) { return { customerId, name: "Acme Corp" }; diff --git a/packages/core/package.json b/packages/core/package.json index 0cf007137..3ad838241 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -95,7 +95,8 @@ "typescript": "5.9.3", "vite": "catalog:", "vite-plugin-dts": "5.0.3", - "vitest": "catalog:" + "vitest": "catalog:", + "zod": "4.4.3" }, "peerDependencies": { "react": ">=19.2.7", diff --git a/packages/core/src/ai/tools.test.ts b/packages/core/src/ai/tools.test.ts index d7eb880cc..71d8fc880 100644 --- a/packages/core/src/ai/tools.test.ts +++ b/packages/core/src/ai/tools.test.ts @@ -1,66 +1,43 @@ import { describe, expect, it } from "vitest"; -import { aiProviderTool, aiToolSchema, defineAIChatTool } from "./tools"; +import { z } from "zod/v4"; +import { aiProviderTool, defineAIChatTool } from "./tools"; -describe("aiToolSchema", () => { - it("validates nested objects and generates json schema", async () => { - const schema = aiToolSchema.object({ - city: aiToolSchema.string({ description: "City name" }), - unit: aiToolSchema.optional(aiToolSchema.enum(["c", "f"])), - tags: aiToolSchema.array(aiToolSchema.string()), - }); - - await expect( - schema["~standard"].validate({ city: "Tokyo", unit: "c", tags: ["capital"] }), - ).resolves.toEqual({ - value: { - city: "Tokyo", - unit: "c", - tags: ["capital"], +describe("AI chat tool helpers", () => { + it("accepts Zod schemas for local tool definitions", async () => { + const lookupCustomer = defineAIChatTool({ + description: "Look up a customer", + schema: z.object({ + customerId: z.string(), + includeInactive: z.boolean().optional(), + }), + async execute({ customerId, includeInactive }) { + return { customerId, includeInactive, name: "Acme" }; }, }); - await expect( - schema["~standard"].validate({ city: "Tokyo", unit: "k", tags: [] }), - ).resolves.toEqual({ - issues: [{ message: "Must be one of: c, f", path: ["unit"] }], + expect(await lookupCustomer.schema["~standard"].validate({ customerId: "cust-1" })).toEqual({ + value: { customerId: "cust-1" }, }); - - expect(schema["~standard"].jsonSchema.input({ target: "draft-07" })).toEqual({ + expect(lookupCustomer.schema["~standard"].jsonSchema.input({ target: "draft-07" })).toEqual({ + $schema: "http://json-schema.org/draft-07/schema#", type: "object", properties: { - city: { type: "string", description: "City name" }, - unit: { type: "string", enum: ["c", "f"] }, - tags: { type: "array", items: { type: "string" } }, - }, - required: ["city", "tags"], - additionalProperties: false, - }); - }); -}); - -describe("AI chat tool helpers", () => { - it("creates local and provider tool definitions", async () => { - const lookupCustomer = defineAIChatTool({ - description: "Look up a customer", - schema: aiToolSchema.object({ - customerId: aiToolSchema.string(), - }), - async execute({ customerId }) { - return { customerId, name: "Acme" }; + customerId: { type: "string" }, + includeInactive: { type: "boolean" }, }, + required: ["customerId"], }); - const webSearch = aiProviderTool.openai.webSearch({ searchContextSize: "high" }); - - expect(lookupCustomer.kind).toBe("local"); await expect( lookupCustomer.execute( { customerId: "cust-1" }, { signal: new AbortController().signal, messages: [] }, ), - ).resolves.toEqual({ customerId: "cust-1", name: "Acme" }); + ).resolves.toEqual({ customerId: "cust-1", includeInactive: undefined, name: "Acme" }); + }); - expect(webSearch).toEqual({ + it("creates provider tool definitions", () => { + expect(aiProviderTool.openai.webSearch({ searchContextSize: "high" })).toEqual({ kind: "provider", provider: "openai", tool: "webSearch", diff --git a/packages/core/src/ai/tools.ts b/packages/core/src/ai/tools.ts index ca9fb4bc7..83f119129 100644 --- a/packages/core/src/ai/tools.ts +++ b/packages/core/src/ai/tools.ts @@ -2,427 +2,24 @@ import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/sp import type { AIChatMessage } from "./use-ai-chat"; /** - * Public schema contract for local AI chat tools. + * Schema contract for local AI chat tools. * - * A schema must both validate runtime inputs and generate JSON Schema for the - * AI Gateway request payload. + * A schema must validate runtime inputs and generate JSON Schema for the AI + * Gateway request. Zod 4 implements both Standard Schema contracts directly. * * @example * ```ts - * import { aiToolSchema } from "@tailor-platform/app-shell"; + * import { z } from "zod/v4"; * - * const schema = aiToolSchema.object({ - * customerId: aiToolSchema.string(), - * includeInactive: aiToolSchema.optional(aiToolSchema.boolean()), + * const schema = z.object({ + * customerId: z.string(), + * includeInactive: z.boolean().optional(), * }); * ``` */ export type AIChatToolSchema = StandardSchemaV1 & StandardJSONSchemaV1; -type AnyAIChatToolSchema = AIChatToolSchema; -const OPTIONAL_TOOL_SCHEMA = Symbol("optional-tool-schema"); - -type AIOptionalToolSchema = - AIChatToolSchema< - StandardSchemaV1.InferInput | undefined, - StandardSchemaV1.InferOutput | undefined - > & { - [OPTIONAL_TOOL_SCHEMA]: TSchema; - }; - -type AISchemaShape = Record; - -type UnwrapOptionalSchema = - TSchema extends AIOptionalToolSchema - ? TInner - : TSchema extends AnyAIChatToolSchema - ? TSchema - : never; - -type SchemaInput = - StandardSchemaV1.InferInput>; - -type SchemaOutput = - StandardSchemaV1.InferOutput>; - -type ObjectInput = { - [K in keyof TShape as TShape[K] extends AIOptionalToolSchema ? never : K]: SchemaInput; -} & { - [K in keyof TShape as TShape[K] extends AIOptionalToolSchema ? K : never]?: SchemaInput< - TShape[K] - >; -}; - -type ObjectOutput = { - [K in keyof TShape as TShape[K] extends AIOptionalToolSchema ? never : K]: SchemaOutput< - TShape[K] - >; -} & { - [K in keyof TShape as TShape[K] extends AIOptionalToolSchema ? K : never]?: SchemaOutput< - TShape[K] - >; -}; - -function success(value: T): StandardSchemaV1.SuccessResult { - return { value }; -} - -function failure(message: string): StandardSchemaV1.FailureResult { - return { issues: [{ message }] }; -} - -function prefixIssues( - issues: readonly StandardSchemaV1.Issue[], - key: PropertyKey, -): StandardSchemaV1.FailureResult { - return { - issues: issues.map((issue) => ({ - ...issue, - path: [key, ...(issue.path ?? [])], - })), - }; -} - -/** - * Creates a Standard Schema + Standard JSON Schema compatible tool schema. - * - * This keeps local tool validation and AI Gateway JSON Schema generation bound - * to the same declaration. - */ -function createToolSchema(config: { - validate: ( - value: unknown, - ) => StandardSchemaV1.Result | Promise>; - jsonSchemaInput: (options: StandardJSONSchemaV1.Options) => Record; - jsonSchemaOutput?: (options: StandardJSONSchemaV1.Options) => Record; -}): AIChatToolSchema { - return { - "~standard": { - version: 1, - vendor: "app-shell", - validate: config.validate, - jsonSchema: { - input: config.jsonSchemaInput, - output: config.jsonSchemaOutput ?? config.jsonSchemaInput, - }, - }, - } as AIChatToolSchema; -} - -/** Marks wrapper schemas created by `aiToolSchema.optional(...)`. */ -function isOptionalSchema( - schema: AnyAIChatToolSchema | AIOptionalToolSchema, -): schema is AIOptionalToolSchema { - return OPTIONAL_TOOL_SCHEMA in schema; -} - -/** Returns the underlying schema for optional and non-optional schema entries. */ -function unwrapOptionalSchema( - schema: TSchema, -): UnwrapOptionalSchema { - return ( - isOptionalSchema(schema) ? schema[OPTIONAL_TOOL_SCHEMA] : schema - ) as UnwrapOptionalSchema; -} - -/** - * Minimal Standard Schema helpers for common local tool inputs. - * - * These helpers intentionally cover the narrow set of JSON-schema-friendly - * shapes AppShell needs for tool calling today. - * - * @example - * ```ts - * import { aiToolSchema } from "@tailor-platform/app-shell"; - * - * const lookupCustomerSchema = aiToolSchema.object({ - * customerId: aiToolSchema.string({ description: "Workspace customer id" }), - * fields: aiToolSchema.optional( - * aiToolSchema.array(aiToolSchema.enum(["name", "email", "status"])), - * ), - * }); - * ``` - */ -export const aiToolSchema = { - /** - * Declares a string input. - * - * Use this for ids, names, prompts, or any free-form text argument. - * - * @example - * ```ts - * aiToolSchema.string({ description: "Customer id" }) - * ``` - */ - string(options?: { - description?: string; - minLength?: number; - maxLength?: number; - }): AIChatToolSchema { - return createToolSchema({ - validate: (value) => { - if (typeof value !== "string") { - return failure("Must be a string"); - } - if (options?.minLength !== undefined && value.length < options.minLength) { - return failure(`Must be at least ${options.minLength} character(s)`); - } - if (options?.maxLength !== undefined && value.length > options.maxLength) { - return failure(`Must be at most ${options.maxLength} character(s)`); - } - return success(value); - }, - jsonSchemaInput: () => ({ - type: "string", - ...(options?.description ? { description: options.description } : {}), - ...(options?.minLength !== undefined ? { minLength: options.minLength } : {}), - ...(options?.maxLength !== undefined ? { maxLength: options.maxLength } : {}), - }), - }); - }, - - /** - * Declares a numeric input. - * - * Set `integer: true` for counts, indexes, and pagination-like arguments. - * - * @example - * ```ts - * aiToolSchema.number({ minimum: 1, integer: true }) - * ``` - */ - number(options?: { - description?: string; - minimum?: number; - maximum?: number; - integer?: boolean; - }): AIChatToolSchema { - return createToolSchema({ - validate: (value) => { - if (typeof value !== "number" || Number.isNaN(value)) { - return failure("Must be a number"); - } - if (options?.integer && !Number.isInteger(value)) { - return failure("Must be an integer"); - } - if (options?.minimum !== undefined && value < options.minimum) { - return failure(`Must be at least ${options.minimum}`); - } - if (options?.maximum !== undefined && value > options.maximum) { - return failure(`Must be at most ${options.maximum}`); - } - return success(value); - }, - jsonSchemaInput: () => ({ - type: options?.integer ? "integer" : "number", - ...(options?.description ? { description: options.description } : {}), - ...(options?.minimum !== undefined ? { minimum: options.minimum } : {}), - ...(options?.maximum !== undefined ? { maximum: options.maximum } : {}), - }), - }); - }, - - /** - * Declares a boolean input. - * - * Useful for enable/disable or include/exclude style arguments. - * - * @example - * ```ts - * aiToolSchema.boolean({ description: "Include archived records" }) - * ``` - */ - boolean(options?: { description?: string }): AIChatToolSchema { - return createToolSchema({ - validate: (value) => - typeof value === "boolean" ? success(value) : failure("Must be a boolean"), - jsonSchemaInput: () => ({ - type: "boolean", - ...(options?.description ? { description: options.description } : {}), - }), - }); - }, - - /** - * Declares a string enum input. - * - * Use this when the model should choose from a fixed set of string literals. - * - * @example - * ```ts - * aiToolSchema.enum(["draft", "published", "archived"]) - * ``` - */ - enum( - values: TValues, - options?: { description?: string }, - ): AIChatToolSchema { - return createToolSchema({ - validate: (value) => - typeof value === "string" && values.includes(value) - ? success(value as TValues[number]) - : failure(`Must be one of: ${values.join(", ")}`), - jsonSchemaInput: () => ({ - type: "string", - enum: [...values], - ...(options?.description ? { description: options.description } : {}), - }), - }); - }, - - /** - * Declares an array input whose items must match another tool schema. - * - * @example - * ```ts - * aiToolSchema.array(aiToolSchema.string(), { - * description: "Fields to include in the response", - * }) - * ``` - */ - array( - schema: TSchema, - options?: { description?: string }, - ): AIChatToolSchema< - StandardSchemaV1.InferInput[], - StandardSchemaV1.InferOutput[] - > { - return createToolSchema({ - validate: async (value) => { - if (!Array.isArray(value)) { - return failure("Must be an array"); - } - - const output: StandardSchemaV1.InferOutput[] = []; - for (const [index, entry] of value.entries()) { - const result = await schema["~standard"].validate(entry); - if (result.issues) { - return prefixIssues(result.issues, index); - } - output.push(result.value); - } - - return success(output); - }, - jsonSchemaInput: (jsonSchemaOptions) => ({ - type: "array", - items: schema["~standard"].jsonSchema.input(jsonSchemaOptions), - ...(options?.description ? { description: options.description } : {}), - }), - jsonSchemaOutput: (jsonSchemaOptions) => ({ - type: "array", - items: schema["~standard"].jsonSchema.output(jsonSchemaOptions), - ...(options?.description ? { description: options.description } : {}), - }), - }); - }, - - /** - * Declares an object input composed from named child schemas. - * - * All properties are required unless wrapped in `aiToolSchema.optional(...)`. - * - * @example - * ```ts - * aiToolSchema.object({ - * customerId: aiToolSchema.string(), - * fields: aiToolSchema.optional( - * aiToolSchema.array(aiToolSchema.enum(["name", "email", "status"])), - * ), - * }) - * ``` - */ - object( - shape: TShape, - options?: { description?: string }, - ): AIChatToolSchema, ObjectOutput> { - return createToolSchema({ - validate: async (value) => { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return failure("Must be an object"); - } - - const output: Record = {}; - - for (const [key, rawSchema] of Object.entries(shape)) { - const schema = unwrapOptionalSchema(rawSchema); - const isOptional = isOptionalSchema(rawSchema); - const inputValue = (value as Record)[key]; - - if (inputValue === undefined) { - if (!isOptional) { - return prefixIssues(failure("Required").issues, key); - } - continue; - } - - const result = await schema["~standard"].validate(inputValue); - if (result.issues) { - return prefixIssues(result.issues, key); - } - output[key] = result.value; - } - - return success(output as ObjectOutput); - }, - jsonSchemaInput: (jsonSchemaOptions) => ({ - type: "object", - properties: Object.fromEntries( - Object.entries(shape).map(([key, rawSchema]) => [ - key, - unwrapOptionalSchema(rawSchema)["~standard"].jsonSchema.input(jsonSchemaOptions), - ]), - ), - required: Object.entries(shape) - .filter(([, rawSchema]) => !isOptionalSchema(rawSchema)) - .map(([key]) => key), - additionalProperties: false, - ...(options?.description ? { description: options.description } : {}), - }), - jsonSchemaOutput: (jsonSchemaOptions) => ({ - type: "object", - properties: Object.fromEntries( - Object.entries(shape).map(([key, rawSchema]) => [ - key, - unwrapOptionalSchema(rawSchema)["~standard"].jsonSchema.output(jsonSchemaOptions), - ]), - ), - required: Object.entries(shape) - .filter(([, rawSchema]) => !isOptionalSchema(rawSchema)) - .map(([key]) => key), - additionalProperties: false, - ...(options?.description ? { description: options.description } : {}), - }), - }); - }, - - /** - * Marks a child schema as optional inside `aiToolSchema.object(...)`. - * - * @example - * ```ts - * aiToolSchema.object({ - * customerId: aiToolSchema.string(), - * fields: aiToolSchema.optional(aiToolSchema.array(aiToolSchema.string())), - * }) - * ``` - */ - optional(schema: TSchema): AIOptionalToolSchema { - return { - ...createToolSchema({ - validate: async (value) => - value === undefined ? success(undefined) : await schema["~standard"].validate(value), - jsonSchemaInput: (jsonSchemaOptions) => - schema["~standard"].jsonSchema.input(jsonSchemaOptions), - jsonSchemaOutput: (jsonSchemaOptions) => - schema["~standard"].jsonSchema.output(jsonSchemaOptions), - }), - [OPTIONAL_TOOL_SCHEMA]: schema, - } as AIOptionalToolSchema; - }, -}; - /** Context passed to local tool executors. */ export interface AIChatToolContext { /** Abort signal for the in-flight chat request. */ @@ -458,12 +55,13 @@ export interface AILocalTool< * * @example * ```ts - * import { defineAIChatTool, aiToolSchema } from "@tailor-platform/app-shell"; + * import { defineAIChatTool } from "@tailor-platform/app-shell"; + * import { z } from "zod/v4"; * * const lookupCustomer = defineAIChatTool({ * description: "Look up a customer in the current workspace", - * schema: aiToolSchema.object({ - * customerId: aiToolSchema.string(), + * schema: z.object({ + * customerId: z.string(), * }), * async execute({ customerId }, { signal }) { * const customer = await fetchCustomer(customerId, { signal }); @@ -526,14 +124,14 @@ export interface AIOpenAIWebSearchTool { * ```ts * import { * aiProviderTool, - * aiToolSchema, * defineAIChatTool, * useAIChat, * } from "@tailor-platform/app-shell"; + * import { z } from "zod/v4"; * * const lookupCustomer = defineAIChatTool({ - * schema: aiToolSchema.object({ - * customerId: aiToolSchema.string(), + * schema: z.object({ + * customerId: z.string(), * }), * async execute({ customerId }) { * return { customerId, name: "Acme Corp" }; diff --git a/packages/core/src/ai/use-ai-chat.test.tsx b/packages/core/src/ai/use-ai-chat.test.tsx index 2608103be..8a02471ca 100644 --- a/packages/core/src/ai/use-ai-chat.test.tsx +++ b/packages/core/src/ai/use-ai-chat.test.tsx @@ -1,8 +1,9 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod/v4"; import { useAIChat } from "./use-ai-chat"; import type { AIGatewayClient, AIChatCompletionEvent } from "./client"; -import { aiProviderTool, aiToolSchema, defineAIChatTool } from "./tools"; +import { aiProviderTool, defineAIChatTool } from "./tools"; function createAbortError(): Error { if (typeof DOMException !== "undefined") { @@ -254,8 +255,8 @@ describe("useAIChat", () => { it("executes local tools and continues the conversation", async () => { const lookupCustomer = defineAIChatTool({ description: "Look up a customer", - schema: aiToolSchema.object({ - customerId: aiToolSchema.string(), + schema: z.object({ + customerId: z.string(), }), async execute({ customerId }) { return { customerId, name: "Acme Corp" }; @@ -274,12 +275,12 @@ describe("useAIChat", () => { name: "lookupCustomer", description: "Look up a customer", parameters: { + $schema: "http://json-schema.org/draft-07/schema#", type: "object", properties: { customerId: { type: "string" }, }, required: ["customerId"], - additionalProperties: false, }, }, }, @@ -347,8 +348,8 @@ describe("useAIChat", () => { it("fails after too many tool rounds", async () => { const loop = defineAIChatTool({ description: "Loop forever", - schema: aiToolSchema.object({ - value: aiToolSchema.string(), + schema: z.object({ + value: z.string(), }), async execute({ value }) { return { value }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f5b3ee3df..6fc0f8c5d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -79,7 +79,6 @@ export { export { useAIChat, type AIChatMessage, type AIChatStatus } from "./ai/use-ai-chat"; export { defineAIChatTool, - aiToolSchema, aiProviderTool, type AIChatConfiguredTool, type AIChatToolContext, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cec6eae4e..85836650d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -478,6 +478,9 @@ importers: vitest: specifier: 'catalog:' version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.6)(@vitest/coverage-v8@4.1.10)(happy-dom@20.9.0)(vite@8.2.2(@types/node@25.9.6)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + zod: + specifier: 4.4.3 + version: 4.4.3 packages/eslint-plugin: devDependencies: From 68d4e5b75c5f7f2aa4de0fb50a1afdf40790871a Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 18 Sep 2026 10:17:50 +0900 Subject: [PATCH 08/19] feat(ai): add browser MCP tool adapters --- .changeset/ai-gateway-tools.md | 21 ++ docs/api/tailor-mcp.md | 73 ++++++ docs/api/use-ai-chat.md | 17 +- packages/core/src/ai/assistant-loop.ts | 200 ++++++++++----- packages/core/src/ai/mcp.test.ts | 127 ++++++++++ packages/core/src/ai/mcp.ts | 292 ++++++++++++++++++++++ packages/core/src/ai/tool-execution.ts | 70 ++++-- packages/core/src/ai/use-ai-chat.test.tsx | 74 ++++++ packages/core/src/ai/use-ai-chat.ts | 7 +- packages/core/src/index.ts | 9 + 10 files changed, 808 insertions(+), 82 deletions(-) create mode 100644 docs/api/tailor-mcp.md create mode 100644 packages/core/src/ai/mcp.test.ts create mode 100644 packages/core/src/ai/mcp.ts diff --git a/.changeset/ai-gateway-tools.md b/.changeset/ai-gateway-tools.md index 835647cc6..bea37f263 100644 --- a/.changeset/ai-gateway-tools.md +++ b/.changeset/ai-gateway-tools.md @@ -93,3 +93,24 @@ function ChatPanel({ client }) { ``` The hook runs up to 8 tool rounds per user message. If the model keeps requesting tools beyond that limit, the request fails with an error. + +## Tailor remote MCP + +Connect the current Tailor Platform application's remote MCP server with `tailorMCP(...)`. The MCP session runs in the browser through the existing authenticated fetch path; no MCP SDK dependency is added to AppShell. + +```tsx +import { tailorMCP, useAIChat } from "@tailor-platform/app-shell"; + +const chat = useAIChat({ + client, + model: "gpt-5-mini", + mcpServers: { + tailor: { + server: tailorMCP({ authClient }), + allowedTools: ["query"], + }, + }, +}); +``` + +`allowedTools` is required. Start by exposing read-only tools; mutation tools need an explicit approval flow in your application. diff --git a/docs/api/tailor-mcp.md b/docs/api/tailor-mcp.md new file mode 100644 index 000000000..79ed8c9e7 --- /dev/null +++ b/docs/api/tailor-mcp.md @@ -0,0 +1,73 @@ +--- +title: tailorMCP +description: Connect useAIChat to the current Tailor Platform application's remote MCP server +--- + +# tailorMCP + +Creates a remote MCP server descriptor for the current Tailor Platform application. Pass it to `useAIChat({ mcpServers })` to let the model use explicitly allowed MCP tools as the authenticated user. + +## Signature + +```typescript +function tailorMCP(options: { authClient: EnhancedAuthClient }): AIChatMCPServer; +``` + +## Parameters + +### `authClient` + +The AppShell auth client used for authenticated requests. `tailorMCP()` uses `authClient.fetch`, so token refresh and DPoP proof generation remain in the auth layer. + +## Usage + +```tsx +import { + createAIGatewayClient, + createAuthClient, + tailorMCP, + useAIChat, +} from "@tailor-platform/app-shell"; + +const authClient = createAuthClient({ + clientId: "your-client-id", + appUri: "https://your-app.erp.dev", +}); + +const aiClient = createAIGatewayClient({ + gatewayUri: "https://your-ai-gateway.example.com", + authClient, +}); + +function ChatScreen() { + const chat = useAIChat({ + client: aiClient, + model: "gpt-5-mini", + mcpServers: { + tailor: { + server: tailorMCP({ authClient }), + // Names returned by the MCP server's tools/list response. + allowedTools: ["query"], + }, + }, + }); + + return ; +} +``` + +## Notes + +- `allowedTools` is required. Start with read-only tools; do not expose mutation tools until your application has an explicit approval flow. +- AppShell opens the MCP session for one `sendMessage()` call and closes it when that call completes, fails, or is stopped. +- MCP tools are translated into internal function tools. They do not appear in the public `messages` array. +- The MCP endpoint must accept the AppShell origin, authenticated DPoP requests, and the Streamable HTTP headers used by MCP. + +## Custom MCP servers + +AppShell does not depend on an MCP SDK. To connect another server, implement the exported `AIChatMCPServer` interface and pass it through `mcpServers`. Your adapter owns its protocol transport and authentication. + +## Related + +- [useAIChat](./use-ai-chat.md) +- [Authentication](../concepts/authentication.md) diff --git a/docs/api/use-ai-chat.md b/docs/api/use-ai-chat.md index cd3e2e510..d791f8a0e 100644 --- a/docs/api/use-ai-chat.md +++ b/docs/api/use-ai-chat.md @@ -1,11 +1,11 @@ --- title: useAIChat -description: AI Gateway chat hook with optional local and provider tools +description: AI Gateway chat hook with optional local, provider, and MCP tools --- # useAIChat -React hook for AI Gateway chat on top of `createAIGatewayClient`, with optional local and provider tool support. +React hook for AI Gateway chat on top of `createAIGatewayClient`, with optional local, provider, and remote MCP tool support. ## Signature @@ -14,6 +14,7 @@ const useAIChat: (config: { client: AIGatewayClient; model: string; tools?: Record; + mcpServers?: Record; }) => { messages: AIChatMessage[]; status: "ready" | "submitted" | "streaming" | "error"; @@ -70,6 +71,10 @@ Register tools under a single object: - local tools created with `defineAIChatTool(...)`; their schemas must implement both [Standard Schema](https://standardschema.dev/) validation and Standard JSON Schema generation (for example, Zod 4) - provider tools such as `aiProviderTool.openai.webSearch(...)` +### `mcpServers` + +Register remote MCP servers separately from `tools`, since one MCP server discovers and exposes multiple tools. Each entry requires an explicit `allowedTools` list. Use `tailorMCP({ authClient })` for the current Tailor Platform application's `/mcp` endpoint. + ## Usage ```tsx @@ -78,6 +83,7 @@ import { createAuthClient, createAIGatewayClient, defineAIChatTool, + tailorMCP, useAIChat, } from "@tailor-platform/app-shell"; import { z } from "zod/v4"; @@ -110,6 +116,12 @@ export function ChatScreen() { lookupCustomer, web_search: aiProviderTool.openai.webSearch({ searchContextSize: "high" }), }, + mcpServers: { + tailor: { + server: tailorMCP({ authClient }), + allowedTools: ["query"], + }, + }, }); return ( @@ -140,6 +152,7 @@ export function ChatScreen() { - AppShell chooses the appropriate AI Gateway transport automatically - Public messages stay user/assistant text-first; internal tool messages remain private to the hook - Provider tools can attach optional `sources` to assistant messages +- MCP server entries must list every tool they expose; start with read-only tools until your application has an explicit approval flow for mutations - System prompts and custom history shaping should use the low-level client directly - `stop()` keeps any already-streamed assistant text and ignores late chunks from the stopped request diff --git a/packages/core/src/ai/assistant-loop.ts b/packages/core/src/ai/assistant-loop.ts index 5c40441f6..ddc3f7cf3 100644 --- a/packages/core/src/ai/assistant-loop.ts +++ b/packages/core/src/ai/assistant-loop.ts @@ -7,55 +7,128 @@ import type { AIGatewayToolCall, AIChatSource, } from "./client"; -import type { AIChatConfiguredTool, AILocalTool } from "./tools"; -import { deriveVisibleMessages, resolveToolCalls } from "./tool-execution"; +import type { AIChatMCPConnection, AIChatMCPServerConfig, AIChatMCPTool } from "./mcp"; +import type { AIChatConfiguredTool } from "./tools"; +import { deriveVisibleMessages, resolveToolCalls, type AIChatToolExecutor } from "./tool-execution"; const MAX_TOOL_ROUNDS = 8; /** * Splits the public tools object into: * - normalized AI Gateway tool definitions sent over the wire - * - local tool executors kept for in-process validation and execution + * - local and MCP tool executors kept for in-process execution */ -function normalizeConfiguredTools(tools: Record | undefined): { +async function normalizeConfiguredTools(input: { + tools: Record | undefined; + mcpServers: Record | undefined; + signal: AbortSignal; +}): Promise<{ gatewayTools: AIGatewayTool[]; - localTools: Map; -} { - if (!tools) { - return { - gatewayTools: [], - localTools: new Map(), - }; - } + executors: Map; + close: () => Promise; +}> { + const gatewayTools: AIGatewayTool[] = []; + const executors = new Map(); + const connections: AIChatMCPConnection[] = []; - const localTools = new Map(); - const gatewayTools = Object.entries(tools).map(([name, tool]) => { - if (tool.kind === "local") { - localTools.set(name, tool); - return { - type: "function", - function: { + try { + for (const [name, tool] of Object.entries(input.tools ?? {})) { + if (tool.kind === "local") { + addFunctionTool({ name, - ...(tool.description ? { description: tool.description } : {}), + description: tool.description, parameters: tool.schema["~standard"].jsonSchema.input({ target: "draft-07" }), - }, - } satisfies AIGatewayFunctionTool; + executor: { kind: "local", tool }, + gatewayTools, + executors, + }); + continue; + } + + gatewayTools.push({ + type: "provider", + provider: "openai", + name: "web_search", + ...(tool.options ? { options: tool.options } : {}), + } satisfies AIGatewayProviderTool); } - return { - type: "provider", - provider: "openai", - name: "web_search", - ...(tool.options ? { options: tool.options } : {}), - } satisfies AIGatewayProviderTool; - }); + for (const [serverName, config] of Object.entries(input.mcpServers ?? {})) { + if (config.allowedTools.length === 0) { + throw new Error(`MCP server "${serverName}" must specify at least one allowed tool.`); + } + + const connection = await config.server.connect({ signal: input.signal }); + connections.push(connection); + const availableTools = await connection.listTools({ signal: input.signal }); + const selectedTools = selectMCPTools(serverName, config.allowedTools, availableTools); + + for (const tool of selectedTools) { + addFunctionTool({ + name: `${serverName}__${tool.name}`, + description: tool.description, + parameters: tool.inputSchema, + executor: { kind: "mcp", connection, name: tool.name }, + gatewayTools, + executors, + }); + } + } + } catch (error) { + await closeConnections(connections); + throw error; + } return { gatewayTools, - localTools, + executors, + close: () => closeConnections(connections), }; } +function addFunctionTool(input: { + name: string; + description?: string; + parameters: Record; + executor: AIChatToolExecutor; + gatewayTools: AIGatewayTool[]; + executors: Map; +}): void { + if (input.executors.has(input.name)) { + throw new Error(`Duplicate AI chat tool name: ${input.name}`); + } + + input.executors.set(input.name, input.executor); + input.gatewayTools.push({ + type: "function", + function: { + name: input.name, + ...(input.description ? { description: input.description } : {}), + parameters: input.parameters, + }, + } satisfies AIGatewayFunctionTool); +} + +function selectMCPTools( + serverName: string, + allowedTools: readonly string[], + availableTools: AIChatMCPTool[], +): AIChatMCPTool[] { + const availableByName = new Map(availableTools.map((tool) => [tool.name, tool])); + + return allowedTools.map((name) => { + const tool = availableByName.get(name); + if (!tool) { + throw new Error(`MCP server "${serverName}" does not provide allowed tool "${name}".`); + } + return tool; + }); +} + +async function closeConnections(connections: AIChatMCPConnection[]): Promise { + await Promise.allSettled(connections.map((connection) => connection.close())); +} + export interface AIChatAssistantTurnResult { gatewayAssistantMessage: Extract; toolCalls: AIGatewayToolCall[]; @@ -81,46 +154,55 @@ export async function* runAssistantLoop(input: { client: AIGatewayClient; model: string; tools: Record | undefined; + mcpServers: Record | undefined; transcript: AIGatewayChatMessage[]; signal: AbortSignal; }): AsyncGenerator { - const { gatewayTools, localTools } = normalizeConfiguredTools(input.tools); + const resolvedTools = await normalizeConfiguredTools({ + tools: input.tools, + mcpServers: input.mcpServers, + signal: input.signal, + }); const transcript = [...input.transcript]; - for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) { - const turn: AIChatAssistantTurnResult = yield* streamAssistantTurn({ - client: input.client, - model: input.model, - tools: gatewayTools, - transcript, - signal: input.signal, - }); - - if (turn.gatewayAssistantMessage.content || turn.toolCalls.length > 0) { - transcript.push(turn.gatewayAssistantMessage); - } + try { + for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) { + const turn: AIChatAssistantTurnResult = yield* streamAssistantTurn({ + client: input.client, + model: input.model, + tools: resolvedTools.gatewayTools, + transcript, + signal: input.signal, + }); - yield { type: "turn-end", turn }; + if (turn.gatewayAssistantMessage.content || turn.toolCalls.length > 0) { + transcript.push(turn.gatewayAssistantMessage); + } - if (turn.toolCalls.length === 0) { - yield { type: "complete" }; - return; - } + yield { type: "turn-end", turn }; - yield { type: "tool-resolution-start" }; + if (turn.toolCalls.length === 0) { + yield { type: "complete" }; + return; + } - const toolMessages = await resolveToolCalls({ - toolCalls: turn.toolCalls, - localTools, - signal: input.signal, - visibleMessages: deriveVisibleMessages(transcript), - }); + yield { type: "tool-resolution-start" }; - transcript.push(...toolMessages); - yield { type: "tool-results", messages: toolMessages }; - } + const toolMessages = await resolveToolCalls({ + toolCalls: turn.toolCalls, + tools: resolvedTools.executors, + signal: input.signal, + visibleMessages: deriveVisibleMessages(transcript), + }); + + transcript.push(...toolMessages); + yield { type: "tool-results", messages: toolMessages }; + } - throw new Error(`AI chat exceeded the maximum number of tool rounds (${MAX_TOOL_ROUNDS}).`); + throw new Error(`AI chat exceeded the maximum number of tool rounds (${MAX_TOOL_ROUNDS}).`); + } finally { + await resolvedTools.close(); + } } /** diff --git a/packages/core/src/ai/mcp.test.ts b/packages/core/src/ai/mcp.test.ts new file mode 100644 index 000000000..6bccc000e --- /dev/null +++ b/packages/core/src/ai/mcp.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from "vitest"; +import type { EnhancedAuthClient } from "../contexts/auth-context"; +import { tailorMCP } from "./mcp"; + +function createAuthClient( + handler: (request: { + method: string; + body: Record; + headers: Headers; + }) => Response, +): EnhancedAuthClient { + return { + getAppUri: () => "https://app.example.com", + fetch: vi.fn(async (_input, init) => + handler({ + method: init?.method ?? "GET", + body: init?.body ? JSON.parse(String(init.body)) : {}, + headers: new Headers(init?.headers), + }), + ), + } as unknown as EnhancedAuthClient; +} + +describe("tailorMCP", () => { + it("initializes a session and calls tools with authenticated fetch", async () => { + const authClient = createAuthClient(({ method, body, headers }) => { + if (method === "DELETE") { + expect(headers.get("MCP-Session-Id")).toBe("session-1"); + return new Response(null, { status: 204 }); + } + + if (body.method === "initialize") { + expect(headers.get("MCP-Protocol-Version")).toBeNull(); + return jsonResponse( + { jsonrpc: "2.0", id: body.id, result: { protocolVersion: "2025-03-26" } }, + { "MCP-Session-Id": "session-1" }, + ); + } + + expect(headers.get("MCP-Protocol-Version")).toBe("2025-03-26"); + expect(headers.get("MCP-Session-Id")).toBe("session-1"); + + if (body.method === "notifications/initialized") { + return new Response(null, { status: 202 }); + } + + if (body.method === "tools/list") { + return jsonResponse({ + jsonrpc: "2.0", + id: body.id, + result: { + tools: [ + { + name: "query", + description: "Run a read-only query", + inputSchema: { type: "object", properties: { query: { type: "string" } } }, + }, + ], + }, + }); + } + + if (body.method === "tools/call") { + expect(body.params).toEqual({ name: "query", arguments: { query: "query { me { id } }" } }); + return jsonResponse({ + jsonrpc: "2.0", + id: body.id, + result: { content: [{ type: "text", text: '{"id":"user-1"}' }] }, + }); + } + + throw new Error(`Unexpected MCP request: ${String(body.method)}`); + }); + const connection = await tailorMCP({ authClient }).connect({ + signal: new AbortController().signal, + }); + + await expect(connection.listTools({ signal: new AbortController().signal })).resolves.toEqual([ + { + name: "query", + description: "Run a read-only query", + inputSchema: { type: "object", properties: { query: { type: "string" } } }, + }, + ]); + await expect( + connection.callTool({ + name: "query", + arguments: { query: "query { me { id } }" }, + signal: new AbortController().signal, + }), + ).resolves.toEqual({ content: [{ type: "text", text: '{"id":"user-1"}' }] }); + + await connection.close(); + expect(authClient.fetch).toHaveBeenCalledTimes(5); + }); + + it("reads JSON-RPC results from streamable HTTP SSE responses", async () => { + const authClient = createAuthClient(({ body }) => { + if (body.method === "initialize") { + return jsonResponse({ jsonrpc: "2.0", id: body.id, result: {} }); + } + + if (body.method === "notifications/initialized") { + return new Response(null, { status: 202 }); + } + + return new Response( + `event: message\ndata: {"jsonrpc":"2.0","id":${body.id},"result":{"tools":[]}}\n\n`, + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ); + }); + const connection = await tailorMCP({ authClient }).connect({ + signal: new AbortController().signal, + }); + + await expect(connection.listTools({ signal: new AbortController().signal })).resolves.toEqual( + [], + ); + }); +}); + +function jsonResponse(body: unknown, headers?: HeadersInit): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json", ...headers }, + }); +} diff --git a/packages/core/src/ai/mcp.ts b/packages/core/src/ai/mcp.ts new file mode 100644 index 000000000..11375b0db --- /dev/null +++ b/packages/core/src/ai/mcp.ts @@ -0,0 +1,292 @@ +import type { EnhancedAuthClient } from "../contexts/auth-context"; + +const MCP_PROTOCOL_VERSION = "2025-03-26"; + +/** A remote MCP server that can supply tools to an AI chat. */ +export interface AIChatMCPServer { + connect(options: { signal: AbortSignal }): Promise; +} + +/** A single MCP session, owned by one `useAIChat()` request. */ +export interface AIChatMCPConnection { + listTools(options: { signal: AbortSignal }): Promise; + callTool(options: { + name: string; + arguments: Record; + signal: AbortSignal; + }): Promise; + close(): Promise | void; +} + +/** The subset of an MCP tool definition needed by `useAIChat()`. */ +export interface AIChatMCPTool { + name: string; + description?: string; + inputSchema: Record; +} + +/** The subset of an MCP tool result fed back to the model. */ +export interface AIChatMCPToolResult { + content: unknown; + structuredContent?: unknown; + isError?: boolean; +} + +/** Binds an MCP server to one chat and limits the tools exposed to its model. */ +export interface AIChatMCPServerConfig { + server: AIChatMCPServer; + allowedTools: readonly string[]; +} + +/** Options for Tailor Platform's application-scoped remote MCP server. */ +export interface TailorMCPOptions { + /** AppShell auth client used for authenticated MCP requests. */ + authClient: EnhancedAuthClient; +} + +/** + * Creates an MCP server descriptor for the current Tailor Platform application. + * + * The session itself is opened only when `useAIChat()` sends a message. This + * keeps the MCP session scoped to the chat request that owns it. + */ +export function tailorMCP(options: TailorMCPOptions): AIChatMCPServer { + const url = new URL("/mcp", options.authClient.getAppUri()).toString(); + + return { + connect: async ({ signal }) => { + const connection = new TailorMCPConnection(url, options.authClient.fetch); + await connection.initialize(signal); + return connection; + }, + }; +} + +class TailorMCPConnection implements AIChatMCPConnection { + private nextRequestId = 1; + private sessionId: string | undefined; + private protocolVersion = MCP_PROTOCOL_VERSION; + private closed = false; + + constructor( + private readonly url: string, + private readonly fetch: typeof globalThis.fetch, + ) {} + + async initialize(signal: AbortSignal): Promise { + const result = await this.request( + "initialize", + { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { + name: "tailor-app-shell", + version: "1", + }, + }, + signal, + false, + ); + + if (isRecord(result) && typeof result.protocolVersion === "string") { + this.protocolVersion = result.protocolVersion; + } + + await this.notify("notifications/initialized", {}, signal); + } + + async listTools({ signal }: { signal: AbortSignal }): Promise { + const result = await this.request("tools/list", {}, signal); + const tools = isRecord(result) ? result.tools : undefined; + + if (!Array.isArray(tools)) { + throw new Error("Tailor MCP returned an invalid tools/list response."); + } + + return tools.map(toMCPTool); + } + + async callTool({ + name, + arguments: args, + signal, + }: { + name: string; + arguments: Record; + signal: AbortSignal; + }): Promise { + const result = await this.request("tools/call", { name, arguments: args }, signal); + + if (!isRecord(result) || !("content" in result)) { + throw new Error("Tailor MCP returned an invalid tools/call response."); + } + + return { + content: result.content, + ...("structuredContent" in result ? { structuredContent: result.structuredContent } : {}), + ...(result.isError === true ? { isError: true } : {}), + }; + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + if (!this.sessionId) return; + + try { + await this.fetch(this.url, { + method: "DELETE", + headers: this.headers(), + }); + } catch { + // Closing is best effort; the MCP server may not support DELETE. + } + } + + private async request( + method: string, + params: Record, + signal: AbortSignal, + includeProtocolVersion = true, + ): Promise { + if (this.closed) { + throw new Error("Tailor MCP connection is closed."); + } + + const id = this.nextRequestId++; + const response = await this.fetch(this.url, { + method: "POST", + signal, + headers: this.headers(includeProtocolVersion), + body: JSON.stringify({ jsonrpc: "2.0", id, method, params }), + }); + + const sessionId = response.headers.get("MCP-Session-Id"); + if (sessionId) { + this.sessionId = sessionId; + } + + const payload = await readMCPResponse(response, id); + + if (!response.ok) { + throw new Error( + `Tailor MCP request failed (${response.status}): ${getErrorMessage(payload)}`, + ); + } + + if (!isRecord(payload)) { + throw new Error("Tailor MCP returned an invalid JSON-RPC response."); + } + + if ("error" in payload) { + throw new Error(`Tailor MCP error: ${getErrorMessage(payload.error)}`); + } + + if (!("result" in payload)) { + throw new Error("Tailor MCP response did not include a result."); + } + + return payload.result; + } + + private async notify( + method: string, + params: Record, + signal: AbortSignal, + ): Promise { + const response = await this.fetch(this.url, { + method: "POST", + signal, + headers: this.headers(), + body: JSON.stringify({ jsonrpc: "2.0", method, params }), + }); + + if (!response.ok) { + throw new Error(`Tailor MCP notification failed (${response.status}).`); + } + } + + private headers(includeProtocolVersion = true): Headers { + const headers = new Headers({ + Accept: "application/json, text/event-stream", + "Content-Type": "application/json", + }); + + if (includeProtocolVersion) { + headers.set("MCP-Protocol-Version", this.protocolVersion); + } + + if (this.sessionId) { + headers.set("MCP-Session-Id", this.sessionId); + } + + return headers; + } +} + +async function readMCPResponse(response: Response, requestId: number): Promise { + const body = await response.text(); + const contentType = response.headers.get("Content-Type") ?? ""; + + if (contentType.includes("application/json")) { + return parseJSON(body); + } + + if (contentType.includes("text/event-stream")) { + return parseSSE(body, requestId); + } + + return body; +} + +function parseJSON(value: string): unknown { + try { + return JSON.parse(value); + } catch { + throw new Error("Tailor MCP returned invalid JSON."); + } +} + +function parseSSE(value: string, requestId: number): unknown { + // ponytail: consume only the terminal response; use an SDK-backed adapter for resumable streams. + for (const event of value.split(/\r?\n\r?\n/)) { + const data = event + .split(/\r?\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trimStart()) + .join("\n"); + + if (!data) continue; + + const message = parseJSON(data); + if (isRecord(message) && message.id === requestId) { + return message; + } + } + + throw new Error("Tailor MCP SSE response did not include the JSON-RPC result."); +} + +function toMCPTool(value: unknown): AIChatMCPTool { + if (!isRecord(value) || typeof value.name !== "string" || !isRecord(value.inputSchema)) { + throw new Error("Tailor MCP returned an invalid tool definition."); + } + + return { + name: value.name, + ...(typeof value.description === "string" ? { description: value.description } : {}), + inputSchema: value.inputSchema, + }; +} + +function getErrorMessage(value: unknown): string { + if (isRecord(value) && typeof value.message === "string") { + return value.message; + } + + return typeof value === "string" && value ? value : "Unknown error"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/core/src/ai/tool-execution.ts b/packages/core/src/ai/tool-execution.ts index af74c6697..7df92a2ee 100644 --- a/packages/core/src/ai/tool-execution.ts +++ b/packages/core/src/ai/tool-execution.ts @@ -1,14 +1,19 @@ import type { AIGatewayChatMessage, AIGatewayToolCall } from "./client"; import type { AIChatMessage } from "./use-ai-chat"; +import type { AIChatMCPConnection } from "./mcp"; import type { AIChatToolContext, AILocalTool } from "./tools"; /** * Executes requested local tools in call order and converts their outputs into * internal `role: "tool"` transcript messages for the next model round. */ +export type AIChatToolExecutor = + | { kind: "local"; tool: AILocalTool } + | { kind: "mcp"; connection: AIChatMCPConnection; name: string }; + export async function resolveToolCalls(input: { toolCalls: AIGatewayToolCall[]; - localTools: Map; + tools: Map; signal: AbortSignal; visibleMessages: AIChatToolContext["messages"]; }): Promise[]> { @@ -22,7 +27,7 @@ export async function resolveToolCalls(input: { messages.push( await resolveToolCall({ toolCall, - localTools: input.localTools, + tools: input.tools, context: { signal: input.signal, messages: input.visibleMessages, @@ -42,43 +47,55 @@ export async function resolveToolCalls(input: { */ async function resolveToolCall(input: { toolCall: AIGatewayToolCall; - localTools: Map; + tools: Map; context: AIChatToolContext; }): Promise> { - const tool = input.localTools.get(input.toolCall.name); + const executor = input.tools.get(input.toolCall.name); - if (!tool) { - return { - role: "tool", - toolCallId: input.toolCall.id, - content: JSON.stringify({ error: `Unknown tool: ${input.toolCall.name}` }), - }; + if (!executor) { + return toolError(input.toolCall.id, `Unknown tool: ${input.toolCall.name}`); } try { const rawArguments = parseToolArguments(input.toolCall.argumentsText); - const result = await tool.schema["~standard"].validate(rawArguments); - if (result.issues) { + if (executor.kind === "mcp") { + if (!isRecord(rawArguments)) { + return toolError(input.toolCall.id, "MCP tool arguments must be a JSON object."); + } + + const result = await executor.connection.callTool({ + name: executor.name, + arguments: rawArguments, + signal: input.context.signal, + }); return { role: "tool", toolCallId: input.toolCall.id, - content: JSON.stringify({ error: result.issues.map((issue) => issue.message).join("; ") }), + content: JSON.stringify({ + content: result.content, + ...(result.structuredContent !== undefined + ? { structuredContent: result.structuredContent } + : {}), + ...(result.isError ? { isError: true } : {}), + }), }; } - const output = await tool.execute(result.value, input.context); + const result = await executor.tool.schema["~standard"].validate(rawArguments); + + if (result.issues) { + return toolError(input.toolCall.id, result.issues.map((issue) => issue.message).join("; ")); + } + + const output = await executor.tool.execute(result.value, input.context); return { role: "tool", toolCallId: input.toolCall.id, content: stringifyToolResult(output), }; } catch (error) { - return { - role: "tool", - toolCallId: input.toolCall.id, - content: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }), - }; + return toolError(input.toolCall.id, error instanceof Error ? error.message : String(error)); } } @@ -101,11 +118,26 @@ export function deriveVisibleMessages(transcript: AIGatewayChatMessage[]): AICha return result; } +function toolError( + toolCallId: string, + error: string, +): Extract { + return { + role: "tool", + toolCallId, + content: JSON.stringify({ error }), + }; +} + function parseToolArguments(argumentsText: string): unknown { const text = argumentsText.trim(); return text ? JSON.parse(text) : {}; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + function stringifyToolResult(value: unknown): string { if (typeof value === "string") { return value; diff --git a/packages/core/src/ai/use-ai-chat.test.tsx b/packages/core/src/ai/use-ai-chat.test.tsx index 8a02471ca..245ded696 100644 --- a/packages/core/src/ai/use-ai-chat.test.tsx +++ b/packages/core/src/ai/use-ai-chat.test.tsx @@ -345,6 +345,80 @@ describe("useAIChat", () => { expect(client.streamChatCompletion).toHaveBeenCalledTimes(2); }); + it("executes allowed MCP tools and closes their session", async () => { + const connection = { + listTools: vi.fn().mockResolvedValue([ + { + name: "query", + description: "Run a read-only query", + inputSchema: { type: "object", properties: { query: { type: "string" } } }, + }, + ]), + callTool: vi.fn().mockResolvedValue({ + content: [{ type: "text", text: '{"customer":"Acme Corp"}' }], + }), + close: vi.fn().mockResolvedValue(undefined), + }; + const server = { + connect: vi.fn().mockResolvedValue(connection), + }; + const client = { + streamChatCompletion: vi.fn(async function* ({ messages, tools }) { + if (messages.at(-1)?.role === "user") { + expect(tools).toEqual([ + { + type: "function", + function: { + name: "tailor__query", + description: "Run a read-only query", + parameters: { type: "object", properties: { query: { type: "string" } } }, + }, + }, + ]); + yield { + type: "tool-call", + toolCallId: "call-1", + toolName: "tailor__query", + argumentsText: '{"query":"query { customer { name } }"}', + } as const; + yield { type: "done", finishReason: "tool_calls" } as const; + return; + } + + expect(messages.at(-1)).toEqual({ + role: "tool", + toolCallId: "call-1", + content: '{"content":[{"type":"text","text":"{\\"customer\\":\\"Acme Corp\\"}"}]}', + }); + yield { type: "text-delta", text: "Customer: Acme Corp" } as const; + yield { type: "done", finishReason: "stop" } as const; + }), + } satisfies AIGatewayClient; + + const { result } = renderHook(() => + useAIChat({ + client, + model: "gpt-5-mini", + mcpServers: { + tailor: { server, allowedTools: ["query"] }, + }, + }), + ); + + await act(async () => { + await expect(result.current.sendMessage("Find customer")).resolves.toBe(true); + }); + + expect(server.connect).toHaveBeenCalledWith({ signal: expect.any(AbortSignal) }); + expect(connection.listTools).toHaveBeenCalledWith({ signal: expect.any(AbortSignal) }); + expect(connection.callTool).toHaveBeenCalledWith({ + name: "query", + arguments: { query: "query { customer { name } }" }, + signal: expect.any(AbortSignal), + }); + expect(connection.close).toHaveBeenCalledTimes(1); + }); + it("fails after too many tool rounds", async () => { const loop = defineAIChatTool({ description: "Loop forever", diff --git a/packages/core/src/ai/use-ai-chat.ts b/packages/core/src/ai/use-ai-chat.ts index 0f94a2782..4ba3a329d 100644 --- a/packages/core/src/ai/use-ai-chat.ts +++ b/packages/core/src/ai/use-ai-chat.ts @@ -6,6 +6,7 @@ import { type AIChatAssistantTurnResult, type AssistantLoopEvent, } from "./assistant-loop"; +import type { AIChatMCPServerConfig } from "./mcp"; import type { AIChatConfiguredTool } from "./tools"; /** Public chat message shape exposed by `useAIChat()`. */ @@ -67,7 +68,7 @@ class ChatRequestContext { } /** - * React hook for AI Gateway chat with optional local and provider tools. + * React hook for AI Gateway chat with optional local, provider, and MCP tools. * * Public state stays user/assistant text-first while tool-call and tool-result * protocol messages remain internal to the hook. @@ -76,6 +77,7 @@ export function useAIChat(config: { client: AIGatewayClient; model: string; tools?: Record; + mcpServers?: Record; }): { messages: AIChatMessage[]; status: AIChatStatus; @@ -143,6 +145,7 @@ export function useAIChat(config: { client: config.client, model: config.model, tools: config.tools, + mcpServers: config.mcpServers, transcript: ctx.transcript, signal: controller.signal, })) { @@ -171,7 +174,7 @@ export function useAIChat(config: { return false; } }, - [config.client, config.model, config.tools, updateMessages], + [config.client, config.mcpServers, config.model, config.tools, updateMessages], ); return { messages, status, error, sendMessage, stop }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6fc0f8c5d..46c7ccbf5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -77,6 +77,15 @@ export { type AIChatSource, } from "./ai/client"; export { useAIChat, type AIChatMessage, type AIChatStatus } from "./ai/use-ai-chat"; +export { + tailorMCP, + type AIChatMCPServer, + type AIChatMCPConnection, + type AIChatMCPTool, + type AIChatMCPToolResult, + type AIChatMCPServerConfig, + type TailorMCPOptions, +} from "./ai/mcp"; export { defineAIChatTool, aiProviderTool, From d85febb50cc561fe205441eca80e477546d7b349 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 25 Sep 2026 10:21:11 +0900 Subject: [PATCH 09/19] Revert "feat(ai): add browser MCP tool adapters" This reverts commit 68d4e5b75c5f7f2aa4de0fb50a1afdf40790871a. --- .changeset/ai-gateway-tools.md | 21 -- docs/api/tailor-mcp.md | 73 ------ docs/api/use-ai-chat.md | 17 +- packages/core/src/ai/assistant-loop.ts | 200 +++++---------- packages/core/src/ai/mcp.test.ts | 127 ---------- packages/core/src/ai/mcp.ts | 292 ---------------------- packages/core/src/ai/tool-execution.ts | 70 ++---- packages/core/src/ai/use-ai-chat.test.tsx | 74 ------ packages/core/src/ai/use-ai-chat.ts | 7 +- packages/core/src/index.ts | 9 - 10 files changed, 82 insertions(+), 808 deletions(-) delete mode 100644 docs/api/tailor-mcp.md delete mode 100644 packages/core/src/ai/mcp.test.ts delete mode 100644 packages/core/src/ai/mcp.ts diff --git a/.changeset/ai-gateway-tools.md b/.changeset/ai-gateway-tools.md index bea37f263..835647cc6 100644 --- a/.changeset/ai-gateway-tools.md +++ b/.changeset/ai-gateway-tools.md @@ -93,24 +93,3 @@ function ChatPanel({ client }) { ``` The hook runs up to 8 tool rounds per user message. If the model keeps requesting tools beyond that limit, the request fails with an error. - -## Tailor remote MCP - -Connect the current Tailor Platform application's remote MCP server with `tailorMCP(...)`. The MCP session runs in the browser through the existing authenticated fetch path; no MCP SDK dependency is added to AppShell. - -```tsx -import { tailorMCP, useAIChat } from "@tailor-platform/app-shell"; - -const chat = useAIChat({ - client, - model: "gpt-5-mini", - mcpServers: { - tailor: { - server: tailorMCP({ authClient }), - allowedTools: ["query"], - }, - }, -}); -``` - -`allowedTools` is required. Start by exposing read-only tools; mutation tools need an explicit approval flow in your application. diff --git a/docs/api/tailor-mcp.md b/docs/api/tailor-mcp.md deleted file mode 100644 index 79ed8c9e7..000000000 --- a/docs/api/tailor-mcp.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -title: tailorMCP -description: Connect useAIChat to the current Tailor Platform application's remote MCP server ---- - -# tailorMCP - -Creates a remote MCP server descriptor for the current Tailor Platform application. Pass it to `useAIChat({ mcpServers })` to let the model use explicitly allowed MCP tools as the authenticated user. - -## Signature - -```typescript -function tailorMCP(options: { authClient: EnhancedAuthClient }): AIChatMCPServer; -``` - -## Parameters - -### `authClient` - -The AppShell auth client used for authenticated requests. `tailorMCP()` uses `authClient.fetch`, so token refresh and DPoP proof generation remain in the auth layer. - -## Usage - -```tsx -import { - createAIGatewayClient, - createAuthClient, - tailorMCP, - useAIChat, -} from "@tailor-platform/app-shell"; - -const authClient = createAuthClient({ - clientId: "your-client-id", - appUri: "https://your-app.erp.dev", -}); - -const aiClient = createAIGatewayClient({ - gatewayUri: "https://your-ai-gateway.example.com", - authClient, -}); - -function ChatScreen() { - const chat = useAIChat({ - client: aiClient, - model: "gpt-5-mini", - mcpServers: { - tailor: { - server: tailorMCP({ authClient }), - // Names returned by the MCP server's tools/list response. - allowedTools: ["query"], - }, - }, - }); - - return ; -} -``` - -## Notes - -- `allowedTools` is required. Start with read-only tools; do not expose mutation tools until your application has an explicit approval flow. -- AppShell opens the MCP session for one `sendMessage()` call and closes it when that call completes, fails, or is stopped. -- MCP tools are translated into internal function tools. They do not appear in the public `messages` array. -- The MCP endpoint must accept the AppShell origin, authenticated DPoP requests, and the Streamable HTTP headers used by MCP. - -## Custom MCP servers - -AppShell does not depend on an MCP SDK. To connect another server, implement the exported `AIChatMCPServer` interface and pass it through `mcpServers`. Your adapter owns its protocol transport and authentication. - -## Related - -- [useAIChat](./use-ai-chat.md) -- [Authentication](../concepts/authentication.md) diff --git a/docs/api/use-ai-chat.md b/docs/api/use-ai-chat.md index d791f8a0e..cd3e2e510 100644 --- a/docs/api/use-ai-chat.md +++ b/docs/api/use-ai-chat.md @@ -1,11 +1,11 @@ --- title: useAIChat -description: AI Gateway chat hook with optional local, provider, and MCP tools +description: AI Gateway chat hook with optional local and provider tools --- # useAIChat -React hook for AI Gateway chat on top of `createAIGatewayClient`, with optional local, provider, and remote MCP tool support. +React hook for AI Gateway chat on top of `createAIGatewayClient`, with optional local and provider tool support. ## Signature @@ -14,7 +14,6 @@ const useAIChat: (config: { client: AIGatewayClient; model: string; tools?: Record; - mcpServers?: Record; }) => { messages: AIChatMessage[]; status: "ready" | "submitted" | "streaming" | "error"; @@ -71,10 +70,6 @@ Register tools under a single object: - local tools created with `defineAIChatTool(...)`; their schemas must implement both [Standard Schema](https://standardschema.dev/) validation and Standard JSON Schema generation (for example, Zod 4) - provider tools such as `aiProviderTool.openai.webSearch(...)` -### `mcpServers` - -Register remote MCP servers separately from `tools`, since one MCP server discovers and exposes multiple tools. Each entry requires an explicit `allowedTools` list. Use `tailorMCP({ authClient })` for the current Tailor Platform application's `/mcp` endpoint. - ## Usage ```tsx @@ -83,7 +78,6 @@ import { createAuthClient, createAIGatewayClient, defineAIChatTool, - tailorMCP, useAIChat, } from "@tailor-platform/app-shell"; import { z } from "zod/v4"; @@ -116,12 +110,6 @@ export function ChatScreen() { lookupCustomer, web_search: aiProviderTool.openai.webSearch({ searchContextSize: "high" }), }, - mcpServers: { - tailor: { - server: tailorMCP({ authClient }), - allowedTools: ["query"], - }, - }, }); return ( @@ -152,7 +140,6 @@ export function ChatScreen() { - AppShell chooses the appropriate AI Gateway transport automatically - Public messages stay user/assistant text-first; internal tool messages remain private to the hook - Provider tools can attach optional `sources` to assistant messages -- MCP server entries must list every tool they expose; start with read-only tools until your application has an explicit approval flow for mutations - System prompts and custom history shaping should use the low-level client directly - `stop()` keeps any already-streamed assistant text and ignores late chunks from the stopped request diff --git a/packages/core/src/ai/assistant-loop.ts b/packages/core/src/ai/assistant-loop.ts index ddc3f7cf3..5c40441f6 100644 --- a/packages/core/src/ai/assistant-loop.ts +++ b/packages/core/src/ai/assistant-loop.ts @@ -7,128 +7,55 @@ import type { AIGatewayToolCall, AIChatSource, } from "./client"; -import type { AIChatMCPConnection, AIChatMCPServerConfig, AIChatMCPTool } from "./mcp"; -import type { AIChatConfiguredTool } from "./tools"; -import { deriveVisibleMessages, resolveToolCalls, type AIChatToolExecutor } from "./tool-execution"; +import type { AIChatConfiguredTool, AILocalTool } from "./tools"; +import { deriveVisibleMessages, resolveToolCalls } from "./tool-execution"; const MAX_TOOL_ROUNDS = 8; /** * Splits the public tools object into: * - normalized AI Gateway tool definitions sent over the wire - * - local and MCP tool executors kept for in-process execution + * - local tool executors kept for in-process validation and execution */ -async function normalizeConfiguredTools(input: { - tools: Record | undefined; - mcpServers: Record | undefined; - signal: AbortSignal; -}): Promise<{ +function normalizeConfiguredTools(tools: Record | undefined): { gatewayTools: AIGatewayTool[]; - executors: Map; - close: () => Promise; -}> { - const gatewayTools: AIGatewayTool[] = []; - const executors = new Map(); - const connections: AIChatMCPConnection[] = []; + localTools: Map; +} { + if (!tools) { + return { + gatewayTools: [], + localTools: new Map(), + }; + } - try { - for (const [name, tool] of Object.entries(input.tools ?? {})) { - if (tool.kind === "local") { - addFunctionTool({ + const localTools = new Map(); + const gatewayTools = Object.entries(tools).map(([name, tool]) => { + if (tool.kind === "local") { + localTools.set(name, tool); + return { + type: "function", + function: { name, - description: tool.description, + ...(tool.description ? { description: tool.description } : {}), parameters: tool.schema["~standard"].jsonSchema.input({ target: "draft-07" }), - executor: { kind: "local", tool }, - gatewayTools, - executors, - }); - continue; - } - - gatewayTools.push({ - type: "provider", - provider: "openai", - name: "web_search", - ...(tool.options ? { options: tool.options } : {}), - } satisfies AIGatewayProviderTool); + }, + } satisfies AIGatewayFunctionTool; } - for (const [serverName, config] of Object.entries(input.mcpServers ?? {})) { - if (config.allowedTools.length === 0) { - throw new Error(`MCP server "${serverName}" must specify at least one allowed tool.`); - } - - const connection = await config.server.connect({ signal: input.signal }); - connections.push(connection); - const availableTools = await connection.listTools({ signal: input.signal }); - const selectedTools = selectMCPTools(serverName, config.allowedTools, availableTools); - - for (const tool of selectedTools) { - addFunctionTool({ - name: `${serverName}__${tool.name}`, - description: tool.description, - parameters: tool.inputSchema, - executor: { kind: "mcp", connection, name: tool.name }, - gatewayTools, - executors, - }); - } - } - } catch (error) { - await closeConnections(connections); - throw error; - } + return { + type: "provider", + provider: "openai", + name: "web_search", + ...(tool.options ? { options: tool.options } : {}), + } satisfies AIGatewayProviderTool; + }); return { gatewayTools, - executors, - close: () => closeConnections(connections), + localTools, }; } -function addFunctionTool(input: { - name: string; - description?: string; - parameters: Record; - executor: AIChatToolExecutor; - gatewayTools: AIGatewayTool[]; - executors: Map; -}): void { - if (input.executors.has(input.name)) { - throw new Error(`Duplicate AI chat tool name: ${input.name}`); - } - - input.executors.set(input.name, input.executor); - input.gatewayTools.push({ - type: "function", - function: { - name: input.name, - ...(input.description ? { description: input.description } : {}), - parameters: input.parameters, - }, - } satisfies AIGatewayFunctionTool); -} - -function selectMCPTools( - serverName: string, - allowedTools: readonly string[], - availableTools: AIChatMCPTool[], -): AIChatMCPTool[] { - const availableByName = new Map(availableTools.map((tool) => [tool.name, tool])); - - return allowedTools.map((name) => { - const tool = availableByName.get(name); - if (!tool) { - throw new Error(`MCP server "${serverName}" does not provide allowed tool "${name}".`); - } - return tool; - }); -} - -async function closeConnections(connections: AIChatMCPConnection[]): Promise { - await Promise.allSettled(connections.map((connection) => connection.close())); -} - export interface AIChatAssistantTurnResult { gatewayAssistantMessage: Extract; toolCalls: AIGatewayToolCall[]; @@ -154,55 +81,46 @@ export async function* runAssistantLoop(input: { client: AIGatewayClient; model: string; tools: Record | undefined; - mcpServers: Record | undefined; transcript: AIGatewayChatMessage[]; signal: AbortSignal; }): AsyncGenerator { - const resolvedTools = await normalizeConfiguredTools({ - tools: input.tools, - mcpServers: input.mcpServers, - signal: input.signal, - }); + const { gatewayTools, localTools } = normalizeConfiguredTools(input.tools); const transcript = [...input.transcript]; - try { - for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) { - const turn: AIChatAssistantTurnResult = yield* streamAssistantTurn({ - client: input.client, - model: input.model, - tools: resolvedTools.gatewayTools, - transcript, - signal: input.signal, - }); - - if (turn.gatewayAssistantMessage.content || turn.toolCalls.length > 0) { - transcript.push(turn.gatewayAssistantMessage); - } - - yield { type: "turn-end", turn }; + for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) { + const turn: AIChatAssistantTurnResult = yield* streamAssistantTurn({ + client: input.client, + model: input.model, + tools: gatewayTools, + transcript, + signal: input.signal, + }); + + if (turn.gatewayAssistantMessage.content || turn.toolCalls.length > 0) { + transcript.push(turn.gatewayAssistantMessage); + } - if (turn.toolCalls.length === 0) { - yield { type: "complete" }; - return; - } + yield { type: "turn-end", turn }; - yield { type: "tool-resolution-start" }; + if (turn.toolCalls.length === 0) { + yield { type: "complete" }; + return; + } - const toolMessages = await resolveToolCalls({ - toolCalls: turn.toolCalls, - tools: resolvedTools.executors, - signal: input.signal, - visibleMessages: deriveVisibleMessages(transcript), - }); + yield { type: "tool-resolution-start" }; - transcript.push(...toolMessages); - yield { type: "tool-results", messages: toolMessages }; - } + const toolMessages = await resolveToolCalls({ + toolCalls: turn.toolCalls, + localTools, + signal: input.signal, + visibleMessages: deriveVisibleMessages(transcript), + }); - throw new Error(`AI chat exceeded the maximum number of tool rounds (${MAX_TOOL_ROUNDS}).`); - } finally { - await resolvedTools.close(); + transcript.push(...toolMessages); + yield { type: "tool-results", messages: toolMessages }; } + + throw new Error(`AI chat exceeded the maximum number of tool rounds (${MAX_TOOL_ROUNDS}).`); } /** diff --git a/packages/core/src/ai/mcp.test.ts b/packages/core/src/ai/mcp.test.ts deleted file mode 100644 index 6bccc000e..000000000 --- a/packages/core/src/ai/mcp.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { EnhancedAuthClient } from "../contexts/auth-context"; -import { tailorMCP } from "./mcp"; - -function createAuthClient( - handler: (request: { - method: string; - body: Record; - headers: Headers; - }) => Response, -): EnhancedAuthClient { - return { - getAppUri: () => "https://app.example.com", - fetch: vi.fn(async (_input, init) => - handler({ - method: init?.method ?? "GET", - body: init?.body ? JSON.parse(String(init.body)) : {}, - headers: new Headers(init?.headers), - }), - ), - } as unknown as EnhancedAuthClient; -} - -describe("tailorMCP", () => { - it("initializes a session and calls tools with authenticated fetch", async () => { - const authClient = createAuthClient(({ method, body, headers }) => { - if (method === "DELETE") { - expect(headers.get("MCP-Session-Id")).toBe("session-1"); - return new Response(null, { status: 204 }); - } - - if (body.method === "initialize") { - expect(headers.get("MCP-Protocol-Version")).toBeNull(); - return jsonResponse( - { jsonrpc: "2.0", id: body.id, result: { protocolVersion: "2025-03-26" } }, - { "MCP-Session-Id": "session-1" }, - ); - } - - expect(headers.get("MCP-Protocol-Version")).toBe("2025-03-26"); - expect(headers.get("MCP-Session-Id")).toBe("session-1"); - - if (body.method === "notifications/initialized") { - return new Response(null, { status: 202 }); - } - - if (body.method === "tools/list") { - return jsonResponse({ - jsonrpc: "2.0", - id: body.id, - result: { - tools: [ - { - name: "query", - description: "Run a read-only query", - inputSchema: { type: "object", properties: { query: { type: "string" } } }, - }, - ], - }, - }); - } - - if (body.method === "tools/call") { - expect(body.params).toEqual({ name: "query", arguments: { query: "query { me { id } }" } }); - return jsonResponse({ - jsonrpc: "2.0", - id: body.id, - result: { content: [{ type: "text", text: '{"id":"user-1"}' }] }, - }); - } - - throw new Error(`Unexpected MCP request: ${String(body.method)}`); - }); - const connection = await tailorMCP({ authClient }).connect({ - signal: new AbortController().signal, - }); - - await expect(connection.listTools({ signal: new AbortController().signal })).resolves.toEqual([ - { - name: "query", - description: "Run a read-only query", - inputSchema: { type: "object", properties: { query: { type: "string" } } }, - }, - ]); - await expect( - connection.callTool({ - name: "query", - arguments: { query: "query { me { id } }" }, - signal: new AbortController().signal, - }), - ).resolves.toEqual({ content: [{ type: "text", text: '{"id":"user-1"}' }] }); - - await connection.close(); - expect(authClient.fetch).toHaveBeenCalledTimes(5); - }); - - it("reads JSON-RPC results from streamable HTTP SSE responses", async () => { - const authClient = createAuthClient(({ body }) => { - if (body.method === "initialize") { - return jsonResponse({ jsonrpc: "2.0", id: body.id, result: {} }); - } - - if (body.method === "notifications/initialized") { - return new Response(null, { status: 202 }); - } - - return new Response( - `event: message\ndata: {"jsonrpc":"2.0","id":${body.id},"result":{"tools":[]}}\n\n`, - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ); - }); - const connection = await tailorMCP({ authClient }).connect({ - signal: new AbortController().signal, - }); - - await expect(connection.listTools({ signal: new AbortController().signal })).resolves.toEqual( - [], - ); - }); -}); - -function jsonResponse(body: unknown, headers?: HeadersInit): Response { - return new Response(JSON.stringify(body), { - status: 200, - headers: { "Content-Type": "application/json", ...headers }, - }); -} diff --git a/packages/core/src/ai/mcp.ts b/packages/core/src/ai/mcp.ts deleted file mode 100644 index 11375b0db..000000000 --- a/packages/core/src/ai/mcp.ts +++ /dev/null @@ -1,292 +0,0 @@ -import type { EnhancedAuthClient } from "../contexts/auth-context"; - -const MCP_PROTOCOL_VERSION = "2025-03-26"; - -/** A remote MCP server that can supply tools to an AI chat. */ -export interface AIChatMCPServer { - connect(options: { signal: AbortSignal }): Promise; -} - -/** A single MCP session, owned by one `useAIChat()` request. */ -export interface AIChatMCPConnection { - listTools(options: { signal: AbortSignal }): Promise; - callTool(options: { - name: string; - arguments: Record; - signal: AbortSignal; - }): Promise; - close(): Promise | void; -} - -/** The subset of an MCP tool definition needed by `useAIChat()`. */ -export interface AIChatMCPTool { - name: string; - description?: string; - inputSchema: Record; -} - -/** The subset of an MCP tool result fed back to the model. */ -export interface AIChatMCPToolResult { - content: unknown; - structuredContent?: unknown; - isError?: boolean; -} - -/** Binds an MCP server to one chat and limits the tools exposed to its model. */ -export interface AIChatMCPServerConfig { - server: AIChatMCPServer; - allowedTools: readonly string[]; -} - -/** Options for Tailor Platform's application-scoped remote MCP server. */ -export interface TailorMCPOptions { - /** AppShell auth client used for authenticated MCP requests. */ - authClient: EnhancedAuthClient; -} - -/** - * Creates an MCP server descriptor for the current Tailor Platform application. - * - * The session itself is opened only when `useAIChat()` sends a message. This - * keeps the MCP session scoped to the chat request that owns it. - */ -export function tailorMCP(options: TailorMCPOptions): AIChatMCPServer { - const url = new URL("/mcp", options.authClient.getAppUri()).toString(); - - return { - connect: async ({ signal }) => { - const connection = new TailorMCPConnection(url, options.authClient.fetch); - await connection.initialize(signal); - return connection; - }, - }; -} - -class TailorMCPConnection implements AIChatMCPConnection { - private nextRequestId = 1; - private sessionId: string | undefined; - private protocolVersion = MCP_PROTOCOL_VERSION; - private closed = false; - - constructor( - private readonly url: string, - private readonly fetch: typeof globalThis.fetch, - ) {} - - async initialize(signal: AbortSignal): Promise { - const result = await this.request( - "initialize", - { - protocolVersion: MCP_PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { - name: "tailor-app-shell", - version: "1", - }, - }, - signal, - false, - ); - - if (isRecord(result) && typeof result.protocolVersion === "string") { - this.protocolVersion = result.protocolVersion; - } - - await this.notify("notifications/initialized", {}, signal); - } - - async listTools({ signal }: { signal: AbortSignal }): Promise { - const result = await this.request("tools/list", {}, signal); - const tools = isRecord(result) ? result.tools : undefined; - - if (!Array.isArray(tools)) { - throw new Error("Tailor MCP returned an invalid tools/list response."); - } - - return tools.map(toMCPTool); - } - - async callTool({ - name, - arguments: args, - signal, - }: { - name: string; - arguments: Record; - signal: AbortSignal; - }): Promise { - const result = await this.request("tools/call", { name, arguments: args }, signal); - - if (!isRecord(result) || !("content" in result)) { - throw new Error("Tailor MCP returned an invalid tools/call response."); - } - - return { - content: result.content, - ...("structuredContent" in result ? { structuredContent: result.structuredContent } : {}), - ...(result.isError === true ? { isError: true } : {}), - }; - } - - async close(): Promise { - if (this.closed) return; - this.closed = true; - if (!this.sessionId) return; - - try { - await this.fetch(this.url, { - method: "DELETE", - headers: this.headers(), - }); - } catch { - // Closing is best effort; the MCP server may not support DELETE. - } - } - - private async request( - method: string, - params: Record, - signal: AbortSignal, - includeProtocolVersion = true, - ): Promise { - if (this.closed) { - throw new Error("Tailor MCP connection is closed."); - } - - const id = this.nextRequestId++; - const response = await this.fetch(this.url, { - method: "POST", - signal, - headers: this.headers(includeProtocolVersion), - body: JSON.stringify({ jsonrpc: "2.0", id, method, params }), - }); - - const sessionId = response.headers.get("MCP-Session-Id"); - if (sessionId) { - this.sessionId = sessionId; - } - - const payload = await readMCPResponse(response, id); - - if (!response.ok) { - throw new Error( - `Tailor MCP request failed (${response.status}): ${getErrorMessage(payload)}`, - ); - } - - if (!isRecord(payload)) { - throw new Error("Tailor MCP returned an invalid JSON-RPC response."); - } - - if ("error" in payload) { - throw new Error(`Tailor MCP error: ${getErrorMessage(payload.error)}`); - } - - if (!("result" in payload)) { - throw new Error("Tailor MCP response did not include a result."); - } - - return payload.result; - } - - private async notify( - method: string, - params: Record, - signal: AbortSignal, - ): Promise { - const response = await this.fetch(this.url, { - method: "POST", - signal, - headers: this.headers(), - body: JSON.stringify({ jsonrpc: "2.0", method, params }), - }); - - if (!response.ok) { - throw new Error(`Tailor MCP notification failed (${response.status}).`); - } - } - - private headers(includeProtocolVersion = true): Headers { - const headers = new Headers({ - Accept: "application/json, text/event-stream", - "Content-Type": "application/json", - }); - - if (includeProtocolVersion) { - headers.set("MCP-Protocol-Version", this.protocolVersion); - } - - if (this.sessionId) { - headers.set("MCP-Session-Id", this.sessionId); - } - - return headers; - } -} - -async function readMCPResponse(response: Response, requestId: number): Promise { - const body = await response.text(); - const contentType = response.headers.get("Content-Type") ?? ""; - - if (contentType.includes("application/json")) { - return parseJSON(body); - } - - if (contentType.includes("text/event-stream")) { - return parseSSE(body, requestId); - } - - return body; -} - -function parseJSON(value: string): unknown { - try { - return JSON.parse(value); - } catch { - throw new Error("Tailor MCP returned invalid JSON."); - } -} - -function parseSSE(value: string, requestId: number): unknown { - // ponytail: consume only the terminal response; use an SDK-backed adapter for resumable streams. - for (const event of value.split(/\r?\n\r?\n/)) { - const data = event - .split(/\r?\n/) - .filter((line) => line.startsWith("data:")) - .map((line) => line.slice(5).trimStart()) - .join("\n"); - - if (!data) continue; - - const message = parseJSON(data); - if (isRecord(message) && message.id === requestId) { - return message; - } - } - - throw new Error("Tailor MCP SSE response did not include the JSON-RPC result."); -} - -function toMCPTool(value: unknown): AIChatMCPTool { - if (!isRecord(value) || typeof value.name !== "string" || !isRecord(value.inputSchema)) { - throw new Error("Tailor MCP returned an invalid tool definition."); - } - - return { - name: value.name, - ...(typeof value.description === "string" ? { description: value.description } : {}), - inputSchema: value.inputSchema, - }; -} - -function getErrorMessage(value: unknown): string { - if (isRecord(value) && typeof value.message === "string") { - return value.message; - } - - return typeof value === "string" && value ? value : "Unknown error"; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/packages/core/src/ai/tool-execution.ts b/packages/core/src/ai/tool-execution.ts index 7df92a2ee..af74c6697 100644 --- a/packages/core/src/ai/tool-execution.ts +++ b/packages/core/src/ai/tool-execution.ts @@ -1,19 +1,14 @@ import type { AIGatewayChatMessage, AIGatewayToolCall } from "./client"; import type { AIChatMessage } from "./use-ai-chat"; -import type { AIChatMCPConnection } from "./mcp"; import type { AIChatToolContext, AILocalTool } from "./tools"; /** * Executes requested local tools in call order and converts their outputs into * internal `role: "tool"` transcript messages for the next model round. */ -export type AIChatToolExecutor = - | { kind: "local"; tool: AILocalTool } - | { kind: "mcp"; connection: AIChatMCPConnection; name: string }; - export async function resolveToolCalls(input: { toolCalls: AIGatewayToolCall[]; - tools: Map; + localTools: Map; signal: AbortSignal; visibleMessages: AIChatToolContext["messages"]; }): Promise[]> { @@ -27,7 +22,7 @@ export async function resolveToolCalls(input: { messages.push( await resolveToolCall({ toolCall, - tools: input.tools, + localTools: input.localTools, context: { signal: input.signal, messages: input.visibleMessages, @@ -47,55 +42,43 @@ export async function resolveToolCalls(input: { */ async function resolveToolCall(input: { toolCall: AIGatewayToolCall; - tools: Map; + localTools: Map; context: AIChatToolContext; }): Promise> { - const executor = input.tools.get(input.toolCall.name); + const tool = input.localTools.get(input.toolCall.name); - if (!executor) { - return toolError(input.toolCall.id, `Unknown tool: ${input.toolCall.name}`); + if (!tool) { + return { + role: "tool", + toolCallId: input.toolCall.id, + content: JSON.stringify({ error: `Unknown tool: ${input.toolCall.name}` }), + }; } try { const rawArguments = parseToolArguments(input.toolCall.argumentsText); + const result = await tool.schema["~standard"].validate(rawArguments); - if (executor.kind === "mcp") { - if (!isRecord(rawArguments)) { - return toolError(input.toolCall.id, "MCP tool arguments must be a JSON object."); - } - - const result = await executor.connection.callTool({ - name: executor.name, - arguments: rawArguments, - signal: input.context.signal, - }); + if (result.issues) { return { role: "tool", toolCallId: input.toolCall.id, - content: JSON.stringify({ - content: result.content, - ...(result.structuredContent !== undefined - ? { structuredContent: result.structuredContent } - : {}), - ...(result.isError ? { isError: true } : {}), - }), + content: JSON.stringify({ error: result.issues.map((issue) => issue.message).join("; ") }), }; } - const result = await executor.tool.schema["~standard"].validate(rawArguments); - - if (result.issues) { - return toolError(input.toolCall.id, result.issues.map((issue) => issue.message).join("; ")); - } - - const output = await executor.tool.execute(result.value, input.context); + const output = await tool.execute(result.value, input.context); return { role: "tool", toolCallId: input.toolCall.id, content: stringifyToolResult(output), }; } catch (error) { - return toolError(input.toolCall.id, error instanceof Error ? error.message : String(error)); + return { + role: "tool", + toolCallId: input.toolCall.id, + content: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }), + }; } } @@ -118,26 +101,11 @@ export function deriveVisibleMessages(transcript: AIGatewayChatMessage[]): AICha return result; } -function toolError( - toolCallId: string, - error: string, -): Extract { - return { - role: "tool", - toolCallId, - content: JSON.stringify({ error }), - }; -} - function parseToolArguments(argumentsText: string): unknown { const text = argumentsText.trim(); return text ? JSON.parse(text) : {}; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function stringifyToolResult(value: unknown): string { if (typeof value === "string") { return value; diff --git a/packages/core/src/ai/use-ai-chat.test.tsx b/packages/core/src/ai/use-ai-chat.test.tsx index 245ded696..8a02471ca 100644 --- a/packages/core/src/ai/use-ai-chat.test.tsx +++ b/packages/core/src/ai/use-ai-chat.test.tsx @@ -345,80 +345,6 @@ describe("useAIChat", () => { expect(client.streamChatCompletion).toHaveBeenCalledTimes(2); }); - it("executes allowed MCP tools and closes their session", async () => { - const connection = { - listTools: vi.fn().mockResolvedValue([ - { - name: "query", - description: "Run a read-only query", - inputSchema: { type: "object", properties: { query: { type: "string" } } }, - }, - ]), - callTool: vi.fn().mockResolvedValue({ - content: [{ type: "text", text: '{"customer":"Acme Corp"}' }], - }), - close: vi.fn().mockResolvedValue(undefined), - }; - const server = { - connect: vi.fn().mockResolvedValue(connection), - }; - const client = { - streamChatCompletion: vi.fn(async function* ({ messages, tools }) { - if (messages.at(-1)?.role === "user") { - expect(tools).toEqual([ - { - type: "function", - function: { - name: "tailor__query", - description: "Run a read-only query", - parameters: { type: "object", properties: { query: { type: "string" } } }, - }, - }, - ]); - yield { - type: "tool-call", - toolCallId: "call-1", - toolName: "tailor__query", - argumentsText: '{"query":"query { customer { name } }"}', - } as const; - yield { type: "done", finishReason: "tool_calls" } as const; - return; - } - - expect(messages.at(-1)).toEqual({ - role: "tool", - toolCallId: "call-1", - content: '{"content":[{"type":"text","text":"{\\"customer\\":\\"Acme Corp\\"}"}]}', - }); - yield { type: "text-delta", text: "Customer: Acme Corp" } as const; - yield { type: "done", finishReason: "stop" } as const; - }), - } satisfies AIGatewayClient; - - const { result } = renderHook(() => - useAIChat({ - client, - model: "gpt-5-mini", - mcpServers: { - tailor: { server, allowedTools: ["query"] }, - }, - }), - ); - - await act(async () => { - await expect(result.current.sendMessage("Find customer")).resolves.toBe(true); - }); - - expect(server.connect).toHaveBeenCalledWith({ signal: expect.any(AbortSignal) }); - expect(connection.listTools).toHaveBeenCalledWith({ signal: expect.any(AbortSignal) }); - expect(connection.callTool).toHaveBeenCalledWith({ - name: "query", - arguments: { query: "query { customer { name } }" }, - signal: expect.any(AbortSignal), - }); - expect(connection.close).toHaveBeenCalledTimes(1); - }); - it("fails after too many tool rounds", async () => { const loop = defineAIChatTool({ description: "Loop forever", diff --git a/packages/core/src/ai/use-ai-chat.ts b/packages/core/src/ai/use-ai-chat.ts index 4ba3a329d..0f94a2782 100644 --- a/packages/core/src/ai/use-ai-chat.ts +++ b/packages/core/src/ai/use-ai-chat.ts @@ -6,7 +6,6 @@ import { type AIChatAssistantTurnResult, type AssistantLoopEvent, } from "./assistant-loop"; -import type { AIChatMCPServerConfig } from "./mcp"; import type { AIChatConfiguredTool } from "./tools"; /** Public chat message shape exposed by `useAIChat()`. */ @@ -68,7 +67,7 @@ class ChatRequestContext { } /** - * React hook for AI Gateway chat with optional local, provider, and MCP tools. + * React hook for AI Gateway chat with optional local and provider tools. * * Public state stays user/assistant text-first while tool-call and tool-result * protocol messages remain internal to the hook. @@ -77,7 +76,6 @@ export function useAIChat(config: { client: AIGatewayClient; model: string; tools?: Record; - mcpServers?: Record; }): { messages: AIChatMessage[]; status: AIChatStatus; @@ -145,7 +143,6 @@ export function useAIChat(config: { client: config.client, model: config.model, tools: config.tools, - mcpServers: config.mcpServers, transcript: ctx.transcript, signal: controller.signal, })) { @@ -174,7 +171,7 @@ export function useAIChat(config: { return false; } }, - [config.client, config.mcpServers, config.model, config.tools, updateMessages], + [config.client, config.model, config.tools, updateMessages], ); return { messages, status, error, sendMessage, stop }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 46c7ccbf5..6fc0f8c5d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -77,15 +77,6 @@ export { type AIChatSource, } from "./ai/client"; export { useAIChat, type AIChatMessage, type AIChatStatus } from "./ai/use-ai-chat"; -export { - tailorMCP, - type AIChatMCPServer, - type AIChatMCPConnection, - type AIChatMCPTool, - type AIChatMCPToolResult, - type AIChatMCPServerConfig, - type TailorMCPOptions, -} from "./ai/mcp"; export { defineAIChatTool, aiProviderTool, From 0100b5dc5eee9cf96f6ea630d9011ff857cdbd62 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:05:04 +0000 Subject: [PATCH 10/19] Fix documentation formatting check Co-authored-by: IzumiSy <982850+IzumiSy@users.noreply.github.com> --- decisions/documentation-management-overhaul.md | 1 + 1 file changed, 1 insertion(+) diff --git a/decisions/documentation-management-overhaul.md b/decisions/documentation-management-overhaul.md index 424776439..206443862 100644 --- a/decisions/documentation-management-overhaul.md +++ b/decisions/documentation-management-overhaul.md @@ -83,6 +83,7 @@ Pattern and page units additionally declare the presentation metadata the genera **Rule 9 is advisory, not blocking,** because claiming by name is sometimes the lesser evil. `BadgeVariant` and `BadgeOptions` are declared in `components/badge-list/`; widening the badge unit's glob to reach them would drag in `BadgeList`'s whole surface too. That trade is a judgement call — it just shouldn't pass unremarked. Declaring `kind` also puts a consequential change where review can see it. A unit that stops being hashed shows up as `- kind: code-backed` / `+ kind: prose` in an authored file's diff, rather than as a `typeSurface` quietly going `null` inside an 83-unit JSON blob. + --- ## Workflow From d078fc3dc14d23696d0274e3119465992ffa0567 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:35:51 +0000 Subject: [PATCH 11/19] Remove unrelated decision file from PR diff Co-authored-by: IzumiSy <982850+IzumiSy@users.noreply.github.com> --- decisions/documentation-management-overhaul.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/decisions/documentation-management-overhaul.md b/decisions/documentation-management-overhaul.md index 206443862..c258ebbd8 100644 --- a/decisions/documentation-management-overhaul.md +++ b/decisions/documentation-management-overhaul.md @@ -83,6 +83,21 @@ Pattern and page units additionally declare the presentation metadata the genera **Rule 9 is advisory, not blocking,** because claiming by name is sometimes the lesser evil. `BadgeVariant` and `BadgeOptions` are declared in `components/badge-list/`; widening the badge unit's glob to reach them would drag in `BadgeList`'s whole surface too. That trade is a judgement call — it just shouldn't pass unremarked. Declaring `kind` also puts a consequential change where review can see it. A unit that stops being hashed shows up as `- kind: code-backed` / `+ kind: prose` in an authored file's diff, rather than as a `typeSurface` quietly going `null` inside an 83-unit JSON blob. +||||||| BASE +| Dimension | Decision | +| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Doc inclusion signal** | **Explicit**: a unit exists because a `*.docs.outline.md` claims it. Reconciled against `index.ts` (see Coverage below). **Folders are NOT the signal** — folderization is a free code-organization choice, decoupled from docs. | +| **Scope** | Public surface (exported from `index.ts`). Internal-only files need no outline and may be folder-grouped freely without implying documentation. | +| **Doc unit / grouping** | A unit = one output doc. Its `docs.outline.md` (under `docs-src/`) declares a `sources:` glob list that may span `components/`, `hooks/`, `lib/`, `contexts/`, `types/` — so a unit can gather cross-cutting, even _shared_, code (e.g. `collection` types shared across DataTable/Kanban/Gantt). | +| **Output location** | Inferred from where the outline lives under `docs-src/`: `docs-src/components/…` → `docs/components/`, `docs-src/hooks/…` → `docs/api/`, `docs-src/concepts/…` → `docs/concepts/`, etc. Slug from a `group`/filename. | +| **Change signal** | **Blocking:** type-surface hash + outline hash. **Advisory (non-blocking):** test-snapshot hash. | +| **Examples** | Per unit: one runnable **`[unit].docs.examples.tsx`** — authored SOURCE, a sibling of the outline under `docs-src/` — with keyed named exports; the `.md` code fences are **derived deterministically** by extracting those exports. Compiles in CI. Per-segment keys ⇒ regenerate only the changed example, reuse the rest as baseline. | +| **Docs site** | A dedicated **AppShell-based renderer app** (`docs-browser/`, dogfooding). Build-time `import.meta.glob` of the generated `docs/**/*.md` + the authored `**/*.docs.examples.tsx` — imports in place, **no copy**. Dev serves from source w/ HMR; prod bundles. | +| **Fix path** | Deterministic pre-merge `check-docs` gate (**no LLM**) blocks the PR on drift. The local `resync-docs` skill is the **only** fix path. `docs-update.lock.yml` is **retired**. Pre-commit _warns_; CI _blocks_. | +| **Non-component docs** | Unified source→output; **all** of `docs/` is generated, with no hand-authored exceptions — the root guides are prose units in `docs-src/guides/` that output to the `docs/` root. Hooks/api are **prose units** today (migrated verbatim; upgradable to code-backed with `sources` later). Concepts/patterns/pages = prose outlines in `docs-src/` (outline-hash trigger only; may embed live-example tokens). CSS-token units and re-export **reference units** are **specified but not yet built**. | +| **Drift baseline & integrity** | The **committed `docs-manifest.json`** stores per unit: output path, `sources` globs, the **input** hashes (type-surface / outline / `*.docs.examples.tsx` / snapshot) **and** the generated-`.md` output hash. `check-docs` recomputes and compares — no git-history diffing. Input drift (incl. an examples edit that leaves the `.md` fences stale) ⇒ needs resync; **a hand-edited generated `.md` ⇒ output-hash mismatch ⇒ CI fail**. | +| **Migration** | AI **reverse-generates** outlines + `*.docs.examples.tsx` + manifest from the existing docs; humans curate/restructure; enforcement phases in per-unit. | +| **`app-shell-patterns`** | A **consumer-facing build skill** emitted by `docs-kit sync` from the generated docs + authored fundamentals (`docs-src/skill/`), manifest-tracked (hashed in `docs-manifest.json` under `skill`) and validated by `check-docs`. **Implemented** — it subsumes and replaces the retired `catalogue` generator. | --- From 6bad740f8e01923f064f27e621f11e686a8cfbd1 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Mon, 19 Oct 2026 22:41:46 +0900 Subject: [PATCH 12/19] feat(ai-chat): add waiting indicator and transient tool call UI --- .changeset/chat-progress-tool-names.md | 7 + docs-manifest.json | 17 +- docs-src/components/ai-chat.docs.outline.md | 38 ++- docs-src/hooks/use-ai-chat.docs.outline.md | 19 +- docs/api/use-ai-chat.md | 19 +- docs/components/ai-chat.md | 38 ++- ...components__ai-chat__ai-chat.test.tsx.snap | 2 + ...mponents__ai-chat__tool-call.test.tsx.snap | 3 + packages/core/src/ai/use-ai-chat.test.tsx | 267 ++++++++++++++++++ packages/core/src/ai/use-ai-chat.ts | 22 +- .../src/components/ai-chat/ai-chat.test-d.ts | 11 +- .../src/components/ai-chat/ai-chat.test.tsx | 12 + .../core/src/components/ai-chat/ai-chat.tsx | 4 + .../components/ai-chat/conversation.test.tsx | 26 ++ .../src/components/ai-chat/conversation.tsx | 20 +- packages/core/src/components/ai-chat/index.ts | 1 + .../src/components/ai-chat/tool-call.test.tsx | 34 +++ .../core/src/components/ai-chat/tool-call.tsx | 28 ++ packages/core/src/i18n-labels.ts | 2 + packages/core/src/index.ts | 1 + 20 files changed, 545 insertions(+), 26 deletions(-) create mode 100644 .changeset/chat-progress-tool-names.md create mode 100644 packages/core/__snapshots__/src__components__ai-chat__tool-call.test.tsx.snap create mode 100644 packages/core/src/components/ai-chat/tool-call.test.tsx create mode 100644 packages/core/src/components/ai-chat/tool-call.tsx diff --git a/.changeset/chat-progress-tool-names.md b/.changeset/chat-progress-tool-names.md new file mode 100644 index 000000000..b0f134e88 --- /dev/null +++ b/.changeset/chat-progress-tool-names.md @@ -0,0 +1,7 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Show a localized waiting indicator in `AIChat.Conversation` while a response or tool round is pending. + +Expose requested tool names in a transient `toolCalls` list from `useAIChat()`. Render them with `toolCalls.map(call => )`; the localized labels stack vertically and the list clears on completion, stop, or error without changing the message history. Call metadata does not imply successful execution, and tool arguments/results remain internal. diff --git a/docs-manifest.json b/docs-manifest.json index d647f0ee8..49b7b4fa9 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -57,16 +57,17 @@ "AIChatConversationProps", "AIChatHeaderProps", "AIChatProps", + "AIChatToolCallProps", "ChainOfThoughtStepStatus", "ChatHistoryGroupData", "ChatHistoryItemData", "ToolState" ], "hashes": { - "typeSurface": "7cb8a34c1c36eb02", - "outline": "797e012e34847b80", - "snapshot": "e43bea5b03d30595", - "outputMd": "824d1aa36bb7f194", + "typeSurface": "932ff7674cc6df99", + "outline": "534cb8372956ace8", + "snapshot": "9f6bfd4ab54dc4a7", + "outputMd": "290c2badb845f7af", "examples": null } }, @@ -1475,9 +1476,9 @@ "symbols": [], "hashes": { "typeSurface": null, - "outline": "12b1942ef141b400", + "outline": "4c1124d037b6618c", "snapshot": null, - "outputMd": "fdf6dbc029caf986", + "outputMd": "136a4fed44331256", "examples": null } }, @@ -1714,7 +1715,7 @@ "packages/core/skills/app-shell-patterns/references/concepts/styling-theming.md": "5c55865c499890a8", "packages/core/skills/app-shell-patterns/references/components/action-panel.md": "71794d4b561e4d4b", "packages/core/skills/app-shell-patterns/references/components/activity-card.md": "4f386e49380fb0b0", - "packages/core/skills/app-shell-patterns/references/components/ai-chat.md": "824d1aa36bb7f194", + "packages/core/skills/app-shell-patterns/references/components/ai-chat.md": "290c2badb845f7af", "packages/core/skills/app-shell-patterns/references/components/alert.md": "db2c4388ca783a75", "packages/core/skills/app-shell-patterns/references/components/app-shell.md": "081e4c7bb61db191", "packages/core/skills/app-shell-patterns/references/components/appearance-switcher.md": "b55f1d538651d467", @@ -1770,7 +1771,7 @@ "packages/core/skills/app-shell-patterns/references/api/router/use-location.md": "ddb44cc51af636cc", "packages/core/skills/app-shell-patterns/references/api/router/use-navigate.md": "d018cb13a7ddc38b", "packages/core/skills/app-shell-patterns/references/api/router/use-params.md": "535f701e77dfc4e6", - "packages/core/skills/app-shell-patterns/references/api/use-ai-chat.md": "fdf6dbc029caf986", + "packages/core/skills/app-shell-patterns/references/api/use-ai-chat.md": "136a4fed44331256", "packages/core/skills/app-shell-patterns/references/api/use-app-shell.md": "ce989e06790aade8", "packages/core/skills/app-shell-patterns/references/api/use-app-shell-config.md": "1a0a937f3a412c13", "packages/core/skills/app-shell-patterns/references/api/use-app-shell-data.md": "2d2eaf5aa04d95d3", diff --git a/docs-src/components/ai-chat.docs.outline.md b/docs-src/components/ai-chat.docs.outline.md index f457a9954..a0c96caed 100644 --- a/docs-src/components/ai-chat.docs.outline.md +++ b/docs-src/components/ai-chat.docs.outline.md @@ -86,6 +86,17 @@ Other props spread onto the root `
`. Other props spread onto the region's outer `
`. +While the root's `status` is `"submitted"`, the conversation automatically appends a [`Spinner`](./spinner.md) and localized “Thinking…” status at the end. This covers the initial reply wait, tool execution, and the next reply wait after a tool result. The indicator disappears when text starts streaming, or when the request finishes, fails, or is stopped. It participates in the conversation's normal auto-scroll behavior; no separate loader state is needed. + +### `AIChat.ToolCall` + +A compact label for one model-requested tool call. Each label occupies its own row, so multiple calls stack vertically. It does not imply execution success, render tool arguments/results, or manage history. + +| Prop | Type | Default | Description | +| ----------- | ------------------------------ | -------- | ------------------------------------------------ | +| `call` | `{ id: string; name: string }` | required | A `toolCalls` element returned by `useAIChat()`. | +| `className` | `string` | - | Merged onto the outer row, not the badge. | + ### `AIChat.Composer` | Prop | Type | Default | Description | @@ -114,17 +125,18 @@ Most props stay inside their region. These `AIChat.Composer` props land on **ano `onValueChange` is wrapped rather than forwarded — it is called from the `Textarea`'s `onChange`, and again with `""` after a submit. `defaultValue` seeds the composer's own state and is never forwarded. Nothing on `AIChat`, `AIChat.Header`, or `AIChat.Conversation` reaches another AppShell component. -Three attached parts wrap an AppShell component. They expose only the props the part's job needs rather than inheriting the wrapped component's surface, so its visual treatment is fixed and not yours to change: +Four attached parts wrap an AppShell component. They expose only the props the part's job needs rather than inheriting the wrapped component's surface, so its visual treatment is fixed and not yours to change: | Part | Wraps | Accepts | Fixed | | ----------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------- | ---------------------------------- | | `AIChat.Suggestion` | [`Button`](./button.md) | `suggestion`, `onSelect`, `className`, `disabled`, `children` | `variant="secondary"`, `size="sm"` | | `AIChat.Action` | [`Button`](./button.md) inside a [`Tooltip`](./tooltip.md) | `label`, `onClick`, `className`, `disabled`, `children` | `variant="ghost"`, `size="icon"` | | `AIChat.ChainOfThoughtSearchResult` | [`Badge`](./badge.md) | `className`, `children`, `variant` | — | +| `AIChat.ToolCall` | [`Badge`](./badge.md) | `call`, `className` | `variant="neutral"` | `variant` stays open on `ChainOfThoughtSearchResult` because conveying status is what a `Badge` is for. `AIChat.Action`'s `label` becomes both the accessible name and the tooltip text. -One thing to know about `className` on these: it is merged with `cn()`, which is not configured for AppShell's internal prefix, so it cannot reliably override a base utility from the wrapped component. Add properties the component doesn't set, and reach for the wrapped component's own props or variants to change ones it does — see [Styling AppShell components](../concepts/styling-theming.md#styling-appshell-components). +`ToolCall` applies `className` to its outer row, not the inner badge. For `Suggestion`, `Action`, and `ChainOfThoughtSearchResult`, `className` is merged with `cn()`, which is not configured for AppShell's internal prefix, so it cannot reliably override a base utility from the wrapped component. Add properties the component doesn't set, and reach for the wrapped component's own props or variants to change ones it does — see [Styling AppShell components](../concepts/styling-theming.md#styling-appshell-components). ## Filling the page @@ -263,6 +275,28 @@ Structured step timeline for agentic turns — the discrete plan being executed, `status` is `"complete" | "active" | "pending"`. +### Tool calls during the active flow + +`useAIChat()` returns a transient `toolCalls` list separately from `messages`. Render it with `AIChat.ToolCall` after the message history inside `AIChat.Conversation`: + +```tsx +{ + toolCalls.length > 0 ? ( + + {toolCalls.map((call) => ( + + ))} + + ) : null; +} +``` + +Each call renders on its own row, in the order supplied. Labels are localized automatically. + +The list clears when the response finishes, is stopped, or fails. It does not create tool messages or persist in the conversation log. + +These names record model requests, not execution success. Tool arguments and results stay internal, and provider-managed usage is not exposed here. Do not infer a `ToolHeader` success or running state from name-only metadata. + ### Tool Collapsible tool-call card. Maps 1:1 to the AI SDK's tool-part lifecycle, so a streaming loop can update a call in place as parts arrive. diff --git a/docs-src/hooks/use-ai-chat.docs.outline.md b/docs-src/hooks/use-ai-chat.docs.outline.md index cfa9e7e95..ecb2b2346 100644 --- a/docs-src/hooks/use-ai-chat.docs.outline.md +++ b/docs-src/hooks/use-ai-chat.docs.outline.md @@ -18,6 +18,7 @@ const useAIChat: (config: { tools?: Record; }) => { messages: AIChatMessage[]; + toolCalls: { id: string; name: string }[]; status: "ready" | "submitted" | "streaming" | "error"; error?: Error; sendMessage: (message: string) => Promise; @@ -44,10 +45,17 @@ interface AIChatSource { } ``` +### `toolCalls` + +- **Type:** `{ id: string; name: string }[]` +- **Description:** Tool calls requested by the model in the active flow, including earlier tool rounds of the current request. This transient list is cleared when the response finishes, is stopped, or fails; it is never added to `messages` or conversation history. + +Calls become available before local tool execution. They record requested tool names, not successful execution. Arguments and results remain internal, and provider-managed tool usage is not reported through this field. + ### `status` - **Type:** `"ready" | "submitted" | "streaming" | "error"` -- **Description:** Current request state +- **Description:** Current request state. `submitted` covers waiting for the first text, local tool execution, and waiting for the model's next reply after a tool result. `streaming` begins when text arrives. `AIChat.Conversation` automatically shows a waiting indicator while `submitted`. ### `error` @@ -105,7 +113,7 @@ const lookupCustomer = defineAIChatTool({ }); export function ChatScreen() { - const { messages, sendMessage, status, stop, error } = useAIChat({ + const { messages, toolCalls, sendMessage, status, stop, error } = useAIChat({ client: aiClient, model: "gpt-5-mini", tools: { @@ -121,6 +129,9 @@ export function ChatScreen() { {message.role}: {message.content}
))} + {toolCalls.map((call) => ( +
Tool call: {call.name}
+ ))}
))} + {toolCalls.map((call) => ( +
Tool call: {call.name}
+ ))} "`; + exports[`AIChat > snapshots > default 1`] = `"

Hello there.

"`; exports[`AIChat > snapshots > with header 1`] = `"
Assistant
"`; diff --git a/packages/core/__snapshots__/src__components__ai-chat__tool-call.test.tsx.snap b/packages/core/__snapshots__/src__components__ai-chat__tool-call.test.tsx.snap new file mode 100644 index 000000000..61228a53b --- /dev/null +++ b/packages/core/__snapshots__/src__components__ai-chat__tool-call.test.tsx.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`AIChat.ToolCall > renders a neutral tool-call label without implying success 1`] = `"
Tool call: add
"`; diff --git a/packages/core/src/ai/use-ai-chat.test.tsx b/packages/core/src/ai/use-ai-chat.test.tsx index 8a02471ca..1fd2817db 100644 --- a/packages/core/src/ai/use-ai-chat.test.tsx +++ b/packages/core/src/ai/use-ai-chat.test.tsx @@ -33,11 +33,16 @@ describe("useAIChat", () => { const { result } = renderHook(() => useAIChat({ client, model: "gpt-5-mini" })); expect(result.current.messages).toEqual([]); + expect(result.current.toolCalls).toEqual([]); expect(result.current.status).toBe("ready"); expect(result.current.error).toBeUndefined(); }); it("appends user and assistant messages while streaming", async () => { + let releaseFirstChunk!: () => void; + const firstChunkGate = new Promise((resolve) => { + releaseFirstChunk = resolve; + }); let releaseSecondChunk!: () => void; const secondChunkGate = new Promise((resolve) => { releaseSecondChunk = resolve; @@ -46,6 +51,7 @@ describe("useAIChat", () => { const client = { streamChatCompletion: vi.fn(async function* ({ signal }) { expect(signal).toBeInstanceOf(AbortSignal); + await firstChunkGate; yield { type: "text-delta", text: "Hello" } as const; await secondChunkGate; yield { type: "text-delta", text: " world" } as const; @@ -60,6 +66,13 @@ describe("useAIChat", () => { sendPromise = result.current.sendMessage("Hi"); }); + expect(result.current.status).toBe("submitted"); + expect(result.current.messages).toEqual([{ id: "id-1", role: "user", content: "Hi" }]); + + await act(async () => { + releaseFirstChunk(); + }); + await waitFor(() => { expect(result.current.status).toBe("streaming"); expect(result.current.messages).toEqual([ @@ -340,11 +353,265 @@ describe("useAIChat", () => { { id: "id-1", role: "user", content: "Find customer" }, { id: "id-2", role: "assistant", content: "Customer: Acme Corp" }, ]); + expect(result.current.toolCalls).toEqual([]); }); expect(client.streamChatCompletion).toHaveBeenCalledTimes(2); }); + it("exposes calls only during the active flow across text and tool-only rounds", async () => { + let releaseTool!: () => void; + const toolGate = new Promise((resolve) => { + releaseTool = resolve; + }); + let releaseReply!: () => void; + const replyGate = new Promise((resolve) => { + releaseReply = resolve; + }); + let releaseFinalReply!: () => void; + const finalReplyGate = new Promise((resolve) => { + releaseFinalReply = resolve; + }); + const echo = defineAIChatTool({ + schema: z.object({ value: z.string() }), + async execute({ value }) { + if (value === "first") await toolGate; + return value; + }, + }); + let round = 0; + const client = { + streamChatCompletion: vi.fn(async function* () { + round += 1; + if (round === 1) { + yield { type: "text-delta", text: "Checking" } as const; + for (const value of ["first", "second"]) { + yield { + type: "tool-call", + toolCallId: value, + toolName: "echo", + argumentsText: JSON.stringify({ value }), + } as const; + } + } else if (round === 2) { + await replyGate; + yield { + type: "tool-call", + toolCallId: "third", + toolName: "echo", + argumentsText: '{"value":"third"}', + } as const; + } else { + await finalReplyGate; + yield { type: "text-delta", text: "Done" } as const; + } + yield { type: "done" } as const; + }), + } satisfies AIGatewayClient; + const { result } = renderHook(() => + useAIChat({ client, model: "gpt-5-mini", tools: { echo } }), + ); + + let sendPromise: Promise | undefined; + await act(async () => { + sendPromise = result.current.sendMessage("Check three values"); + }); + + const firstCalls = [ + { id: "first", name: "echo" }, + { id: "second", name: "echo" }, + ]; + expect(result.current.status).toBe("submitted"); + expect(client.streamChatCompletion).toHaveBeenCalledTimes(1); + expect(result.current.toolCalls).toEqual(firstCalls); + expect(result.current.messages).toEqual([ + { id: "id-1", role: "user", content: "Check three values" }, + { id: "id-2", role: "assistant", content: "Checking" }, + ]); + + await act(async () => { + await expect(result.current.sendMessage("Ignored")).resolves.toBe(false); + }); + expect(result.current.toolCalls).toEqual(firstCalls); + + await act(async () => { + releaseTool(); + }); + await waitFor(() => expect(client.streamChatCompletion).toHaveBeenCalledTimes(2)); + expect(result.current.status).toBe("submitted"); + expect(result.current.toolCalls).toEqual(firstCalls); + + await act(async () => { + releaseReply(); + }); + await waitFor(() => expect(client.streamChatCompletion).toHaveBeenCalledTimes(3)); + expect(result.current.toolCalls).toEqual([...firstCalls, { id: "third", name: "echo" }]); + expect(result.current.messages).toHaveLength(2); + expect(result.current.status).toBe("submitted"); + + await act(async () => { + releaseFinalReply(); + await expect(sendPromise).resolves.toBe(true); + }); + + expect(result.current.status).toBe("ready"); + expect(result.current.toolCalls).toEqual([]); + expect(result.current.messages).toEqual([ + { id: "id-1", role: "user", content: "Check three values" }, + { id: "id-2", role: "assistant", content: "Checking" }, + { id: "id-3", role: "assistant", content: "Done" }, + ]); + }); + + it("clears calls on stop and ignores late events while the next flow is active", async () => { + let releaseStoppedRequest!: () => void; + const stoppedRequestGate = new Promise((resolve) => { + releaseStoppedRequest = resolve; + }); + let releaseSecondReply!: () => void; + const secondReplyGate = new Promise((resolve) => { + releaseSecondReply = resolve; + }); + const lookup = defineAIChatTool({ schema: z.object({}), execute: () => "found" }); + const client = { + streamChatCompletion: vi.fn(async function* ({ messages }) { + const lastMessage = messages.at(-1); + if (lastMessage?.role === "user") { + expect(messages).toEqual([{ role: "user", content: lastMessage.content }]); + yield { + type: "tool-call", + toolCallId: lastMessage.content === "First" ? "observed" : "fresh", + toolName: "lookup", + argumentsText: "{}", + } as const; + } else if (messages[0]?.content === "First") { + await stoppedRequestGate; + yield { + type: "tool-call", + toolCallId: "late", + toolName: "lookup", + argumentsText: "{}", + } as const; + } else { + await secondReplyGate; + yield { type: "text-delta", text: "Fresh" } as const; + } + yield { type: "done" } as const; + }), + } satisfies AIGatewayClient; + const { result } = renderHook(() => + useAIChat({ client, model: "gpt-5-mini", tools: { lookup } }), + ); + + let firstSendPromise: Promise | undefined; + await act(async () => { + firstSendPromise = result.current.sendMessage("First"); + }); + expect(result.current.status).toBe("submitted"); + expect(result.current.toolCalls).toEqual([{ id: "observed", name: "lookup" }]); + expect(result.current.messages).toEqual([{ id: "id-1", role: "user", content: "First" }]); + + act(() => result.current.stop()); + expect(result.current.status).toBe("ready"); + expect(result.current.toolCalls).toEqual([]); + + let secondSendPromise: Promise | undefined; + await act(async () => { + secondSendPromise = result.current.sendMessage("Second"); + }); + expect(result.current.toolCalls).toEqual([{ id: "fresh", name: "lookup" }]); + expect(result.current.messages).toEqual([ + { id: "id-1", role: "user", content: "First" }, + { id: "id-2", role: "user", content: "Second" }, + ]); + + await act(async () => { + releaseStoppedRequest(); + await expect(firstSendPromise).resolves.toBe(false); + }); + expect(result.current.toolCalls).toEqual([{ id: "fresh", name: "lookup" }]); + expect(result.current.status).toBe("submitted"); + + await act(async () => { + releaseSecondReply(); + await expect(secondSendPromise).resolves.toBe(true); + }); + expect(result.current.toolCalls).toEqual([]); + expect(result.current.messages).toEqual([ + { id: "id-1", role: "user", content: "First" }, + { id: "id-2", role: "user", content: "Second" }, + { id: "id-3", role: "assistant", content: "Fresh" }, + ]); + expect(result.current.status).toBe("ready"); + expect(result.current.error).toBeUndefined(); + expect(client.streamChatCompletion).toHaveBeenCalledTimes(4); + }); + + it("clears calls after execution and reply errors without adding tool history", async () => { + let releaseFailure!: () => void; + const failureGate = new Promise((resolve) => { + releaseFailure = resolve; + }); + const failingTool = defineAIChatTool({ + schema: z.object({}), + async execute() { + await failureGate; + throw new Error("tool failed"); + }, + }); + const client = { + streamChatCompletion: vi.fn(async function* ({ messages }) { + if (messages.at(-1)?.content === "Retry") { + yield { type: "text-delta", text: "Recovered" } as const; + } else if (messages.at(-1)?.role === "tool") { + expect(messages.at(-1)?.content).toBe('{"error":"tool failed"}'); + throw new Error("reply failed"); + } else { + yield { + type: "tool-call", + toolCallId: "failed-call", + toolName: "failingTool", + argumentsText: "{}", + } as const; + } + yield { type: "done" } as const; + }), + } satisfies AIGatewayClient; + const { result } = renderHook(() => + useAIChat({ client, model: "gpt-5-mini", tools: { failingTool } }), + ); + + let sendPromise: Promise | undefined; + await act(async () => { + sendPromise = result.current.sendMessage("Try the tool"); + }); + expect(result.current.toolCalls).toEqual([{ id: "failed-call", name: "failingTool" }]); + expect(result.current.messages).toEqual([ + { id: "id-1", role: "user", content: "Try the tool" }, + ]); + + await act(async () => { + releaseFailure(); + await expect(sendPromise).resolves.toBe(false); + }); + expect(result.current.status).toBe("error"); + expect(result.current.error?.message).toBe("reply failed"); + expect(result.current.toolCalls).toEqual([]); + expect(result.current.messages).toEqual([ + { id: "id-1", role: "user", content: "Try the tool" }, + ]); + + await act(async () => { + await expect(result.current.sendMessage("Retry")).resolves.toBe(true); + }); + expect(result.current.toolCalls).toEqual([]); + expect(result.current.messages).toEqual([ + { id: "id-1", role: "user", content: "Try the tool" }, + { id: "id-2", role: "user", content: "Retry" }, + { id: "id-3", role: "assistant", content: "Recovered" }, + ]); + }); + it("fails after too many tool rounds", async () => { const loop = defineAIChatTool({ description: "Loop forever", diff --git a/packages/core/src/ai/use-ai-chat.ts b/packages/core/src/ai/use-ai-chat.ts index 0f94a2782..3018687ad 100644 --- a/packages/core/src/ai/use-ai-chat.ts +++ b/packages/core/src/ai/use-ai-chat.ts @@ -18,6 +18,8 @@ export interface AIChatMessage { export type AIChatStatus = "ready" | "submitted" | "streaming" | "error"; +type ActiveToolCall = { id: string; name: string }; + /** Active request handle stored in the ref for staleness checks + cancellation. */ interface ActiveHandle { readonly controller: AbortController; @@ -69,8 +71,8 @@ class ChatRequestContext { /** * React hook for AI Gateway chat with optional local and provider tools. * - * Public state stays user/assistant text-first while tool-call and tool-result - * protocol messages remain internal to the hook. + * Messages stay text-first. Requested tool names are exposed only for the + * active request; tool-call and tool-result protocol messages remain internal. */ export function useAIChat(config: { client: AIGatewayClient; @@ -78,6 +80,8 @@ export function useAIChat(config: { tools?: Record; }): { messages: AIChatMessage[]; + /** Requested tools in the active flow; cleared on completion, stop, or error. */ + toolCalls: ActiveToolCall[]; status: AIChatStatus; error?: Error; /** @@ -88,6 +92,7 @@ export function useAIChat(config: { stop: () => void; } { const [messages, setMessages] = useState([]); + const [toolCalls, setToolCalls] = useState([]); const [status, setStatus] = useState("ready"); const [error, setError] = useState(undefined); const transcriptRef = useRef([]); @@ -103,6 +108,7 @@ export function useAIChat(config: { activeRef.current = null; active.controller.abort(); + setToolCalls([]); setStatus("ready"); }, []); @@ -127,6 +133,7 @@ export function useAIChat(config: { }; updateMessages((previous) => [...previous, userMessage]); + setToolCalls([]); setError(undefined); setStatus("submitted"); @@ -147,7 +154,7 @@ export function useAIChat(config: { signal: controller.signal, })) { if (activeRef.current !== handle) break; - applyEvent(event, ctx, updateMessages, setStatus); + applyEvent(event, ctx, updateMessages, setStatus, setToolCalls); } if (activeRef.current !== handle) return false; @@ -155,11 +162,13 @@ export function useAIChat(config: { // Commit staged transcript to persistent history transcriptRef.current = ctx.transcript; activeRef.current = null; + setToolCalls([]); setStatus("ready"); return true; } catch (caughtError) { if (activeRef.current !== handle) return false; activeRef.current = null; + setToolCalls([]); if (isAbortError(caughtError)) { setStatus("ready"); @@ -174,7 +183,7 @@ export function useAIChat(config: { [config.client, config.model, config.tools, updateMessages], ); - return { messages, status, error, sendMessage, stop }; + return { messages, toolCalls, status, error, sendMessage, stop }; } /** Single dispatch point: loop events → React state updates + context mutations. */ @@ -183,6 +192,7 @@ function applyEvent( ctx: ChatRequestContext, updateMessages: (updater: (previous: AIChatMessage[]) => AIChatMessage[]) => void, setStatus: (status: AIChatStatus) => void, + setToolCalls: (updater: (previous: ActiveToolCall[]) => ActiveToolCall[]) => void, ): void { switch (event.type) { case "text-delta": { @@ -210,6 +220,10 @@ function applyEvent( } case "turn-end": { + if (event.turn.toolCalls.length > 0) { + const calls = event.turn.toolCalls.map(({ id, name }) => ({ id, name })); + setToolCalls((previous) => [...previous, ...calls]); + } ctx.commitTurn(event.turn); break; } diff --git a/packages/core/src/components/ai-chat/ai-chat.test-d.ts b/packages/core/src/components/ai-chat/ai-chat.test-d.ts index 2bb76bbc2..4725e8438 100644 --- a/packages/core/src/components/ai-chat/ai-chat.test-d.ts +++ b/packages/core/src/components/ai-chat/ai-chat.test-d.ts @@ -19,6 +19,7 @@ import { type AIChatHeaderProps, type AIChatProps, } from "./ai-chat"; +import type { AIChatToolCallProps, useAIChat } from "../../index"; describe("region ownership", () => { it("keeps the root to status (plus div props) — the regions own the rest", () => { @@ -58,10 +59,18 @@ describe("callback names that collide with DOM handlers", () => { }); }); -// These three wrap a Button or a Badge. They Pick what the part's job needs +// These parts wrap a Button or a Badge. They expose what the part's job needs // rather than inheriting, so the wrapped component's surface cannot leak out // and become contract we did not intend to offer. describe("wrapper parts expose only picked props", () => { + it("accepts a call from useAIChat directly with only call and className", () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<"call" | "className">(); + expectTypeOf().toEqualTypeOf< + ReturnType["toolCalls"][number] + >(); + }); + it("keeps Suggestion to its own props plus className/disabled/children", () => { expectTypeOf>().toEqualTypeOf< "className" | "disabled" | "children" | "suggestion" | "onSelect" diff --git a/packages/core/src/components/ai-chat/ai-chat.test.tsx b/packages/core/src/components/ai-chat/ai-chat.test.tsx index 0bfe8a9e8..ebb2bedc0 100644 --- a/packages/core/src/components/ai-chat/ai-chat.test.tsx +++ b/packages/core/src/components/ai-chat/ai-chat.test.tsx @@ -77,6 +77,18 @@ describe("AIChat", () => { }); }); + it("shows the submitted state", () => { + const { container } = render( + + + Hello + + + , + ); + expect(container.innerHTML).toMatchSnapshot(); + }); + describe("region placement", () => { it("renders Header, Conversation, Composer in that order regardless of source order", () => { const { container } = render( diff --git a/packages/core/src/components/ai-chat/ai-chat.tsx b/packages/core/src/components/ai-chat/ai-chat.tsx index 2dae9973b..ffa9d2549 100644 --- a/packages/core/src/components/ai-chat/ai-chat.tsx +++ b/packages/core/src/components/ai-chat/ai-chat.tsx @@ -31,6 +31,7 @@ import { Response } from "./response"; import { Source, Sources, SourcesContent, SourcesTrigger } from "./sources"; import { Suggestion, Suggestions } from "./suggestion"; import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from "./tool"; +import { ToolCall } from "./tool-call"; type AIChatProps = Omit, "children"> & { /** @@ -199,6 +200,7 @@ type AIChatComponent = typeof AIChatRoot & { ChainOfThoughtSearchResults: typeof ChainOfThoughtSearchResults; ChainOfThoughtSearchResult: typeof ChainOfThoughtSearchResult; Tool: typeof Tool; + ToolCall: typeof ToolCall; ToolHeader: typeof ToolHeader; ToolContent: typeof ToolContent; ToolInput: typeof ToolInput; @@ -231,6 +233,7 @@ const AIChat: AIChatComponent = Object.assign(AIChatRoot, { ChainOfThoughtSearchResults, ChainOfThoughtSearchResult, Tool, + ToolCall, ToolHeader, ToolContent, ToolInput, @@ -247,5 +250,6 @@ export type { AIChatHeaderProps } from "./header"; export type { AIChatConversationProps } from "./conversation"; export type { AIChatComposerProps } from "./composer"; export type { ToolState } from "./tool"; +export type { AIChatToolCallProps } from "./tool-call"; export type { ChainOfThoughtStepStatus } from "./chain-of-thought"; export type { ChatHistoryGroupData, ChatHistoryItemData } from "./chat-history"; diff --git a/packages/core/src/components/ai-chat/conversation.test.tsx b/packages/core/src/components/ai-chat/conversation.test.tsx index 1c33b389e..489c5d30c 100644 --- a/packages/core/src/components/ai-chat/conversation.test.tsx +++ b/packages/core/src/components/ai-chat/conversation.test.tsx @@ -3,6 +3,8 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Conversation, ConversationScrollButton } from "./conversation"; +import { AIChatContext } from "./ai-chat-context"; +import type { AIChatStatus } from "@/ai/use-ai-chat"; afterEach(() => { cleanup(); @@ -31,7 +33,31 @@ function renderConversation() { const scrollButton = () => screen.queryByRole("button", { name: "Scroll to latest message" }); +const conversationWithStatus = (status: AIChatStatus) => ( + + content + +); + describe("Conversation", () => { + it("shows the waiting indicator only while submitted, including between tool rounds", () => { + const { rerender } = render(conversationWithStatus("submitted")); + + expect(screen.getByRole("status").textContent).toBe("Thinking…"); + expect(screen.getByRole("status").querySelector('[data-slot="spinner"]')).not.toBeNull(); + + for (const status of ["streaming", "submitted", "ready", "submitted", "error"] as const) { + rerender(conversationWithStatus(status)); + expect(screen.queryByRole("status") !== null).toBe(status === "submitted"); + } + }); + + it("remains usable outside AIChat without a waiting indicator", () => { + renderConversation(); + expect(screen.getByRole("log").textContent).toBe("content"); + expect(screen.queryByRole("status")).toBeNull(); + }); + it("hides the scroll button while the reader is at the bottom", () => { renderConversation(); expect(scrollButton()).toBeNull(); diff --git a/packages/core/src/components/ai-chat/conversation.tsx b/packages/core/src/components/ai-chat/conversation.tsx index 51295930c..5d32031cd 100644 --- a/packages/core/src/components/ai-chat/conversation.tsx +++ b/packages/core/src/components/ai-chat/conversation.tsx @@ -11,8 +11,11 @@ import { } from "react"; import { Button } from "@/components/button"; +import { Spinner } from "@/components/spinner"; import { useT } from "@/i18n-labels"; import { cn } from "@/lib/utils"; +import { AIChatContext } from "./ai-chat-context"; +import { Message } from "./message"; // How close (px) to the bottom still counts as "pinned". const AT_BOTTOM_THRESHOLD_PX = 32; @@ -57,6 +60,8 @@ function Conversation({ children, ...props }: AIChatConversationProps) { + const status = useContext(AIChatContext)?.status; + const t = useT(); const viewportRef = useRef(null); const contentRef = useRef(null); const pinnedRef = useRef(true); @@ -123,7 +128,20 @@ function Conversation({ against the relative root above, so it stays pinned while content scrolls underneath it. */}
- {children} + + {children} + {status === "submitted" ? ( + + + + {t("aiChatThinking")} + + + ) : null} +
diff --git a/packages/core/src/components/ai-chat/index.ts b/packages/core/src/components/ai-chat/index.ts index 21a273493..8da82b9d8 100644 --- a/packages/core/src/components/ai-chat/index.ts +++ b/packages/core/src/components/ai-chat/index.ts @@ -4,6 +4,7 @@ export { type AIChatHeaderProps, type AIChatConversationProps, type AIChatComposerProps, + type AIChatToolCallProps, type ToolState, type ChainOfThoughtStepStatus, type ChatHistoryGroupData, diff --git a/packages/core/src/components/ai-chat/tool-call.test.tsx b/packages/core/src/components/ai-chat/tool-call.test.tsx new file mode 100644 index 000000000..282dc84b8 --- /dev/null +++ b/packages/core/src/components/ai-chat/tool-call.test.tsx @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; + +import { AIChat } from "./index"; + +afterEach(() => { + cleanup(); +}); + +describe("AIChat.ToolCall", () => { + it("renders a neutral tool-call label without implying success", () => { + const { container } = render(); + + expect(screen.getByText("Tool call: add")).toBeDefined(); + expect(screen.queryByText("Done")).toBeNull(); + expect(screen.queryByRole("button")).toBeNull(); + expect(container.querySelector("svg")?.getAttribute("aria-hidden")).toBe("true"); + expect(container.innerHTML).toMatchSnapshot(); + }); + + it("renders multiple calls as separate rows in order and forwards className to the row", () => { + const { container } = render( + <> + + + , + ); + const rows = Array.from(container.querySelectorAll('[data-slot="ai-chat-tool-call"]')); + + expect(rows.map((row) => row.textContent)).toEqual(["Tool call: first", "Tool call: second"]); + expect(rows.every((row) => row.classList.contains("astw:flex"))).toBe(true); + expect(rows[0]?.classList.contains("consumer-class")).toBe(true); + }); +}); diff --git a/packages/core/src/components/ai-chat/tool-call.tsx b/packages/core/src/components/ai-chat/tool-call.tsx new file mode 100644 index 000000000..1b93f7ff5 --- /dev/null +++ b/packages/core/src/components/ai-chat/tool-call.tsx @@ -0,0 +1,28 @@ +import { Wrench } from "lucide-react"; + +import type { useAIChat } from "@/ai/use-ai-chat"; +import { Badge } from "@/components/badge"; +import { useT } from "@/i18n-labels"; +import { cn } from "@/lib/utils"; + +type AIChatToolCallProps = { + /** A requested tool from useAIChat().toolCalls, not an execution success receipt. */ + call: ReturnType["toolCalls"][number]; + className?: string; +}; + +/** Compact tool-call label for the active flow, rendered as its own row. */ +function ToolCall({ call, className }: AIChatToolCallProps) { + const t = useT(); + + return ( +
+ + + {t("aiChatToolCall", { name: call.name })} + +
+ ); +} + +export { ToolCall, type AIChatToolCallProps }; diff --git a/packages/core/src/i18n-labels.ts b/packages/core/src/i18n-labels.ts index a206de124..4e3db30a4 100644 --- a/packages/core/src/i18n-labels.ts +++ b/packages/core/src/i18n-labels.ts @@ -42,6 +42,7 @@ export const labels = defineI18nLabels({ aiChatThoughtFor: (props: { seconds: number }) => `Thought for ${props.seconds}s`, aiChatReasoning: "Reasoning", aiChatChainOfThought: "Chain of thought", + aiChatToolCall: (props: { name: string }) => `Tool call: ${props.name}`, aiChatToolPreparing: "Preparing", aiChatToolRunning: "Running", aiChatToolDone: "Done", @@ -92,6 +93,7 @@ export const labels = defineI18nLabels({ aiChatThoughtFor: (props: { seconds: number }) => `${props.seconds}秒 思考しました`, aiChatReasoning: "思考", aiChatChainOfThought: "思考の流れ", + aiChatToolCall: (props: { name: string }) => `ツール呼び出し: ${props.name}`, aiChatToolPreparing: "準備中", aiChatToolRunning: "実行中", aiChatToolDone: "完了", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7c59b6d48..302bfa311 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -95,6 +95,7 @@ export { type AIChatHeaderProps, type AIChatConversationProps, type AIChatComposerProps, + type AIChatToolCallProps, type ToolState, type ChainOfThoughtStepStatus, type ChatHistoryGroupData, From 79992c0ce0a46874d103d8b71c0c9c71a47d5364 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Tue, 20 Oct 2026 00:58:25 +0900 Subject: [PATCH 13/19] fix(ai-chat): respect cancellation during tool validation --- .changeset/ai-gateway-tools.md | 2 + packages/core/src/ai/tool-execution.test.ts | 72 +++++++++++++++++++++ packages/core/src/ai/tool-execution.ts | 13 +++- 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/ai/tool-execution.test.ts diff --git a/.changeset/ai-gateway-tools.md b/.changeset/ai-gateway-tools.md index 835647cc6..520a50417 100644 --- a/.changeset/ai-gateway-tools.md +++ b/.changeset/ai-gateway-tools.md @@ -38,6 +38,8 @@ The `execute` function receives a second argument with: - `signal` — the `AbortSignal` for the in-flight chat request - `messages` — the public user/assistant message history at the time of execution +Stopping a request while tool arguments are being validated prevents the tool from starting. Cancellation is propagated rather than returned to the model as a tool error. + ## Provider tools Provider tools are not executed locally — they are passed through to the AI Gateway and handled upstream by the model provider. diff --git a/packages/core/src/ai/tool-execution.test.ts b/packages/core/src/ai/tool-execution.test.ts new file mode 100644 index 000000000..2967ed154 --- /dev/null +++ b/packages/core/src/ai/tool-execution.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod/v4"; +import type { AILocalTool } from "./tools"; +import { resolveToolCalls } from "./tool-execution"; + +const toolCall = { id: "call-1", name: "lookup", argumentsText: "{}" }; + +describe("resolveToolCalls", () => { + it.each([false, true])( + "propagates cancellation while validation is pending (validation rejects: %s)", + async (rejectValidation) => { + let releaseValidation!: () => void; + const validationGate = new Promise((resolve) => { + releaseValidation = resolve; + }); + const schema = z.object({}); + const validate = vi.spyOn(schema["~standard"], "validate").mockImplementation(async () => { + await validationGate; + if (rejectValidation) throw new Error("validation failed"); + return { value: {} }; + }); + const execute = vi.fn(() => "found"); + const controller = new AbortController(); + const pending = resolveToolCalls({ + toolCalls: [toolCall], + localTools: new Map([["lookup", { kind: "local", schema, execute }]]), + signal: controller.signal, + visibleMessages: [], + }); + + expect(validate).toHaveBeenCalledOnce(); + controller.abort(); + releaseValidation(); + + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + expect(execute).not.toHaveBeenCalled(); + }, + ); + + it("propagates cancellation errors from the executor", async () => { + const error = new DOMException("Stopped", "AbortError"); + const execute = vi.fn().mockRejectedValue(error); + + await expect( + resolveToolCalls({ + toolCalls: [toolCall], + localTools: new Map([ + ["lookup", { kind: "local", schema: z.object({}), execute }], + ]), + signal: new AbortController().signal, + visibleMessages: [], + }), + ).rejects.toBe(error); + }); + + it("still returns ordinary executor failures as tool results", async () => { + const execute = vi.fn().mockRejectedValue(new Error("lookup failed")); + + await expect( + resolveToolCalls({ + toolCalls: [toolCall], + localTools: new Map([ + ["lookup", { kind: "local", schema: z.object({}), execute }], + ]), + signal: new AbortController().signal, + visibleMessages: [], + }), + ).resolves.toEqual([ + { role: "tool", toolCallId: "call-1", content: '{"error":"lookup failed"}' }, + ]); + }); +}); diff --git a/packages/core/src/ai/tool-execution.ts b/packages/core/src/ai/tool-execution.ts index af74c6697..533482e3f 100644 --- a/packages/core/src/ai/tool-execution.ts +++ b/packages/core/src/ai/tool-execution.ts @@ -1,4 +1,4 @@ -import type { AIGatewayChatMessage, AIGatewayToolCall } from "./client"; +import { isAbortError, type AIGatewayChatMessage, type AIGatewayToolCall } from "./client"; import type { AIChatMessage } from "./use-ai-chat"; import type { AIChatToolContext, AILocalTool } from "./tools"; @@ -59,6 +59,10 @@ async function resolveToolCall(input: { const rawArguments = parseToolArguments(input.toolCall.argumentsText); const result = await tool.schema["~standard"].validate(rawArguments); + if (input.context.signal.aborted) { + throw createAbortError(); + } + if (result.issues) { return { role: "tool", @@ -74,6 +78,13 @@ async function resolveToolCall(input: { content: stringifyToolResult(output), }; } catch (error) { + if (input.context.signal.aborted) { + throw createAbortError(); + } + if (isAbortError(error)) { + throw error; + } + return { role: "tool", toolCallId: input.toolCall.id, From a9f7499bcea23dcf670bb71d740ff8a893052854 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Tue, 20 Oct 2026 00:59:12 +0900 Subject: [PATCH 14/19] fix(ai-chat): allow a final answer after eight tool rounds --- .changeset/ai-gateway-tools.md | 2 +- packages/core/src/ai/assistant-loop.ts | 6 +- packages/core/src/ai/use-ai-chat.test.tsx | 97 +++++++++++++---------- 3 files changed, 59 insertions(+), 46 deletions(-) diff --git a/.changeset/ai-gateway-tools.md b/.changeset/ai-gateway-tools.md index 520a50417..97c7ac72f 100644 --- a/.changeset/ai-gateway-tools.md +++ b/.changeset/ai-gateway-tools.md @@ -94,4 +94,4 @@ function ChatPanel({ client }) { } ``` -The hook runs up to 8 tool rounds per user message. If the model keeps requesting tools beyond that limit, the request fails with an error. +The hook runs up to 8 tool rounds per user message, then allows a final model response using the eighth round's results. If the model requests a ninth tool batch, the request fails before those tools execute. diff --git a/packages/core/src/ai/assistant-loop.ts b/packages/core/src/ai/assistant-loop.ts index 5c40441f6..6080f9bea 100644 --- a/packages/core/src/ai/assistant-loop.ts +++ b/packages/core/src/ai/assistant-loop.ts @@ -87,7 +87,7 @@ export async function* runAssistantLoop(input: { const { gatewayTools, localTools } = normalizeConfiguredTools(input.tools); const transcript = [...input.transcript]; - for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) { + for (let round = 0; round <= MAX_TOOL_ROUNDS; round += 1) { const turn: AIChatAssistantTurnResult = yield* streamAssistantTurn({ client: input.client, model: input.model, @@ -107,6 +107,10 @@ export async function* runAssistantLoop(input: { return; } + if (round === MAX_TOOL_ROUNDS) { + break; + } + yield { type: "tool-resolution-start" }; const toolMessages = await resolveToolCalls({ diff --git a/packages/core/src/ai/use-ai-chat.test.tsx b/packages/core/src/ai/use-ai-chat.test.tsx index 1fd2817db..04bc61b3f 100644 --- a/packages/core/src/ai/use-ai-chat.test.tsx +++ b/packages/core/src/ai/use-ai-chat.test.tsx @@ -612,50 +612,59 @@ describe("useAIChat", () => { ]); }); - it("fails after too many tool rounds", async () => { - const loop = defineAIChatTool({ - description: "Loop forever", - schema: z.object({ - value: z.string(), - }), - async execute({ value }) { - return { value }; - }, - }); - - let callCount = 0; - const client = { - streamChatCompletion: vi.fn(async function* () { - callCount += 1; - yield { - type: "tool-call", - toolCallId: `call-${callCount}`, - toolName: "loop", - argumentsText: '{"value":"again"}', - } as const; - yield { type: "done", finishReason: "tool_calls" } as const; - }), - } satisfies AIGatewayClient; - - const { result } = renderHook(() => - useAIChat({ - client, - model: "gpt-5-mini", - tools: { - loop, - }, - }), - ); - - await act(async () => { - await expect(result.current.sendMessage("Start looping")).resolves.toBe(false); - }); - - await waitFor(() => { - expect(result.current.status).toBe("error"); - expect(result.current.error?.message).toContain("maximum number of tool rounds"); - }); - }); + it.each([false, true])( + "allows a final answer after eight tool batches but rejects a ninth batch (requests ninth: %s)", + async (requestNinthBatch) => { + const execute = vi.fn(({ value }: { value: string }) => ({ value })); + const loop = defineAIChatTool({ + schema: z.object({ value: z.string() }), + execute, + }); + let callCount = 0; + const client = { + streamChatCompletion: vi.fn(async function* ({ messages }) { + callCount += 1; + if (callCount === 9) { + expect(messages.at(-1)).toEqual({ + role: "tool", + toolCallId: "call-8", + content: '{"value":"again"}', + }); + if (!requestNinthBatch) { + yield { type: "text-delta", text: "Finished" } as const; + yield { type: "done" } as const; + return; + } + } + yield { + type: "tool-call", + toolCallId: `call-${callCount}`, + toolName: "loop", + argumentsText: '{"value":"again"}', + } as const; + yield { type: "done", finishReason: "tool_calls" } as const; + }), + } satisfies AIGatewayClient; + const { result } = renderHook(() => + useAIChat({ client, model: "gpt-5-mini", tools: { loop } }), + ); + + await act(async () => { + await expect(result.current.sendMessage("Start looping")).resolves.toBe(!requestNinthBatch); + }); + + expect(client.streamChatCompletion).toHaveBeenCalledTimes(9); + expect(execute).toHaveBeenCalledTimes(8); + expect(result.current.toolCalls).toEqual([]); + if (requestNinthBatch) { + expect(result.current.status).toBe("error"); + expect(result.current.error?.message).toContain("maximum number of tool rounds"); + } else { + expect(result.current.status).toBe("ready"); + expect(result.current.messages.at(-1)?.content).toBe("Finished"); + } + }, + ); it("passes provider tools through and exposes sources", async () => { const client = { From d9ea9f4483c756754647893b026479d613a754c4 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Tue, 20 Oct 2026 00:59:30 +0900 Subject: [PATCH 15/19] fix(ai-chat): accumulate streamed tool name fragments --- .changeset/ai-gateway-tools.md | 2 +- packages/core/src/ai/client.test.ts | 38 +++++++++++++++++++++++++++++ packages/core/src/ai/client.ts | 2 +- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/.changeset/ai-gateway-tools.md b/.changeset/ai-gateway-tools.md index 97c7ac72f..965ad83ce 100644 --- a/.changeset/ai-gateway-tools.md +++ b/.changeset/ai-gateway-tools.md @@ -2,7 +2,7 @@ "@tailor-platform/app-shell": minor --- -Add tool support to `useAIChat()` and the AI Gateway transport layer. +Add tool support to `useAIChat()` and the AI Gateway transport layer. Streamed function names and arguments are assembled across chunks independently for each tool call. You can now register **local tools** and **provider tools** via the `tools` option on `useAIChat()`. Tool-call and tool-result protocol messages stay internal to the hook — the public `messages` array remains user/assistant text only. diff --git a/packages/core/src/ai/client.test.ts b/packages/core/src/ai/client.test.ts index 752e57f63..db1cd2571 100644 --- a/packages/core/src/ai/client.test.ts +++ b/packages/core/src/ai/client.test.ts @@ -240,6 +240,44 @@ describe("createAIGatewayClient", () => { }); }); + it("accumulates split function names independently by tool-call index", async () => { + const authClient = createMockAuthClient( + new Response( + createSSEStream([ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":1,"id":"call_2","type":"function","function":{"name":"lookup","arguments":"{\\"orderId\\":\\""}}]}}]}\n\n', + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\\"customerId\\":\\""}}]}}]}\n\n', + 'data: {"choices":[{"delta":{"tool_calls":[{"index":1,"function":{"name":"Order","arguments":"order-1\\"}"}},{"index":0,"function":{"name":"Customer","arguments":"cust-1\\"}"}}]}}]}\n\n', + 'data: {"choices":[{"finish_reason":"tool_calls"}]}\n\n', + "data: [DONE]\n\n", + ]), + { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }, + ), + ); + const client = createAIGatewayClient({ + gatewayUri: "https://gateway.example.com", + authClient, + }); + + await expect(collectEvents(client, createRequest())).resolves.toEqual([ + { + type: "tool-call", + toolCallId: "call_1", + toolName: "lookupCustomer", + argumentsText: '{"customerId":"cust-1"}', + }, + { + type: "tool-call", + toolCallId: "call_2", + toolName: "lookupOrder", + argumentsText: '{"orderId":"order-1"}', + }, + { type: "done", finishReason: "tool_calls" }, + ]); + }); + it("emits sources from json responses", async () => { const authClient = createMockAuthClient( new Response( diff --git a/packages/core/src/ai/client.ts b/packages/core/src/ai/client.ts index 3ca22dfeb..519e3fe67 100644 --- a/packages/core/src/ai/client.ts +++ b/packages/core/src/ai/client.ts @@ -297,7 +297,7 @@ function collectStreamingToolCalls( } if (toolCallDelta.function?.name) { - existing.name = toolCallDelta.function.name; + existing.name = `${existing.name ?? ""}${toolCallDelta.function.name}`; } if (toolCallDelta.function?.arguments) { From 875b3d78239279a916292ed702be13dc21a0d5a4 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Tue, 20 Oct 2026 01:36:17 +0900 Subject: [PATCH 16/19] fix(ai-chat): preserve safe history after interruption --- .changeset/ai-gateway-tools.md | 2 + packages/core/src/ai/use-ai-chat.test.tsx | 196 +++++++++++++++++++++- packages/core/src/ai/use-ai-chat.ts | 24 ++- 3 files changed, 215 insertions(+), 7 deletions(-) diff --git a/.changeset/ai-gateway-tools.md b/.changeset/ai-gateway-tools.md index 965ad83ce..67bf44206 100644 --- a/.changeset/ai-gateway-tools.md +++ b/.changeset/ai-gateway-tools.md @@ -6,6 +6,8 @@ Add tool support to `useAIChat()` and the AI Gateway transport layer. Streamed f You can now register **local tools** and **provider tools** via the `tools` option on `useAIChat()`. Tool-call and tool-result protocol messages stay internal to the hook — the public `messages` array remains user/assistant text only. +Stopped or failed requests retain user messages, partial assistant text, and completed tool exchanges in subsequent request history. Unanswered tool calls are excluded. Stopping does not undo tool side effects or guarantee safe retries of tools that were already running. + ## Local tools Define tools that run inside AppShell using `defineAIChatTool`. The model decides when to call them, AppShell validates arguments with the schema, executes the handler, and feeds results back into the next model turn automatically. diff --git a/packages/core/src/ai/use-ai-chat.test.tsx b/packages/core/src/ai/use-ai-chat.test.tsx index 04bc61b3f..bbd0b8de6 100644 --- a/packages/core/src/ai/use-ai-chat.test.tsx +++ b/packages/core/src/ai/use-ai-chat.test.tsx @@ -2,7 +2,7 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod/v4"; import { useAIChat } from "./use-ai-chat"; -import type { AIGatewayClient, AIChatCompletionEvent } from "./client"; +import type { AIGatewayChatRequest, AIGatewayClient, AIChatCompletionEvent } from "./client"; import { aiProviderTool, defineAIChatTool } from "./tools"; function createAbortError(): Error { @@ -265,6 +265,93 @@ describe("useAIChat", () => { }); }); + it.each(["stop", "error"] as const)( + "includes retained text and earlier tool history in the next request after %s", + async (interruption) => { + let releaseReply!: () => void; + const replyGate = new Promise((resolve) => { + releaseReply = resolve; + }); + const earlierToolCall = { id: "earlier-call", name: "lookup", argumentsText: "{}" }; + const earlierHistory = [ + { role: "user", content: "Earlier" }, + { role: "assistant", toolCalls: [earlierToolCall] }, + { role: "tool", toolCallId: "earlier-call", content: "found" }, + { role: "assistant", content: "Earlier answer" }, + ]; + const client = { + streamChatCompletion: vi.fn(async function* ({ messages }) { + const lastMessage = messages.at(-1); + if (lastMessage?.content === "Earlier") { + yield { + type: "tool-call", + toolCallId: earlierToolCall.id, + toolName: earlierToolCall.name, + argumentsText: "{}", + } as const; + } else if (lastMessage?.role === "tool") { + yield { type: "text-delta", text: "Earlier answer" } as const; + } else if (lastMessage?.content === "First") { + yield { type: "text-delta", text: "Partial" } as const; + await replyGate; + if (interruption === "error") throw new Error("reply failed"); + yield { type: "text-delta", text: " stale" } as const; + } else { + expect(messages).toEqual([ + ...earlierHistory, + { role: "user", content: "First" }, + { role: "assistant", content: "Partial" }, + { role: "user", content: "Continue" }, + ]); + yield { type: "text-delta", text: "Fresh" } as const; + } + yield { type: "done" } as const; + }), + } satisfies AIGatewayClient; + const lookup = defineAIChatTool({ schema: z.object({}), execute: () => "found" }); + const { result } = renderHook(() => + useAIChat({ client, model: "gpt-5-mini", tools: { lookup } }), + ); + await act(async () => { + await expect(result.current.sendMessage("Earlier")).resolves.toBe(true); + }); + + let interruptedSend!: Promise; + await act(async () => { + interruptedSend = result.current.sendMessage("First"); + }); + expect(result.current.messages.at(-1)?.content).toBe("Partial"); + + let nextSend!: Promise; + if (interruption === "stop") { + act(() => { + result.current.stop(); + nextSend = result.current.sendMessage("Continue"); + }); + } else { + await act(async () => { + releaseReply(); + await expect(interruptedSend).resolves.toBe(false); + }); + act(() => { + nextSend = result.current.sendMessage("Continue"); + }); + } + await act(async () => { + await expect(nextSend).resolves.toBe(true); + }); + if (interruption === "stop") { + await act(async () => { + releaseReply(); + await expect(interruptedSend).resolves.toBe(false); + }); + } + expect(result.current.status).toBe("ready"); + expect(result.current.messages.at(-1)?.content).toBe("Fresh"); + expect(result.current.error).toBeUndefined(); + }, + ); + it("executes local tools and continues the conversation", async () => { const lookupCustomer = defineAIChatTool({ description: "Look up a customer", @@ -463,6 +550,84 @@ describe("useAIChat", () => { ]); }); + it.each([false, true])( + "drops the unfinished tool batch on stop (has text: %s)", + async (hasText) => { + let releaseTool!: () => void; + const toolGate = new Promise((resolve) => { + releaseTool = resolve; + }); + const executePending = vi.fn(async () => { + await toolGate; + return "late result"; + }); + const completedCall = { id: "completed", name: "lookup", argumentsText: "{}" }; + const lookup = defineAIChatTool({ schema: z.object({}), execute: () => "found" }); + const pending = defineAIChatTool({ schema: z.object({}), execute: executePending }); + const client = { + streamChatCompletion: vi.fn(async function* ({ messages }) { + const lastMessage = messages.at(-1); + if (lastMessage?.content === "First") { + yield { type: "text-delta", text: "Checking" } as const; + yield { + type: "tool-call", + toolCallId: completedCall.id, + toolName: completedCall.name, + argumentsText: "{}", + } as const; + } else if (lastMessage?.role === "tool") { + if (hasText) yield { type: "text-delta", text: "Checking again" } as const; + yield { + type: "tool-call", + toolCallId: "pending", + toolName: "pending", + argumentsText: "{}", + } as const; + } else { + expect(messages).toEqual([ + { role: "user", content: "First" }, + { role: "assistant", content: "Checking", toolCalls: [completedCall] }, + { role: "tool", toolCallId: "completed", content: "found" }, + ...(hasText ? [{ role: "assistant", content: "Checking again" }] : []), + { role: "user", content: "Continue" }, + ]); + yield { type: "text-delta", text: "Fresh" } as const; + } + yield { type: "done" } as const; + }), + } satisfies AIGatewayClient; + const { result } = renderHook(() => + useAIChat({ client, model: "gpt-5-mini", tools: { lookup, pending } }), + ); + let interruptedSend!: Promise; + await act(async () => { + interruptedSend = result.current.sendMessage("First"); + }); + expect(executePending).toHaveBeenCalledOnce(); + + let nextSend!: Promise; + act(() => { + result.current.stop(); + nextSend = result.current.sendMessage("Continue"); + }); + await act(async () => { + await expect(nextSend).resolves.toBe(true); + releaseTool(); + await expect(interruptedSend).resolves.toBe(false); + }); + expect(result.current.messages.map(({ content }) => content)).toEqual([ + "First", + "Checking", + ...(hasText ? ["Checking again"] : []), + "Continue", + "Fresh", + ]); + expect(result.current.toolCalls).toEqual([]); + expect(result.current.status).toBe("ready"); + expect(client.streamChatCompletion).toHaveBeenCalledTimes(3); + }, + ); + it("clears calls on stop and ignores late events while the next flow is active", async () => { let releaseStoppedRequest!: () => void; const stoppedRequestGate = new Promise((resolve) => { @@ -474,17 +639,29 @@ describe("useAIChat", () => { }); const lookup = defineAIChatTool({ schema: z.object({}), execute: () => "found" }); const client = { - streamChatCompletion: vi.fn(async function* ({ messages }) { + streamChatCompletion: vi.fn(async function* ({ messages }: AIGatewayChatRequest) { const lastMessage = messages.at(-1); if (lastMessage?.role === "user") { - expect(messages).toEqual([{ role: "user", content: lastMessage.content }]); + expect(messages).toEqual( + lastMessage.content === "First" + ? [{ role: "user", content: "First" }] + : [ + { role: "user", content: "First" }, + { + role: "assistant", + toolCalls: [{ id: "observed", name: "lookup", argumentsText: "{}" }], + }, + { role: "tool", toolCallId: "observed", content: "found" }, + { role: "user", content: "Second" }, + ], + ); yield { type: "tool-call", toolCallId: lastMessage.content === "First" ? "observed" : "fresh", toolName: "lookup", argumentsText: "{}", } as const; - } else if (messages[0]?.content === "First") { + } else if (messages.findLast((message) => message.role === "user")?.content === "First") { await stoppedRequestGate; yield { type: "tool-call", @@ -547,7 +724,7 @@ describe("useAIChat", () => { expect(client.streamChatCompletion).toHaveBeenCalledTimes(4); }); - it("clears calls after execution and reply errors without adding tool history", async () => { + it("clears active calls but retains completed tool history after a reply error", async () => { let releaseFailure!: () => void; const failureGate = new Promise((resolve) => { releaseFailure = resolve; @@ -562,6 +739,15 @@ describe("useAIChat", () => { const client = { streamChatCompletion: vi.fn(async function* ({ messages }) { if (messages.at(-1)?.content === "Retry") { + expect(messages).toEqual([ + { role: "user", content: "Try the tool" }, + { + role: "assistant", + toolCalls: [{ id: "failed-call", name: "failingTool", argumentsText: "{}" }], + }, + { role: "tool", toolCallId: "failed-call", content: '{"error":"tool failed"}' }, + { role: "user", content: "Retry" }, + ]); yield { type: "text-delta", text: "Recovered" } as const; } else if (messages.at(-1)?.role === "tool") { expect(messages.at(-1)?.content).toBe('{"error":"tool failed"}'); diff --git a/packages/core/src/ai/use-ai-chat.ts b/packages/core/src/ai/use-ai-chat.ts index 3018687ad..e87f40fae 100644 --- a/packages/core/src/ai/use-ai-chat.ts +++ b/packages/core/src/ai/use-ai-chat.ts @@ -29,8 +29,8 @@ interface ActiveHandle { /** * Encapsulates per-request mutable state: transcript staging + streaming turn buffer. * - * Transcript is "staged" here and only committed to the persistent ref on success, - * so failed/aborted requests don't pollute conversation history. + * Success commits the full transcript; interruption preserves completed tool + * exchanges and visible text without retaining unanswered tool calls. */ class ChatRequestContext { transcript: AIGatewayChatMessage[]; @@ -66,6 +66,24 @@ class ChatRequestContext { commitToolResults(messages: Extract[]): void { this.transcript.push(...messages); } + + snapshotInterruptedTranscript(): AIGatewayChatMessage[] { + const transcript = [...this.transcript]; + const last = transcript.at(-1); + + // Tool results arrive as a complete batch, so only the tail can be unanswered. + if (last?.role === "assistant" && last.toolCalls?.length) { + transcript.pop(); + if (last.content) { + transcript.push({ role: "assistant", content: last.content }); + } + } + if (this.turnContent) { + transcript.push({ role: "assistant", content: this.turnContent }); + } + + return transcript; + } } /** @@ -106,6 +124,7 @@ export function useAIChat(config: { const active = activeRef.current; if (!active) return; + transcriptRef.current = active.ctx.snapshotInterruptedTranscript(); activeRef.current = null; active.controller.abort(); setToolCalls([]); @@ -167,6 +186,7 @@ export function useAIChat(config: { return true; } catch (caughtError) { if (activeRef.current !== handle) return false; + transcriptRef.current = ctx.snapshotInterruptedTranscript(); activeRef.current = null; setToolCalls([]); From 3a5897a9a38fe02f58387e536b3e88b73306cbbd Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Tue, 20 Oct 2026 10:14:02 +0900 Subject: [PATCH 17/19] docs(ai-chat): note low-level union compatibility --- .changeset/ai-gateway-tools.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/ai-gateway-tools.md b/.changeset/ai-gateway-tools.md index 67bf44206..f81836262 100644 --- a/.changeset/ai-gateway-tools.md +++ b/.changeset/ai-gateway-tools.md @@ -8,6 +8,8 @@ You can now register **local tools** and **provider tools** via the `tools` opti Stopped or failed requests retain user messages, partial assistant text, and completed tool exchanges in subsequent request history. Unanswered tool calls are excluded. Stopping does not undo tool side effects or guarantee safe retries of tools that were already running. +**Low-level API compatibility:** `AIGatewayChatMessage` now includes `role: "tool"`, and `AIChatCompletionEvent` now includes `type: "tool-call"`. Exhaustive handlers for these exported unions must handle the new cases, even when tools are not enabled. The high-level `useAIChat().messages` role union remains `"user" | "assistant"`. + ## Local tools Define tools that run inside AppShell using `defineAIChatTool`. The model decides when to call them, AppShell validates arguments with the schema, executes the handler, and feeds results back into the next model turn automatically. From 314562724580fec3c2c478a07d0e56651d9fcd2c Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 30 Oct 2026 11:45:05 +0900 Subject: [PATCH 18/19] refactor(ai-chat): limit tool calling to local functions --- .changeset/ai-gateway-tools.md | 28 +--- docs-manifest.json | 20 +-- docs-src/components/ai-chat.docs.outline.md | 4 +- .../create-ai-gateway-client.docs.outline.md | 38 ++---- docs-src/hooks/use-ai-chat.docs.outline.md | 23 +--- docs/api/create-ai-gateway-client.md | 38 ++---- docs/api/use-ai-chat.md | 23 +--- docs/components/ai-chat.md | 4 +- packages/core/src/ai/assistant-loop.ts | 46 ++----- packages/core/src/ai/client.test.ts | 63 +++++++-- packages/core/src/ai/client.ts | 88 ++----------- packages/core/src/ai/tools.test-d.ts | 47 +++++++ packages/core/src/ai/tools.test.ts | 11 +- packages/core/src/ai/tools.ts | 123 +----------------- packages/core/src/ai/use-ai-chat.test.tsx | 52 +------- packages/core/src/ai/use-ai-chat.ts | 23 +--- packages/core/src/index.ts | 7 - 17 files changed, 186 insertions(+), 452 deletions(-) create mode 100644 packages/core/src/ai/tools.test-d.ts diff --git a/.changeset/ai-gateway-tools.md b/.changeset/ai-gateway-tools.md index f81836262..1bdc347a8 100644 --- a/.changeset/ai-gateway-tools.md +++ b/.changeset/ai-gateway-tools.md @@ -2,9 +2,9 @@ "@tailor-platform/app-shell": minor --- -Add tool support to `useAIChat()` and the AI Gateway transport layer. Streamed function names and arguments are assembled across chunks independently for each tool call. +Add local tool support to `useAIChat()` and the AI Gateway transport layer. Streamed function names and arguments are assembled across chunks independently for each tool call. -You can now register **local tools** and **provider tools** via the `tools` option on `useAIChat()`. Tool-call and tool-result protocol messages stay internal to the hook — the public `messages` array remains user/assistant text only. +You can now register **local tools** via the `tools` option on `useAIChat()`. Tool-call and tool-result protocol messages stay internal to the hook — the public `messages` array remains user/assistant text only. Provider-native tools are not supported. Stopped or failed requests retain user messages, partial assistant text, and completed tool exchanges in subsequent request history. Unanswered tool calls are excluded. Stopping does not undo tool side effects or guarantee safe retries of tools that were already running. @@ -44,33 +44,12 @@ The `execute` function receives a second argument with: Stopping a request while tool arguments are being validated prevents the tool from starting. Cancellation is propagated rather than returned to the model as a tool error. -## Provider tools - -Provider tools are not executed locally — they are passed through to the AI Gateway and handled upstream by the model provider. - -```ts -import { aiProviderTool } from "@tailor-platform/app-shell"; - -const webSearch = aiProviderTool.openai.webSearch({ - searchContextSize: "high", - userLocation: { - type: "approximate", - country: "JP", - city: "Tokyo", - timezone: "Asia/Tokyo", - }, - filters: { - allowedDomains: ["nikkei.com", "reuters.com"], - }, -}); -``` - ## Registering tools with `useAIChat` Pass tools as a record to the `tools` option. The key becomes the tool name sent to the model. ```tsx -import { useAIChat, defineAIChatTool, aiProviderTool } from "@tailor-platform/app-shell"; +import { useAIChat, defineAIChatTool } from "@tailor-platform/app-shell"; import { z } from "zod/v4"; const lookupCustomer = defineAIChatTool({ @@ -89,7 +68,6 @@ function ChatPanel({ client }) { model: "gpt-5-mini", tools: { lookupCustomer, - web_search: aiProviderTool.openai.webSearch({ searchContextSize: "high" }), }, }); diff --git a/docs-manifest.json b/docs-manifest.json index df244255a..3863785ca 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -82,9 +82,9 @@ ], "hashes": { "typeSurface": "932ff7674cc6df99", - "outline": "534cb8372956ace8", + "outline": "0d84350ee1e55312", "snapshot": "327ac79ab66aaa45", - "outputMd": "290c2badb845f7af", + "outputMd": "82313d5b6fe140d2", "examples": null } }, @@ -327,9 +327,9 @@ "symbols": [], "hashes": { "typeSurface": null, - "outline": "ad6adf21742598f2", + "outline": "43a13487e8f3e6bd", "snapshot": null, - "outputMd": "c9813319e3089d5d", + "outputMd": "ed0db6372d25e2f9", "examples": null } }, @@ -1535,9 +1535,9 @@ "symbols": [], "hashes": { "typeSurface": null, - "outline": "4c1124d037b6618c", + "outline": "807ee6ee2b33d5de", "snapshot": null, - "outputMd": "136a4fed44331256", + "outputMd": "3bcaceab3290ca92", "examples": null } }, @@ -1774,7 +1774,7 @@ "packages/core/skills/app-shell-patterns/references/concepts/styling-theming.md": "1ed3e6d92e582e26", "packages/core/skills/app-shell-patterns/references/components/action-panel.md": "dc861082aa9610af", "packages/core/skills/app-shell-patterns/references/components/activity-card.md": "4f386e49380fb0b0", - "packages/core/skills/app-shell-patterns/references/components/ai-chat.md": "290c2badb845f7af", + "packages/core/skills/app-shell-patterns/references/components/ai-chat.md": "82313d5b6fe140d2", "packages/core/skills/app-shell-patterns/references/components/alert.md": "db2c4388ca783a75", "packages/core/skills/app-shell-patterns/references/components/app-shell.md": "081e4c7bb61db191", "packages/core/skills/app-shell-patterns/references/components/appearance-switcher.md": "b55f1d538651d467", @@ -1820,7 +1820,7 @@ "packages/core/skills/app-shell-patterns/references/components/tooltip.md": "113c95b2995917ed", "packages/core/skills/app-shell-patterns/references/components/with-guard.md": "d79aaf40cb97073f", "packages/core/skills/app-shell-patterns/references/api/aggregations.md": "daf6c93e7285ee88", - "packages/core/skills/app-shell-patterns/references/api/create-ai-gateway-client.md": "c9813319e3089d5d", + "packages/core/skills/app-shell-patterns/references/api/create-ai-gateway-client.md": "ed0db6372d25e2f9", "packages/core/skills/app-shell-patterns/references/api/create-typed-paths.md": "4c3a858d3eb02a2a", "packages/core/skills/app-shell-patterns/references/api/define-i18n-labels.md": "3035dea9ac31b7f5", "packages/core/skills/app-shell-patterns/references/api/define-module.md": "199295d8e7a9f4fa", @@ -1832,7 +1832,7 @@ "packages/core/skills/app-shell-patterns/references/api/router/use-location.md": "ddb44cc51af636cc", "packages/core/skills/app-shell-patterns/references/api/router/use-navigate.md": "d018cb13a7ddc38b", "packages/core/skills/app-shell-patterns/references/api/router/use-params.md": "535f701e77dfc4e6", - "packages/core/skills/app-shell-patterns/references/api/use-ai-chat.md": "136a4fed44331256", + "packages/core/skills/app-shell-patterns/references/api/use-ai-chat.md": "3bcaceab3290ca92", "packages/core/skills/app-shell-patterns/references/api/use-app-shell.md": "ce989e06790aade8", "packages/core/skills/app-shell-patterns/references/api/use-app-shell-config.md": "1a0a937f3a412c13", "packages/core/skills/app-shell-patterns/references/api/use-app-shell-data.md": "2d2eaf5aa04d95d3", @@ -1857,6 +1857,6 @@ "packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md": "59c93617dc135033", "packages/core/skills/app-shell-patterns/references/pages/document-detail.md": "5a6612349cb50ef1", "packages/core/skills/app-shell-patterns/references/migrations.md": "e0286979718e2769", - "packages/core/skills/app-shell-patterns/SKILL.md": "2cf44b0ad531ff84" + "packages/core/skills/app-shell-patterns/SKILL.md": "2c900e3b4d203b31" } } diff --git a/docs-src/components/ai-chat.docs.outline.md b/docs-src/components/ai-chat.docs.outline.md index a0c96caed..a80ab866c 100644 --- a/docs-src/components/ai-chat.docs.outline.md +++ b/docs-src/components/ai-chat.docs.outline.md @@ -295,7 +295,7 @@ Each call renders on its own row, in the order supplied. Labels are localized au The list clears when the response finishes, is stopped, or fails. It does not create tool messages or persist in the conversation log. -These names record model requests, not execution success. Tool arguments and results stay internal, and provider-managed usage is not exposed here. Do not infer a `ToolHeader` success or running state from name-only metadata. +These names record model requests, not execution success. Tool arguments and results stay internal. Do not infer a `ToolHeader` success or running state from name-only metadata. ### Tool @@ -315,7 +315,7 @@ Collapsible tool-call card. Maps 1:1 to the AI SDK's tool-part lifecycle, so a s ### Sources -Compact "Used N sources" citation trigger under a grounded answer. +Compact "Used N sources" citation trigger under a grounded answer. Supply source data from your application; `useAIChat()` does not return citation metadata. ```tsx diff --git a/docs-src/hooks/create-ai-gateway-client.docs.outline.md b/docs-src/hooks/create-ai-gateway-client.docs.outline.md index fda7603e8..64463135d 100644 --- a/docs-src/hooks/create-ai-gateway-client.docs.outline.md +++ b/docs-src/hooks/create-ai-gateway-client.docs.outline.md @@ -7,7 +7,7 @@ description: Create a low-level AI Gateway client that reuses AppShell authentic # createAIGatewayClient -Creates a small AI Gateway transport client for chat completions, tool calls, and optional sources. +Creates a small AI Gateway transport client for chat completions and local function tool calls. ## Signature @@ -44,7 +44,7 @@ The iterable yields completion events: - `text-delta` — append `event.text` to build the assistant response - `tool-call` — a local function tool call requested by the model -- `done` — terminal event with an optional `finishReason` and optional `sources` +- `done` — terminal event with an optional `finishReason` ## Related Types @@ -71,26 +71,19 @@ interface AIGatewayToolCall { argumentsText: string; } -type AIGatewayTool = - | { - type: "function"; - function: { - name: string; - description?: string; - parameters: Record; - }; - } - | { - type: "provider"; - provider: "openai"; - name: "web_search"; - options?: unknown; - }; +interface AIGatewayFunctionTool { + type: "function"; + function: { + name: string; + description?: string; + parameters: Record; + }; +} interface AIGatewayChatRequest { model: string; messages: AIGatewayChatMessage[]; - tools?: AIGatewayTool[]; + tools?: AIGatewayFunctionTool[]; signal?: AbortSignal; } @@ -108,14 +101,7 @@ type AIChatCompletionEvent = | { type: "done"; finishReason?: string; - sources?: AIChatSource[]; }; - -interface AIChatSource { - type: "url"; - url: string; - title?: string; -} ``` ## Usage @@ -149,7 +135,7 @@ console.log(text); ## Notes - AppShell chooses the appropriate AI Gateway transport automatically -- Local tools are sent as normalized `type: "function"` definitions; provider tools are passed through as normalized `type: "provider"` definitions +- Local tools are sent as normalized `type: "function"` definitions; provider-native tools are not supported - The low-level API stays event-based so hooks can layer streaming UI and tool loops on top - `request.signal` is passed through so callers can abort in-flight work diff --git a/docs-src/hooks/use-ai-chat.docs.outline.md b/docs-src/hooks/use-ai-chat.docs.outline.md index ecb2b2346..25f4d8913 100644 --- a/docs-src/hooks/use-ai-chat.docs.outline.md +++ b/docs-src/hooks/use-ai-chat.docs.outline.md @@ -2,12 +2,12 @@ kind: prose group: use-ai-chat title: useAIChat -description: AI Gateway chat hook with optional local and provider tools +description: AI Gateway chat hook with optional local tools --- # useAIChat -React hook for AI Gateway chat on top of `createAIGatewayClient`, with optional local and provider tool support. +React hook for AI Gateway chat on top of `createAIGatewayClient`, with optional local tool support. ## Signature @@ -15,7 +15,7 @@ React hook for AI Gateway chat on top of `createAIGatewayClient`, with optional const useAIChat: (config: { client: AIGatewayClient; model: string; - tools?: Record; + tools?: Record; }) => { messages: AIChatMessage[]; toolCalls: { id: string; name: string }[]; @@ -35,13 +35,6 @@ interface AIChatMessage { id: string; role: "user" | "assistant"; content: string; - sources?: AIChatSource[]; -} - -interface AIChatSource { - type: "url"; - url: string; - title?: string; } ``` @@ -50,7 +43,7 @@ interface AIChatSource { - **Type:** `{ id: string; name: string }[]` - **Description:** Tool calls requested by the model in the active flow, including earlier tool rounds of the current request. This transient list is cleared when the response finishes, is stopped, or fails; it is never added to `messages` or conversation history. -Calls become available before local tool execution. They record requested tool names, not successful execution. Arguments and results remain internal, and provider-managed tool usage is not reported through this field. +Calls become available before local tool execution. They record requested tool names, not successful execution. Arguments and results remain internal. ### `status` @@ -75,16 +68,12 @@ Calls become available before local tool execution. They record requested tool n ### `tools` -Register tools under a single object: - -- local tools created with `defineAIChatTool(...)`; their schemas must implement both [Standard Schema](https://standardschema.dev/) validation and Standard JSON Schema generation (for example, Zod 4) -- provider tools such as `aiProviderTool.openai.webSearch(...)` +Register local tools under a single object using `defineAIChatTool(...)`. Their schemas must implement both [Standard Schema](https://standardschema.dev/) validation and Standard JSON Schema generation (for example, Zod 4). Provider-native tools are not supported. ## Usage ```tsx import { - aiProviderTool, createAuthClient, createAIGatewayClient, defineAIChatTool, @@ -118,7 +107,6 @@ export function ChatScreen() { model: "gpt-5-mini", tools: { lookupCustomer, - web_search: aiProviderTool.openai.webSearch({ searchContextSize: "high" }), }, }); @@ -152,7 +140,6 @@ export function ChatScreen() { - AppShell chooses the appropriate AI Gateway transport automatically - Public messages stay user/assistant text-first; tool calls and results never become conversation messages -- Provider tools can attach optional `sources` to assistant messages - System prompts and custom history shaping should use the low-level client directly - `stop()` keeps already-streamed assistant text, clears the active `toolCalls`, and ignores late events from the stopped request diff --git a/docs/api/create-ai-gateway-client.md b/docs/api/create-ai-gateway-client.md index 7e58cc49e..5e7c2296e 100644 --- a/docs/api/create-ai-gateway-client.md +++ b/docs/api/create-ai-gateway-client.md @@ -7,7 +7,7 @@ description: Create a low-level AI Gateway client that reuses AppShell authentic # createAIGatewayClient -Creates a small AI Gateway transport client for chat completions, tool calls, and optional sources. +Creates a small AI Gateway transport client for chat completions and local function tool calls. ## Signature @@ -44,7 +44,7 @@ The iterable yields completion events: - `text-delta` — append `event.text` to build the assistant response - `tool-call` — a local function tool call requested by the model -- `done` — terminal event with an optional `finishReason` and optional `sources` +- `done` — terminal event with an optional `finishReason` ## Related Types @@ -71,26 +71,19 @@ interface AIGatewayToolCall { argumentsText: string; } -type AIGatewayTool = - | { - type: "function"; - function: { - name: string; - description?: string; - parameters: Record; - }; - } - | { - type: "provider"; - provider: "openai"; - name: "web_search"; - options?: unknown; - }; +interface AIGatewayFunctionTool { + type: "function"; + function: { + name: string; + description?: string; + parameters: Record; + }; +} interface AIGatewayChatRequest { model: string; messages: AIGatewayChatMessage[]; - tools?: AIGatewayTool[]; + tools?: AIGatewayFunctionTool[]; signal?: AbortSignal; } @@ -108,14 +101,7 @@ type AIChatCompletionEvent = | { type: "done"; finishReason?: string; - sources?: AIChatSource[]; }; - -interface AIChatSource { - type: "url"; - url: string; - title?: string; -} ``` ## Usage @@ -149,7 +135,7 @@ console.log(text); ## Notes - AppShell chooses the appropriate AI Gateway transport automatically -- Local tools are sent as normalized `type: "function"` definitions; provider tools are passed through as normalized `type: "provider"` definitions +- Local tools are sent as normalized `type: "function"` definitions; provider-native tools are not supported - The low-level API stays event-based so hooks can layer streaming UI and tool loops on top - `request.signal` is passed through so callers can abort in-flight work diff --git a/docs/api/use-ai-chat.md b/docs/api/use-ai-chat.md index 0bacc33b8..9b1b3fbd8 100644 --- a/docs/api/use-ai-chat.md +++ b/docs/api/use-ai-chat.md @@ -1,13 +1,13 @@ --- title: useAIChat -description: AI Gateway chat hook with optional local and provider tools +description: AI Gateway chat hook with optional local tools --- # useAIChat -React hook for AI Gateway chat on top of `createAIGatewayClient`, with optional local and provider tool support. +React hook for AI Gateway chat on top of `createAIGatewayClient`, with optional local tool support. ## Signature @@ -15,7 +15,7 @@ React hook for AI Gateway chat on top of `createAIGatewayClient`, with optional const useAIChat: (config: { client: AIGatewayClient; model: string; - tools?: Record; + tools?: Record; }) => { messages: AIChatMessage[]; toolCalls: { id: string; name: string }[]; @@ -35,13 +35,6 @@ interface AIChatMessage { id: string; role: "user" | "assistant"; content: string; - sources?: AIChatSource[]; -} - -interface AIChatSource { - type: "url"; - url: string; - title?: string; } ``` @@ -50,7 +43,7 @@ interface AIChatSource { - **Type:** `{ id: string; name: string }[]` - **Description:** Tool calls requested by the model in the active flow, including earlier tool rounds of the current request. This transient list is cleared when the response finishes, is stopped, or fails; it is never added to `messages` or conversation history. -Calls become available before local tool execution. They record requested tool names, not successful execution. Arguments and results remain internal, and provider-managed tool usage is not reported through this field. +Calls become available before local tool execution. They record requested tool names, not successful execution. Arguments and results remain internal. ### `status` @@ -75,16 +68,12 @@ Calls become available before local tool execution. They record requested tool n ### `tools` -Register tools under a single object: - -- local tools created with `defineAIChatTool(...)`; their schemas must implement both [Standard Schema](https://standardschema.dev/) validation and Standard JSON Schema generation (for example, Zod 4) -- provider tools such as `aiProviderTool.openai.webSearch(...)` +Register local tools under a single object using `defineAIChatTool(...)`. Their schemas must implement both [Standard Schema](https://standardschema.dev/) validation and Standard JSON Schema generation (for example, Zod 4). Provider-native tools are not supported. ## Usage ```tsx import { - aiProviderTool, createAuthClient, createAIGatewayClient, defineAIChatTool, @@ -118,7 +107,6 @@ export function ChatScreen() { model: "gpt-5-mini", tools: { lookupCustomer, - web_search: aiProviderTool.openai.webSearch({ searchContextSize: "high" }), }, }); @@ -152,7 +140,6 @@ export function ChatScreen() { - AppShell chooses the appropriate AI Gateway transport automatically - Public messages stay user/assistant text-first; tool calls and results never become conversation messages -- Provider tools can attach optional `sources` to assistant messages - System prompts and custom history shaping should use the low-level client directly - `stop()` keeps already-streamed assistant text, clears the active `toolCalls`, and ignores late events from the stopped request diff --git a/docs/components/ai-chat.md b/docs/components/ai-chat.md index 2e0ef98da..b7910e83b 100644 --- a/docs/components/ai-chat.md +++ b/docs/components/ai-chat.md @@ -293,7 +293,7 @@ Each call renders on its own row, in the order supplied. Labels are localized au The list clears when the response finishes, is stopped, or fails. It does not create tool messages or persist in the conversation log. -These names record model requests, not execution success. Tool arguments and results stay internal, and provider-managed usage is not exposed here. Do not infer a `ToolHeader` success or running state from name-only metadata. +These names record model requests, not execution success. Tool arguments and results stay internal. Do not infer a `ToolHeader` success or running state from name-only metadata. ### Tool @@ -313,7 +313,7 @@ Collapsible tool-call card. Maps 1:1 to the AI SDK's tool-part lifecycle, so a s ### Sources -Compact "Used N sources" citation trigger under a grounded answer. +Compact "Used N sources" citation trigger under a grounded answer. Supply source data from your application; `useAIChat()` does not return citation metadata. ```tsx diff --git a/packages/core/src/ai/assistant-loop.ts b/packages/core/src/ai/assistant-loop.ts index 6080f9bea..cab8aa351 100644 --- a/packages/core/src/ai/assistant-loop.ts +++ b/packages/core/src/ai/assistant-loop.ts @@ -2,12 +2,9 @@ import type { AIGatewayChatMessage, AIGatewayClient, AIGatewayFunctionTool, - AIGatewayProviderTool, - AIGatewayTool, AIGatewayToolCall, - AIChatSource, } from "./client"; -import type { AIChatConfiguredTool, AILocalTool } from "./tools"; +import type { AILocalTool } from "./tools"; import { deriveVisibleMessages, resolveToolCalls } from "./tool-execution"; const MAX_TOOL_ROUNDS = 8; @@ -17,8 +14,8 @@ const MAX_TOOL_ROUNDS = 8; * - normalized AI Gateway tool definitions sent over the wire * - local tool executors kept for in-process validation and execution */ -function normalizeConfiguredTools(tools: Record | undefined): { - gatewayTools: AIGatewayTool[]; +function normalizeConfiguredTools(tools: Record | undefined): { + gatewayTools: AIGatewayFunctionTool[]; localTools: Map; } { if (!tools) { @@ -30,24 +27,15 @@ function normalizeConfiguredTools(tools: Record | const localTools = new Map(); const gatewayTools = Object.entries(tools).map(([name, tool]) => { - if (tool.kind === "local") { - localTools.set(name, tool); - return { - type: "function", - function: { - name, - ...(tool.description ? { description: tool.description } : {}), - parameters: tool.schema["~standard"].jsonSchema.input({ target: "draft-07" }), - }, - } satisfies AIGatewayFunctionTool; - } - + localTools.set(name, tool); return { - type: "provider", - provider: "openai", - name: "web_search", - ...(tool.options ? { options: tool.options } : {}), - } satisfies AIGatewayProviderTool; + type: "function", + function: { + name, + ...(tool.description ? { description: tool.description } : {}), + parameters: tool.schema["~standard"].jsonSchema.input({ target: "draft-07" }), + }, + } satisfies AIGatewayFunctionTool; }); return { @@ -63,7 +51,6 @@ export interface AIChatAssistantTurnResult { export type AssistantLoopEvent = | { type: "text-delta"; delta: string } - | { type: "sources"; sources: AIChatSource[] } | { type: "turn-end"; turn: AIChatAssistantTurnResult } | { type: "tool-resolution-start" } | { @@ -80,7 +67,7 @@ export type AssistantLoopEvent = export async function* runAssistantLoop(input: { client: AIGatewayClient; model: string; - tools: Record | undefined; + tools: Record | undefined; transcript: AIGatewayChatMessage[]; signal: AbortSignal; }): AsyncGenerator { @@ -129,13 +116,13 @@ export async function* runAssistantLoop(input: { /** * Streams one assistant turn from the AI Gateway. - * Yields text-delta and sources events as they arrive. + * Yields text-delta events as they arrive. * Returns the collected turn result (content + tool calls) via generator return. */ async function* streamAssistantTurn(input: { client: AIGatewayClient; model: string; - tools: AIGatewayTool[]; + tools: AIGatewayFunctionTool[]; transcript: AIGatewayChatMessage[]; signal: AbortSignal; }): AsyncGenerator { @@ -160,11 +147,6 @@ async function* streamAssistantTurn(input: { name: event.toolName, argumentsText: event.argumentsText, }); - continue; - } - - if (event.sources?.length) { - yield { type: "sources", sources: event.sources }; } } diff --git a/packages/core/src/ai/client.test.ts b/packages/core/src/ai/client.test.ts index db1cd2571..b0c713de8 100644 --- a/packages/core/src/ai/client.test.ts +++ b/packages/core/src/ai/client.test.ts @@ -278,17 +278,23 @@ describe("createAIGatewayClient", () => { ]); }); - it("emits sources from json responses", async () => { + it("emits local function tool calls from json responses", async () => { const authClient = createMockAuthClient( new Response( JSON.stringify({ choices: [ { message: { - content: "Grounded answer", - sources: [{ type: "url", url: "https://example.com", title: "Example" }], + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "lookupCustomer", arguments: '{"customerId":"cust-1"}' }, + }, + ], }, - finish_reason: "stop", + finish_reason: "tool_calls", }, ], }), @@ -302,19 +308,58 @@ describe("createAIGatewayClient", () => { gatewayUri: "https://gateway.example.com", authClient, }); + const tools = [ + { + type: "function" as const, + function: { + name: "lookupCustomer", + parameters: { type: "object", properties: { customerId: { type: "string" } } }, + }, + }, + ]; await expect( - collectEvents(client, createRequest({ model: "gemini-2.5-flash" })), + collectEvents(client, createRequest({ model: "gemini-2.5-flash", tools })), ).resolves.toEqual([ - { type: "text-delta", text: "Grounded answer" }, { - type: "done", - finishReason: "stop", - sources: [{ type: "url", url: "https://example.com", title: "Example" }], + type: "tool-call", + toolCallId: "call_1", + toolName: "lookupCustomer", + argumentsText: '{"customerId":"cust-1"}', }, + { type: "done", finishReason: "tool_calls" }, ]); + + const [input, init] = getRequestCall(authClient); + expect(JSON.parse(String(await readRequestBody(input, init)))).toMatchObject({ + tools, + stream: false, + }); }); + it.each(["gpt-5-mini", "gemini-2.5-flash"])( + "rejects provider tools before sending a request for %s", + async (model) => { + const authClient = createMockAuthClient(new Response()); + const client = createAIGatewayClient({ + gatewayUri: "https://gateway.example.com", + authClient, + }); + + await expect( + collectEvents( + client, + createRequest({ + model, + // @ts-expect-error Provider tools are not supported. + tools: [{ type: "provider", provider: "openai", name: "web_search" }], + }), + ), + ).rejects.toThrow("AI Gateway only supports local function tools."); + expect(authClient.fetch).not.toHaveBeenCalled(); + }, + ); + it("emits done even when json responses do not include assistant text", async () => { const authClient = createMockAuthClient( new Response( diff --git a/packages/core/src/ai/client.ts b/packages/core/src/ai/client.ts index 519e3fe67..931d2857a 100644 --- a/packages/core/src/ai/client.ts +++ b/packages/core/src/ai/client.ts @@ -6,13 +6,6 @@ import type { } from "openai/resources/chat/completions"; import type { AuthClient } from "@tailor-platform/auth-public-client"; -/** Source/citation metadata attached to assistant messages when available. */ -export interface AIChatSource { - type: "url"; - url: string; - title?: string; -} - /** Normalized local function tool call emitted by the AI Gateway transport. */ export interface AIGatewayToolCall { id: string; @@ -47,20 +40,10 @@ export interface AIGatewayFunctionTool { }; } -/** Normalized provider tool definition passed through to the AI Gateway. */ -export interface AIGatewayProviderTool { - type: "provider"; - provider: "openai"; - name: "web_search"; - options?: unknown; -} - -export type AIGatewayTool = AIGatewayFunctionTool | AIGatewayProviderTool; - export interface AIGatewayChatRequest { model: string; messages: AIGatewayChatMessage[]; - tools?: AIGatewayTool[]; + tools?: AIGatewayFunctionTool[]; signal?: AbortSignal; } @@ -79,7 +62,6 @@ export type AIChatCompletionEvent = | { type: "done"; finishReason?: string; - sources?: AIChatSource[]; }; export interface AIGatewayClient { @@ -161,14 +143,12 @@ async function* streamOpenAICompatibleResponse(input: { )) as AsyncIterable; let finishReason: string | undefined; - let sources: AIChatSource[] | undefined; const toolCalls = new Map>(); for await (const chunk of stream) { const choice = chunk.choices[0]; const delta = extractStreamingText(choice); finishReason = extractFinishReason(choice?.finish_reason) ?? finishReason; - sources = extractSources(choice) ?? sources; collectStreamingToolCalls(toolCalls, choice); if (delta) { @@ -185,7 +165,7 @@ async function* streamOpenAICompatibleResponse(input: { }; } - yield createDoneEvent(finishReason, sources); + yield createDoneEvent(finishReason); } catch (error) { throw normalizeGatewayError(error, input.request.signal); } @@ -219,7 +199,7 @@ async function* streamJSONResponse(input: { }; } - yield createDoneEvent(extractFinishReason(choice?.finish_reason), extractSources(choice)); + yield createDoneEvent(extractFinishReason(choice?.finish_reason)); } catch (error) { throw normalizeGatewayError(error, input.request.signal); } @@ -233,6 +213,10 @@ function buildChatRequestBody( request: AIGatewayChatRequest, stream: boolean, ): Parameters[0] { + if (request.tools?.some((tool) => tool.type !== "function")) { + throw new Error("AI Gateway only supports local function tools."); + } + return { model: request.model, messages: request.messages.map(toOpenAIMessage), @@ -369,66 +353,12 @@ function extractText(content: unknown): string { .join(""); } -/** - * Best-effort parser for normalized provider sources/citations. - * - * This intentionally tolerates response-shape differences so AppShell can - * surface sources without depending on one provider-specific payload format. - */ -function extractSources(value: unknown): AIChatSource[] | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - - if ("message" in value) { - return extractSources((value as { message?: unknown }).message); - } - - if ("delta" in value) { - return extractSources((value as { delta?: unknown }).delta); - } - - if (!("sources" in value) || !Array.isArray(value.sources)) { - return undefined; - } - - const sources = value.sources - .map((source) => { - if (!source || typeof source !== "object") { - return null; - } - - if ((source as { type?: unknown }).type !== "url") { - return null; - } - - const url = (source as { url?: unknown }).url; - if (typeof url !== "string" || url.length === 0) { - return null; - } - - const title = (source as { title?: unknown }).title; - return { - type: "url" as const, - url, - ...(typeof title === "string" && title.length > 0 ? { title } : {}), - }; - }) - .filter((source) => source !== null); - - return sources.length > 0 ? sources : undefined; -} - function extractFinishReason(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } -function createDoneEvent(finishReason?: string, sources?: AIChatSource[]): AIChatCompletionEvent { - return { - type: "done", - ...(finishReason ? { finishReason } : {}), - ...(sources?.length ? { sources } : {}), - }; +function createDoneEvent(finishReason?: string): AIChatCompletionEvent { + return finishReason ? { type: "done", finishReason } : { type: "done" }; } function normalizeGatewayError(error: unknown, signal?: AbortSignal): Error { diff --git a/packages/core/src/ai/tools.test-d.ts b/packages/core/src/ai/tools.test-d.ts new file mode 100644 index 000000000..738d4b790 --- /dev/null +++ b/packages/core/src/ai/tools.test-d.ts @@ -0,0 +1,47 @@ +import { describe, expectTypeOf, it } from "vitest"; +import { z } from "zod/v4"; +import { + defineAIChatTool, + type AIGatewayChatRequest, + type AILocalTool, + type useAIChat, +} from "../index"; + +describe("local-only tool contract", () => { + it("accepts only local tools in useAIChat", () => { + type ConfiguredTool = NonNullable[0]["tools"]>[string]; + + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<"local">(); + }); + + it("accepts heterogeneous schemas without weakening schema or executor types", () => { + const text = defineAIChatTool({ + schema: z.object({ value: z.string() }), + execute: ({ value }) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + }); + const number = defineAIChatTool({ + schema: z.object({ value: z.number() }), + execute: ({ value }) => value, + }); + const tools = { text, number }; + const widerSchema = z.object({ value: z.union([z.string(), z.number()]) }); + + expectTypeOf(tools).toMatchTypeOf[0]["tools"]>>(); + expectTypeOf<{}>().not.toMatchTypeOf(); + expectTypeOf(text).not.toMatchTypeOf>(); + }); + + it("accepts only function tools in AI Gateway requests", () => { + type GatewayTool = NonNullable[number]; + + expectTypeOf().toEqualTypeOf<"function">(); + }); + + it("does not export provider tool helpers", () => { + expectTypeOf().not.toHaveProperty("aiProviderTool"); + }); +}); diff --git a/packages/core/src/ai/tools.test.ts b/packages/core/src/ai/tools.test.ts index 71d8fc880..2070b78a8 100644 --- a/packages/core/src/ai/tools.test.ts +++ b/packages/core/src/ai/tools.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { z } from "zod/v4"; -import { aiProviderTool, defineAIChatTool } from "./tools"; +import { defineAIChatTool } from "./tools"; describe("AI chat tool helpers", () => { it("accepts Zod schemas for local tool definitions", async () => { @@ -35,13 +35,4 @@ describe("AI chat tool helpers", () => { ), ).resolves.toEqual({ customerId: "cust-1", includeInactive: undefined, name: "Acme" }); }); - - it("creates provider tool definitions", () => { - expect(aiProviderTool.openai.webSearch({ searchContextSize: "high" })).toEqual({ - kind: "provider", - provider: "openai", - tool: "webSearch", - options: { searchContextSize: "high" }, - }); - }); }); diff --git a/packages/core/src/ai/tools.ts b/packages/core/src/ai/tools.ts index 83f119129..4388b4e74 100644 --- a/packages/core/src/ai/tools.ts +++ b/packages/core/src/ai/tools.ts @@ -34,18 +34,18 @@ export interface AIChatToolContext { * Local tools are validated with Standard Schema, executed in-process, and then * written back into the internal AI Gateway transcript as `role: "tool"` * messages for the next model round. + * Schema and executor are split so heterogeneous schemas compare structurally. */ -export interface AILocalTool< - TSchema extends AIChatToolSchema = AIChatToolSchema, -> { +export type AILocalTool = AIChatToolSchema> = { kind: "local"; description?: string; schema: TSchema; +} & { execute: ( args: StandardSchemaV1.InferOutput, context: AIChatToolContext, ) => unknown | Promise; -} +}; /** * Small typed helper for defining local AI chat tools. @@ -87,118 +87,3 @@ export function defineAIChatTool { }, ); - it("passes provider tools through and exposes sources", async () => { - const client = { - streamChatCompletion: vi.fn(async function* ({ tools }) { - expect(tools).toEqual([ - { - type: "provider", - provider: "openai", - name: "web_search", - options: { searchContextSize: "high" }, - }, - ]); - yield { type: "text-delta", text: "Latest market news" } as const; - const doneEvent: AIChatCompletionEvent = { - type: "done", - finishReason: "stop", - sources: [{ type: "url", url: "https://example.com", title: "Example" }], - }; - yield doneEvent; - }), - } satisfies AIGatewayClient; - - const { result } = renderHook(() => - useAIChat({ - client, - model: "gpt-5-mini", - tools: { - web_search: aiProviderTool.openai.webSearch({ searchContextSize: "high" }), - }, - }), - ); - - await act(async () => { - await expect(result.current.sendMessage("Search the web")).resolves.toBe(true); - }); - - await waitFor(() => { - expect(result.current.messages).toEqual([ - { id: "id-1", role: "user", content: "Search the web" }, - { - id: "id-2", - role: "assistant", - content: "Latest market news", - sources: [{ type: "url", url: "https://example.com", title: "Example" }], - }, - ]); - }); - }); - it("ignores blank and concurrent sends", async () => { let releaseRequest!: () => void; const requestGate = new Promise((resolve) => { diff --git a/packages/core/src/ai/use-ai-chat.ts b/packages/core/src/ai/use-ai-chat.ts index e87f40fae..4c7d475cb 100644 --- a/packages/core/src/ai/use-ai-chat.ts +++ b/packages/core/src/ai/use-ai-chat.ts @@ -1,19 +1,18 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { isAbortError } from "./client"; -import type { AIGatewayChatMessage, AIGatewayClient, AIChatSource } from "./client"; +import type { AIGatewayChatMessage, AIGatewayClient } from "./client"; import { runAssistantLoop, type AIChatAssistantTurnResult, type AssistantLoopEvent, } from "./assistant-loop"; -import type { AIChatConfiguredTool } from "./tools"; +import type { AILocalTool } from "./tools"; /** Public chat message shape exposed by `useAIChat()`. */ export interface AIChatMessage { id: string; role: "user" | "assistant"; content: string; - sources?: AIChatSource[]; } export type AIChatStatus = "ready" | "submitted" | "streaming" | "error"; @@ -51,10 +50,6 @@ class ChatRequestContext { return { messageId: this.turnMessageId!, content: this.turnContent, isNew }; } - get currentMessageId(): string | null { - return this.turnMessageId; - } - commitTurn(turn: AIChatAssistantTurnResult): void { if (turn.gatewayAssistantMessage.content || turn.toolCalls.length > 0) { this.transcript.push(turn.gatewayAssistantMessage); @@ -87,7 +82,7 @@ class ChatRequestContext { } /** - * React hook for AI Gateway chat with optional local and provider tools. + * React hook for AI Gateway chat with optional local tools. * * Messages stay text-first. Requested tool names are exposed only for the * active request; tool-call and tool-result protocol messages remain internal. @@ -95,7 +90,7 @@ class ChatRequestContext { export function useAIChat(config: { client: AIGatewayClient; model: string; - tools?: Record; + tools?: Record; }): { messages: AIChatMessage[]; /** Requested tools in the active flow; cleared on completion, stop, or error. */ @@ -229,16 +224,6 @@ function applyEvent( break; } - case "sources": { - const id = ctx.currentMessageId; - if (!id) break; - const { sources } = event; - updateMessages((previous) => - previous.map((entry) => (entry.id === id ? { ...entry, sources } : entry)), - ); - break; - } - case "turn-end": { if (event.turn.toolCalls.length > 0) { const calls = event.turn.toolCalls.map(({ id, name }) => ({ id, name })); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a9e3cd8b2..ed65dfd63 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -72,23 +72,16 @@ export { type AIGatewayClient, type AIGatewayChatMessage, type AIGatewayChatRequest, - type AIGatewayTool, type AIGatewayToolCall, type AIGatewayFunctionTool, - type AIGatewayProviderTool, type AIChatCompletionEvent, - type AIChatSource, } from "./ai/client"; export { useAIChat, type AIChatMessage, type AIChatStatus } from "./ai/use-ai-chat"; export { defineAIChatTool, - aiProviderTool, - type AIChatConfiguredTool, type AIChatToolContext, type AIChatToolSchema, type AILocalTool, - type AIOpenAIWebSearchTool, - type OpenAIWebSearchToolOptions, } from "./ai/tools"; export { AIChat, From 0157f3139fb0eb36fcfba89fad3c63434ee5832d Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 30 Oct 2026 13:13:36 +0900 Subject: [PATCH 19/19] test(ai-chat): sync tool call snapshot with typography roles --- docs-manifest.json | 2 +- .../src__components__ai-chat__tool-call.test.tsx.snap | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-manifest.json b/docs-manifest.json index 3863785ca..94dba88bf 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -83,7 +83,7 @@ "hashes": { "typeSurface": "932ff7674cc6df99", "outline": "0d84350ee1e55312", - "snapshot": "327ac79ab66aaa45", + "snapshot": "39edd53bc6562561", "outputMd": "82313d5b6fe140d2", "examples": null } diff --git a/packages/core/__snapshots__/src__components__ai-chat__tool-call.test.tsx.snap b/packages/core/__snapshots__/src__components__ai-chat__tool-call.test.tsx.snap index 61228a53b..7f64063a8 100644 --- a/packages/core/__snapshots__/src__components__ai-chat__tool-call.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__ai-chat__tool-call.test.tsx.snap @@ -1,3 +1,3 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`AIChat.ToolCall > renders a neutral tool-call label without implying success 1`] = `"
Tool call: add
"`; +exports[`AIChat.ToolCall > renders a neutral tool-call label without implying success 1`] = `"
Tool call: add
"`;