From b369e8fdca54b4f179147ad2ee53a1704a853a24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 7 May 2026 13:21:58 +0200 Subject: [PATCH 1/7] Move schemas to repo root and manual types to client package Move AsyncAPI schemas from packages/types/schemas/ to schemas/ at repo root, with symlinks from both packages. Move manually maintained types (Role, Mode, Status, Callbacks, etc.) from @elevenlabs/types to @elevenlabs/client, making the types package generated-code-only. This is preparation for event codegen in the client package, which needs direct access to the schemas. Co-Authored-By: Claude Opus 4.6 --- packages/client/schemas | 1 + packages/client/src/BaseConversation.ts | 15 ++++-- packages/client/src/index.ts | 10 ++-- packages/{types => client}/src/types.ts | 49 ++++++++++--------- packages/client/src/utils/BaseConnection.ts | 5 +- packages/client/src/utils/events.ts | 2 +- packages/types/schemas | 1 + packages/types/src/index.ts | 2 - .../schemas => schemas}/agent.asyncapi.yaml | 0 .../schemas => schemas}/scribe.asyncapi.yaml | 0 10 files changed, 47 insertions(+), 38 deletions(-) create mode 120000 packages/client/schemas rename packages/{types => client}/src/types.ts (65%) create mode 120000 packages/types/schemas rename {packages/types/schemas => schemas}/agent.asyncapi.yaml (100%) rename {packages/types/schemas => schemas}/scribe.asyncapi.yaml (100%) diff --git a/packages/client/schemas b/packages/client/schemas new file mode 120000 index 000000000..30aed58dd --- /dev/null +++ b/packages/client/schemas @@ -0,0 +1 @@ +../../schemas \ No newline at end of file diff --git a/packages/client/src/BaseConversation.ts b/packages/client/src/BaseConversation.ts index 5ebeacbb5..9de90c38e 100644 --- a/packages/client/src/BaseConversation.ts +++ b/packages/client/src/BaseConversation.ts @@ -1,7 +1,6 @@ -import { Callbacks, Mode, Status } from "@elevenlabs/types"; +import type { Callbacks, Mode, Status, DisconnectionDetails } from "./types.js"; import type { BaseConnection, - DisconnectionDetails, SessionConfig, FormatConfig, } from "./utils/BaseConnection.js"; @@ -32,8 +31,16 @@ import type { OutputConfig } from "./utils/output.js"; const HTTPS_API_ORIGIN = "https://api.elevenlabs.io"; -export type { Role, Mode, Status, Callbacks } from "@elevenlabs/types"; -export { CALLBACK_KEYS } from "@elevenlabs/types"; +export type { + Role, + Mode, + Status, + Callbacks, + DisconnectionDetails, + MessagePayload, + AudioAlignmentEvent, +} from "./types.js"; +export { CALLBACK_KEYS } from "./types.js"; /** Allows self-hosting the worklets to avoid whitelisting blob: and data: in the CSP script-src */ export type AudioWorkletConfig = { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index b04be91ce..7183d1161 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -16,6 +16,9 @@ export type { ConversationCreatedCallback, ConversationLifecycleOptions, ContextualUpdateOptions, + DisconnectionDetails, + MessagePayload, + AudioAlignmentEvent, } from "./BaseConversation.js"; export type { InputController, InputDeviceConfig } from "./InputController.js"; export type { @@ -24,15 +27,10 @@ export type { } from "./OutputController.js"; export type { InputConfig } from "./utils/input.js"; export type { OutputConfig } from "./utils/output.js"; -export type { - IncomingSocketEvent, - VadScoreEvent, - AudioAlignmentEvent, -} from "./utils/events.js"; +export type { IncomingSocketEvent, VadScoreEvent } from "./utils/events.js"; export type { SessionConfig, BaseSessionConfig, - DisconnectionDetails, Language, ConnectionType, FormatConfig, diff --git a/packages/types/src/types.ts b/packages/client/src/types.ts similarity index 65% rename from packages/types/src/types.ts rename to packages/client/src/types.ts index 496bfecc6..684fdf030 100644 --- a/packages/types/src/types.ts +++ b/packages/client/src/types.ts @@ -1,11 +1,23 @@ -import type * as Generated from "../generated/types/asyncapi-types.js"; +import type { + AgentChatResponsePartClientEvent, + AgentResponseCorrection, + AgentToolRequestClientEvent, + AgentToolResponseClientEvent, + AsrInitiationMetadataEvent as AsrInitiationMetadataWireEvent, + AudioEventAlignment, + ClientToolCallClientEvent, + ConversationMetadata, + Interruption, + McpConnectionStatusClientEvent, + McpToolCallClientEvent, +} from "@elevenlabs/types"; /** * Role in the conversation */ export type Role = "user" | "agent"; -export type AudioAlignmentEvent = Generated.AudioEventAlignment; +export type AudioAlignmentEvent = AudioEventAlignment; /** * Current mode of the conversation @@ -58,54 +70,47 @@ export interface MessagePayload { export type Callbacks = { onConnect?: (props: { conversationId: string }) => void; onDisconnect?: (details: DisconnectionDetails) => void; - onError?: (message: string, context?: any) => void; + onError?: (message: string, context?: unknown) => void; onMessage?: (props: MessagePayload) => void; onAudio?: (base64Audio: string) => void; onModeChange?: (prop: { mode: Mode }) => void; onStatusChange?: (prop: { status: Status }) => void; onCanSendFeedbackChange?: (prop: { canSendFeedback: boolean }) => void; onUnhandledClientToolCall?: ( - params: Generated.ClientToolCallClientEvent["client_tool_call"] + params: ClientToolCallClientEvent["client_tool_call"] ) => void; onVadScore?: (props: { vadScore: number }) => void; - onMCPToolCall?: ( - props: Generated.McpToolCallClientEvent["mcp_tool_call"] - ) => void; + onMCPToolCall?: (props: McpToolCallClientEvent["mcp_tool_call"]) => void; onMCPConnectionStatus?: ( - props: Generated.McpConnectionStatusClientEvent["mcp_connection_status"] + props: McpConnectionStatusClientEvent["mcp_connection_status"] ) => void; onAgentToolRequest?: ( - props: Generated.AgentToolRequestClientEvent["agent_tool_request"] + props: AgentToolRequestClientEvent["agent_tool_request"] ) => void; onAgentToolResponse?: ( - props: Generated.AgentToolResponseClientEvent["agent_tool_response"] + props: AgentToolResponseClientEvent["agent_tool_response"] ) => void; onConversationMetadata?: ( - props: Generated.ConversationMetadata["conversation_initiation_metadata_event"] + props: ConversationMetadata["conversation_initiation_metadata_event"] ) => void; onAsrInitiationMetadata?: ( - props: Generated.AsrInitiationMetadataEvent["asr_initiation_metadata_event"] - ) => void; - onInterruption?: ( - props: Generated.Interruption["interruption_event"] + props: AsrInitiationMetadataWireEvent["asr_initiation_metadata_event"] ) => void; + onInterruption?: (props: Interruption["interruption_event"]) => void; onAgentResponseCorrection?: ( - props: Generated.AgentResponseCorrection["agent_response_correction_event"] + props: AgentResponseCorrection["agent_response_correction_event"] ) => void; onAgentChatResponsePart?: ( - props: Generated.AgentChatResponsePartClientEvent["text_response_part"] + props: AgentChatResponsePartClientEvent["text_response_part"] ) => void; onGuardrailTriggered?: () => void; - onAudioAlignment?: ( - props: AudioAlignmentEvent - ) => void; + onAudioAlignment?: (props: AudioAlignmentEvent) => void; // internal debug events, not to be used - onDebug?: (props: any) => void; + onDebug?: (props: unknown) => void; }; /** * Runtime array of all keys in `Callbacks`, kept in sync with the type above. - * Used by the React SDK to pre-initialize listener maps for callback composition. */ export const CALLBACK_KEYS = [ "onConnect", diff --git a/packages/client/src/utils/BaseConnection.ts b/packages/client/src/utils/BaseConnection.ts index 1dba82a74..c325ac33b 100644 --- a/packages/client/src/utils/BaseConnection.ts +++ b/packages/client/src/utils/BaseConnection.ts @@ -1,14 +1,13 @@ import type { IncomingSocketEvent, OutgoingSocketEvent } from "./events.js"; -import type { Mode } from "../BaseConversation.js"; +import type { DisconnectionDetails, Mode } from "../types.js"; import type { ConversationConfigOverrideAgentPrompt, - DisconnectionDetails, ConversationConfigOverrideAgentLanguage as Language, } from "@elevenlabs/types"; +export type { DisconnectionDetails } from "../types.js"; export type { ConversationConfigOverrideAgentPrompt, - DisconnectionDetails, ConversationConfigOverrideAgentLanguage as Language, } from "@elevenlabs/types"; diff --git a/packages/client/src/utils/events.ts b/packages/client/src/utils/events.ts index 6dc8307f8..138adee2b 100644 --- a/packages/client/src/utils/events.ts +++ b/packages/client/src/utils/events.ts @@ -5,7 +5,6 @@ import type { AgentToolResponseClientEvent, AsrInitiationMetadataEvent as AsrMetadataEvent, Audio, - AudioAlignmentEvent, AgentToolRequestClientEvent, ClientToolCallMessage, ConversationMetadata, @@ -20,6 +19,7 @@ import type { UserTranscript, VadScore, } from "@elevenlabs/types"; +import type { AudioAlignmentEvent } from "../types.js"; // Compatibility layer - incoming events export type UserTranscriptionEvent = UserTranscript; diff --git a/packages/types/schemas b/packages/types/schemas new file mode 120000 index 000000000..30aed58dd --- /dev/null +++ b/packages/types/schemas @@ -0,0 +1 @@ +../../schemas \ No newline at end of file diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index e54db4756..6d190a860 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -10,5 +10,3 @@ export * as Outgoing from "../generated/types/outgoing.js"; export * from "../generated/types/asyncapi-types.js"; -// SDK-specific shared types -export * from "./types.js"; diff --git a/packages/types/schemas/agent.asyncapi.yaml b/schemas/agent.asyncapi.yaml similarity index 100% rename from packages/types/schemas/agent.asyncapi.yaml rename to schemas/agent.asyncapi.yaml diff --git a/packages/types/schemas/scribe.asyncapi.yaml b/schemas/scribe.asyncapi.yaml similarity index 100% rename from packages/types/schemas/scribe.asyncapi.yaml rename to schemas/scribe.asyncapi.yaml From ac29a230e2b75c948da3f4fe24641e2401752895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 7 May 2026 13:53:57 +0200 Subject: [PATCH 2/7] Add changeset for types package breaking change Records the removal of manual types from @elevenlabs/types as a minor bump (pre-v1). Consumers should import these types from @elevenlabs/client instead. Co-Authored-By: Claude Opus 4.6 --- .changeset/move-types-to-client.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/move-types-to-client.md diff --git a/.changeset/move-types-to-client.md b/.changeset/move-types-to-client.md new file mode 100644 index 000000000..100e66111 --- /dev/null +++ b/.changeset/move-types-to-client.md @@ -0,0 +1,5 @@ +--- +"@elevenlabs/types": minor +--- + +Remove manually maintained types (`Role`, `Mode`, `Status`, `Callbacks`, `CALLBACK_KEYS`, `DisconnectionDetails`, `MessagePayload`, `AudioAlignmentEvent`) from `@elevenlabs/types`. These types now live in `@elevenlabs/client` — import them from there instead. The types package now contains only generated code. From ccc9b90925cada389df356f268ee92bf05c82f3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 7 May 2026 13:48:52 +0200 Subject: [PATCH 3/7] Add event codegen script and generated event types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `scripts/generate-events.ts` to the client package that reads `agent.asyncapi.yaml` and generates: - `GeneratedEventMap` with typed event callbacks for each wire event - `GeneratedWireType` union of all generated wire type strings - `WIRE_TYPE_TO_EVENT_NAME` / `WIRE_TYPE_TO_PAYLOAD_FIELD` lookup tables - `dispatchGeneratedEvent()` helper for runtime dispatch Internally handled events (audio, agent_response, user_transcript, client_tool_call, ping, error, internal_tentative_agent_response) are excluded — they need custom dispatch logic in BaseConversation. Co-Authored-By: Claude Opus 4.6 --- packages/client/package.json | 5 +- packages/client/scripts/generate-events.ts | 255 +++++++++++++++++++++ packages/client/src/generated/dispatch.ts | 26 +++ packages/client/src/generated/events.ts | 108 +++++++++ pnpm-lock.yaml | 250 ++++++++++---------- turbo.json | 6 +- 6 files changed, 528 insertions(+), 122 deletions(-) create mode 100644 packages/client/scripts/generate-events.ts create mode 100644 packages/client/src/generated/dispatch.ts create mode 100644 packages/client/src/generated/events.ts diff --git a/packages/client/package.json b/packages/client/package.json index 971509db0..cb4177a9b 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -14,6 +14,7 @@ }, "scripts": { "generate-version": "printf \"// This file is auto-generated during build\\nexport const PACKAGE_VERSION = \\\"%s\\\";\\n\" \"$npm_package_version\" > src/version.ts", + "generate-events": "node scripts/generate-events.ts && prettier --write src/generated/**/*.ts", "generate-worklets": "node scripts/generateWorklets.js", "build:esm": "tsc --build", "build:iife": "rolldown src/index.ts -f iife --name ElevenLabsClient -o dist/lib.iife.js --sourcemap --external react --external react-dom", @@ -38,10 +39,12 @@ "mock-socket": "^9.3.1", "node-wav": "^0.0.2", "playwright": "^1.54.1", + "prettier": "^3.8.3", "rolldown": "^1.0.0-rc.9", "typescript": "^5.5.4", "typescript-eslint": "^8.59.2", - "vitest": "^4.1.5" + "vitest": "^4.1.5", + "yaml": "^2.8.4" }, "repository": { "type": "git", diff --git a/packages/client/scripts/generate-events.ts b/packages/client/scripts/generate-events.ts new file mode 100644 index 000000000..ba603f4a3 --- /dev/null +++ b/packages/client/scripts/generate-events.ts @@ -0,0 +1,255 @@ +/** + * Event codegen script — reads agent.asyncapi.yaml and generates: + * src/generated/events.ts — GeneratedEventMap, wire type unions, lookup tables + * src/generated/dispatch.ts — dispatchGeneratedEvent() helper + * + * Run: node scripts/generate-events.ts + */ + +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import path from "node:path"; +import { parse as parseYaml } from "yaml"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Resolve a JSON-pointer $ref within the parsed schema object. */ +function resolveRef(schema: Record, ref: string): unknown { + const parts = ref.replace("#/", "").split("/"); + let current: unknown = schema; + for (const part of parts) { + current = (current as Record)[part]; + } + return current; +} + +/** + * Replicate Modelina's PascalCase normalisation for model names. + * Consecutive uppercase letters are lowered except the first character: + * MCPToolCall → McpToolCall, VADScore → VadScore, ASRInit → AsrInit + */ +function toModelinaPascalCase(name: string): string { + return name.replace( + /([A-Z]+)([A-Z][a-z])/g, + (_, prefix: string, suffix: string) => + prefix[0] + prefix.slice(1).toLowerCase() + suffix, + ); +} + +// --------------------------------------------------------------------------- +// Parse schema +// --------------------------------------------------------------------------- + +const schemasDir = path.resolve(process.cwd(), "schemas"); +const outDir = path.resolve(process.cwd(), "src/generated"); +const schemaPath = path.join(schemasDir, "agent.asyncapi.yaml"); +const schema = parseYaml(readFileSync(schemaPath, "utf8")) as Record< + string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any +>; + +// --------------------------------------------------------------------------- +// Internally handled events — need custom dispatch logic in BaseConversation +// --------------------------------------------------------------------------- + +const INTERNALLY_HANDLED_EVENTS: ReadonlySet = new Set([ + "agent_response", // combined with user_transcript → "message" + "user_transcript", // combined with agent_response → "message" + "audio", // unwraps base64 + alignment + mode tracking + "client_tool_call", // internal tool dispatch, not emitted + "internal_tentative_agent_response", // emits "debug" + "ping", // internal pong response + "error", // routed through handleErrorEvent +]); + +// --------------------------------------------------------------------------- +// Extract server → client messages from the publish channel +// --------------------------------------------------------------------------- + +const publishRefs = schema.channels.AgentMessages.publish.message.oneOf as { + $ref: string; +}[]; + +type EventInfo = { + messageKey: string; + typeName: string; + wireType: string; + eventName: string; + payloadField: string | null; +}; + +const allWireTypes: string[] = []; +const events: EventInfo[] = []; + +for (const ref of publishRefs) { + const messageKey = ref.$ref.split("/").pop()!; + const message = schema.components.messages[messageKey]; + + // Resolve payload schema (always a $ref in our spec) + const payloadSchema = message.payload.$ref + ? resolveRef(schema, message.payload.$ref) + : message.payload; + + // Extract wire type from the `type` property's const value + const wireType = (payloadSchema as Record & { + properties: Record; + }).properties.type?.const; + if (!wireType) { + throw new Error(`No type const found in payload for message ${messageKey}`); + } + + allWireTypes.push(wireType); + + if (INTERNALLY_HANDLED_EVENTS.has(wireType)) continue; + + // Find the single non-`type` property (the payload field to unwrap) + const payloadFields = Object.keys( + (payloadSchema as { properties: Record }).properties, + ).filter((k) => k !== "type"); + if (payloadFields.length > 1) { + throw new Error( + `Multiple non-type fields in ${messageKey}: ${payloadFields.join(", ")}`, + ); + } + const payloadField = payloadFields.length === 1 ? payloadFields[0] : null; + + const typeName = toModelinaPascalCase(messageKey); + const eventName = wireType.replace(/_/g, "-"); + + events.push({ messageKey, typeName, wireType, eventName, payloadField }); +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +// Every internally-handled wire type must actually exist in the spec +for (const handled of INTERNALLY_HANDLED_EVENTS) { + if (!allWireTypes.includes(handled)) { + console.warn( + `⚠ Internally handled event "${handled}" not found in schema`, + ); + } +} + +// Every wire type must be either internally handled or generated +const unaccounted = allWireTypes.filter( + (wt) => + !INTERNALLY_HANDLED_EVENTS.has(wt) && + !events.some((e) => e.wireType === wt), +); +if (unaccounted.length > 0) { + throw new Error(`Unaccounted wire types: ${unaccounted.join(", ")}`); +} + +// --------------------------------------------------------------------------- +// Generate events.ts +// --------------------------------------------------------------------------- + +function generateEventsFile(): string { + const imports = events.map((e) => ` ${e.typeName},`).join("\n"); + + const wireTypeUnion = events + .map((e) => ` | "${e.wireType}"`) + .join("\n"); + + const eventMapEntries = events + .map((e) => { + if (e.payloadField) { + return ` "${e.eventName}": (props: ${e.typeName}["${e.payloadField}"]) => void;`; + } + return ` "${e.eventName}": () => void;`; + }) + .join("\n"); + + const wireTypeToEventName = events + .map((e) => ` "${e.wireType}": "${e.eventName}",`) + .join("\n"); + + const wireTypeToPayloadField = events + .map( + (e) => + ` "${e.wireType}": ${e.payloadField ? `"${e.payloadField}"` : "null"},`, + ) + .join("\n"); + + return `// Auto-generated from agent.asyncapi.yaml — DO NOT EDIT MANUALLY + +import type { +${imports} +} from "@elevenlabs/types"; + +/** Union of all generated wire type strings */ +export type GeneratedWireType = +${wireTypeUnion}; + +/** Generated event map — simple unwrap events only */ +export type GeneratedEventMap = { +${eventMapEntries} +}; + +/** Runtime: wire \`type\` → event name (generated events only) */ +export const WIRE_TYPE_TO_EVENT_NAME: Record< + GeneratedWireType, + keyof GeneratedEventMap +> = { +${wireTypeToEventName} +}; + +/** Runtime: wire \`type\` → payload field to unwrap (null = no payload) */ +export const WIRE_TYPE_TO_PAYLOAD_FIELD: Record< + GeneratedWireType, + string | null +> = { +${wireTypeToPayloadField} +}; +`; +} + +// --------------------------------------------------------------------------- +// Generate dispatch.ts +// --------------------------------------------------------------------------- + +function generateDispatchFile(): string { + return `// Auto-generated from agent.asyncapi.yaml — DO NOT EDIT MANUALLY + +import { + WIRE_TYPE_TO_EVENT_NAME, + WIRE_TYPE_TO_PAYLOAD_FIELD, +} from "./events.js"; +import type { GeneratedWireType } from "./events.js"; + +/** + * Dispatches a generated wire event by looking up the event name and + * unwrapping the payload field. Caller provides the emit function + * to avoid circular dependencies with BaseConversation. + */ +export function dispatchGeneratedEvent( + emit: (event: string, ...args: unknown[]) => void, + wireType: GeneratedWireType, + parsedEvent: Record, +): void { + const eventName = WIRE_TYPE_TO_EVENT_NAME[wireType]; + const payloadField = WIRE_TYPE_TO_PAYLOAD_FIELD[wireType]; + if (payloadField) { + emit(eventName, parsedEvent[payloadField]); + } else { + emit(eventName); + } +} +`; +} + +// --------------------------------------------------------------------------- +// Write output +// --------------------------------------------------------------------------- + +mkdirSync(outDir, { recursive: true }); +writeFileSync(path.join(outDir, "events.ts"), generateEventsFile()); +writeFileSync(path.join(outDir, "dispatch.ts"), generateDispatchFile()); + +console.log(`✅ Generated event types (${events.length} events):`); +console.log(` ${path.join(outDir, "events.ts")}`); +console.log(` ${path.join(outDir, "dispatch.ts")}`); diff --git a/packages/client/src/generated/dispatch.ts b/packages/client/src/generated/dispatch.ts new file mode 100644 index 000000000..356336ac0 --- /dev/null +++ b/packages/client/src/generated/dispatch.ts @@ -0,0 +1,26 @@ +// Auto-generated from agent.asyncapi.yaml — DO NOT EDIT MANUALLY + +import { + WIRE_TYPE_TO_EVENT_NAME, + WIRE_TYPE_TO_PAYLOAD_FIELD, +} from "./events.js"; +import type { GeneratedWireType } from "./events.js"; + +/** + * Dispatches a generated wire event by looking up the event name and + * unwrapping the payload field. Caller provides the emit function + * to avoid circular dependencies with BaseConversation. + */ +export function dispatchGeneratedEvent( + emit: (event: string, ...args: unknown[]) => void, + wireType: GeneratedWireType, + parsedEvent: Record +): void { + const eventName = WIRE_TYPE_TO_EVENT_NAME[wireType]; + const payloadField = WIRE_TYPE_TO_PAYLOAD_FIELD[wireType]; + if (payloadField) { + emit(eventName, parsedEvent[payloadField]); + } else { + emit(eventName); + } +} diff --git a/packages/client/src/generated/events.ts b/packages/client/src/generated/events.ts new file mode 100644 index 000000000..9c0bc1130 --- /dev/null +++ b/packages/client/src/generated/events.ts @@ -0,0 +1,108 @@ +// Auto-generated from agent.asyncapi.yaml — DO NOT EDIT MANUALLY + +import type { + TentativeUserTranscript, + AgentResponseCorrection, + AgentChatResponsePart, + Interruption, + ConversationMetadata, + AgentToolRequestMessage, + AgentToolResponseMessage, + McpToolCall, + McpConnectionStatusMessage, + VadScore, + AsrInitiationMetadata, + GuardrailTriggered, + InternalTurnProbability, +} from "@elevenlabs/types"; + +/** Union of all generated wire type strings */ +export type GeneratedWireType = + | "tentative_user_transcript" + | "agent_response_correction" + | "agent_chat_response_part" + | "interruption" + | "conversation_initiation_metadata" + | "agent_tool_request" + | "agent_tool_response" + | "mcp_tool_call" + | "mcp_connection_status" + | "vad_score" + | "asr_initiation_metadata" + | "guardrail_triggered" + | "internal_turn_probability"; + +/** Generated event map — simple unwrap events only */ +export type GeneratedEventMap = { + "tentative-user-transcript": ( + props: TentativeUserTranscript["tentative_user_transcription_event"] + ) => void; + "agent-response-correction": ( + props: AgentResponseCorrection["agent_response_correction_event"] + ) => void; + "agent-chat-response-part": ( + props: AgentChatResponsePart["text_response_part"] + ) => void; + interruption: (props: Interruption["interruption_event"]) => void; + "conversation-initiation-metadata": ( + props: ConversationMetadata["conversation_initiation_metadata_event"] + ) => void; + "agent-tool-request": ( + props: AgentToolRequestMessage["agent_tool_request"] + ) => void; + "agent-tool-response": ( + props: AgentToolResponseMessage["agent_tool_response"] + ) => void; + "mcp-tool-call": (props: McpToolCall["mcp_tool_call"]) => void; + "mcp-connection-status": ( + props: McpConnectionStatusMessage["mcp_connection_status"] + ) => void; + "vad-score": (props: VadScore["vad_score_event"]) => void; + "asr-initiation-metadata": ( + props: AsrInitiationMetadata["asr_initiation_metadata_event"] + ) => void; + "guardrail-triggered": () => void; + "internal-turn-probability": ( + props: InternalTurnProbability["turn_probability_internal_event"] + ) => void; +}; + +/** Runtime: wire `type` → event name (generated events only) */ +export const WIRE_TYPE_TO_EVENT_NAME: Record< + GeneratedWireType, + keyof GeneratedEventMap +> = { + tentative_user_transcript: "tentative-user-transcript", + agent_response_correction: "agent-response-correction", + agent_chat_response_part: "agent-chat-response-part", + interruption: "interruption", + conversation_initiation_metadata: "conversation-initiation-metadata", + agent_tool_request: "agent-tool-request", + agent_tool_response: "agent-tool-response", + mcp_tool_call: "mcp-tool-call", + mcp_connection_status: "mcp-connection-status", + vad_score: "vad-score", + asr_initiation_metadata: "asr-initiation-metadata", + guardrail_triggered: "guardrail-triggered", + internal_turn_probability: "internal-turn-probability", +}; + +/** Runtime: wire `type` → payload field to unwrap (null = no payload) */ +export const WIRE_TYPE_TO_PAYLOAD_FIELD: Record< + GeneratedWireType, + string | null +> = { + tentative_user_transcript: "tentative_user_transcription_event", + agent_response_correction: "agent_response_correction_event", + agent_chat_response_part: "text_response_part", + interruption: "interruption_event", + conversation_initiation_metadata: "conversation_initiation_metadata_event", + agent_tool_request: "agent_tool_request", + agent_tool_response: "agent_tool_response", + mcp_tool_call: "mcp_tool_call", + mcp_connection_status: "mcp_connection_status", + vad_score: "vad_score_event", + asr_initiation_metadata: "asr_initiation_metadata_event", + guardrail_triggered: null, + internal_turn_probability: "turn_probability_internal_event", +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f320167e..1dd44739e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,7 +72,7 @@ importers: version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tailwindcss/vite': specifier: ^4.0.6 - version: 4.1.18(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.18(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)) '@tanstack/react-devtools': specifier: ^0.7.0 version: 0.7.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11) @@ -87,10 +87,10 @@ importers: version: 1.166.12(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.4))(@tanstack/react-router@1.169.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.169.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-start': specifier: ^1.167.64 - version: 1.167.64(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 1.167.64(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)) '@tanstack/router-plugin': specifier: ^1.167.34 - version: 1.167.34(@tanstack/react-router@1.169.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 1.167.34(@tanstack/react-router@1.169.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -102,7 +102,7 @@ importers: version: 0.561.0(react@19.2.4) nitro: specifier: npm:nitro-nightly@latest - version: nitro-nightly@3.0.1-20260505-131157-aee73f19(@netlify/blobs@8.2.0)(chokidar@4.0.3)(dotenv@16.6.1)(jiti@2.7.0)(lru-cache@11.3.6)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: nitro-nightly@3.0.1-20260506-172449-e1c63186(@netlify/blobs@8.2.0)(chokidar@4.0.3)(dotenv@16.6.1)(jiti@2.7.0)(lru-cache@11.3.6)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)) radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -123,14 +123,14 @@ importers: version: 1.4.0 vite-tsconfig-paths: specifier: ^6.1.1 - version: 6.1.1(typescript@5.9.3)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 6.1.1(typescript@5.9.3)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)) devDependencies: '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.3.0(jiti@2.7.0)) '@tanstack/devtools-vite': specifier: ^0.3.11 - version: 0.3.12(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 0.3.12(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)) '@testing-library/dom': specifier: ^10.4.0 version: 10.4.1 @@ -148,7 +148,7 @@ importers: version: 19.2.3(@types/react@19.2.7) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)) eslint: specifier: ^10.3.0 version: 10.3.0(jiti@2.7.0) @@ -163,7 +163,7 @@ importers: version: 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3) vite: specifier: ^8.0.10 - version: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + version: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -243,10 +243,10 @@ importers: version: 0.0.3 '@vitest/browser': specifier: ^4.1.5 - version: 4.1.5(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.5) + version: 4.1.5(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5) '@vitest/browser-playwright': specifier: ^4.1.5 - version: 4.1.5(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(playwright@1.57.0)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.5) + version: 4.1.5(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(playwright@1.57.0)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5) eslint: specifier: ^10.3.0 version: 10.3.0(jiti@2.7.0) @@ -265,6 +265,9 @@ importers: playwright: specifier: ^1.54.1 version: 1.57.0 + prettier: + specifier: ^3.8.3 + version: 3.8.3 rolldown: specifier: ^1.0.0-rc.9 version: 1.0.0-rc.9 @@ -276,7 +279,10 @@ importers: version: 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3) vitest: specifier: ^4.1.5 - version: 4.1.5(@types/node@22.19.2)(@vitest/browser-playwright@4.1.5)(jsdom@28.1.0)(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@types/node@22.19.2)(@vitest/browser-playwright@4.1.5)(jsdom@28.1.0)(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)) + yaml: + specifier: ^2.8.4 + version: 2.8.4 packages/convai-widget-core: dependencies: @@ -370,7 +376,7 @@ importers: devDependencies: '@preact/preset-vite': specifier: ^2.10.5 - version: 2.10.5(@babel/core@7.29.0)(preact@10.28.0)(rollup@2.80.0)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 2.10.5(@babel/core@7.29.0)(preact@10.28.0)(rollup@2.80.0)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)) '@radix-ui/react-select': specifier: ^2.2.2 version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -379,7 +385,7 @@ importers: version: 1.2.4(@types/react@19.2.7)(react@19.2.4) '@tailwindcss/vite': specifier: ^4.1.18 - version: 4.1.18(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.18(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)) '@types/hast': specifier: ^3.0.4 version: 3.0.4 @@ -388,10 +394,10 @@ importers: version: 4.0.4 '@vitest/browser': specifier: ^4.1.5 - version: 4.1.5(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.5) + version: 4.1.5(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5) '@vitest/browser-playwright': specifier: ^4.1.5 - version: 4.1.5(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(playwright@1.57.0)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.5) + version: 4.1.5(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(playwright@1.57.0)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5) eslint: specifier: ^10.3.0 version: 10.3.0(jiti@2.7.0) @@ -412,13 +418,13 @@ importers: version: 11.0.5 vite: specifier: ^8.0.10 - version: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + version: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4) vite-bundle-analyzer: specifier: ^1.3.8 version: 1.3.8 vitest: specifier: ^4.1.5 - version: 4.1.5(@types/node@22.19.2)(@vitest/browser-playwright@4.1.5)(jsdom@28.1.0)(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@types/node@22.19.2)(@vitest/browser-playwright@4.1.5)(jsdom@28.1.0)(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)) packages/convai-widget-embed: devDependencies: @@ -433,7 +439,7 @@ importers: version: 5.9.3 vite: specifier: ^8.0.10 - version: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + version: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4) vite-bundle-analyzer: specifier: ^1.3.8 version: 1.3.8 @@ -482,7 +488,7 @@ importers: version: 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3) vitest: specifier: ^4.1.5 - version: 4.1.5(@types/node@22.19.2)(@vitest/browser-playwright@4.1.5)(jsdom@28.1.0)(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@types/node@22.19.2)(@vitest/browser-playwright@4.1.5)(jsdom@28.1.0)(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)) packages/react-native: dependencies: @@ -7617,8 +7623,8 @@ packages: crossws: optional: true - h3@2.0.1-rc.21: - resolution: {integrity: sha512-lDeqAgCQXWT7C+5Zs3ler2phZPeX5yTk9KqQuL8taSSngIhcPR0r83TZyYwTO/cLogm6a4+9slZcngrfdyZtrQ==} + h3@2.0.1-rc.22: + resolution: {integrity: sha512-Esv0DMIuPkCTSWCA0vO73vcTqwzH1wjSrAO1TXNu/K3up1sZHa9EKMapbmxCDYBeymC3fVTk4qxp7ogQWQ+KgA==} engines: {node: '>=20.11.1'} hasBin: true peerDependencies: @@ -9263,8 +9269,8 @@ packages: resolution: {integrity: sha512-1ZOI8J+1PKKGceo/5CT5GfQOG6H8I2BencSK06YarZ2wXwH37BSSUWldqJmMJYA5JfqDqffxDXynt6f11AyKcA==} engines: {node: ^12.20 || >=14.13} - nitro-nightly@3.0.1-20260505-131157-aee73f19: - resolution: {integrity: sha512-EYB15UbZCwXVC8WRTUg1uIUm7MxIRvM/thKHYDoNu8YgDz7U9qhUzjvacY4dd1tn/+KbafgxYd88cpdNvMqW+w==} + nitro-nightly@3.0.1-20260506-172449-e1c63186: + resolution: {integrity: sha512-qqrh5EPipNLDm+tAWgQ+4YZnTb0LxE2BsKmBwCn1GAxFPMLhVbHT8DNJo/TmjNQuBYQymEH5uPEPFTsTZNiljw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -12061,13 +12067,13 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} - yaml@2.8.2: - resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} engines: {node: '>= 14.6'} hasBin: true - yaml@2.8.3: - resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + yaml@2.8.4: + resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==} engines: {node: '>= 14.6'} hasBin: true @@ -14990,7 +14996,7 @@ snapshots: '@eslint/config-array@0.23.5': dependencies: '@eslint/object-schema': 3.0.5 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 minimatch: 10.2.5 transitivePeerDependencies: - supports-color @@ -16458,19 +16464,19 @@ snapshots: '@popperjs/core@2.11.8': {} - '@preact/preset-vite@2.10.5(@babel/core@7.29.0)(preact@10.28.0)(rollup@2.80.0)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))': + '@preact/preset-vite@2.10.5(@babel/core@7.29.0)(preact@10.28.0)(rollup@2.80.0)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.0) - '@prefresh/vite': 2.4.12(preact@10.28.0)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@prefresh/vite': 2.4.12(preact@10.28.0)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)) '@rollup/pluginutils': 5.3.0(rollup@2.80.0) babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.29.0) debug: 4.4.3(supports-color@8.1.1) magic-string: 0.30.21 picocolors: 1.1.1 - vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) - vite-prerender-plugin: 0.5.13(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4) + vite-prerender-plugin: 0.5.13(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)) zimmerframe: 1.1.4 transitivePeerDependencies: - preact @@ -16492,7 +16498,7 @@ snapshots: '@prefresh/utils@1.2.1': {} - '@prefresh/vite@2.4.12(preact@10.28.0)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))': + '@prefresh/vite@2.4.12(preact@10.28.0)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4))': dependencies: '@babel/core': 7.29.0 '@prefresh/babel-plugin': 0.5.3 @@ -16500,7 +16506,7 @@ snapshots: '@prefresh/utils': 1.2.1 '@rollup/pluginutils': 4.2.1 preact: 10.28.0 - vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4) transitivePeerDependencies: - supports-color @@ -18570,12 +18576,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - '@tailwindcss/vite@4.1.18(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))': + '@tailwindcss/vite@4.1.18(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4))': dependencies: '@tailwindcss/node': 4.1.18 '@tailwindcss/oxide': 4.1.18 tailwindcss: 4.1.18 - vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4) '@tanstack/devtools-client@0.0.3': dependencies: @@ -18604,7 +18610,7 @@ snapshots: transitivePeerDependencies: - csstype - '@tanstack/devtools-vite@0.3.12(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))': + '@tanstack/devtools-vite@0.3.12(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4))': dependencies: '@babel/core': 7.28.5 '@babel/generator': 7.28.5 @@ -18616,7 +18622,7 @@ snapshots: chalk: 5.6.2 launch-editor: 2.12.0 picomatch: 4.0.3 - vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4) transitivePeerDependencies: - bufferutil - supports-color @@ -18699,7 +18705,7 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@tanstack/react-start-rsc@0.0.43(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))': + '@tanstack/react-start-rsc@0.0.43(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4))': dependencies: '@tanstack/react-router': 1.169.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-start-server': 1.166.52(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -18707,7 +18713,7 @@ snapshots: '@tanstack/router-utils': 1.161.8 '@tanstack/start-client-core': 1.168.2 '@tanstack/start-fn-stubs': 1.161.6 - '@tanstack/start-plugin-core': 1.169.19(@tanstack/react-router@1.169.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.5(srvx@0.11.15))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@tanstack/start-plugin-core': 1.169.19(@tanstack/react-router@1.169.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.5(srvx@0.11.15))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)) '@tanstack/start-server-core': 1.167.30(crossws@0.4.5(srvx@0.11.15)) '@tanstack/start-storage-context': 1.166.35 pathe: 2.0.3 @@ -18733,21 +18739,21 @@ snapshots: transitivePeerDependencies: - crossws - '@tanstack/react-start@1.167.64(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))': + '@tanstack/react-start@1.167.64(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4))': dependencies: '@tanstack/react-router': 1.169.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-start-client': 1.166.48(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/react-start-rsc': 0.0.43(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@tanstack/react-start-rsc': 0.0.43(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)) '@tanstack/react-start-server': 1.166.52(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/router-utils': 1.161.8 '@tanstack/start-client-core': 1.168.2 - '@tanstack/start-plugin-core': 1.169.19(@tanstack/react-router@1.169.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.5(srvx@0.11.15))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@tanstack/start-plugin-core': 1.169.19(@tanstack/react-router@1.169.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.5(srvx@0.11.15))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)) '@tanstack/start-server-core': 1.167.30(crossws@0.4.5(srvx@0.11.15)) pathe: 2.0.3 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) optionalDependencies: - vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4) transitivePeerDependencies: - '@rspack/core' - crossws @@ -18797,7 +18803,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.167.34(@tanstack/react-router@1.169.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))': + '@tanstack/router-plugin@1.167.34(@tanstack/react-router@1.169.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -18814,7 +18820,7 @@ snapshots: zod: 3.25.76 optionalDependencies: '@tanstack/react-router': 1.169.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4) transitivePeerDependencies: - supports-color @@ -18846,7 +18852,7 @@ snapshots: '@tanstack/start-fn-stubs@1.161.6': {} - '@tanstack/start-plugin-core@1.169.19(@tanstack/react-router@1.169.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.5(srvx@0.11.15))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))': + '@tanstack/start-plugin-core@1.169.19(@tanstack/react-router@1.169.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.5(srvx@0.11.15))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4))': dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.29.0 @@ -18854,7 +18860,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.40 '@tanstack/router-core': 1.169.2 '@tanstack/router-generator': 1.166.41 - '@tanstack/router-plugin': 1.167.34(@tanstack/react-router@1.169.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@tanstack/router-plugin': 1.167.34(@tanstack/react-router@1.169.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)) '@tanstack/router-utils': 1.161.8 '@tanstack/start-client-core': 1.168.2 '@tanstack/start-server-core': 1.167.30(crossws@0.4.5(srvx@0.11.15)) @@ -18868,11 +18874,11 @@ snapshots: srvx: 0.11.15 tinyglobby: 0.2.16 ufo: 1.6.4 - vitefu: 1.1.3(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + vitefu: 1.1.3(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)) xmlbuilder2: 4.0.3 zod: 3.25.76 optionalDependencies: - vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4) transitivePeerDependencies: - '@tanstack/react-router' - crossws @@ -19307,7 +19313,7 @@ snapshots: '@typescript-eslint/types': 8.59.2 '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.59.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 eslint: 10.3.0(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: @@ -19317,7 +19323,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) '@typescript-eslint/types': 8.59.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -19336,7 +19342,7 @@ snapshots: '@typescript-eslint/types': 8.59.2 '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) '@typescript-eslint/utils': 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3) - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 eslint: 10.3.0(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -19353,7 +19359,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) '@typescript-eslint/types': 8.59.2 '@typescript-eslint/visitor-keys': 8.59.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 minimatch: 10.2.5 semver: 7.7.4 tinyglobby: 0.2.16 @@ -19451,36 +19457,36 @@ snapshots: '@urql/core': 5.2.0(graphql@16.12.0) wonka: 6.3.5 - '@vitejs/plugin-react@6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vitejs/plugin-react@6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4) optionalDependencies: babel-plugin-react-compiler: 1.0.0 - '@vitest/browser-playwright@4.1.5(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(playwright@1.57.0)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.5)': + '@vitest/browser-playwright@4.1.5(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(playwright@1.57.0)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5)': dependencies: - '@vitest/browser': 4.1.5(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.5) - '@vitest/mocker': 4.1.5(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/browser': 4.1.5(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5) + '@vitest/mocker': 4.1.5(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)) playwright: 1.57.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@types/node@22.19.2)(@vitest/browser-playwright@4.1.5)(jsdom@28.1.0)(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@types/node@22.19.2)(@vitest/browser-playwright@4.1.5)(jsdom@28.1.0)(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.5(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.5)': + '@vitest/browser@4.1.5(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.5(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/mocker': 4.1.5(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)) '@vitest/utils': 4.1.5 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.5(@types/node@22.19.2)(@vitest/browser-playwright@4.1.5)(jsdom@28.1.0)(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@types/node@22.19.2)(@vitest/browser-playwright@4.1.5)(jsdom@28.1.0)(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)) ws: 8.20.0 transitivePeerDependencies: - bufferutil @@ -19497,14 +19503,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.5(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vitest/mocker@4.1.5(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.4(@types/node@22.19.2)(typescript@5.9.3) - vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4) '@vitest/pretty-format@4.1.5': dependencies: @@ -19809,9 +19815,9 @@ snapshots: arraybuffer.prototype.slice@1.0.4: dependencies: array-buffer-byte-length: 1.0.2 - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 @@ -20312,7 +20318,6 @@ snapshots: es-define-property: 1.0.1 get-intrinsic: 1.3.0 set-function-length: 1.2.2 - optional: true call-bound@1.0.4: dependencies: @@ -20844,6 +20849,10 @@ snapshots: dependencies: ms: 2.1.2 + debug@4.4.3: + dependencies: + ms: 2.1.3 + debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3 @@ -21228,7 +21237,6 @@ snapshots: typed-array-length: 1.0.7 unbox-primitive: 1.1.0 which-typed-array: 1.1.20 - optional: true es-aggregate-error@1.0.14: dependencies: @@ -21270,11 +21278,11 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.3 es-shim-unscopables@1.1.0: dependencies: - hasown: 2.0.2 + hasown: 2.0.3 es-to-primitive@1.3.0: dependencies: @@ -21341,7 +21349,7 @@ snapshots: eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.3.0(jiti@2.7.0)))(eslint-plugin-import@2.32.0)(eslint@10.3.0(jiti@2.7.0)): dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 eslint: 10.3.0(jiti@2.7.0) eslint-import-context: 0.1.9(unrs-resolver@1.11.1) get-tsconfig: 4.13.0 @@ -21350,15 +21358,16 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@10.3.0(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@10.3.0(jiti@2.7.0)) eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.3.0(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@10.3.0(jiti@2.7.0)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@10.3.0(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: + '@typescript-eslint/parser': 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3) eslint: 10.3.0(jiti@2.7.0) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.3.0(jiti@2.7.0)))(eslint-plugin-import@2.32.0)(eslint@10.3.0(jiti@2.7.0)) @@ -21371,7 +21380,7 @@ snapshots: '@package-json/types': 0.0.12 '@typescript-eslint/types': 8.59.1 comment-parser: 1.4.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 eslint: 10.3.0(jiti@2.7.0) eslint-import-context: 0.1.9(unrs-resolver@1.11.1) is-glob: 4.0.3 @@ -21385,7 +21394,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@10.3.0(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@10.3.0(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -21396,7 +21405,7 @@ snapshots: doctrine: 2.1.0 eslint: 10.3.0(jiti@2.7.0) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@10.3.0(jiti@2.7.0)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@10.3.0(jiti@2.7.0)) hasown: 2.0.3 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -21407,6 +21416,8 @@ snapshots: semver: 6.3.1 string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -21449,7 +21460,7 @@ snapshots: '@types/estree': 1.0.8 ajv: 6.15.0 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 escape-string-regexp: 4.0.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 @@ -21962,11 +21973,11 @@ snapshots: function.prototype.name@1.1.8: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 functions-have-names: 1.2.3 - hasown: 2.0.2 + hasown: 2.0.3 is-callable: 1.2.7 functions-have-names@1.2.3: {} @@ -22000,7 +22011,7 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.3 math-intrinsics: 1.1.0 get-nonce@1.0.1: {} @@ -22149,7 +22160,7 @@ snapshots: optionalDependencies: crossws: 0.4.5(srvx@0.11.15) - h3@2.0.1-rc.21(crossws@0.4.5(srvx@0.11.15)): + h3@2.0.1-rc.22(crossws@0.4.5(srvx@0.11.15)): dependencies: rou3: 0.8.1 srvx: 0.11.15 @@ -22320,7 +22331,7 @@ snapshots: html-encoding-sniffer@6.0.0: dependencies: - '@exodus/bytes': 1.10.0 + '@exodus/bytes': 1.15.0 transitivePeerDependencies: - '@noble/hashes' @@ -22373,7 +22384,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -22392,7 +22403,7 @@ snapshots: https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -22496,7 +22507,7 @@ snapshots: internal-slot@1.1.0: dependencies: es-errors: 1.3.0 - hasown: 2.0.2 + hasown: 2.0.3 side-channel: 1.1.0 invariant@2.2.4: @@ -22521,7 +22532,7 @@ snapshots: is-array-buffer@3.0.5: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 get-intrinsic: 1.3.0 @@ -22643,7 +22654,7 @@ snapshots: call-bound: 1.0.4 gopd: 1.2.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.3 is-retry-allowed@1.2.0: {} @@ -23516,7 +23527,7 @@ snapshots: metro-cache: 0.83.3 metro-core: 0.83.3 metro-runtime: 0.83.3 - yaml: 2.8.2 + yaml: 2.8.3 transitivePeerDependencies: - bufferutil - supports-color @@ -24077,7 +24088,7 @@ snapshots: rettime: 0.7.0 statuses: 2.0.2 strict-event-emitter: 0.5.1 - tough-cookie: 6.0.0 + tough-cookie: 6.0.1 type-fest: 5.3.1 until-async: 3.0.2 yargs: 17.7.2 @@ -24153,13 +24164,13 @@ snapshots: jsonpath-plus: 10.4.0 lodash.topath: 4.5.2 - nitro-nightly@3.0.1-20260505-131157-aee73f19(@netlify/blobs@8.2.0)(chokidar@4.0.3)(dotenv@16.6.1)(jiti@2.7.0)(lru-cache@11.3.6)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)): + nitro-nightly@3.0.1-20260506-172449-e1c63186(@netlify/blobs@8.2.0)(chokidar@4.0.3)(dotenv@16.6.1)(jiti@2.7.0)(lru-cache@11.3.6)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)): dependencies: consola: 3.4.2 crossws: 0.4.5(srvx@0.11.15) db0: 0.3.4 env-runner: 0.1.7 - h3: 2.0.1-rc.21(crossws@0.4.5(srvx@0.11.15)) + h3: 2.0.1-rc.22(crossws@0.4.5(srvx@0.11.15)) hookable: 6.1.1 nf3: 0.3.16 ocache: 0.1.4 @@ -24172,7 +24183,7 @@ snapshots: optionalDependencies: dotenv: 16.6.1 jiti: 2.7.0 - vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -24455,7 +24466,7 @@ snapshots: object.assign@4.1.7: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 es-object-atoms: 1.1.1 @@ -25434,9 +25445,9 @@ snapshots: reflect.getprototypeof@1.0.10: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 es-object-atoms: 1.1.1 get-intrinsic: 1.3.0 @@ -25453,7 +25464,7 @@ snapshots: regexp.prototype.flags@1.5.4: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 es-errors: 1.3.0 get-proto: 1.0.1 @@ -25731,7 +25742,6 @@ snapshots: get-intrinsic: 1.3.0 has-symbols: 1.1.0 isarray: 2.0.5 - optional: true safe-buffer@5.1.2: {} @@ -26173,24 +26183,24 @@ snapshots: string.prototype.trim@1.2.10: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-data-property: 1.1.4 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-object-atoms: 1.1.1 has-property-descriptors: 1.0.2 string.prototype.trimend@1.0.9: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 es-object-atoms: 1.1.1 string.prototype.trimstart@1.0.8: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 es-object-atoms: 1.1.1 @@ -26612,7 +26622,7 @@ snapshots: typed-array-byte-length@1.0.3: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 for-each: 0.3.5 gopd: 1.2.0 has-proto: 1.2.0 @@ -26621,7 +26631,7 @@ snapshots: typed-array-byte-offset@1.0.4: dependencies: available-typed-arrays: 1.0.7 - call-bind: 1.0.8 + call-bind: 1.0.9 for-each: 0.3.5 gopd: 1.2.0 has-proto: 1.2.0 @@ -26630,7 +26640,7 @@ snapshots: typed-array-length@1.0.7: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 for-each: 0.3.5 gopd: 1.2.0 is-typed-array: 1.1.15 @@ -26917,7 +26927,7 @@ snapshots: vite-bundle-analyzer@1.3.8: {} - vite-prerender-plugin@0.5.13(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)): + vite-prerender-plugin@0.5.13(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)): dependencies: kolorist: 1.8.0 magic-string: 0.30.21 @@ -26925,19 +26935,19 @@ snapshots: simple-code-frame: 1.3.0 source-map: 0.7.6 stack-trace: 1.0.0 - vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4) - vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)): + vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)): dependencies: debug: 4.4.3(supports-color@8.1.1) globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) - vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4) transitivePeerDependencies: - supports-color - typescript - vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3): + vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -26951,16 +26961,16 @@ snapshots: jiti: 2.7.0 terser: 5.44.1 tsx: 4.21.0 - yaml: 2.8.3 + yaml: 2.8.4 - vitefu@1.1.3(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)): + vitefu@1.1.3(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)): optionalDependencies: - vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4) - vitest@4.1.5(@types/node@22.19.2)(@vitest/browser-playwright@4.1.5)(jsdom@28.1.0)(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.5(@types/node@22.19.2)(@vitest/browser-playwright@4.1.5)(jsdom@28.1.0)(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/mocker': 4.1.5(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -26977,11 +26987,11 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.2 - '@vitest/browser-playwright': 4.1.5(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(playwright@1.57.0)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.5) + '@vitest/browser-playwright': 4.1.5(msw@2.12.4(@types/node@22.19.2)(typescript@5.9.3))(playwright@1.57.0)(vite@8.0.10(@types/node@22.19.2)(esbuild@0.27.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5) jsdom: 28.1.0 transitivePeerDependencies: - msw @@ -27123,7 +27133,7 @@ snapshots: which-typed-array@1.1.20: dependencies: available-typed-arrays: 1.0.7 - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 for-each: 0.3.5 get-proto: 1.0.1 @@ -27253,10 +27263,10 @@ snapshots: yallist@5.0.0: {} - yaml@2.8.2: {} - yaml@2.8.3: {} + yaml@2.8.4: {} + yargs-parser@21.1.1: {} yargs@17.7.2: diff --git a/turbo.json b/turbo.json index 52e625079..beca8a47e 100644 --- a/turbo.json +++ b/turbo.json @@ -52,7 +52,11 @@ "dependsOn": ["generate", "^build"] }, "generate": { - "dependsOn": ["generate-types", "generate-version", "generate-worklets"] + "dependsOn": ["generate-types", "generate-version", "generate-worklets", "generate-events"] + }, + "generate-events": { + "inputs": ["schemas/**/*.yaml", "scripts/generate-events.ts"], + "outputs": ["src/generated/**"] }, "generate-types": { "inputs": ["schemas/**/*.yaml", "schemas/**/*.yml"], From b5b5b87f49f9607f20ca64c3fc5890c6cdb0c72b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 7 May 2026 14:09:43 +0200 Subject: [PATCH 4/7] Add ConversationEventMap, Callbacks, and event-name mapping to events.ts Move Callbacks and CALLBACK_KEYS from types.ts to a new events.ts module that combines generated events with hand-written internal and extra events into a unified ConversationEventMap. Add CALLBACK_KEY_TO_EVENT_NAME mapping for wiring callbacks to event names. Co-Authored-By: Claude Opus 4.6 --- packages/client/src/BaseConversation.ts | 7 +- packages/client/src/events.ts | 174 ++++++++++++++++++++++++ packages/client/src/index.ts | 1 + packages/client/src/types.ts | 87 +----------- 4 files changed, 180 insertions(+), 89 deletions(-) create mode 100644 packages/client/src/events.ts diff --git a/packages/client/src/BaseConversation.ts b/packages/client/src/BaseConversation.ts index 9de90c38e..35f17357d 100644 --- a/packages/client/src/BaseConversation.ts +++ b/packages/client/src/BaseConversation.ts @@ -1,4 +1,5 @@ -import type { Callbacks, Mode, Status, DisconnectionDetails } from "./types.js"; +import type { Callbacks } from "./events.js"; +import type { Mode, Status, DisconnectionDetails } from "./types.js"; import type { BaseConnection, SessionConfig, @@ -35,12 +36,12 @@ export type { Role, Mode, Status, - Callbacks, DisconnectionDetails, MessagePayload, AudioAlignmentEvent, } from "./types.js"; -export { CALLBACK_KEYS } from "./types.js"; +export type { Callbacks, ConversationEventMap } from "./events.js"; +export { CALLBACK_KEYS } from "./events.js"; /** Allows self-hosting the worklets to avoid whitelisting blob: and data: in the CSP script-src */ export type AudioWorkletConfig = { diff --git a/packages/client/src/events.ts b/packages/client/src/events.ts new file mode 100644 index 000000000..3f70b7756 --- /dev/null +++ b/packages/client/src/events.ts @@ -0,0 +1,174 @@ +import type { + AgentChatResponsePartClientEvent, + AgentResponseCorrection, + AgentToolRequestClientEvent, + AgentToolResponseClientEvent, + AsrInitiationMetadataEvent as AsrInitiationMetadataWireEvent, + ClientToolCallClientEvent, + ConversationMetadata, + Interruption, + McpConnectionStatusClientEvent, + McpToolCallClientEvent, +} from "@elevenlabs/types"; +import type { GeneratedEventMap } from "./generated/events.js"; +import type { + AudioAlignmentEvent, + DisconnectionDetails, + MessagePayload, + Mode, + Status, +} from "./types.js"; + +// --------------------------------------------------------------------------- +// Internally handled wire events — hand-written dispatch in BaseConversation +// --------------------------------------------------------------------------- + +type InternalEventMap = { + message: (props: MessagePayload) => void; + audio: (base64Audio: string) => void; + "unhandled-client-tool-call": ( + params: ClientToolCallClientEvent["client_tool_call"] + ) => void; + debug: (props: unknown) => void; +}; + +// --------------------------------------------------------------------------- +// Extra events not in the wire protocol +// --------------------------------------------------------------------------- + +type ExtraEventMap = { + connect: (props: { conversationId: string }) => void; + disconnect: (details: DisconnectionDetails) => void; + error: (message: string, context?: unknown) => void; + "status-change": (prop: { status: Status }) => void; + "mode-change": (prop: { mode: Mode }) => void; + "can-send-feedback-change": (prop: { canSendFeedback: boolean }) => void; + "audio-alignment": (props: AudioAlignmentEvent) => void; +}; + +// --------------------------------------------------------------------------- +// Full event map = generated + internal + extra +// --------------------------------------------------------------------------- + +/** + * Complete event map for conversation instances. + * + * Generated events use raw wire payload shapes, except `vad-score` which + * is transformed to `{ vadScore }` for backward compatibility. + */ +export type ConversationEventMap = Omit & { + "vad-score": (props: { vadScore: number }) => void; +} & InternalEventMap & + ExtraEventMap; + +// --------------------------------------------------------------------------- +// Callbacks — backward-compatible single-callback interface +// --------------------------------------------------------------------------- + +/** + * Shared Callbacks, ensures all callbacks are implemented across all SDKs + */ +export type Callbacks = { + onConnect?: (props: { conversationId: string }) => void; + onDisconnect?: (details: DisconnectionDetails) => void; + onError?: (message: string, context?: unknown) => void; + onMessage?: (props: MessagePayload) => void; + onAudio?: (base64Audio: string) => void; + onModeChange?: (prop: { mode: Mode }) => void; + onStatusChange?: (prop: { status: Status }) => void; + onCanSendFeedbackChange?: (prop: { canSendFeedback: boolean }) => void; + onUnhandledClientToolCall?: ( + params: ClientToolCallClientEvent["client_tool_call"] + ) => void; + onVadScore?: (props: { vadScore: number }) => void; + onMCPToolCall?: (props: McpToolCallClientEvent["mcp_tool_call"]) => void; + onMCPConnectionStatus?: ( + props: McpConnectionStatusClientEvent["mcp_connection_status"] + ) => void; + onAgentToolRequest?: ( + props: AgentToolRequestClientEvent["agent_tool_request"] + ) => void; + onAgentToolResponse?: ( + props: AgentToolResponseClientEvent["agent_tool_response"] + ) => void; + onConversationMetadata?: ( + props: ConversationMetadata["conversation_initiation_metadata_event"] + ) => void; + onAsrInitiationMetadata?: ( + props: AsrInitiationMetadataWireEvent["asr_initiation_metadata_event"] + ) => void; + onInterruption?: (props: Interruption["interruption_event"]) => void; + onAgentResponseCorrection?: ( + props: AgentResponseCorrection["agent_response_correction_event"] + ) => void; + onAgentChatResponsePart?: ( + props: AgentChatResponsePartClientEvent["text_response_part"] + ) => void; + onGuardrailTriggered?: () => void; + onAudioAlignment?: (props: AudioAlignmentEvent) => void; + // internal debug events, not to be used + onDebug?: (props: unknown) => void; +}; + +/** + * Runtime array of all keys in `Callbacks`, kept in sync with the type above. + */ +export const CALLBACK_KEYS = [ + "onConnect", + "onDisconnect", + "onError", + "onMessage", + "onAudio", + "onModeChange", + "onStatusChange", + "onCanSendFeedbackChange", + "onUnhandledClientToolCall", + "onVadScore", + "onMCPToolCall", + "onMCPConnectionStatus", + "onAgentToolRequest", + "onAgentToolResponse", + "onConversationMetadata", + "onAsrInitiationMetadata", + "onInterruption", + "onAgentResponseCorrection", + "onAgentChatResponsePart", + "onAudioAlignment", + "onGuardrailTriggered", + "onDebug", +] as const satisfies readonly (keyof Callbacks)[]; + +// --------------------------------------------------------------------------- +// Callback key ↔ event name mapping +// --------------------------------------------------------------------------- + +/** + * Maps each callback key to its corresponding event name. + * Hand-written because the mapping isn't a simple mechanical transform + * (e.g. `onMCPToolCall` → `mcp-tool-call`, `onConversationMetadata` → + * `conversation-initiation-metadata`). + */ +export const CALLBACK_KEY_TO_EVENT_NAME = { + onConnect: "connect", + onDisconnect: "disconnect", + onError: "error", + onMessage: "message", + onAudio: "audio", + onModeChange: "mode-change", + onStatusChange: "status-change", + onCanSendFeedbackChange: "can-send-feedback-change", + onUnhandledClientToolCall: "unhandled-client-tool-call", + onVadScore: "vad-score", + onMCPToolCall: "mcp-tool-call", + onMCPConnectionStatus: "mcp-connection-status", + onAgentToolRequest: "agent-tool-request", + onAgentToolResponse: "agent-tool-response", + onConversationMetadata: "conversation-initiation-metadata", + onAsrInitiationMetadata: "asr-initiation-metadata", + onInterruption: "interruption", + onAgentResponseCorrection: "agent-response-correction", + onAgentChatResponsePart: "agent-chat-response-part", + onAudioAlignment: "audio-alignment", + onGuardrailTriggered: "guardrail-triggered", + onDebug: "debug", +} as const satisfies Record; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 7183d1161..fd77f03e9 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -9,6 +9,7 @@ export type { PartialOptions, ClientToolsConfig, Callbacks, + ConversationEventMap, Status, AudioWorkletConfig, MultimodalMessageInput, diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 684fdf030..90843cfbc 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -1,16 +1,4 @@ -import type { - AgentChatResponsePartClientEvent, - AgentResponseCorrection, - AgentToolRequestClientEvent, - AgentToolResponseClientEvent, - AsrInitiationMetadataEvent as AsrInitiationMetadataWireEvent, - AudioEventAlignment, - ClientToolCallClientEvent, - ConversationMetadata, - Interruption, - McpConnectionStatusClientEvent, - McpToolCallClientEvent, -} from "@elevenlabs/types"; +import type { AudioEventAlignment } from "@elevenlabs/types"; /** * Role in the conversation @@ -63,76 +51,3 @@ export interface MessagePayload { source: "user" | "ai"; role: Role; } - -/** - * Shared Callbacks, ensures all callbacks are implemented across all SDKs - */ -export type Callbacks = { - onConnect?: (props: { conversationId: string }) => void; - onDisconnect?: (details: DisconnectionDetails) => void; - onError?: (message: string, context?: unknown) => void; - onMessage?: (props: MessagePayload) => void; - onAudio?: (base64Audio: string) => void; - onModeChange?: (prop: { mode: Mode }) => void; - onStatusChange?: (prop: { status: Status }) => void; - onCanSendFeedbackChange?: (prop: { canSendFeedback: boolean }) => void; - onUnhandledClientToolCall?: ( - params: ClientToolCallClientEvent["client_tool_call"] - ) => void; - onVadScore?: (props: { vadScore: number }) => void; - onMCPToolCall?: (props: McpToolCallClientEvent["mcp_tool_call"]) => void; - onMCPConnectionStatus?: ( - props: McpConnectionStatusClientEvent["mcp_connection_status"] - ) => void; - onAgentToolRequest?: ( - props: AgentToolRequestClientEvent["agent_tool_request"] - ) => void; - onAgentToolResponse?: ( - props: AgentToolResponseClientEvent["agent_tool_response"] - ) => void; - onConversationMetadata?: ( - props: ConversationMetadata["conversation_initiation_metadata_event"] - ) => void; - onAsrInitiationMetadata?: ( - props: AsrInitiationMetadataWireEvent["asr_initiation_metadata_event"] - ) => void; - onInterruption?: (props: Interruption["interruption_event"]) => void; - onAgentResponseCorrection?: ( - props: AgentResponseCorrection["agent_response_correction_event"] - ) => void; - onAgentChatResponsePart?: ( - props: AgentChatResponsePartClientEvent["text_response_part"] - ) => void; - onGuardrailTriggered?: () => void; - onAudioAlignment?: (props: AudioAlignmentEvent) => void; - // internal debug events, not to be used - onDebug?: (props: unknown) => void; -}; - -/** - * Runtime array of all keys in `Callbacks`, kept in sync with the type above. - */ -export const CALLBACK_KEYS = [ - "onConnect", - "onDisconnect", - "onError", - "onMessage", - "onAudio", - "onModeChange", - "onStatusChange", - "onCanSendFeedbackChange", - "onUnhandledClientToolCall", - "onVadScore", - "onMCPToolCall", - "onMCPConnectionStatus", - "onAgentToolRequest", - "onAgentToolResponse", - "onConversationMetadata", - "onAsrInitiationMetadata", - "onInterruption", - "onAgentResponseCorrection", - "onAgentChatResponsePart", - "onAudioAlignment", - "onGuardrailTriggered", - "onDebug", -] as const satisfies readonly (keyof Callbacks)[]; From 269e93c3c1868c51b3f3cbd4c4aa616d04ca30bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 7 May 2026 14:16:37 +0200 Subject: [PATCH 5/7] Add typed event emitter (on/off/emit) to BaseConversation Replace direct this.options.onXxx() callback invocations with this.emit() calls through a typed event emitter. Constructor-provided callbacks are automatically registered as initial listeners. The emitter supports multiple listeners per event, with on() returning an unsubscribe function for ergonomic cleanup. Co-Authored-By: Claude Opus 4.6 --- .changeset/add-event-emitter.md | 5 + packages/client/src/BaseConversation.test.ts | 2 +- packages/client/src/BaseConversation.ts | 198 ++++++++++--------- 3 files changed, 112 insertions(+), 93 deletions(-) create mode 100644 .changeset/add-event-emitter.md diff --git a/.changeset/add-event-emitter.md b/.changeset/add-event-emitter.md new file mode 100644 index 000000000..f43148472 --- /dev/null +++ b/.changeset/add-event-emitter.md @@ -0,0 +1,5 @@ +--- +"@elevenlabs/client": minor +--- + +Add typed event emitter (`on`/`off`) to conversation instances. Listeners registered via `on()` receive the same payloads as the existing constructor callbacks. `on()` returns an unsubscribe function; `off()` removes a specific listener. Multiple listeners per event are supported. diff --git a/packages/client/src/BaseConversation.test.ts b/packages/client/src/BaseConversation.test.ts index 65ab1c843..a965d1ab3 100644 --- a/packages/client/src/BaseConversation.test.ts +++ b/packages/client/src/BaseConversation.test.ts @@ -54,7 +54,7 @@ class TestConversation extends BaseConversation { public receiveMessage( event: Parameters[0]>[0] ) { - return this["onMessage"](event); + return this["onMessageHandler"](event); } } diff --git a/packages/client/src/BaseConversation.ts b/packages/client/src/BaseConversation.ts index 35f17357d..d43e8606c 100644 --- a/packages/client/src/BaseConversation.ts +++ b/packages/client/src/BaseConversation.ts @@ -1,4 +1,5 @@ -import type { Callbacks } from "./events.js"; +import type { Callbacks, ConversationEventMap } from "./events.js"; +import { CALLBACK_KEY_TO_EVENT_NAME } from "./events.js"; import type { Mode, Status, DisconnectionDetails } from "./types.js"; import type { BaseConnection, @@ -125,6 +126,7 @@ export abstract class BaseConversation { protected currentEventId = 1; protected lastFeedbackEventId = 0; protected canSendFeedback = false; + private listeners = new Map void>>(); protected static getFullOptions(partialOptions: PartialOptions): Options { const textOnly = isTextOnly(partialOptions); @@ -157,9 +159,63 @@ export abstract class BaseConversation { protected readonly options: Options, protected readonly connection: BaseConnection ) { - this.connection.onMessage(this.onMessage); + this.connection.onMessage(this.onMessageHandler); this.connection.onDisconnect(this.endSessionWithDetails); this.connection.onModeChange(mode => this.updateMode(mode)); + + // Register constructor-provided callbacks as listeners + for (const [callbackKey, eventName] of Object.entries( + CALLBACK_KEY_TO_EVENT_NAME + )) { + const fn = options[callbackKey as keyof Callbacks]; + if (fn) { + this.on( + eventName as keyof ConversationEventMap, + fn as ConversationEventMap[keyof ConversationEventMap] + ); + } + } + } + + public on( + event: K, + listener: ConversationEventMap[K] + ): () => void { + let set = this.listeners.get(event); + if (!set) { + set = new Set(); + this.listeners.set(event, set); + } + set.add(listener as (...args: unknown[]) => void); + return () => { + set.delete(listener as (...args: unknown[]) => void); + }; + } + + public off( + event: K, + listener: ConversationEventMap[K] + ): void { + this.listeners.get(event)?.delete(listener as (...args: unknown[]) => void); + } + + protected emit( + event: K, + ...args: Parameters + ): void { + const set = this.listeners.get(event); + if (set) { + for (const listener of set) { + try { + listener(...args); + } catch (error) { + // Avoid infinite recursion if an "error" listener itself throws + if (event !== "error") { + this.emit("error", String(error)); + } + } + } + } } protected markConnected() { @@ -175,9 +231,7 @@ export abstract class BaseConversation { this.updateStatus("disconnecting"); await this.handleEndSession(); this.updateStatus("disconnected"); - if (this.options.onDisconnect) { - this.options.onDisconnect(details); - } + this.emit("disconnect", details); }; protected async handleEndSession() { @@ -187,18 +241,14 @@ export abstract class BaseConversation { protected updateMode(mode: Mode) { if (mode !== this.mode) { this.mode = mode; - if (this.options.onModeChange) { - this.options.onModeChange({ mode }); - } + this.emit("mode-change", { mode }); } } protected updateStatus(status: Status) { if (status !== this.status) { this.status = status; - if (this.options.onStatusChange) { - this.options.onStatusChange({ status }); - } + this.emit("status-change", { status }); } } @@ -206,73 +256,58 @@ export abstract class BaseConversation { const canSendFeedback = this.currentEventId !== this.lastFeedbackEventId; if (this.canSendFeedback !== canSendFeedback) { this.canSendFeedback = canSendFeedback; - if (this.options.onCanSendFeedbackChange) { - this.options.onCanSendFeedbackChange({ canSendFeedback }); - } + this.emit("can-send-feedback-change", { canSendFeedback }); } } protected handleInterruption(event: InterruptionEvent) { if (event.interruption_event) { this.lastInterruptTimestamp = event.interruption_event.event_id; - - if (this.options.onInterruption) { - this.options.onInterruption({ - event_id: event.interruption_event.event_id, - }); - } + this.emit("interruption", { + event_id: event.interruption_event.event_id, + }); } } protected handleAgentResponse(event: AgentResponseEvent) { - if (this.options.onMessage) { - this.options.onMessage({ - source: "ai", - role: "agent", - message: event.agent_response_event.agent_response, - event_id: event.agent_response_event.event_id, - }); - } + this.emit("message", { + source: "ai", + role: "agent", + message: event.agent_response_event.agent_response, + event_id: event.agent_response_event.event_id, + }); } protected handleAgentResponseCorrection(event: AgentResponseCorrectionEvent) { - if (this.options.onAgentResponseCorrection) { - this.options.onAgentResponseCorrection( - event.agent_response_correction_event - ); - } + this.emit( + "agent-response-correction", + event.agent_response_correction_event + ); } protected handleUserTranscript(event: UserTranscriptionEvent) { - if (this.options.onMessage) { - this.options.onMessage({ - source: "user", - role: "user", - message: event.user_transcription_event.user_transcript, - event_id: event.user_transcription_event.event_id, - }); - } + this.emit("message", { + source: "user", + role: "user", + message: event.user_transcription_event.user_transcript, + event_id: event.user_transcription_event.event_id, + }); } protected handleTentativeAgentResponse( event: InternalTentativeAgentResponseEvent ) { - if (this.options.onDebug) { - this.options.onDebug({ - type: "tentative_agent_response", - response: - event.tentative_agent_response_internal_event - .tentative_agent_response, - }); - } + this.emit("debug", { + type: "tentative_agent_response", + response: + event.tentative_agent_response_internal_event.tentative_agent_response, + }); } protected handleVadScore(event: VadScoreEvent) { - if (this.options.onVadScore) { - this.options.onVadScore({ - vadScore: event.vad_score_event.vad_score, - }); - } + this.emit("vad-score", { + vadScore: event.vad_score_event.vad_score, + }); } protected async handleClientToolCall(event: ClientToolCallEvent) { @@ -313,9 +348,8 @@ export abstract class BaseConversation { }); } } else { - if (this.options.onUnhandledClientToolCall) { - this.options.onUnhandledClientToolCall(event.client_tool_call); - + if (this.listeners.get("unhandled-client-tool-call")?.size) { + this.emit("unhandled-client-tool-call", event.client_tool_call); return; } @@ -337,21 +371,15 @@ export abstract class BaseConversation { protected handleAudio(event: AgentAudioEvent) {} protected handleMCPToolCall(event: MCPToolCallClientEvent) { - if (this.options.onMCPToolCall) { - this.options.onMCPToolCall(event.mcp_tool_call); - } + this.emit("mcp-tool-call", event.mcp_tool_call); } protected handleMCPConnectionStatus(event: MCPConnectionStatusEvent) { - if (this.options.onMCPConnectionStatus) { - this.options.onMCPConnectionStatus(event.mcp_connection_status); - } + this.emit("mcp-connection-status", event.mcp_connection_status); } protected handleAgentToolRequest(event: AgentToolRequestEvent) { - if (this.options.onAgentToolRequest) { - this.options.onAgentToolRequest(event.agent_tool_request); - } + this.emit("agent-tool-request", event.agent_tool_request); } protected handleAgentToolResponse(event: AgentToolResponseEvent) { @@ -361,36 +389,26 @@ export abstract class BaseConversation { context: new CloseEvent("end_call", { reason: "Agent ended the call" }), }); } - - if (this.options.onAgentToolResponse) { - this.options.onAgentToolResponse(event.agent_tool_response); - } + this.emit("agent-tool-response", event.agent_tool_response); } protected handleConversationMetadata(event: ConversationMetadataEvent) { - if (this.options.onConversationMetadata) { - this.options.onConversationMetadata( - event.conversation_initiation_metadata_event - ); - } + this.emit( + "conversation-initiation-metadata", + event.conversation_initiation_metadata_event + ); } protected handleAsrInitiationMetadata(event: AsrInitiationMetadataEvent) { - if (this.options.onAsrInitiationMetadata) { - this.options.onAsrInitiationMetadata(event.asr_initiation_metadata_event); - } + this.emit("asr-initiation-metadata", event.asr_initiation_metadata_event); } protected handleAgentChatResponsePart(event: AgentChatResponsePartEvent) { - if (this.options.onAgentChatResponsePart) { - this.options.onAgentChatResponsePart(event.text_response_part); - } + this.emit("agent-chat-response-part", event.text_response_part); } protected handleGuardrailTriggered(_event: GuardrailTriggeredEvent) { - if (this.options.onGuardrailTriggered) { - this.options.onGuardrailTriggered(); - } + this.emit("guardrail-triggered"); } protected handleErrorEvent(event: ErrorMessageEvent) { @@ -415,7 +433,7 @@ export abstract class BaseConversation { }); } - private onMessage = async (parsedEvent: IncomingSocketEvent) => { + private onMessageHandler = async (parsedEvent: IncomingSocketEvent) => { switch (parsedEvent.type) { case "interruption": { this.handleInterruption(parsedEvent); @@ -517,19 +535,15 @@ export abstract class BaseConversation { } default: { - if (this.options.onDebug) { - this.options.onDebug(parsedEvent); - } + this.emit("debug", parsedEvent); return; } } }; - private onError(message: string, context?: any) { + private onError(message: string, context?: unknown) { console.error(message, context); - if (this.options.onError) { - this.options.onError(message, context); - } + this.emit("error", message, context); } public getId() { From fa661f6093c484297d3b53ab171886e031389549 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 7 May 2026 14:21:29 +0200 Subject: [PATCH 6/7] Update conversation subclasses to use emit and remove no-op defaults - Replace this.options.onAudio/onAudioAlignment/onError in VoiceConversation with this.emit() calls - Remove no-op callback defaults from getFullOptions (the emitter handles the no-listeners case) - Keep pre-instance callbacks (onStatusChange, onConnect) as direct calls since errors during startSession must propagate to the caller Co-Authored-By: Claude Opus 4.6 --- packages/client/src/BaseConversation.ts | 11 ----------- packages/client/src/VoiceConversation.ts | 8 ++++---- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/packages/client/src/BaseConversation.ts b/packages/client/src/BaseConversation.ts index d43e8606c..4f9e44a1d 100644 --- a/packages/client/src/BaseConversation.ts +++ b/packages/client/src/BaseConversation.ts @@ -132,17 +132,6 @@ export abstract class BaseConversation { const textOnly = isTextOnly(partialOptions); return { clientTools: {}, - onConnect: () => {}, - onDebug: () => {}, - onDisconnect: () => {}, - onError: () => {}, - onMessage: () => {}, - onAudio: () => {}, - onModeChange: () => {}, - onStatusChange: () => {}, - onCanSendFeedbackChange: () => {}, - onInterruption: () => {}, - onAgentResponseCorrection: () => {}, ...partialOptions, textOnly, overrides: { diff --git a/packages/client/src/VoiceConversation.ts b/packages/client/src/VoiceConversation.ts index b0a379d4a..198eb46b4 100644 --- a/packages/client/src/VoiceConversation.ts +++ b/packages/client/src/VoiceConversation.ts @@ -176,13 +176,13 @@ export class VoiceConversation extends BaseConversation { protected override handleAudio(event: AgentAudioEvent) { super.handleAudio(event); - if (event.audio_event.alignment && this.options.onAudioAlignment) { - this.options.onAudioAlignment(event.audio_event.alignment); + if (event.audio_event.alignment) { + this.emit("audio-alignment", event.audio_event.alignment); } if (this.lastInterruptTimestamp <= event.audio_event.event_id) { if (event.audio_event.audio_base_64) { - this.options.onAudio?.(event.audio_event.audio_base_64); + this.emit("audio", event.audio_event.audio_base_64); // Audio routing is handled by attachConnectionToOutput for WebSocket // WebRTC handles audio playback directly through LiveKit tracks } @@ -197,7 +197,7 @@ export class VoiceConversation extends BaseConversation { public setMicMuted(isMuted: boolean) { this.input.setMuted(isMuted).catch(error => { - this.options.onError?.("Failed to set input muted state", error); + this.emit("error", "Failed to set input muted state", error); }); } From 39282de6385d61fcf7f0068919d920469ea5cc61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 7 May 2026 16:21:21 +0200 Subject: [PATCH 7/7] Refactor React sub-providers to use conversation.on() event subscriptions Replace ListenerMap/ListenerSet callback composition with direct event subscriptions on the conversation instance. Sub-providers now subscribe via conversation.on() and derive status from isStarting/startupError context values. Hook-level callbacks compose via mergeOptions instead of the removed registerCallbacks mechanism. Co-Authored-By: Claude Opus 4.6 --- .changeset/react-event-emitter-refactor.md | 5 + .../ConversationClientTools.test.tsx | 3 +- .../conversation/ConversationContext.test.tsx | 134 ++++-------- .../src/conversation/ConversationContext.tsx | 60 ++---- .../ConversationControls.test.tsx | 27 +-- .../ConversationFeedback.test.tsx | 37 ++-- .../src/conversation/ConversationFeedback.tsx | 39 ++-- .../conversation/ConversationInput.test.tsx | 23 +-- .../src/conversation/ConversationInput.tsx | 12 +- .../conversation/ConversationMode.test.tsx | 36 ++-- .../src/conversation/ConversationMode.tsx | 27 +-- .../ConversationProvider.test.tsx | 25 +-- .../src/conversation/ConversationProvider.tsx | 49 ++--- .../conversation/ConversationStatus.test.tsx | 194 ++++++++---------- .../src/conversation/ConversationStatus.tsx | 63 +++--- .../src/conversation/ListenerMap.test.ts | 176 ---------------- .../react/src/conversation/ListenerMap.ts | 73 ------- .../src/conversation/ListenerSet.test.ts | 79 ------- .../react/src/conversation/ListenerSet.ts | 18 -- packages/react/src/conversation/test-utils.ts | 56 +++++ .../src/conversation/useConversation.test.tsx | 21 +- .../react/src/conversation/useConversation.ts | 32 ++- 22 files changed, 385 insertions(+), 804 deletions(-) create mode 100644 .changeset/react-event-emitter-refactor.md delete mode 100644 packages/react/src/conversation/ListenerMap.test.ts delete mode 100644 packages/react/src/conversation/ListenerMap.ts delete mode 100644 packages/react/src/conversation/ListenerSet.test.ts delete mode 100644 packages/react/src/conversation/ListenerSet.ts create mode 100644 packages/react/src/conversation/test-utils.ts diff --git a/.changeset/react-event-emitter-refactor.md b/.changeset/react-event-emitter-refactor.md new file mode 100644 index 000000000..2cb9d30cb --- /dev/null +++ b/.changeset/react-event-emitter-refactor.md @@ -0,0 +1,5 @@ +--- +"@elevenlabs/react": patch +--- + +Refactor sub-providers to subscribe via `conversation.on()` instead of composed callback objects. Removes `ListenerMap` and `ListenerSet` internal classes. No public API changes. diff --git a/packages/react/src/conversation/ConversationClientTools.test.tsx b/packages/react/src/conversation/ConversationClientTools.test.tsx index d9b6a6445..97ef518d0 100644 --- a/packages/react/src/conversation/ConversationClientTools.test.tsx +++ b/packages/react/src/conversation/ConversationClientTools.test.tsx @@ -22,7 +22,8 @@ function createContextValue( conversationRef: { current: null }, startSession: vi.fn(), endSession: vi.fn(), - registerCallbacks: vi.fn(), + isStarting: false, + startupError: null, clientToolsRegistry: new Map(), clientToolsRef: { current: {} }, ...overrides, diff --git a/packages/react/src/conversation/ConversationContext.test.tsx b/packages/react/src/conversation/ConversationContext.test.tsx index 1131b0d1f..81cb236c4 100644 --- a/packages/react/src/conversation/ConversationContext.test.tsx +++ b/packages/react/src/conversation/ConversationContext.test.tsx @@ -5,11 +5,27 @@ import { ConversationContext, useRawConversation, useRawConversationRef, - useRegisterCallbacks, + useSessionLifecycle, type ConversationContextValue, } from "./ConversationContext.js"; import type { Conversation } from "@elevenlabs/client"; +function createMockContextValue( + overrides: Partial = {} +): ConversationContextValue { + return { + conversation: null, + conversationRef: { current: null }, + startSession: vi.fn(), + endSession: vi.fn(), + isStarting: false, + startupError: null, + clientToolsRegistry: new Map(), + clientToolsRef: { current: {} }, + ...overrides, + }; +} + describe("useRawConversation", () => { it("returns null when used outside a ConversationProvider", () => { const { result } = renderHook(() => useRawConversation()); @@ -18,15 +34,10 @@ describe("useRawConversation", () => { it("returns the conversation instance from the context", () => { const mockConversation = { getId: vi.fn() } as unknown as Conversation; - const value: ConversationContextValue = { + const value = createMockContextValue({ conversation: mockConversation, conversationRef: { current: mockConversation }, - startSession: vi.fn(), - endSession: vi.fn(), - registerCallbacks: vi.fn(), - clientToolsRegistry: new Map(), - clientToolsRef: { current: {} }, - }; + }); const wrapper = ({ children }: React.PropsWithChildren) => ( @@ -39,15 +50,7 @@ describe("useRawConversation", () => { }); it("returns null when conversation is null in context", () => { - const value: ConversationContextValue = { - conversation: null, - conversationRef: { current: null }, - startSession: vi.fn(), - endSession: vi.fn(), - registerCallbacks: vi.fn(), - clientToolsRegistry: new Map(), - clientToolsRef: { current: {} }, - }; + const value = createMockContextValue(); const wrapper = ({ children }: React.PropsWithChildren) => ( @@ -70,17 +73,12 @@ describe("useRawConversationRef", () => { it("returns the conversationRef from the context", () => { const mockConversation = { getId: vi.fn() } as unknown as Conversation; const conversationRef = { current: mockConversation }; - const value: ConversationContextValue = { + const value = createMockContextValue({ conversation: mockConversation, conversationRef, - startSession: vi.fn(), - endSession: vi.fn(), - registerCallbacks: vi.fn(), - clientToolsRegistry: new Map(), - clientToolsRef: { current: {} }, - }; - - const wrapper = ({ children }: { children: React.ReactNode }) => ( + }); + + const wrapper = ({ children }: React.PropsWithChildren) => ( {children} @@ -91,85 +89,27 @@ describe("useRawConversationRef", () => { }); }); -describe("useRegisterCallbacks", () => { +describe("useSessionLifecycle", () => { it("throws when used outside a ConversationProvider", () => { - expect(() => - renderHook(() => useRegisterCallbacks({ onConnect: vi.fn() })) - ).toThrow("useRegisterCallbacks must be used within a ConversationProvider"); - }); - - it("calls registerCallbacks with stable wrappers and cleans up on unmount", () => { - const unsubscribe = vi.fn(); - const registerCallbacks = vi.fn().mockReturnValue(unsubscribe); - const value: ConversationContextValue = { - conversation: null, - conversationRef: { current: null }, - startSession: vi.fn(), - endSession: vi.fn(), - registerCallbacks, - clientToolsRegistry: new Map(), - clientToolsRef: { current: {} }, - }; - - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - - const onConnect = vi.fn(); - const { unmount } = renderHook( - () => useRegisterCallbacks({ onConnect }), - { wrapper } + expect(() => renderHook(() => useSessionLifecycle())).toThrow( + "useSessionLifecycle must be used within a ConversationProvider" ); - - expect(registerCallbacks).toHaveBeenCalledTimes(1); - // The registered callback should delegate to the original - const registered = registerCallbacks.mock.calls[0][0]; - registered.onConnect({ conversationId: "test" }); - expect(onConnect).toHaveBeenCalledWith({ conversationId: "test" }); - - unmount(); - expect(unsubscribe).toHaveBeenCalled(); }); - it("delegates to the latest callback without re-subscribing", () => { - const unsubscribe = vi.fn(); - const registerCallbacks = vi.fn().mockReturnValue(unsubscribe); - const value: ConversationContextValue = { - conversation: null, - conversationRef: { current: null }, - startSession: vi.fn(), - endSession: vi.fn(), - registerCallbacks, - clientToolsRegistry: new Map(), - clientToolsRef: { current: {} }, - }; - - const wrapper = ({ children }: { children: React.ReactNode }) => ( + it("returns isStarting and startupError from context", () => { + const value = createMockContextValue({ + isStarting: true, + startupError: "test error", + }); + + const wrapper = ({ children }: React.PropsWithChildren) => ( {children} ); - const firstCallback = vi.fn(); - const secondCallback = vi.fn(); - - const { rerender } = renderHook( - ({ cb }) => useRegisterCallbacks({ onConnect: cb }), - { wrapper, initialProps: { cb: firstCallback } } - ); - - // Re-render with a new callback - rerender({ cb: secondCallback }); - - // Should not have re-subscribed - expect(registerCallbacks).toHaveBeenCalledTimes(1); - - // The stable wrapper should delegate to the latest callback - const registered = registerCallbacks.mock.calls[0][0]; - registered.onConnect({ conversationId: "test" }); - expect(firstCallback).not.toHaveBeenCalled(); - expect(secondCallback).toHaveBeenCalledWith({ conversationId: "test" }); + const { result } = renderHook(() => useSessionLifecycle(), { wrapper }); + expect(result.current.isStarting).toBe(true); + expect(result.current.startupError).toBe("test error"); }); }); diff --git a/packages/react/src/conversation/ConversationContext.tsx b/packages/react/src/conversation/ConversationContext.tsx index a6ecbfbfe..1bcab79c5 100644 --- a/packages/react/src/conversation/ConversationContext.tsx +++ b/packages/react/src/conversation/ConversationContext.tsx @@ -1,16 +1,10 @@ import { createContext, useContext, - useLayoutEffect, - useRef, type MutableRefObject, type RefObject, } from "react"; -import type { - Callbacks, - ClientToolsConfig, - Conversation, -} from "@elevenlabs/client"; +import type { ClientToolsConfig, Conversation } from "@elevenlabs/client"; import type { HookOptions } from "./types.js"; type ClientToolEntry = ClientToolsConfig["clientTools"][string]; @@ -21,11 +15,10 @@ export type ConversationContextValue = { conversationRef: RefObject; startSession: (options?: HookOptions) => void; endSession: () => void; - /** - * For sub-providers — register callback handlers to be composed into the - * next `Conversation.startSession()` call. Returns an unsubscribe function. - */ - registerCallbacks: (callbacks: Partial) => () => void; + /** True while a `startSession` call is in progress. */ + isStarting: boolean; + /** Error message from a failed `startSession` call. Cleared on next attempt. */ + startupError: string | null; /** Registry of hook-registered client tools. Survives across sessions. */ clientToolsRegistry: Map; /** Ref to the live clientTools object currently held by BaseConversation. */ @@ -65,46 +58,21 @@ export function useRawConversationRef(): RefObject { } /** - * Registers callback handlers with the nearest `ConversationProvider`. - * Uses a ref internally so the latest callback values are always invoked - * without re-subscribing on every render. + * Returns the `isStarting` and `startupError` state from the nearest + * `ConversationProvider`. Useful for sub-providers that need to know about + * session lifecycle phases (e.g., showing "connecting" status). * * Must be used within a `ConversationProvider`. */ -export function useRegisterCallbacks(callbacks: Partial): void { +export function useSessionLifecycle(): { + isStarting: boolean; + startupError: string | null; +} { const ctx = useContext(ConversationContext); if (!ctx) { throw new Error( - "useRegisterCallbacks must be used within a ConversationProvider" + "useSessionLifecycle must be used within a ConversationProvider" ); } - - const { registerCallbacks } = ctx; - const callbacksRef = useRef(callbacks); - - // Re-subscribe when the set of provided callback keys changes. - const activeKeyToken = Object.keys(callbacks) - .filter(key => callbacks[key as keyof Callbacks] !== undefined) - .sort() - .join("|"); - - useLayoutEffect(() => { - callbacksRef.current = callbacks; - }); - - useLayoutEffect(() => { - const activeKeys = activeKeyToken === "" ? [] : activeKeyToken.split("|"); - const stableCallbacks = Object.fromEntries( - activeKeys.map((key: string) => [ - key, - (...args: never[]) => { - const fn = callbacksRef.current[key as keyof Callbacks]; - if (typeof fn === "function") { - (fn as (...a: never[]) => void)(...args); - } - }, - ]) - ) as Partial; - return registerCallbacks(stableCallbacks); - }, [registerCallbacks, activeKeyToken]); + return { isStarting: ctx.isStarting, startupError: ctx.startupError }; } diff --git a/packages/react/src/conversation/ConversationControls.test.tsx b/packages/react/src/conversation/ConversationControls.test.tsx index fdb50b453..84fdbc9e3 100644 --- a/packages/react/src/conversation/ConversationControls.test.tsx +++ b/packages/react/src/conversation/ConversationControls.test.tsx @@ -4,30 +4,13 @@ import { render, renderHook, act } from "@testing-library/react"; import { Conversation } from "@elevenlabs/client"; import { ConversationProvider } from "./ConversationProvider.js"; import { useConversationControls } from "./ConversationControls.js"; +import { createMockConversation } from "./test-utils.js"; vi.mock("@elevenlabs/client", async importOriginal => { const actual = await importOriginal(); return { ...actual, Conversation: { startSession: vi.fn() } }; }); -const createMockConversation = (id = "test-id") => - ({ - getId: vi.fn().mockReturnValue(id), - isOpen: vi.fn().mockReturnValue(true), - endSession: vi.fn().mockResolvedValue(undefined), - setMicMuted: vi.fn(), - setVolume: vi.fn(), - sendUserMessage: vi.fn(), - sendContextualUpdate: vi.fn(), - sendUserActivity: vi.fn(), - sendMCPToolApprovalResult: vi.fn(), - sendFeedback: vi.fn(), - getInputByteFrequencyData: vi.fn().mockReturnValue(new Uint8Array(4)), - getOutputByteFrequencyData: vi.fn().mockReturnValue(new Uint8Array(4)), - getInputVolume: vi.fn().mockReturnValue(0.5), - getOutputVolume: vi.fn().mockReturnValue(0.8), - }) as unknown as Conversation; - function createWrapper(props: Record = {}) { return function Wrapper({ children }: React.PropsWithChildren) { return {children}; @@ -150,6 +133,14 @@ describe("useConversationControls", () => { it("returns frequency data and volume from the conversation", async () => { const mockConversation = createMockConversation(); + vi.mocked(mockConversation.getInputVolume).mockReturnValue(0.5); + vi.mocked(mockConversation.getOutputVolume).mockReturnValue(0.8); + vi.mocked(mockConversation.getInputByteFrequencyData).mockReturnValue( + new Uint8Array(4) + ); + vi.mocked(mockConversation.getOutputByteFrequencyData).mockReturnValue( + new Uint8Array(4) + ); vi.mocked(Conversation.startSession).mockResolvedValue(mockConversation); const { result } = renderHook(() => useConversationControls(), { diff --git a/packages/react/src/conversation/ConversationFeedback.test.tsx b/packages/react/src/conversation/ConversationFeedback.test.tsx index 74ae9818b..2ef3cb40e 100644 --- a/packages/react/src/conversation/ConversationFeedback.test.tsx +++ b/packages/react/src/conversation/ConversationFeedback.test.tsx @@ -8,22 +8,13 @@ import { type ConversationContextValue, } from "./ConversationContext.js"; import { useConversationFeedback } from "./ConversationFeedback.js"; +import { createMockConversation } from "./test-utils.js"; vi.mock("@elevenlabs/client", async importOriginal => { const actual = await importOriginal(); return { ...actual, Conversation: { startSession: vi.fn() } }; }); -const createMockConversation = (id = "test-id") => - ({ - getId: vi.fn().mockReturnValue(id), - isOpen: vi.fn().mockReturnValue(true), - endSession: vi.fn().mockResolvedValue(undefined), - setMicMuted: vi.fn(), - setVolume: vi.fn(), - sendFeedback: vi.fn(), - }) as unknown as Conversation; - function useTestHook() { const ctx = useContext(ConversationContext) as ConversationContextValue; const feedback = useConversationFeedback(); @@ -60,7 +51,7 @@ describe("ConversationFeedback", () => { expect(typeof result.current.sendFeedback).toBe("function"); }); - it("reflects canSendFeedback changes from onCanSendFeedbackChange callback", async () => { + it("reflects canSendFeedback changes from can-send-feedback-change event", async () => { const mockConversation = createMockConversation(); vi.mocked(Conversation.startSession).mockResolvedValue(mockConversation); @@ -72,15 +63,13 @@ describe("ConversationFeedback", () => { result.current.startSession(); }); - const [[opts]] = vi.mocked(Conversation.startSession).mock.calls; - act(() => { - opts.onCanSendFeedbackChange!({ canSendFeedback: true }); + mockConversation.__emit("can-send-feedback-change", { canSendFeedback: true }); }); expect(result.current.feedback.canSendFeedback).toBe(true); act(() => { - opts.onCanSendFeedbackChange!({ canSendFeedback: false }); + mockConversation.__emit("can-send-feedback-change", { canSendFeedback: false }); }); expect(result.current.feedback.canSendFeedback).toBe(false); }); @@ -125,13 +114,21 @@ describe("ConversationFeedback", () => { const [[opts]] = vi.mocked(Conversation.startSession).mock.calls; + // The user's onCanSendFeedbackChange is passed through as a session option callback + expect(opts.onCanSendFeedbackChange).toBeDefined(); + + // Fire via opts to trigger the user callback (stableCallbacks pass-through) act(() => { opts.onCanSendFeedbackChange!({ canSendFeedback: true }); }); - expect(userOnCanSendFeedbackChange).toHaveBeenCalledWith({ canSendFeedback: true, }); + + // Fire via event to trigger the feedback sub-provider + act(() => { + mockConversation.__emit("can-send-feedback-change", { canSendFeedback: true }); + }); expect(result.current.feedback.canSendFeedback).toBe(true); }); @@ -147,15 +144,15 @@ describe("ConversationFeedback", () => { result.current.startSession(); }); - const [[opts]] = vi.mocked(Conversation.startSession).mock.calls; - act(() => { - opts.onCanSendFeedbackChange!({ canSendFeedback: true }); + mockConversation.__emit("can-send-feedback-change", { canSendFeedback: true }); }); expect(result.current.feedback.canSendFeedback).toBe(true); + // Disconnect event causes ConversationProvider to clear conversation to null, + // which triggers ConversationFeedbackProvider's effect to reset canSendFeedback act(() => { - opts.onDisconnect!({ reason: "agent" }); + mockConversation.__emit("disconnect", { reason: "agent" }); }); expect(result.current.feedback.canSendFeedback).toBe(false); }); diff --git a/packages/react/src/conversation/ConversationFeedback.tsx b/packages/react/src/conversation/ConversationFeedback.tsx index 96504d900..b7bada252 100644 --- a/packages/react/src/conversation/ConversationFeedback.tsx +++ b/packages/react/src/conversation/ConversationFeedback.tsx @@ -2,10 +2,14 @@ import { createContext, useCallback, useContext, + useEffect, useMemo, useState, } from "react"; -import { useRawConversationRef, useRegisterCallbacks } from "./ConversationContext.js"; +import { + useRawConversation, + useRawConversationRef, +} from "./ConversationContext.js"; export type ConversationFeedbackValue = { canSendFeedback: boolean; @@ -16,29 +20,38 @@ const ConversationFeedbackContext = createContext(null); /** - * Reads from `ConversationContext` and registers an `onCanSendFeedbackChange` - * callback. Manages its own `canSendFeedback` state and provides it along with + * Subscribes to can-send-feedback-change events on the active conversation. + * Manages its own `canSendFeedback` state and provides it along with * a `sendFeedback` action through `ConversationFeedbackContext`. * Must be rendered inside a `ConversationProvider`. */ export function ConversationFeedbackProvider({ children, }: React.PropsWithChildren) { + const conversation = useRawConversation(); const conversationRef = useRawConversationRef(); const [canSendFeedback, setCanSendFeedback] = useState(false); - useRegisterCallbacks({ - onCanSendFeedbackChange({ canSendFeedback: newValue }) { - setCanSendFeedback(newValue); - }, - onDisconnect() { + useEffect(() => { + if (!conversation) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional reset when conversation disconnects setCanSendFeedback(false); - }, - }); + return; + } + return conversation.on( + "can-send-feedback-change", + ({ canSendFeedback: newValue }) => { + setCanSendFeedback(newValue); + } + ); + }, [conversation]); - const sendFeedback = useCallback((like: boolean) => { - conversationRef.current?.sendFeedback(like); - }, [conversationRef]); + const sendFeedback = useCallback( + (like: boolean) => { + conversationRef.current?.sendFeedback(like); + }, + [conversationRef] + ); const value = useMemo( () => ({ diff --git a/packages/react/src/conversation/ConversationInput.test.tsx b/packages/react/src/conversation/ConversationInput.test.tsx index 798d08430..a26c9fab6 100644 --- a/packages/react/src/conversation/ConversationInput.test.tsx +++ b/packages/react/src/conversation/ConversationInput.test.tsx @@ -8,21 +8,13 @@ import { type ConversationContextValue, } from "./ConversationContext.js"; import { useConversationInput } from "./ConversationInput.js"; +import { createMockConversation } from "./test-utils.js"; vi.mock("@elevenlabs/client", async importOriginal => { const actual = await importOriginal(); return { ...actual, Conversation: { startSession: vi.fn() } }; }); -const createMockConversation = (id = "test-id") => - ({ - getId: vi.fn().mockReturnValue(id), - isOpen: vi.fn().mockReturnValue(true), - endSession: vi.fn().mockResolvedValue(undefined), - setMicMuted: vi.fn(), - setVolume: vi.fn(), - }) as unknown as Conversation; - function useTestHook() { const ctx = useContext(ConversationContext) as ConversationContextValue; const input = useConversationInput(); @@ -145,10 +137,9 @@ describe("ConversationInput", () => { expect(result.current.input.isMuted).toBe(true); - // Simulate the onDisconnect callback that the conversation instance fires - const startSessionCall = vi.mocked(Conversation.startSession).mock.calls[0][0]; - act(() => { - startSessionCall?.onDisconnect?.({ reason: "agent" }); + // endSession sets conversation to null, which triggers the useEffect reset + await act(async () => { + result.current.endSession(); }); expect(result.current.input.isMuted).toBe(false); @@ -302,9 +293,9 @@ describe("ConversationInput", () => { result.current.startSession(); }); - const startSessionCall = vi.mocked(Conversation.startSession).mock.calls[0][0]; - act(() => { - startSessionCall?.onDisconnect?.({ reason: "agent" }); + // endSession sets conversation to null, but controlled mode preserves isMuted + await act(async () => { + result.current.endSession(); }); expect(result.current.input.isMuted).toBe(true); diff --git a/packages/react/src/conversation/ConversationInput.tsx b/packages/react/src/conversation/ConversationInput.tsx index 90c518196..21d370dea 100644 --- a/packages/react/src/conversation/ConversationInput.tsx +++ b/packages/react/src/conversation/ConversationInput.tsx @@ -9,7 +9,6 @@ import { import { useRawConversation, useRawConversationRef, - useRegisterCallbacks, } from "./ConversationContext.js"; export type ConversationInputValue = { @@ -44,13 +43,16 @@ export function ConversationInputProvider({ const [uncontrolledIsMuted, setUncontrolledIsMuted] = useState(false); const isMuted = isControlled ? controlledIsMuted : uncontrolledIsMuted; - useRegisterCallbacks({ - onDisconnect() { + // Reset uncontrolled mute state when conversation disconnects + useEffect(() => { + if (!conversation) { if (!isControlled) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional reset when conversation disconnects setUncontrolledIsMuted(false); } - }, - }); + return; + } + }, [conversation, isControlled]); useEffect(() => { if (isControlled && conversation) { diff --git a/packages/react/src/conversation/ConversationMode.test.tsx b/packages/react/src/conversation/ConversationMode.test.tsx index b4a60e6c5..4b4d2e12e 100644 --- a/packages/react/src/conversation/ConversationMode.test.tsx +++ b/packages/react/src/conversation/ConversationMode.test.tsx @@ -8,21 +8,13 @@ import { type ConversationContextValue, } from "./ConversationContext.js"; import { useConversationMode } from "./ConversationMode.js"; +import { createMockConversation } from "./test-utils.js"; vi.mock("@elevenlabs/client", async importOriginal => { const actual = await importOriginal(); return { ...actual, Conversation: { startSession: vi.fn() } }; }); -const createMockConversation = (id = "test-id") => - ({ - getId: vi.fn().mockReturnValue(id), - isOpen: vi.fn().mockReturnValue(true), - endSession: vi.fn().mockResolvedValue(undefined), - setMicMuted: vi.fn(), - setVolume: vi.fn(), - }) as unknown as Conversation; - function useTestHook() { const ctx = useContext(ConversationContext) as ConversationContextValue; const mode = useConversationMode(); @@ -60,7 +52,7 @@ describe("ConversationMode", () => { expect(result.current.isSpeaking).toBe(false); }); - it("reflects mode changes from onModeChange callback", async () => { + it("reflects mode changes from mode-change event", async () => { const mockConversation = createMockConversation(); vi.mocked(Conversation.startSession).mockResolvedValue(mockConversation); @@ -72,17 +64,15 @@ describe("ConversationMode", () => { result.current.startSession(); }); - const [[opts]] = vi.mocked(Conversation.startSession).mock.calls; - act(() => { - opts.onModeChange!({ mode: "speaking" }); + mockConversation.__emit("mode-change", { mode: "speaking" }); }); expect(result.current.mode.mode).toBe("speaking"); expect(result.current.mode.isSpeaking).toBe(true); expect(result.current.mode.isListening).toBe(false); act(() => { - opts.onModeChange!({ mode: "listening" }); + mockConversation.__emit("mode-change", { mode: "listening" }); }); expect(result.current.mode.mode).toBe("listening"); expect(result.current.mode.isListening).toBe(true); @@ -104,11 +94,19 @@ describe("ConversationMode", () => { const [[opts]] = vi.mocked(Conversation.startSession).mock.calls; + // The user's onModeChange is passed through as a session option callback + expect(opts.onModeChange).toBeDefined(); + + // Fire via opts to trigger the user callback (stableCallbacks pass-through) act(() => { opts.onModeChange!({ mode: "speaking" }); }); - expect(userOnModeChange).toHaveBeenCalledWith({ mode: "speaking" }); + + // Fire via event to trigger the mode sub-provider + act(() => { + mockConversation.__emit("mode-change", { mode: "speaking" }); + }); expect(result.current.mode.mode).toBe("speaking"); }); @@ -124,15 +122,15 @@ describe("ConversationMode", () => { result.current.startSession(); }); - const [[opts]] = vi.mocked(Conversation.startSession).mock.calls; - act(() => { - opts.onModeChange!({ mode: "speaking" }); + mockConversation.__emit("mode-change", { mode: "speaking" }); }); expect(result.current.mode.mode).toBe("speaking"); + // Disconnect event causes ConversationProvider to clear conversation to null, + // which triggers ConversationModeProvider's effect to reset mode to "listening" act(() => { - opts.onDisconnect!({ reason: "agent" }); + mockConversation.__emit("disconnect", { reason: "agent" }); }); expect(result.current.mode.mode).toBe("listening"); expect(result.current.mode.isListening).toBe(true); diff --git a/packages/react/src/conversation/ConversationMode.tsx b/packages/react/src/conversation/ConversationMode.tsx index 5c327bf01..018e7189f 100644 --- a/packages/react/src/conversation/ConversationMode.tsx +++ b/packages/react/src/conversation/ConversationMode.tsx @@ -1,6 +1,6 @@ -import { createContext, useContext, useMemo, useState } from "react"; +import { createContext, useContext, useEffect, useMemo, useState } from "react"; import type { Mode } from "@elevenlabs/client"; -import { useRegisterCallbacks } from "./ConversationContext.js"; +import { useRawConversation } from "./ConversationContext.js"; export type ConversationModeValue = { mode: "speaking" | "listening"; @@ -13,23 +13,26 @@ const ConversationModeContext = createContext( ); /** - * Reads from `ConversationContext` and registers an `onModeChange` callback. - * Manages its own `mode` state and provides it through - * `ConversationModeContext`. Must be rendered inside a `ConversationProvider`. + * Subscribes to mode-change events on the active conversation and provides + * the current mode through `ConversationModeContext`. + * Must be rendered inside a `ConversationProvider`. */ export function ConversationModeProvider({ children, }: React.PropsWithChildren) { + const conversation = useRawConversation(); const [mode, setMode] = useState("listening"); - useRegisterCallbacks({ - onModeChange({ mode: newMode }) { - setMode(newMode); - }, - onDisconnect() { + useEffect(() => { + if (!conversation) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional reset when conversation disconnects setMode("listening"); - }, - }); + return; + } + return conversation.on("mode-change", ({ mode: newMode }) => { + setMode(newMode); + }); + }, [conversation]); const value = useMemo( () => ({ diff --git a/packages/react/src/conversation/ConversationProvider.test.tsx b/packages/react/src/conversation/ConversationProvider.test.tsx index 59e360c74..cdbb090a4 100644 --- a/packages/react/src/conversation/ConversationProvider.test.tsx +++ b/packages/react/src/conversation/ConversationProvider.test.tsx @@ -13,6 +13,7 @@ import { useRawConversation, type ConversationContextValue, } from "./ConversationContext.js"; +import { createMockConversation, type MockConversation } from "./test-utils.js"; /** Test helper — accesses the full context value (conversation + lifecycle methods). */ function useTestContext(): ConversationContextValue { @@ -29,16 +30,6 @@ vi.mock("@elevenlabs/client", async importOriginal => { return { ...actual, Conversation: { startSession: vi.fn() } }; }); -const createMockConversation = (id = "test-id") => - ({ - getId: vi.fn().mockReturnValue(id), - isOpen: vi.fn().mockReturnValue(true), - endSession: vi.fn().mockResolvedValue(undefined), - setMicMuted: vi.fn(), - setVolume: vi.fn(), - sendUserMessage: vi.fn(), - }) as unknown as Conversation; - function createWrapper(props: Record = {}) { return function Wrapper({ children }: React.PropsWithChildren) { return ( @@ -324,9 +315,10 @@ describe("ConversationProvider", () => { expect(result.current.conversation).toBe(mockConversation); // Simulate an external disconnect (agent hangs up, raw endSession(), etc.) - const [[opts]] = vi.mocked(Conversation.startSession).mock.calls; act(() => { - opts.onDisconnect!({ reason: "agent" }); + (mockConversation as MockConversation).__emit("disconnect", { + reason: "agent", + }); }); expect(result.current.conversation).toBeNull(); @@ -347,7 +339,11 @@ describe("ConversationProvider", () => { const [[opts]] = vi.mocked(Conversation.startSession).mock.calls; act(() => { + // Simulate SDK emitting disconnect: fires both on() listeners and options callback opts.onDisconnect!({ reason: "agent" }); + (mockConversation as MockConversation).__emit("disconnect", { + reason: "agent", + }); }); expect(userOnDisconnect).toHaveBeenCalledWith({ reason: "agent" }); @@ -595,8 +591,9 @@ describe("ConversationProvider", () => { expect( typeof (startSessionCall as MockStartSessionOptions).onConversationCreated ).toBe("function"); - // onDisconnect is registered internally by the provider - expect(typeof startSessionCall.onDisconnect).toBe("function"); + // onDisconnect is handled via conversation.on(), not session options, + // so it should be undefined when the user doesn't provide one. + expect(startSessionCall.onDisconnect).toBeUndefined(); // Unprovided callbacks are omitted so client feature guards work expect(startSessionCall.onUnhandledClientToolCall).toBeUndefined(); // onConnect should still have been called diff --git a/packages/react/src/conversation/ConversationProvider.tsx b/packages/react/src/conversation/ConversationProvider.tsx index ca76890a2..e43c60d0f 100644 --- a/packages/react/src/conversation/ConversationProvider.tsx +++ b/packages/react/src/conversation/ConversationProvider.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, - useLayoutEffect, useMemo, useRef, useState, @@ -37,7 +36,6 @@ import { ConversationClientToolsProvider, buildClientTools, } from "./ConversationClientTools.js"; -import { ListenerMap } from "./ListenerMap.js"; import { useStableCallbacks } from "./useStableCallbacks.js"; type ConversationInputControlProps = Pick< @@ -82,32 +80,24 @@ export function ConversationProvider({ // eslint-disable-next-line react-hooks/refs -- intentional sync during render for latest-ref pattern defaultOptionsRef.current = defaultOptions; - /** Callback registry for sub-providers (status, mode, feedback, etc.). */ - const [listenerMap] = useState( - () => new ListenerMap(CALLBACK_KEYS) - ); - /** Reactive mirror of conversationRef, triggers re-renders for context consumers. */ const [conversation, setConversation] = useState(null); + /** True while a `startSession` call is in progress. */ + const [isStarting, setIsStarting] = useState(false); + /** Error message from a failed `startSession` call. Cleared on next attempt. */ + const [startupError, setStartupError] = useState(null); const stableCallbacks = useStableCallbacks(defaultOptions); - const registerCallbacks = useCallback( - (callbacks: Partial) => listenerMap.register(callbacks), - [listenerMap] - ); - // Sync provider state when session ends externally (agent disconnect, - // raw instance endSession(), etc.). Uses the listener map so it composes - // with user-provided onDisconnect callbacks. - useLayoutEffect(() => { - return listenerMap.register({ - onDisconnect: () => { - conversationRef.current = null; - setConversation(null); - }, + // raw instance endSession(), etc.). + useEffect(() => { + if (!conversation) return; + return conversation.on("disconnect", () => { + conversationRef.current = null; + setConversation(null); }); - }, [listenerMap]); + }, [conversation]); const startSession = useCallback( (options?: HookOptions) => { @@ -119,6 +109,8 @@ export function ConversationProvider({ } shouldEndRef.current = false; + setIsStarting(true); + setStartupError(null); const startSessionId = ++startSessionIdRef.current; const defaults = defaultOptionsRef.current; @@ -141,7 +133,6 @@ export function ConversationProvider({ { livekitUrl: calculatedLivekitUrl }, defaultConfig, stableCallbacks, - listenerMap.compose(), options ?? {}, { origin } ); @@ -192,6 +183,7 @@ export function ConversationProvider({ if (isStaleStartSession()) { return; } + setIsStarting(false); if (shouldEndRef.current) { conv.endSession(); lockRef.current = null; @@ -207,25 +199,23 @@ export function ConversationProvider({ if (isStaleStartSession()) { return; } + setIsStarting(false); conversationRef.current = null; setConversation(null); lockRef.current = null; if (shouldEndRef.current) { return; } - // The client SDK calls onStatusChange("disconnected") before - // rejecting, but never calls onError — surface the failure here - // so listeners (e.g. ConversationStatusProvider) transition to - // the "error" state with a meaningful message. const message = error instanceof Error ? error.message : "Session failed to start"; + setStartupError(message); sessionOptions.onError?.(message, error); } ); }, - [stableCallbacks, listenerMap, clientToolsRegistry, clientToolsRef] + [stableCallbacks, clientToolsRegistry, clientToolsRef] ); const endSession = useCallback(() => { @@ -260,11 +250,12 @@ export function ConversationProvider({ conversationRef, startSession, endSession, - registerCallbacks, + isStarting, + startupError, clientToolsRegistry, clientToolsRef, }), - [conversation, conversationRef, startSession, endSession, registerCallbacks, clientToolsRegistry, clientToolsRef] + [conversation, conversationRef, startSession, endSession, isStarting, startupError, clientToolsRegistry, clientToolsRef] ); const wrappedChildren = SUB_PROVIDERS_WITHOUT_PROPS.reduceRight( diff --git a/packages/react/src/conversation/ConversationStatus.test.tsx b/packages/react/src/conversation/ConversationStatus.test.tsx index ccbd2b8eb..876edd19a 100644 --- a/packages/react/src/conversation/ConversationStatus.test.tsx +++ b/packages/react/src/conversation/ConversationStatus.test.tsx @@ -1,63 +1,44 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import React, { useContext } from "react"; import { renderHook, act } from "@testing-library/react"; -import { - Conversation, - type Callbacks, - type ConversationLifecycleOptions, -} from "@elevenlabs/client"; +import { Conversation } from "@elevenlabs/client"; import { ConversationProvider } from "./ConversationProvider.js"; import { ConversationContext, type ConversationContextValue, } from "./ConversationContext.js"; import { useConversationStatus } from "./ConversationStatus.js"; +import { createMockConversation, type MockConversation } from "./test-utils.js"; vi.mock("@elevenlabs/client", async importOriginal => { const actual = await importOriginal(); return { ...actual, Conversation: { startSession: vi.fn() } }; }); -const createMockConversation = (id = "test-id") => - ({ - getId: vi.fn().mockReturnValue(id), - isOpen: vi.fn().mockReturnValue(true), - endSession: vi.fn().mockResolvedValue(undefined), - setMicMuted: vi.fn(), - setVolume: vi.fn(), - }) as unknown as Conversation; - function useTestHook() { const ctx = useContext(ConversationContext) as ConversationContextValue; const status = useConversationStatus(); - return { startSession: ctx.startSession, status }; + return { + startSession: ctx.startSession, + endSession: ctx.endSession, + status, + }; } function createWrapper(props: Record = {}) { return function Wrapper({ children }: React.PropsWithChildren) { return ( - - {children} - + {children} ); }; } -type MockStartSessionOptions = Partial & - Record; - -function driveConnectedSessionLifecycle( - options: MockStartSessionOptions, - conversation: Conversation -) { - options.onConversationCreated?.(conversation); - options.onStatusChange?.({ status: "connected" }); - options.onConnect?.({ conversationId: conversation.getId() }); -} - describe("ConversationStatus", () => { + let mockConversation: MockConversation; + beforeEach(() => { vi.clearAllMocks(); + mockConversation = createMockConversation(); }); it("throws when used outside a ConversationProvider", () => { @@ -75,39 +56,63 @@ describe("ConversationStatus", () => { expect(result.current.message).toBeUndefined(); }); - it("reflects status changes from onStatusChange callback", async () => { - const mockConversation = createMockConversation(); - vi.mocked(Conversation.startSession).mockResolvedValue(mockConversation); + it("returns connecting status while session is starting", async () => { + const { promise, resolve } = Promise.withResolvers(); + vi.mocked(Conversation.startSession).mockReturnValue(promise); const { result } = renderHook(() => useTestHook(), { wrapper: createWrapper(), }); - await act(async () => { + act(() => { result.current.startSession(); }); - const [[opts]] = vi.mocked(Conversation.startSession).mock.calls; + expect(result.current.status.status).toBe("connecting"); - act(() => { - opts.onStatusChange!({ status: "connecting" }); + await act(async () => { + resolve(mockConversation as Conversation); }); - expect(result.current.status.status).toBe("connecting"); + }); - act(() => { - opts.onStatusChange!({ status: "connected" }); + it("returns connected status after session starts", async () => { + vi.mocked(Conversation.startSession).mockResolvedValue( + mockConversation as Conversation + ); + + const { result } = renderHook(() => useTestHook(), { + wrapper: createWrapper(), }); + + await act(async () => { + result.current.startSession(); + }); + expect(result.current.status.status).toBe("connected"); + expect(result.current.status.message).toBeUndefined(); + }); - act(() => { - opts.onStatusChange!({ status: "disconnected" }); + it("returns error status with message when startSession rejects", async () => { + vi.mocked(Conversation.startSession).mockRejectedValue( + new Error("agent not found") + ); + + const { result } = renderHook(() => useTestHook(), { + wrapper: createWrapper(), }); - expect(result.current.status.status).toBe("disconnected"); + + await act(async () => { + result.current.startSession(); + }); + + expect(result.current.status.status).toBe("error"); + expect(result.current.status.message).toBe("agent not found"); }); - it("sets error status and message from onError callback", async () => { - const mockConversation = createMockConversation(); - vi.mocked(Conversation.startSession).mockResolvedValue(mockConversation); + it("returns error status with message from runtime error event", async () => { + vi.mocked(Conversation.startSession).mockResolvedValue( + mockConversation as Conversation + ); const { result } = renderHook(() => useTestHook(), { wrapper: createWrapper(), @@ -117,68 +122,70 @@ describe("ConversationStatus", () => { result.current.startSession(); }); - const [[opts]] = vi.mocked(Conversation.startSession).mock.calls; + expect(result.current.status.status).toBe("connected"); act(() => { - opts.onError!("Something went wrong"); + mockConversation.__emit("error", "Something went wrong"); }); expect(result.current.status.status).toBe("error"); expect(result.current.status.message).toBe("Something went wrong"); }); - it("clears error message when status transitions to non-error", async () => { - const mockConversation = createMockConversation(); - vi.mocked(Conversation.startSession).mockResolvedValue(mockConversation); + it("clears runtime error when starting a new session", async () => { + const firstConversation = createMockConversation("first"); + const secondConversation = createMockConversation("second"); + + vi.mocked(Conversation.startSession).mockResolvedValueOnce( + firstConversation as Conversation + ); const { result } = renderHook(() => useTestHook(), { wrapper: createWrapper(), }); + // Start first session await act(async () => { result.current.startSession(); }); - const [[opts]] = vi.mocked(Conversation.startSession).mock.calls; - + // Trigger a runtime error act(() => { - opts.onError!("Something went wrong"); + firstConversation.__emit("error", "Something went wrong"); }); + expect(result.current.status.status).toBe("error"); expect(result.current.status.message).toBe("Something went wrong"); + // End the session so we can start a new one act(() => { - opts.onStatusChange!({ status: "connected" }); + result.current.endSession(); }); - expect(result.current.status.status).toBe("connected"); - expect(result.current.status.message).toBeUndefined(); - }); - it("transitions to error status when startSession rejects", async () => { - vi.mocked(Conversation.startSession).mockRejectedValue( - new Error("agent not found") - ); + // Start a new session — error should clear during connecting phase + const { promise, resolve } = Promise.withResolvers(); + vi.mocked(Conversation.startSession).mockReturnValue(promise); - const { result } = renderHook(() => useTestHook(), { - wrapper: createWrapper(), + act(() => { + result.current.startSession(); }); + expect(result.current.status.status).toBe("connecting"); + expect(result.current.status.message).toBeUndefined(); + await act(async () => { - result.current.startSession(); + resolve(secondConversation as Conversation); }); - expect(result.current.status.status).toBe("error"); - expect(result.current.status.message).toBe("agent not found"); + expect(result.current.status.status).toBe("connected"); }); - it("transitions to error status when onConnect throws", async () => { - const mockConversation = createMockConversation(); + it("returns error status when onConnect throws", async () => { vi.mocked(Conversation.startSession).mockImplementation(async options => { - driveConnectedSessionLifecycle( - options as MockStartSessionOptions, - mockConversation - ); - return mockConversation; + const conv = mockConversation as Conversation; + options.onConversationCreated?.(conv); + options.onConnect?.({ conversationId: conv.getId() }); + return conv; }); const { result } = renderHook(() => useTestHook(), { @@ -197,9 +204,10 @@ describe("ConversationStatus", () => { expect(result.current.status.message).toBe("boom"); }); - it("ignores disconnecting status (transient)", async () => { - const mockConversation = createMockConversation(); - vi.mocked(Conversation.startSession).mockResolvedValue(mockConversation); + it("returns disconnected status after endSession", async () => { + vi.mocked(Conversation.startSession).mockResolvedValue( + mockConversation as Conversation + ); const { result } = renderHook(() => useTestHook(), { wrapper: createWrapper(), @@ -209,39 +217,13 @@ describe("ConversationStatus", () => { result.current.startSession(); }); - const [[opts]] = vi.mocked(Conversation.startSession).mock.calls; - - act(() => { - opts.onStatusChange!({ status: "connected" }); - }); expect(result.current.status.status).toBe("connected"); act(() => { - opts.onStatusChange!({ status: "disconnecting" }); + result.current.endSession(); }); - expect(result.current.status.status).toBe("connected"); - }); - it("composes with user-provided onStatusChange callback", async () => { - const userOnStatusChange = vi.fn(); - const mockConversation = createMockConversation(); - vi.mocked(Conversation.startSession).mockResolvedValue(mockConversation); - - const { result } = renderHook(() => useTestHook(), { - wrapper: createWrapper({ onStatusChange: userOnStatusChange }), - }); - - await act(async () => { - result.current.startSession(); - }); - - const [[opts]] = vi.mocked(Conversation.startSession).mock.calls; - - act(() => { - opts.onStatusChange!({ status: "connected" }); - }); - - expect(userOnStatusChange).toHaveBeenCalledWith({ status: "connected" }); - expect(result.current.status.status).toBe("connected"); + expect(result.current.status.status).toBe("disconnected"); + expect(result.current.status.message).toBeUndefined(); }); }); diff --git a/packages/react/src/conversation/ConversationStatus.tsx b/packages/react/src/conversation/ConversationStatus.tsx index 0d7bd6806..72d5bd5a6 100644 --- a/packages/react/src/conversation/ConversationStatus.tsx +++ b/packages/react/src/conversation/ConversationStatus.tsx @@ -1,5 +1,8 @@ -import { createContext, useContext, useMemo, useState } from "react"; -import { useRegisterCallbacks } from "./ConversationContext.js"; +import { createContext, useContext, useEffect, useMemo, useState } from "react"; +import { + useRawConversation, + useSessionLifecycle, +} from "./ConversationContext.js"; export type ConversationStatus = | "disconnected" @@ -17,32 +20,46 @@ const ConversationStatusContext = createContext( ); /** - * Reads from `ConversationContext` and registers `onStatusChange` + `onError` - * callbacks. Manages its own `status`/`message` state and provides it through - * `ConversationStatusContext`. Must be rendered inside a `ConversationProvider`. + * Derives conversation status from the session lifecycle and subscribes to + * error events on the active conversation. Must be rendered inside a + * `ConversationProvider`. */ export function ConversationStatusProvider({ children, }: React.PropsWithChildren) { - const [status, setStatus] = - useState("disconnected"); - const [message, setMessage] = useState(undefined); + const conversation = useRawConversation(); + const { isStarting, startupError } = useSessionLifecycle(); + const [errorMessage, setErrorMessage] = useState(); - useRegisterCallbacks({ - onStatusChange({ status: newStatus }) { - if (newStatus === "disconnecting") { - // Transient state — keep current status - return; - } - setStatus(newStatus); - // Clear error message when transitioning to a non-error state - setMessage(undefined); - }, - onError(errorMessage) { - setStatus("error"); - setMessage(errorMessage); - }, - }); + // Subscribe to error events on the conversation + useEffect(() => { + if (!conversation) return; + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional reset when conversation reconnects + setErrorMessage(undefined); + return conversation.on("error", msg => { + setErrorMessage(msg); + }); + }, [conversation]); + + // Clear error when starting a new session + useEffect(() => { + if (isStarting) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional reset when new session starts + setErrorMessage(undefined); + } + }, [isStarting]); + + const status: ConversationStatus = startupError + ? "error" + : errorMessage !== undefined + ? "error" + : isStarting + ? "connecting" + : conversation + ? "connected" + : "disconnected"; + + const message = startupError ?? errorMessage; const value = useMemo( () => ({ status, message }), diff --git a/packages/react/src/conversation/ListenerMap.test.ts b/packages/react/src/conversation/ListenerMap.test.ts deleted file mode 100644 index 3db021ec7..000000000 --- a/packages/react/src/conversation/ListenerMap.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { ListenerMap } from "./ListenerMap.js"; - -type TestCallbacks = { - onConnect?: (props: { id: string }) => void; - onError?: (message: string) => void; - onDisconnect?: () => void; -}; - -describe("ListenerMap", () => { - it("invokes a registered listener through compose", () => { - const map = new ListenerMap([ - "onConnect", - "onError", - "onDisconnect", - ]); - const fn = vi.fn(); - map.register({ onConnect: fn }); - - const composed = map.compose(); - composed.onConnect?.({ id: "abc" }); - - expect(fn).toHaveBeenCalledWith({ id: "abc" }); - }); - - it("invokes multiple listeners for the same key", () => { - const map = new ListenerMap([ - "onConnect", - "onError", - "onDisconnect", - ]); - const fn1 = vi.fn(); - const fn2 = vi.fn(); - map.register({ onError: fn1 }); - map.register({ onError: fn2 }); - - const composed = map.compose(); - composed.onError?.("fail"); - - expect(fn1).toHaveBeenCalledWith("fail"); - expect(fn2).toHaveBeenCalledWith("fail"); - }); - - it("register returns a function that removes all its listeners", () => { - const map = new ListenerMap([ - "onConnect", - "onError", - "onDisconnect", - ]); - const kept = vi.fn(); - const removed = vi.fn(); - map.register({ onError: kept }); - const remove = map.register({ onError: removed }); - - remove(); - - const composed = map.compose(); - composed.onError?.("after"); - - expect(kept).toHaveBeenCalledWith("after"); - expect(removed).not.toHaveBeenCalled(); - }); - - it("register handles multiple keys at once", () => { - const map = new ListenerMap([ - "onConnect", - "onError", - "onDisconnect", - ]); - const onConnect = vi.fn(); - const onError = vi.fn(); - const remove = map.register({ onConnect, onError }); - - const composed = map.compose(); - composed.onConnect?.({ id: "x" }); - composed.onError?.("bad"); - - expect(onConnect).toHaveBeenCalledWith({ id: "x" }); - expect(onError).toHaveBeenCalledWith("bad"); - - remove(); - - // Removed listeners are no longer invoked, but late-registered ones still fire - const onConnectSpy = vi.fn(); - map.register({ onConnect: onConnectSpy }); - const composed2 = map.compose(); - composed2.onConnect?.({ id: "y" }); - expect(onConnect).not.toHaveBeenCalledWith({ id: "y" }); - expect(onConnectSpy).toHaveBeenCalledWith({ id: "y" }); - }); - - it("compose only includes keys with registered listeners", () => { - const map = new ListenerMap([ - "onConnect", - "onError", - "onDisconnect", - ]); - const composed = map.compose(); - - expect(composed.onConnect).toBeUndefined(); - expect(composed.onError).toBeUndefined(); - expect(composed.onDisconnect).toBeUndefined(); - - // After registering a listener, only that key appears - map.register({ onError: vi.fn() }); - const composed2 = map.compose(); - expect(composed2.onConnect).toBeUndefined(); - expect(typeof composed2.onError).toBe("function"); - expect(composed2.onDisconnect).toBeUndefined(); - }); - - it("compose reflects live listener state", () => { - const map = new ListenerMap([ - "onConnect", - "onError", - "onDisconnect", - ]); - const fn = vi.fn(); - const remove = map.register({ onDisconnect: fn }); - - // Compose before removal - const composed = map.compose(); - - // Remove the listener after compose - remove(); - - // The composed function should reflect the removal - composed.onDisconnect?.(); - expect(fn).not.toHaveBeenCalled(); - }); - - it("compose picks up listeners registered after compose was called", () => { - const map = new ListenerMap([ - "onConnect", - "onError", - "onDisconnect", - ]); - - // Register an initial listener so compose includes the key - const early = vi.fn(); - map.register({ onConnect: early }); - const composed = map.compose(); - - // Register another listener after compose - const late = vi.fn(); - map.register({ onConnect: late }); - - // The previously composed function should invoke both listeners - composed.onConnect?.({ id: "late" }); - expect(early).toHaveBeenCalledWith({ id: "late" }); - expect(late).toHaveBeenCalledWith({ id: "late" }); - }); - - it("skips undefined values in the callbacks object", () => { - const map = new ListenerMap([ - "onConnect", - "onError", - "onDisconnect", - ]); - const onError = vi.fn(); - const remove = map.register({ onConnect: undefined, onError }); - const composed = map.compose(); - composed.onConnect?.({ id: "test" }); - expect(onError).not.toHaveBeenCalled(); - composed.onError?.("boom"); - expect(onError).toHaveBeenCalledWith("boom"); - remove(); - }); - - it("throws on unknown callback keys", () => { - const map = new ListenerMap(["onConnect"]); - expect(() => - map.register({ onError: vi.fn() } as Partial) - ).toThrow('Unknown callback key "onError"'); - }); -}); diff --git a/packages/react/src/conversation/ListenerMap.ts b/packages/react/src/conversation/ListenerMap.ts deleted file mode 100644 index 5349b965c..000000000 --- a/packages/react/src/conversation/ListenerMap.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { ListenerSet } from "./ListenerSet.js"; - -function assertFunction( - value: unknown, - key: string -): asserts value is (...args: unknown[]) => void { - if (typeof value !== "function") { - throw new Error(`Expected function for key "${key}", got ${typeof value}`); - } -} - -/** - * A map of named listener sets. Each key maps to a `ListenerSet` that can have - * multiple listeners registered. Typed through `T` so that `register` and - * `compose` preserve per-key callback signatures. - * - * All keys are pre-initialized in the constructor so `register` can validate - * keys. `compose()` only includes keys with at least one registered listener, - * preserving callback-presence semantics used by the client as feature guards. - * For included keys, composed functions delegate to the live listener set, so - * listeners added/removed after `compose()` still take effect. Keys with no - * listeners at compose time are omitted entirely; call `compose()` again after - * registering listeners to pick up newly populated keys. - */ -export class ListenerMap< - T extends Record void) | undefined>, -> { - private sets = new Map>(); - - constructor(keys: readonly (keyof T & string)[]) { - for (const key of keys) { - this.sets.set(key, new ListenerSet()); - } - } - - /** - * Register listeners for one or more keys. Returns a function that removes - * all listeners added by this call. - */ - register(callbacks: Partial): () => void { - const removers = Object.entries(callbacks) - .filter(([, fn]) => fn !== undefined) - .map(([key, fn]) => { - assertFunction(fn, key); - const set = this.sets.get(key); - if (!set) { - throw new Error(`Unknown callback key "${key}"`); - } - return set.add(fn); - }); - return () => { - for (const remove of removers) remove(); - }; - } - - /** - * Compose all registered listeners into a single callbacks object. Each - * composed function delegates to the live listener set, so listeners - * added/removed after this call still take effect. - */ - compose(): Partial { - return Object.fromEntries( - Array.from(this.sets.entries()) - .filter(([, set]) => set.size > 0) - .map(([key, set]) => [ - key, - (...args: never[]) => { - set.invoke(...args); - }, - ]) - ) as Partial; - } -} diff --git a/packages/react/src/conversation/ListenerSet.test.ts b/packages/react/src/conversation/ListenerSet.test.ts deleted file mode 100644 index 0d9208dbf..000000000 --- a/packages/react/src/conversation/ListenerSet.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { ListenerSet } from "./ListenerSet.js"; - -describe("ListenerSet", () => { - it("invokes a single listener", () => { - const set = new ListenerSet<[string]>(); - const fn = vi.fn(); - set.add(fn); - - set.invoke("hello"); - - expect(fn).toHaveBeenCalledWith("hello"); - }); - - it("invokes multiple listeners in registration order", () => { - const set = new ListenerSet<[number]>(); - const calls: number[] = []; - set.add(v => calls.push(v * 10)); - set.add(v => calls.push(v * 100)); - - set.invoke(3); - - expect(calls).toEqual([30, 300]); - }); - - it("add returns a function that removes the listener", () => { - const set = new ListenerSet<[string]>(); - const kept = vi.fn(); - const removed = vi.fn(); - - set.add(kept); - const remove = set.add(removed); - - remove(); - set.invoke("after"); - - expect(kept).toHaveBeenCalledWith("after"); - expect(removed).not.toHaveBeenCalled(); - }); - - it("invoking with no listeners does nothing", () => { - const set = new ListenerSet<[string]>(); - expect(() => set.invoke("nothing")).not.toThrow(); - }); - - it("size reflects current listener count", () => { - const set = new ListenerSet<[string]>(); - expect(set.size).toBe(0); - - const remove1 = set.add(() => {}); - expect(set.size).toBe(1); - - set.add(() => {}); - expect(set.size).toBe(2); - - remove1(); - expect(set.size).toBe(1); - }); - - it("handles multiple argument types", () => { - const set = new ListenerSet<[string, number, boolean]>(); - const fn = vi.fn(); - set.add(fn); - - set.invoke("test", 42, true); - - expect(fn).toHaveBeenCalledWith("test", 42, true); - }); - - it("handles zero-argument listeners", () => { - const set = new ListenerSet<[]>(); - const fn = vi.fn(); - set.add(fn); - - set.invoke(); - - expect(fn).toHaveBeenCalledWith(); - }); -}); diff --git a/packages/react/src/conversation/ListenerSet.ts b/packages/react/src/conversation/ListenerSet.ts deleted file mode 100644 index 1eb94cc60..000000000 --- a/packages/react/src/conversation/ListenerSet.ts +++ /dev/null @@ -1,18 +0,0 @@ -export class ListenerSet { - private listeners = new Set<(...args: Args) => void>(); - - add(fn: (...args: Args) => void): () => void { - this.listeners.add(fn); - return () => { - this.listeners.delete(fn); - }; - } - - invoke(...args: Args): void { - for (const fn of this.listeners) fn(...args); - } - - get size(): number { - return this.listeners.size; - } -} diff --git a/packages/react/src/conversation/test-utils.ts b/packages/react/src/conversation/test-utils.ts new file mode 100644 index 000000000..ce3c493b6 --- /dev/null +++ b/packages/react/src/conversation/test-utils.ts @@ -0,0 +1,56 @@ +import { vi } from "vitest"; +import type { Conversation } from "@elevenlabs/client"; + +type MockEventListeners = Map void>>; + +export type MockConversation = Conversation & { + /** Test helper: emit an event to all registered listeners */ + __emit: (event: string, ...args: unknown[]) => void; + /** Test helper: access the raw listener map */ + __listeners: MockEventListeners; +}; + +export function createMockConversation(id = "test-id"): MockConversation { + const listeners: MockEventListeners = new Map(); + + const on = vi.fn((event: string, listener: (...args: unknown[]) => void) => { + let set = listeners.get(event); + if (!set) { + set = new Set(); + listeners.set(event, set); + } + set.add(listener); + return () => { + set.delete(listener); + }; + }); + + const __emit = (event: string, ...args: unknown[]) => { + const set = listeners.get(event); + if (set) { + for (const listener of set) listener(...args); + } + }; + + return { + getId: vi.fn().mockReturnValue(id), + isOpen: vi.fn().mockReturnValue(true), + endSession: vi.fn().mockResolvedValue(undefined), + setMicMuted: vi.fn(), + setVolume: vi.fn(), + sendUserMessage: vi.fn(), + sendMultimodalMessage: vi.fn(), + uploadFile: vi.fn(), + sendContextualUpdate: vi.fn(), + sendUserActivity: vi.fn(), + sendMCPToolApprovalResult: vi.fn(), + sendFeedback: vi.fn(), + getInputByteFrequencyData: vi.fn().mockReturnValue(new Uint8Array(0)), + getOutputByteFrequencyData: vi.fn().mockReturnValue(new Uint8Array(0)), + getInputVolume: vi.fn().mockReturnValue(0), + getOutputVolume: vi.fn().mockReturnValue(0), + on, + __emit, + __listeners: listeners, + } as unknown as MockConversation; +} diff --git a/packages/react/src/conversation/useConversation.test.tsx b/packages/react/src/conversation/useConversation.test.tsx index 37afe3b1f..71d804a4c 100644 --- a/packages/react/src/conversation/useConversation.test.tsx +++ b/packages/react/src/conversation/useConversation.test.tsx @@ -8,30 +8,13 @@ import { } from "@elevenlabs/client"; import { useConversation } from "./useConversation.js"; import { ConversationProvider } from "./ConversationProvider.js"; +import { createMockConversation } from "./test-utils.js"; vi.mock("@elevenlabs/client", async importOriginal => { const actual = await importOriginal(); return { ...actual, Conversation: { startSession: vi.fn() } }; }); -const createMockConversation = (id = "test-id") => - ({ - getId: vi.fn().mockReturnValue(id), - isOpen: vi.fn().mockReturnValue(true), - endSession: vi.fn().mockResolvedValue(undefined), - setMicMuted: vi.fn(), - setVolume: vi.fn(), - sendFeedback: vi.fn(), - sendUserMessage: vi.fn(), - sendContextualUpdate: vi.fn(), - sendUserActivity: vi.fn(), - sendMCPToolApprovalResult: vi.fn(), - getInputByteFrequencyData: vi.fn(), - getOutputByteFrequencyData: vi.fn(), - getInputVolume: vi.fn().mockReturnValue(0), - getOutputVolume: vi.fn().mockReturnValue(0), - }) as unknown as Conversation; - function createWrapper(props: Record = {}) { return function Wrapper({ children }: React.PropsWithChildren) { return ( @@ -341,7 +324,7 @@ describe("useConversation", () => { }); // agentId should be forwarded, and the provider should wrap onConnect while - // still registering the hook callback via useRegisterCallbacks. + // still composing the hook callback via mergeOptions. const [[opts]] = vi.mocked(Conversation.startSession).mock.calls; expect(opts.agentId).toBe("hook-agent-id"); expect(typeof opts.onConnect).toBe("function"); diff --git a/packages/react/src/conversation/useConversation.ts b/packages/react/src/conversation/useConversation.ts index 30c1c101f..684004fc6 100644 --- a/packages/react/src/conversation/useConversation.ts +++ b/packages/react/src/conversation/useConversation.ts @@ -1,15 +1,12 @@ import { useCallback, useEffect, useRef } from "react"; -import { CALLBACK_KEYS } from "@elevenlabs/client/internal"; +import { CALLBACK_KEYS, mergeOptions } from "@elevenlabs/client/internal"; import { useConversationControls } from "./ConversationControls.js"; import { useConversationStatus } from "./ConversationStatus.js"; import { useConversationInput } from "./ConversationInput.js"; import { useConversationMode } from "./ConversationMode.js"; import { useConversationFeedback } from "./ConversationFeedback.js"; -import { - useRawConversation, - useRegisterCallbacks, -} from "./ConversationContext.js"; +import { useRawConversation } from "./ConversationContext.js"; import { useStableCallbacks } from "./useStableCallbacks.js"; import type { HookOptions } from "./types.js"; @@ -25,8 +22,8 @@ export type UseConversationOptions = HookOptions & { * * Accepts optional `micMuted`, `volume`, session config, and callback props. * Session config and callbacks passed here are used as defaults when calling - * `startSession()` without arguments. Callbacks are also registered with the - * provider so they stay up-to-date across re-renders. + * `startSession()` without arguments. Callbacks are ref-stable across renders + * and compose with provider-level and per-session callbacks. * * Must be used within a `ConversationProvider`. */ @@ -34,7 +31,6 @@ export function useConversation(props: UseConversationOptions = {}) { const { micMuted, volume, ...hookOptions } = props; const stableCallbacks = useStableCallbacks(hookOptions); - useRegisterCallbacks(stableCallbacks); const hookOptionsRef = useRef(hookOptions); // eslint-disable-next-line react-hooks/refs -- intentional sync during render for latest-ref pattern @@ -48,23 +44,19 @@ export function useConversation(props: UseConversationOptions = {}) { const startSession = useCallback( (options?: HookOptions) => { - // Strip callbacks from the hook-level defaults: those are registered via - // useRegisterCallbacks and kept ref-stable across renders. - // NOTE: We intentionally do NOT strip callbacks from the `options` parameter - // here. Callbacks passed directly to startSession() are treated as one-shot - // per-session overrides, and may capture render-local state. This asymmetry - // (hook callbacks are ref-stable; startSession callbacks are one-shot) is - // intentional and relied on by the public API. + // Strip raw callbacks from hook defaults — stableCallbacks provides + // ref-backed versions that won't go stale across renders. const sessionConfig = { ...hookOptionsRef.current }; for (const key of CALLBACK_KEYS) { delete (sessionConfig as Record)[key]; } - controls.startSession({ - ...sessionConfig, - ...options, - } as HookOptions); + // Use mergeOptions so that hook callbacks compose with (rather than + // override) provider-level and per-session callbacks. + controls.startSession( + mergeOptions(sessionConfig, stableCallbacks, options ?? {}) + ); }, - [controls, hookOptionsRef] + [controls, stableCallbacks] ); const conversation = useRawConversation();