diff --git a/.changeset/client-tool-result-type.md b/.changeset/client-tool-result-type.md new file mode 100644 index 000000000..5c2a0a3d9 --- /dev/null +++ b/.changeset/client-tool-result-type.md @@ -0,0 +1,6 @@ +--- +"@elevenlabs/client": minor +"@elevenlabs/react": minor +--- + +Widen the client tool return type to everything the SDK already coerces. `BaseConversation` serialises an object result with `JSON.stringify` and sends anything else through `String`, but the declared type stopped at `string | number | void`, so returning an object or a boolean was a type error even though it worked. Both packages now share one exported `ClientToolResult`, so `clientTools`, `ClientTool` and `useConversationClientTool` accept the same results. No runtime change. diff --git a/packages/client/src/BaseConversation.test.ts b/packages/client/src/BaseConversation.test.ts index 1d89fd2a0..fa03ed030 100644 --- a/packages/client/src/BaseConversation.test.ts +++ b/packages/client/src/BaseConversation.test.ts @@ -4,6 +4,7 @@ import { BaseConversation, Options, PartialOptions, + type ClientToolsConfig, } from "./BaseConversation.js"; import type { BaseConnection } from "./utils/BaseConnection.js"; @@ -1348,4 +1349,96 @@ describe("BaseConversation", () => { }); }); }); + + describe("client tool results", () => { + function toolCall(toolCallId = "call-1", toolName = "lookup") { + return { + type: "client_tool_call", + client_tool_call: { + tool_name: toolName, + tool_call_id: toolCallId, + parameters: { query: "refunds" }, + event_id: 1, + }, + } as Parameters[0]; + } + + function createWithTool(handler: ClientToolsConfig["clientTools"][string]) { + const sendMessage = vi.fn(); + const connection = { + ...noopConnection, + sendMessage, + } as unknown as BaseConnection; + const conversation = TestConversation.create( + { clientTools: { lookup: handler } }, + connection + ); + return { conversation, sendMessage }; + } + + function resultOf(sendMessage: ReturnType) { + expect(sendMessage).toHaveBeenCalledTimes(1); + return sendMessage.mock.calls[0][0]; + } + + it("serialises an object result", async () => { + const { conversation, sendMessage } = createWithTool(async () => ({ + status: "ok", + count: 2, + })); + + await conversation.receiveMessage(toolCall()); + + expect(resultOf(sendMessage)).toEqual({ + type: "client_tool_result", + tool_call_id: "call-1", + result: JSON.stringify({ status: "ok", count: 2 }), + is_error: false, + }); + }); + + it("serialises an array result", async () => { + const { conversation, sendMessage } = createWithTool(() => [1, "two"]); + + await conversation.receiveMessage(toolCall()); + + expect(resultOf(sendMessage).result).toEqual(JSON.stringify([1, "two"])); + }); + + it("sends a boolean result as its string form", async () => { + // `false` is not nullish, so the default result does not apply to it and + // it has to survive as "false" rather than becoming the success message. + const { conversation, sendMessage } = createWithTool(() => false); + + await conversation.receiveMessage(toolCall()); + + expect(resultOf(sendMessage).result).toEqual("false"); + }); + + it("falls back to the default result when a tool returns nothing", async () => { + const { conversation, sendMessage } = createWithTool(() => {}); + + await conversation.receiveMessage(toolCall()); + + expect(resultOf(sendMessage).result).toEqual( + "Client tool execution successful." + ); + }); + + it("accepts every result the coercion handles, at the type level", () => { + // Compile-time only: each of these was rejected before the return type + // was widened, although the runtime has always handled them. + const handlers: ClientToolsConfig["clientTools"] = { + object: () => ({ ok: true }), + array: () => [1, 2, 3], + promisedObject: async () => ({ ok: true }), + boolean: () => true, + string: () => "text", + number: () => 1, + nothing: () => {}, + }; + + expect(Object.keys(handlers)).toHaveLength(7); + }); + }); }); diff --git a/packages/client/src/BaseConversation.ts b/packages/client/src/BaseConversation.ts index f32a57d5d..e43b44481 100644 --- a/packages/client/src/BaseConversation.ts +++ b/packages/client/src/BaseConversation.ts @@ -125,12 +125,21 @@ type MCPApproval = { controller: AbortController; }; +/** + * What a client tool handler may return. + * + * Everything reaches the agent as a string: an object or array is serialised + * with `JSON.stringify`, anything else goes through `String`, and returning + * nothing sends the default "Client tool execution successful." result. The + * type covers what that coercion handles, so a handler does not have to + * serialise on the caller's side to satisfy it. + */ +export type ClientToolResult = string | number | boolean | object | void; + export type ClientToolsConfig = { clientTools: Record< string, - ( - parameters: any - ) => Promise | string | number | void + (parameters: any) => Promise | ClientToolResult >; }; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 1192340ad..38a9512c4 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -8,6 +8,7 @@ export type { Role, Options, PartialOptions, + ClientToolResult, ClientToolsConfig, MCPToolApprovalConfig, MCPToolApprovalHandler, diff --git a/packages/react/src/conversation/ConversationClientTools.test.tsx b/packages/react/src/conversation/ConversationClientTools.test.tsx index d9b6a6445..43858d51e 100644 --- a/packages/react/src/conversation/ConversationClientTools.test.tsx +++ b/packages/react/src/conversation/ConversationClientTools.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest"; import React from "react"; import { renderHook } from "@testing-library/react"; import type { ClientToolsConfig } from "@elevenlabs/client"; +import type { ClientTool, ClientTools } from "./types.js"; import { ConversationContext, type ConversationContextValue, @@ -224,4 +225,22 @@ describe("buildClientTools", () => { expect(result).toEqual({ my_tool: handler }); }); + + it("types a hook-registered tool by what the runtime coerces", () => { + // Compile-time only. `ClientTool` used to cap the result at + // `string | number | void`, which is narrower than the coercion in + // `BaseConversation` accepts and narrower than `ClientToolsConfig`, so a + // handler could be valid for `clientTools` and invalid for this hook. + const objectTool: ClientTool = async () => ({ ok: true }); + const booleanTool: ClientTool = () => true; + const tools: ClientTools = { objectTool, booleanTool }; + + // The two types have to stay interchangeable, in both directions. + const asEntry: ClientToolEntry = objectTool; + const asTool: ClientTool = (() => ({ ok: true })) as ClientToolEntry; + + expect(Object.keys(tools)).toEqual(["objectTool", "booleanTool"]); + expect(asEntry).toBe(objectTool); + expect(typeof asTool).toBe("function"); + }); }); diff --git a/packages/react/src/conversation/types.ts b/packages/react/src/conversation/types.ts index c11c7545c..0342bb9e2 100644 --- a/packages/react/src/conversation/types.ts +++ b/packages/react/src/conversation/types.ts @@ -1,5 +1,6 @@ import type { SessionConfig, + ClientToolResult, ClientToolsConfig, InputConfig, AudioWorkletConfig, @@ -10,7 +11,12 @@ import type { Location, } from "@elevenlabs/client"; -export type ClientToolResult = string | number | void; +/** + * Re-exported so the hook API and `@elevenlabs/client` agree on what a client + * tool may return. This used to be declared here as `string | number | void`, + * which was narrower than the coercion in `BaseConversation` accepts. + */ +export type { ClientToolResult }; export type ClientTool< Parameters extends Record = Record,