Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/client-tool-result-type.md
Original file line number Diff line number Diff line change
@@ -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.
93 changes: 93 additions & 0 deletions packages/client/src/BaseConversation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
BaseConversation,
Options,
PartialOptions,
type ClientToolsConfig,
} from "./BaseConversation.js";
import type { BaseConnection } from "./utils/BaseConnection.js";

Expand Down Expand Up @@ -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<TestConversation["receiveMessage"]>[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<typeof vi.fn>) {
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);
});
});
});
15 changes: 12 additions & 3 deletions packages/client/src/BaseConversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> | string | number | void
(parameters: any) => Promise<ClientToolResult> | ClientToolResult
>;
};

Expand Down
1 change: 1 addition & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type {
Role,
Options,
PartialOptions,
ClientToolResult,
ClientToolsConfig,
MCPToolApprovalConfig,
MCPToolApprovalHandler,
Expand Down
19 changes: 19 additions & 0 deletions packages/react/src/conversation/ConversationClientTools.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
});
});
8 changes: 7 additions & 1 deletion packages/react/src/conversation/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
SessionConfig,
ClientToolResult,
ClientToolsConfig,
InputConfig,
AudioWorkletConfig,
Expand All @@ -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<string, unknown> = Record<string, unknown>,
Expand Down