UNPKG

@assistant-ui/react

Version:

Open-source TypeScript/React library for building production-grade AI chat experiences

606 lines (543 loc) • 19.9 kB
import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; import { ToolResponse, type Tool } from "assistant-stream"; import { defaultWebMcpFilter, toMcpContent, toWebMcpTool, } from "./convertTools"; type WeatherArgs = { city: string; unit?: "c" | "f" | undefined }; type FrontendTool = Extract<Tool<WeatherArgs, unknown>, { type: "frontend" }>; const jsonSchema: FrontendTool["parameters"] = { type: "object", properties: { city: { type: "string" } }, required: ["city"], }; const frontendTool = (overrides: Partial<FrontendTool> = {}): FrontendTool => ({ type: "frontend", description: "Get the weather for a city.", parameters: jsonSchema, execute: async ({ city }) => `Sunny in ${city}`, ...overrides, }); const frontendToolWithoutExecute = (): FrontendTool => ({ type: "frontend", description: "Get the weather for a city.", parameters: jsonSchema, }); const typeLessTool = (): Tool<WeatherArgs, unknown> => ({ description: "Get the weather for a city.", parameters: jsonSchema, execute: async ({ city }) => `Sunny in ${city}`, }); const typeLessToolWithoutExecute = (): Tool<WeatherArgs, unknown> => ({ type: undefined, }); const descriptorFor = ( overrides: Partial<FrontendTool> = {}, lifecycleSignal?: AbortSignal, ) => toWebMcpTool("t", () => frontendTool(overrides), lifecycleSignal); const text = (value: string) => ({ type: "text", text: value }); describe("defaultWebMcpFilter", () => { it.for([ ["exposes an enabled frontend tool", frontendTool(), true], [ "hides a backend tool", { ...frontendTool(), type: "backend" } as unknown as Tool< WeatherArgs, unknown >, false, ], [ "hides a frontend tool with no execute", frontendToolWithoutExecute(), false, ], ["hides a disabled frontend tool", frontendTool({ disabled: true }), false], ["exposes a tool authored without a type", typeLessTool(), true], [ "hides a type-less tool with no execute", typeLessToolWithoutExecute(), false, ], ] as const)("%s", ([, tool, expected]) => { expect(defaultWebMcpFilter("t", tool)).toBe(expected); }); }); describe("toWebMcpTool descriptor", () => { it("projects name, description, and the input schema", () => { const descriptor = toWebMcpTool("get_weather", () => frontendTool()); expect(descriptor.name).toBe("get_weather"); expect(descriptor.description).toBe("Get the weather for a city."); expect(descriptor.inputSchema).toEqual(jsonSchema); expect( descriptorFor({ parameters: z.object({ city: z.string() }) }).inputSchema, ).toMatchObject(jsonSchema); const bare = toWebMcpTool("bare", () => ({ type: "backend" })); expect(bare.description).toBe(""); expect(bare.inputSchema).toEqual({ type: "object", properties: {} }); }); it("throws at construction for a schema that cannot convert", () => { const badSchema = { "~standard": { version: 1, validate: () => ({ issues: undefined }) }, }; expect(() => descriptorFor({ parameters: badSchema as any })).toThrow(); }); }); describe("toWebMcpTool execute", () => { it("passes the arguments through, defaulting missing arguments to {}", async () => { const execute = vi.fn(async () => "Sunny in Paris"); const descriptor = descriptorFor({ execute }); const result = await descriptor.execute({ city: "Paris" }); expect(execute).toHaveBeenCalledWith( { city: "Paris" }, expect.objectContaining({ toolCallId: expect.any(String) }), ); expect(result).toEqual({ content: [text("Sunny in Paris")] }); await descriptor.execute(undefined); expect(execute).toHaveBeenLastCalledWith({}, expect.anything()); }); it.for([ ["serializes a non-string result", { ok: true }, '{"ok":true}'], [ "falls back to String() for an unserializable result", Symbol("opaque"), "Symbol(opaque)", ], ] as const)("%s", async ([, value, expected]) => { const result = await descriptorFor({ execute: async () => value, }).execute({}); expect(result).toEqual({ content: [text(expected)] }); }); it("reports a thrown value as an error result", async () => { const throwing = (value: unknown) => descriptorFor({ execute: async () => { throw value; }, }); await expect(throwing(new Error("boom")).execute({})).resolves.toEqual({ isError: true, content: [text("boom")], }); await expect(throwing("raw string boom").execute({})).resolves.toEqual({ isError: true, content: [text("raw string boom")], }); await expect(throwing({ code: 500 }).execute({})).resolves.toEqual({ isError: true, content: [text("[object Object]")], }); const circular: Record<string, unknown> = { a: 1 }; circular["self"] = circular; const unserializable = await descriptorFor({ execute: async () => circular, }).execute({}); expect(unserializable.isError).toBe(true); }); it("reports an error when a published tool has no client-side execute", async () => { await expect( toWebMcpTool("t", frontendToolWithoutExecute).execute({}), ).resolves.toEqual({ isError: true, content: [text('Tool "t" has no client-side implementation.')], }); }); it("projects values JSON cannot serialize but that have a string form", async () => { await expect( descriptorFor({ execute: async () => 9007199254740993n }).execute({}), ).resolves.toEqual({ content: [text("9007199254740993")] }); await expect( descriptorFor({ execute: async () => Symbol("ticket") }).execute({}), ).resolves.toEqual({ content: [text("Symbol(ticket)")] }); }); it("rejects human input requests", async () => { const result = await descriptorFor({ execute: async (_args, context) => await context.human(undefined), }).execute({}); expect(result).toEqual({ isError: true, content: [text("human input not supported in WebMCP context")], }); }); }); describe("toWebMcpTool schema validation", () => { const zodTool = (overrides: Omit<Partial<FrontendTool>, "parameters"> = {}) => descriptorFor({ parameters: z.object({ city: z.string() }), ...overrides, }); it("runs execute unchanged when the arguments validate", async () => { const execute = vi.fn(async () => "ok"); const result = await zodTool({ execute }).execute({ city: "Paris" }); expect(execute).toHaveBeenCalledWith({ city: "Paris" }, expect.anything()); expect(result).toEqual({ content: [text("ok")] }); }); it("uses schema output for execution and model content", async () => { const args = { city: " Paris ", extra: true }; const result = await descriptorFor({ parameters: z.object({ city: z.string().trim(), unit: z.enum(["c", "f"]).default("c"), }), execute: ({ city, unit }) => `Weather in ${city} (${unit})`, toModelOutput: ({ input, output }) => [ { type: "text", text: `${JSON.stringify(input)}: ${output}` }, ], }).execute(args); expect(result).toEqual({ content: [text('{"city":"Paris","unit":"c"}: Weather in Paris (c)')], }); expect(args).toEqual({ city: " Paris ", extra: true }); }); it("returns a validation error when the arguments do not validate", async () => { const execute = vi.fn(async () => "ok"); const result = await zodTool({ execute }).execute({ city: 42 }); expect(execute).not.toHaveBeenCalled(); expect(result.isError).toBe(true); expect(result.content[0]).toMatchObject({ text: expect.stringContaining("Function parameter validation failed."), }); }); it("hands invalid arguments to experimental_onSchemaValidationError", async () => { const execute = vi.fn(async () => "ok"); const result = await zodTool({ execute, experimental_onSchemaValidationError: async (args) => `Invalid: ${JSON.stringify(args)}`, }).execute({ city: 42 }); expect(execute).not.toHaveBeenCalled(); expect(result).toEqual({ content: [text('Invalid: {"city":42}')] }); }); it("awaits a validator that returns a non-Promise thenable", async () => { const execute = vi.fn(async () => "ok"); const schema = z.object({ city: z.string() }); (schema as any)["~standard"] = { ...schema["~standard"], validate: () => ({ then: (resolve: (value: { issues: unknown[] }) => void) => { resolve({ issues: [{ message: "cross-realm" }] }); }, }), }; const result = await descriptorFor({ execute, parameters: schema }).execute( { city: 42 }, ); expect(execute).not.toHaveBeenCalled(); expect(result.isError).toBe(true); expect(result.content[0]).toMatchObject({ text: expect.stringContaining("cross-realm"), }); }); it("awaits an async Standard Schema validation", async () => { const execute = vi.fn(async () => "ok"); const schema = z.object({ city: z.string() }); const sync = schema["~standard"].validate; (schema as any)["~standard"] = { ...schema["~standard"], validate: async (value: unknown) => sync(value), }; const result = await descriptorFor({ execute, parameters: schema }).execute( { city: 42 }, ); expect(execute).not.toHaveBeenCalled(); expect(result.isError).toBe(true); }); }); describe("toWebMcpTool cancellation", () => { it("refuses to run once its lifecycle signal is aborted", async () => { const execute = vi.fn(async () => "never"); const controller = new AbortController(); controller.abort(); const result = await descriptorFor({ execute }, controller.signal).execute( {}, ); expect(execute).not.toHaveBeenCalled(); expect(result).toEqual({ isError: true, content: [text('Tool "t" is no longer registered')], }); }); it("refuses to run when the caller signal is already aborted", async () => { const execute = vi.fn(async () => "never"); const controller = new AbortController(); controller.abort(); const result = await descriptorFor({ execute }).execute( {}, { signal: controller.signal }, ); expect(execute).not.toHaveBeenCalled(); expect(result).toEqual({ isError: true, content: [text("Tool execution was cancelled.")], }); }); it.for(["caller", "lifecycle"] as const)( "settles while async validation is pending when the %s signal aborts", async (abortedSignal) => { const lifecycle = new AbortController(); const caller = new AbortController(); let finishValidation!: (result: { issues?: readonly unknown[] }) => void; const schema = z.object({ city: z.string() }); (schema as any)["~standard"] = { ...schema["~standard"], validate: () => new Promise<{ issues?: readonly unknown[] }>((resolve) => { finishValidation = resolve; }), }; const execute = vi.fn(async () => "never"); const pending = descriptorFor( { execute, parameters: schema }, lifecycle.signal, ).execute({ city: "Paris" }, { signal: caller.signal }); (abortedSignal === "caller" ? caller : lifecycle).abort(); await expect(pending).resolves.toEqual({ isError: true, content: [text("Tool execution was cancelled.")], }); expect(execute).not.toHaveBeenCalled(); finishValidation({}); }, ); it("consumes a validator rejection after cancellation", async () => { const caller = new AbortController(); let failValidation!: (error: unknown) => void; const schema = z.object({ city: z.string() }); (schema as any)["~standard"] = { ...schema["~standard"], validate: () => new Promise((_resolve, reject) => { failValidation = reject; }), }; const execute = vi.fn(async () => "never"); const pending = descriptorFor({ execute, parameters: schema }).execute( { city: "Paris" }, { signal: caller.signal }, ); caller.abort(); await expect(pending).resolves.toEqual({ isError: true, content: [text("Tool execution was cancelled.")], }); failValidation(new Error("late validation failure")); await new Promise((resolve) => setTimeout(resolve, 0)); expect(execute).not.toHaveBeenCalled(); }); it("prefers cancellation when validation aborts before rejecting", async () => { const caller = new AbortController(); const schema = z.object({ city: z.string() }); (schema as any)["~standard"] = { ...schema["~standard"], validate: () => { caller.abort(); return Promise.reject(new Error("validation failed")); }, }; const execute = vi.fn(async () => "never"); const result = await descriptorFor({ execute, parameters: schema }).execute( { city: "Paris" }, { signal: caller.signal }, ); expect(result).toEqual({ isError: true, content: [text("Tool execution was cancelled.")], }); expect(execute).not.toHaveBeenCalled(); }); it("does not execute when cancellation follows validation", async () => { const caller = new AbortController(); const schema = z.object({ city: z.string() }); (schema as any)["~standard"] = { ...schema["~standard"], validate: () => ({ then: (resolve: (value: { issues?: readonly unknown[] }) => void) => { resolve({}); caller.abort(); }, }), }; const execute = vi.fn(async () => "never"); const result = await descriptorFor({ execute, parameters: schema }).execute( { city: "Paris" }, { signal: caller.signal }, ); expect(result).toEqual({ isError: true, content: [text("Tool execution was cancelled.")], }); expect(execute).not.toHaveBeenCalled(); }); it.for(["caller", "lifecycle"] as const)( "merges signals without AbortSignal.any when the %s signal aborts", async (abortedSignal) => { const lifecycle = new AbortController(); const caller = new AbortController(); const abortSignalConstructor = AbortSignal as typeof AbortSignal & { any?: (signals: Iterable<AbortSignal>) => AbortSignal; }; const originalAbortSignalAny = abortSignalConstructor.any; Object.defineProperty(abortSignalConstructor, "any", { configurable: true, value: () => { throw new Error("AbortSignal.any is not available"); }, }); try { const descriptor = descriptorFor( { execute: async (_args: unknown, context: any) => new Promise((_resolve, reject) => { context.abortSignal.addEventListener("abort", () => reject(new Error("aborted")), ); }), }, lifecycle.signal, ); const pending = descriptor.execute({}, { signal: caller.signal }); (abortedSignal === "caller" ? caller : lifecycle).abort(); await expect(pending).resolves.toEqual({ isError: true, content: [text("aborted")], }); } finally { Object.defineProperty(abortSignalConstructor, "any", { configurable: true, value: originalAbortSignalAny, }); } }, ); it("removes merged signal listeners after execution", async () => { const lifecycle = new AbortController(); const caller = new AbortController(); const callerRemove = vi.spyOn(caller.signal, "removeEventListener"); const lifecycleRemove = vi.spyOn(lifecycle.signal, "removeEventListener"); const onAbort = vi.fn(); const result = await descriptorFor( { execute: async (_args, context) => { context.abortSignal.addEventListener("abort", onAbort); return "ok"; }, }, lifecycle.signal, ).execute({}, { signal: caller.signal }); expect(result).toEqual({ content: [text("ok")] }); expect(callerRemove).toHaveBeenCalledTimes(1); expect(lifecycleRemove).toHaveBeenCalledTimes(1); caller.abort(); lifecycle.abort(); expect(onAbort).not.toHaveBeenCalled(); }); it("merges a caller signal that is not a native AbortSignal", async () => { const listeners: (() => void)[] = []; const foreignSignal = { aborted: false, reason: new Error("host cancelled"), addEventListener: (_type: string, listener: () => void) => { listeners.push(listener); }, removeEventListener: () => {}, } as unknown as AbortSignal; const pending = descriptorFor( { execute: async (_args: unknown, context: any) => new Promise((_resolve, reject) => { context.abortSignal.addEventListener("abort", () => reject(new Error("aborted")), ); }), }, new AbortController().signal, ).execute({}, { signal: foreignSignal }); for (const listener of listeners) listener(); await expect(pending).resolves.toEqual({ isError: true, content: [text("aborted")], }); }); }); describe("toMcpContent", () => { const options = { tool: frontendTool(), toolCallId: "1", args: {} }; it.for([ ["text parts", [{ type: "text", text: "hello" }], [text("hello")]], [ "image file parts", [{ type: "file", data: "AAA", mediaType: "image/png" }], [{ type: "image", data: "AAA", mimeType: "image/png" }], ], [ "image file parts with no data", [{ type: "file", mediaType: "image/png" }], [{ type: "image", data: "", mimeType: "image/png" }], ], [ "non-image file parts", [{ type: "file", data: "raw", mediaType: "text/plain" }], [text("raw")], ], [ "unknown parts", [{ type: "reasoning", text: "why" }], [text('{"type":"reasoning","text":"why"}')], ], ] as const)("maps %s", async ([, modelContent, expected]) => { const response = await toMcpContent( new ToolResponse({ result: "r", modelContent: modelContent as any }), options, ); expect(response).toEqual({ content: expected }); }); it("marks an error response, with or without an explicit modelContent", async () => { await expect( toMcpContent( new ToolResponse({ result: "r", isError: true, modelContent: [{ type: "text", text: "failed" }], }), options, ), ).resolves.toEqual({ isError: true, content: [text("failed")] }); await expect( toMcpContent( new ToolResponse({ result: "went wrong", isError: true }), options, ), ).resolves.toEqual({ isError: true, content: [text("went wrong")] }); }); it("projects a successful result through toModelOutput", async () => { const toModelOutput = vi.fn<NonNullable<FrontendTool["toModelOutput"]>>( async () => [{ type: "text", text: "projected" }], ); const response = await toMcpContent("raw", { ...options, tool: frontendTool({ toModelOutput }), }); expect(toModelOutput).toHaveBeenCalledWith({ toolCallId: "1", input: {}, output: "raw", }); expect(response).toEqual({ content: [text("projected")] }); }); it("falls back to the default projection when toModelOutput throws", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const response = await toMcpContent("raw", { ...options, tool: frontendTool({ toModelOutput: () => { throw new Error("bad projection"); }, }), }); expect(response).toEqual({ content: [text("raw")] }); expect(warn).toHaveBeenCalled(); warn.mockRestore(); }); });