diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index d78de38..e7545a3 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -10,7 +10,7 @@ Thanks for your interest in contributing to `@deepgram/react`! ## Setup ```bash -# Clone the agent SDK (required -- @deepgram/react depends on it via file: pointer) +# Clone and build the sibling agent SDK used by the local TypeScript path mapping git clone git@github.com:deepgram/agent.git ../agent cd ../agent && bun install && bun run build cd - diff --git a/README.md b/README.md index a19e0ea..269095d 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ function App() { fetch('/api/deepgram-token').then(r => r.text()) }, - agent: { think: { provider: { type: 'open_ai' }, model: 'gpt-4o-mini' } }, + agent: { think: { provider: { type: 'open_ai', model: 'gpt-4o-mini' } } }, }} > @@ -47,16 +47,18 @@ function VoiceAgent() { } ``` +`config`, `playerSampleRate`, and the initial `autoStart` value establish the provider session for that component lifetime. Use the runtime update methods for supported listen, think, speak, and prompt changes rather than changing `config` in place. + ## Hooks | Hook | Purpose | |------|---------| | `useAgentState` | Connection state (`idle`, `connecting`, `connected`, etc.) and `start`/`stop` controls | -| `useAgentMode` | Speaking/listening mode tracking | -| `useAgentConversation` | Conversation transcript and `sendUserMessage` | +| `useAgentMode` | Listening/thinking/speaking mode tracking | +| `useAgentConversation` | Conversation transcript plus user and agent messages | | `useAgentMicrophone` | Mic state, mute controls, input volume | | `useAgentPlayer` | Audio playback state, mute controls, output volume | -| `useAgentControls` | Stable action methods (never change identity) | +| `useAgentControls` | Grouped lifecycle, messaging, settings, and mute controls | | `useAgentClientTool` | Register client-side function call handlers scoped to component lifecycle | | `useAgentSession` | Direct access to the underlying `AgentSession` (escape hatch) | | `useDeepgramAgent` | Standalone hook -- no provider needed | @@ -81,7 +83,7 @@ See the [package README](packages/react/README.md) for full API documentation. **Prerequisites:** [Bun](https://bun.sh/) 1.3+ -This package depends on `@deepgram/agents` via a `file:` pointer. Clone the agent repo as a sibling: +The published package depends on the npm release of `@deepgram/agents`. For coordinated local development, this repository's TypeScript config maps `@deepgram/agents` to a built sibling checkout: ```bash git clone git@github.com:deepgram/agent.git ../agent diff --git a/bun.lock b/bun.lock index cf620f2..c38ee34 100644 --- a/bun.lock +++ b/bun.lock @@ -7,11 +7,12 @@ }, "packages/react": { "name": "@deepgram/react", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { - "@deepgram/agents": "^0.1.1", + "@deepgram/agents": "^0.1.2", }, "devDependencies": { + "@biomejs/biome": "2.4.10", "@testing-library/react": "16.3.2", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", @@ -21,7 +22,7 @@ "react": "19.2.5", "react-dom": "19.2.5", "terser": "5.46.2", - "typescript": "6.0.2", + "typescript": "5.9.3", "vite": "8.0.10", "vite-plugin-dts": "4.5.4", }, @@ -47,11 +48,29 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@deepgram/agents": ["@deepgram/agents@0.1.1", "", { "dependencies": { "@deepgram/sdk": "5.1.0", "eventemitter3": "5.0.4" } }, "sha512-oqyIMg6Y9PMEfMvHMu7W+9niCtr9wV/RqTXgvrcMvgUI7B3eV1CDkKmVVjjHzhTShh5CeoeTQW/KyhHG3DB8ag=="], + "@biomejs/biome": ["@biomejs/biome@2.4.10", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.10", "@biomejs/cli-darwin-x64": "2.4.10", "@biomejs/cli-linux-arm64": "2.4.10", "@biomejs/cli-linux-arm64-musl": "2.4.10", "@biomejs/cli-linux-x64": "2.4.10", "@biomejs/cli-linux-x64-musl": "2.4.10", "@biomejs/cli-win32-arm64": "2.4.10", "@biomejs/cli-win32-x64": "2.4.10" }, "bin": { "biome": "bin/biome" } }, "sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vuzzI1cWqDVzOMIkYyHbKqp+AkQq4K7k+UCXWpkYcY/HDn1UxdsbsfgtVpa40shem8Kax4TLDLlx8kMAecgqiw=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-14fzASRo+BPotwp7nWULy2W5xeUyFnTaq1V13Etrrxkrih+ez/2QfgFm5Ehtf5vSjtgx/IJycMMpn5kPd5ZNaA=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-7MH1CMW5uuxQ/s7FLST63qF8B3Hgu2HRdZ7tA1X1+mk+St4JOuIrqdhIBnnyqeyWJNI+Bww7Es5QZ0wIc1Cmkw=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-WrJY6UuiSD/Dh+nwK2qOTu8kdMDlLV3dLMmychIghHPAysWFq1/DGC1pVZx8POE3ZkzKR3PUUnVrtZfMfaJjyQ=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.10", "", { "os": "linux", "cpu": "x64" }, "sha512-tZLvEEi2u9Xu1zAqRjTcpIDGVtldigVvzug2fTuPG0ME/g8/mXpRPcNgLB22bGn6FvLJpHHnqLnwliOu8xjYrg=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.10", "", { "os": "linux", "cpu": "x64" }, "sha512-kDTi3pI6PBN6CiczsWYOyP2zk0IJI08EWEQyDMQWW221rPaaEz6FvjLhnU07KMzLv8q3qSuoB93ua6inSQ55Tw=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-umwQU6qPzH+ISTf/eHyJ/QoQnJs3V9Vpjz2OjZXe9MVBZ7prgGafMy7yYeRGnlmDAn87AKTF3Q6weLoMGpeqdQ=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.10", "", { "os": "win32", "cpu": "x64" }, "sha512-aW/JU5GuyH4uxMrNYpoC2kjaHlyJGLgIa3XkhPEZI0uKhZhJZU8BuEyJmvgzSPQNGozBwWjC972RaNdcJ9KyJg=="], + + "@deepgram/agents": ["@deepgram/agents@0.1.2", "", { "dependencies": { "@deepgram/sdk": "5.9.0", "eventemitter3": "5.0.4" } }, "sha512-DLRJOGSe2c6THHLrHD67K0Kg6Le4+dqS04uRpwJBI+vGL2GjLCKX+OCyLMQskNTuDwRgRVfqleeZx4mgvtcPIw=="], "@deepgram/react": ["@deepgram/react@workspace:packages/react"], - "@deepgram/sdk": ["@deepgram/sdk@5.1.0", "", { "dependencies": { "ws": "^8.20.0" } }, "sha512-pw4JsFLglvIAjk1C4rALHYBW5dHt30d3j25jsJhvoexTziLSTBK9LJlpeml/UDwHMILu4ZbBs3o0ZsF9DaHuZQ=="], + "@deepgram/sdk": ["@deepgram/sdk@5.9.0", "", { "dependencies": { "ws": "^8.20.0" } }, "sha512-MZge3ClQnUbFb+3cz93WI+aljPo8xPVOH5aI/oQGZNuN7JbNJBkmtXnv0Y/3ocFyqmAJltrF6U8t0gpdXzNXUw=="], "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], @@ -349,7 +368,7 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], @@ -367,8 +386,6 @@ "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], - "@microsoft/api-extractor/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "@vue/language-core/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], diff --git a/examples/basic/App.tsx b/examples/basic/App.tsx index d875190..9bcebf1 100644 --- a/examples/basic/App.tsx +++ b/examples/basic/App.tsx @@ -10,7 +10,7 @@ const config = { tokenFactory: () => fetch("/api/deepgram-token").then((r) => r.text()), }, agent: { - think: { provider: { type: "open_ai" as const }, model: "gpt-4o-mini" }, + think: { provider: { type: "open_ai" as const, model: "gpt-4o-mini" } }, }, }; diff --git a/examples/basic/README.md b/examples/basic/README.md index 1a7a417..a2f0526 100644 --- a/examples/basic/README.md +++ b/examples/basic/README.md @@ -20,4 +20,4 @@ This example is a standalone React component. To use it in your project: - Wrapping your app with `AgentProvider` - Using `useAgentState` for connection lifecycle (`start`/`stop`) - Using `useAgentConversation` for transcript display and text messaging -- Using `useAgentMode` for speaking/listening state +- Using `useAgentMode` for listening, thinking, and speaking mode tracking diff --git a/packages/react/README.md b/packages/react/README.md index 723ecee..1448f81 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -20,7 +20,7 @@ function App() { fetch('/api/deepgram-token').then(r => r.text()) }, - agent: { think: { provider: { type: 'open_ai' }, model: 'gpt-4o-mini' } }, + agent: { think: { provider: { type: 'open_ai', model: 'gpt-4o-mini' } } }, }} > @@ -58,14 +58,23 @@ Wraps your component tree with agent state management. Creates and manages an `A playerSampleRate={24_000} // Agent audio sample rate (default: 24_000) autoStart={false} // Auto-connect on mount (default: false) onFunctionCall={handler} // Fallback function call handler + onError={handleError} // Protocol Error notification + onSdkError={handleSdkError} // Connection or transport failure + onWarning={handleWarning} // Protocol Warning notification + onLatencyReport={handleLatency} + onInjectionRefused={handleRefusal} + onListenUpdated={handleListenUpdate} + onHistory={handleHistory} > {children} ``` +`config`, `playerSampleRate`, and the initial `autoStart` value establish resources for the provider's lifetime. Changing those props does not reconstruct or automatically restart the session. Use `updateListen`, `updateThink`, `updateSpeak`, and `updatePrompt` for supported mid-session changes; remount the provider when a new session config or player sample rate is required. + ### Mode Tracking -The provider tracks three agent modes: `"idle"`, `"listening"`, and `"speaking"`. +The provider tracks four agent modes: `"idle"`, `"listening"`, `"thinking"`, and `"speaking"`. The speaking-to-listening transition is **playback-aware** -- when the server fires `AgentAudioDone`, the provider waits until `AgentPlayer.getRemainingPlaybackTime()` reaches zero before switching to `"listening"`. This prevents premature mode changes while audio is still playing. @@ -91,13 +100,14 @@ const { ### useAgentMode -Speaking/listening mode. +Speaking/listening/thinking mode. ```ts const { - mode, // "idle" | "listening" | "speaking" - isSpeaking, // boolean - isListening, // boolean + mode, // "idle" | "listening" | "thinking" | "speaking" + isSpeaking, // boolean + isListening, // boolean + isThinking, // boolean } = useAgentMode(); ``` @@ -110,6 +120,7 @@ const { conversation, // ConversationEntry[] -- { id, role, content, timestamp } clearConversation, // () => void sendUserMessage, // (text: string) => void + sendAgentMessage, // (message: string, behavior?) => void } = useAgentConversation(); ``` @@ -144,13 +155,18 @@ const { ### useAgentControls -Stable action methods that never change identity. Use in components that trigger actions but do not display state. +Lifecycle, messaging, runtime settings, and mute actions grouped in one hook. Like the other focused hooks, it consumes `AgentContext`, so consumers still re-render when the provider value changes. ```ts const { start, stop, sendUserMessage, + sendAgentMessage, + updateListen, + updateThink, + updateSpeak, + updatePrompt, clearConversation, setMicMuted, setOutputMuted, @@ -164,7 +180,7 @@ Register a client-side function call handler scoped to the component's lifecycle ```tsx function WeatherPanel() { useAgentClientTool("getWeather", async (fn) => { - const { city } = JSON.parse(fn.input); + const { city } = JSON.parse(fn.arguments); const data = await fetchWeather(city); return JSON.stringify(data); }); @@ -186,21 +202,28 @@ session.on("warning", (msg) => console.warn(msg)); ### useAgentContext -Raw context value (escape hatch). Returns the full `AgentContextValue`. Prefer focused hooks for better render performance. +Raw context value (escape hatch). Returns the full `AgentContextValue`. Prefer focused hooks for a smaller, purpose-specific API surface. ### useDeepgramAgent (standalone) Self-contained hook that does not require `AgentProvider`. Creates and manages its own session, microphone, and player. Useful for simple integrations or when you don't need the provider/context pattern. +The initial `config` and `playerSampleRate` similarly apply for the hook's lifetime. Use the returned update methods for supported runtime settings changes. + ```ts const { - state, micActive, outputMuted, conversation, - start, stop, setMicMuted, setOutputMuted, sendUserMessage, interrupt, + state, mode, micActive, micMuted, outputMuted, conversation, + start, stop, setMicMuted, setOutputMuted, + sendUserMessage, sendAgentMessage, + updateListen, updateThink, updateSpeak, updatePrompt, + clearConversation, interrupt, } = useDeepgramAgent({ config: { auth: { tokenFactory: () => fetch('/api/token').then(r => r.text()) }, - agent: { think: { provider: { type: 'open_ai' }, model: 'gpt-4o-mini' } }, + agent: { think: { provider: { type: 'open_ai', model: 'gpt-4o-mini' } } }, }, + onWarning: (message) => console.warn(message), + onLatencyReport: (report) => console.debug(report), }); ``` @@ -211,7 +234,7 @@ All hooks, the provider, context types, and common SDK types (re-exported from ` ```ts // Provider export { AgentProvider }; -export type { AgentProviderProps }; +export type { AgentNotificationCallbacks, AgentProviderProps }; // Hooks export { @@ -235,8 +258,11 @@ export type { AgentContextValue, ConversationEntry, AgentMode }; // SDK types (re-exported from @deepgram/agents) export type { AgentSessionConfig, AuthConfig, TokenFactory, - AgentSettingsObject, ThinkSettings, SpeakSettings, - MicrophoneOptions, + AgentSettingsObject, AgentMessageBehavior, ListenSettings, + ThinkSettings, SpeakSettings, MicrophoneOptions, + AgentThinkingMessage, ListenUpdatedMessage, LatencyReportMessage, + HistoryMessage, InjectionRefusedMessage, + AgentErrorMessage, AgentWarningMessage, }; ``` diff --git a/packages/react/package.json b/packages/react/package.json index b2694ef..d48807d 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -30,16 +30,18 @@ "typecheck": "tsc --noEmit", "dev": "vite build --watch", "test": "bun test", - "test:watch": "bun test --watch" + "test:watch": "bun test --watch", + "lint": "biome lint src/index.ts src/context.ts src/provider.tsx src/hooks --error-on-warnings" }, "dependencies": { - "@deepgram/agents": "^0.1.1" + "@deepgram/agents": "^0.1.2" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" }, "devDependencies": { + "@biomejs/biome": "2.4.10", "@testing-library/react": "16.3.2", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", @@ -49,7 +51,7 @@ "react": "19.2.5", "react-dom": "19.2.5", "terser": "5.46.2", - "typescript": "6.0.2", + "typescript": "5.9.3", "vite": "8.0.10", "vite-plugin-dts": "4.5.4" }, diff --git a/packages/react/src/__tests__/helpers/mock-sdk.ts b/packages/react/src/__tests__/helpers/mock-sdk.ts index 4a291a3..70afbb3 100644 --- a/packages/react/src/__tests__/helpers/mock-sdk.ts +++ b/packages/react/src/__tests__/helpers/mock-sdk.ts @@ -24,39 +24,88 @@ export class MockAgentSession extends EventEmitter { sendFunctionCallResponse = jest.fn(); injectUserMessage = jest.fn(); injectAgentMessage = jest.fn(); + updateListen = jest.fn(); updateSpeak = jest.fn(); updateThink = jest.fn(); updatePrompt = jest.fn(); + clearConversationHistory = jest.fn(() => { + this.conversationHistory.length = 0; + }); } +let nextMicrophoneStartError: Error | null = null; +let nextMicrophoneStartDeferred: Promise | null = null; + export class MockAgentMicrophone { muted = false; - start = jest.fn(async () => {}); + start = jest.fn(async () => { + if (nextMicrophoneStartDeferred) { + const deferred = nextMicrophoneStartDeferred; + nextMicrophoneStartDeferred = null; + await deferred; + } + if (nextMicrophoneStartError) { + const error = nextMicrophoneStartError; + nextMicrophoneStartError = null; + throw error; + } + }); stop = jest.fn(); mute = jest.fn(() => { this.muted = true; }); unmute = jest.fn(() => { this.muted = false; }); + getInputVolume = jest.fn(() => 0); + getInputByteFrequencyData = jest.fn(() => new Uint8Array(0)); on = jest.fn(); off = jest.fn(); } export class MockAgentPlayer { muted = false; - queue = jest.fn(); + disposed = false; + queue = jest.fn(() => { + if (this.disposed) throw new Error("cannot queue on a disposed player"); + }); interrupt = jest.fn(); mute = jest.fn(() => { this.muted = true; }); unmute = jest.fn(() => { this.muted = false; }); - dispose = jest.fn(); + setVolume = jest.fn(); + getOutputVolume = jest.fn(() => 0); + getOutputByteFrequencyData = jest.fn(() => new Uint8Array(0)); + getRemainingPlaybackTime = jest.fn(() => 0); + dispose = jest.fn(() => { this.disposed = true; }); } // Track instances created so tests can access them export let lastSession: MockAgentSession; export let lastMicrophone: MockAgentMicrophone; export let lastPlayer: MockAgentPlayer; +export const sessions: MockAgentSession[] = []; +export const microphones: MockAgentMicrophone[] = []; +export const players: MockAgentPlayer[] = []; + +export function failNextMicrophoneStart(error: Error) { + nextMicrophoneStartError = error; +} + +export function deferNextMicrophoneStart() { + let resolve!: () => void; + let reject!: (error: Error) => void; + nextMicrophoneStartDeferred = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { resolve, reject }; +} export function resetMocks() { lastSession = undefined!; lastMicrophone = undefined!; lastPlayer = undefined!; + sessions.length = 0; + microphones.length = 0; + players.length = 0; + nextMicrophoneStartError = null; + nextMicrophoneStartDeferred = null; } // Install module-level mock @@ -65,18 +114,21 @@ mock.module("@deepgram/agents", () => ({ constructor(...args: unknown[]) { super(); lastSession = this as unknown as MockAgentSession; + sessions.push(lastSession); } }, AgentMicrophone: class extends MockAgentMicrophone { constructor(...args: unknown[]) { super(); lastMicrophone = this as unknown as MockAgentMicrophone; + microphones.push(lastMicrophone); } }, AgentPlayer: class extends MockAgentPlayer { constructor(...args: unknown[]) { super(); lastPlayer = this as unknown as MockAgentPlayer; + players.push(lastPlayer); } }, })); diff --git a/packages/react/src/__tests__/helpers/test-wrapper.tsx b/packages/react/src/__tests__/helpers/test-wrapper.tsx index b7b6d6a..59e86d6 100644 --- a/packages/react/src/__tests__/helpers/test-wrapper.tsx +++ b/packages/react/src/__tests__/helpers/test-wrapper.tsx @@ -9,7 +9,7 @@ const { AgentProvider } = await import("../../provider.js"); const DEFAULT_CONFIG = { auth: { apiKey: "test-key" }, - agent: { think: { type: "open_ai" as const, model: "gpt-4o-mini" } }, + agent: { think: { provider: { type: "open_ai" as const, model: "gpt-4o-mini" } } }, }; /** diff --git a/packages/react/src/__tests__/hooks.test.tsx b/packages/react/src/__tests__/hooks.test.tsx index dffb918..86f1e0d 100644 --- a/packages/react/src/__tests__/hooks.test.tsx +++ b/packages/react/src/__tests__/hooks.test.tsx @@ -1,16 +1,18 @@ import React from "react"; -import { describe, it, expect, beforeEach } from "bun:test"; -import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, beforeEach, jest } from "bun:test"; +import { render, renderHook, act, waitFor } from "@testing-library/react"; // Install mocks before importing hooks import { resetMocks, lastSession } from "./helpers/mock-sdk.js"; -import { createWrapper } from "./helpers/test-wrapper.js"; +import { createWrapper, TestProvider } from "./helpers/test-wrapper.js"; const { useAgentState } = await import("../hooks/useAgentState.js"); const { useAgentConversation } = await import("../hooks/useAgentConversation.js"); const { useAgentMicrophone } = await import("../hooks/useAgentMicrophone.js"); const { useAgentPlayer } = await import("../hooks/useAgentPlayer.js"); const { useAgentSession } = await import("../hooks/useAgentSession.js"); +const { useAgentControls } = await import("../hooks/useAgentControls.js"); +const { useAgentClientTool } = await import("../hooks/useAgentClientTool.js"); const { useAgentContext } = await import("../context.js"); describe("useAgentState", () => { @@ -104,9 +106,104 @@ describe("useAgentConversation", () => { }); expect(result.current.conversation).toEqual([]); + expect(lastSession.clearConversationHistory).toHaveBeenCalledTimes(1); }); }); +describe("useAgentControls", () => { + beforeEach(resetMocks); + + it("exposes current agent message and settings controls", () => { + const { result } = renderHook(() => useAgentControls(), { + wrapper: createWrapper(), + }); + const listen = { provider: { type: "deepgram", model: "flux-general-en" } }; + const think = { provider: { type: "open_ai", model: "gpt-4o" } }; + const speak = { provider: { type: "deepgram", model: "aura-2-thalia-en" } }; + + act(() => { + result.current.sendAgentMessage("One moment", "interrupt"); + result.current.updateListen(listen); + result.current.updateThink(think); + result.current.updateSpeak(speak); + result.current.updatePrompt("Updated prompt"); + }); + + expect(lastSession.injectAgentMessage).toHaveBeenCalledWith("One moment", "interrupt"); + expect(lastSession.updateListen).toHaveBeenCalledWith(listen); + expect(lastSession.updateThink).toHaveBeenCalledWith(think); + expect(lastSession.updateSpeak).toHaveBeenCalledWith(speak); + expect(lastSession.updatePrompt).toHaveBeenCalledWith("Updated prompt"); + }); +}); + +describe("useAgentClientTool", () => { + beforeEach(resetMocks); + + it("handles function calls using fn.arguments", async () => { + renderHook( + () => useAgentClientTool("lookup", (fn) => fn.arguments), + { wrapper: createWrapper() }, + ); + + act(() => { + lastSession.emit("function-call-request", { + type: "FunctionCallRequest", + functions: [{ id: "tool-1", name: "lookup", arguments: "{\"city\":\"Paris\"}", client_side: true }], + }); + }); + + await waitFor(() => { + expect(lastSession.sendFunctionCallResponse).toHaveBeenCalledWith( + "tool-1", + "lookup", + "{\"city\":\"Paris\"}", + ); + }); + }); + + it("does not let an older duplicate registration remove the newer handler", async () => { + const first = jest.fn(() => "first"); + const second = jest.fn(() => "second"); + const { rerender } = render( + + + + , + ); + + rerender( + + + , + ); + act(() => { + lastSession.emit("function-call-request", { + type: "FunctionCallRequest", + functions: [{ id: "tool-2", name: "lookup", arguments: "{}", client_side: true }], + }); + }); + + await waitFor(() => { + expect(lastSession.sendFunctionCallResponse).toHaveBeenCalledWith( + "tool-2", + "lookup", + "second", + ); + }); + expect(first).not.toHaveBeenCalled(); + }); +}); + +function ToolRegistration({ + handler, +}: { + handler: () => string; +}) { + useAgentClientTool("lookup", handler); + return null; +} + describe("useAgentMicrophone", () => { beforeEach(resetMocks); diff --git a/packages/react/src/__tests__/integration/provider-session-lifecycle.test.tsx b/packages/react/src/__tests__/integration/provider-session-lifecycle.test.tsx index 2b290d3..9d1f7b7 100644 --- a/packages/react/src/__tests__/integration/provider-session-lifecycle.test.tsx +++ b/packages/react/src/__tests__/integration/provider-session-lifecycle.test.tsx @@ -77,7 +77,10 @@ describe("Integration: Provider ↔ Session lifecycle", () => { expect(lastSession.disconnect).toHaveBeenCalled(); // 7. Unmount → full cleanup - unmount(); + await act(async () => { + unmount(); + await Promise.resolve(); + }); expect(lastSession.eventNames()).toHaveLength(0); expect(lastPlayer.dispose).toHaveBeenCalled(); }); diff --git a/packages/react/src/__tests__/provider.test.tsx b/packages/react/src/__tests__/provider.test.tsx index 0a7d091..8a7b528 100644 --- a/packages/react/src/__tests__/provider.test.tsx +++ b/packages/react/src/__tests__/provider.test.tsx @@ -1,12 +1,23 @@ import React from "react"; -import { describe, it, expect, beforeEach } from "bun:test"; -import { render, act } from "@testing-library/react"; - -import { resetMocks, lastSession, lastPlayer, lastMicrophone } from "./helpers/mock-sdk.js"; +import { describe, it, expect, beforeEach, jest } from "bun:test"; +import { render, act, waitFor } from "@testing-library/react"; + +import { + deferNextMicrophoneStart, + failNextMicrophoneStart, + lastMicrophone, + lastPlayer, + lastSession, + microphones, + players, + resetMocks, + sessions, +} from "./helpers/mock-sdk.js"; import { TestProvider } from "./helpers/test-wrapper.js"; const { useAgentState } = await import("../hooks/useAgentState.js"); const { useAgentConversation } = await import("../hooks/useAgentConversation.js"); +const { useAgentContext } = await import("../context.js"); // Helper component to extract context values function StateReader({ onState }: { onState: (s: ReturnType) => void }) { @@ -58,7 +69,7 @@ describe("AgentProvider", () => { it("updates state on session events", () => { let currentState: ReturnType | undefined; render( - + { currentState = s; }} /> , ); @@ -243,17 +254,18 @@ describe("AgentProvider", () => { expect(lastMicrophone).toBeUndefined(); }); - it("cleanup on unmount removes listeners and disposes", () => { + it("cleanup on unmount removes owned listeners and disposes", async () => { const { unmount } = render(
, ); - unmount(); + await act(async () => { + unmount(); + await Promise.resolve(); + }); - // removeAllListeners comes from the real EventEmitter, so check - // that no listeners remain after unmount expect(lastSession.eventNames()).toHaveLength(0); expect(lastSession.disconnect).toHaveBeenCalled(); expect(lastPlayer.dispose).toHaveBeenCalled(); @@ -275,5 +287,502 @@ describe("AgentProvider", () => { expect(lastSession.injectUserMessage).toHaveBeenCalledWith("Hello agent"); }); + + it("delegates agent messages and runtime setting updates", () => { + let context: ReturnType | undefined; + render( + + { context = value; }} /> + , + ); + + const listen = { provider: { type: "deepgram", model: "flux-general-en" } }; + const think = { provider: { type: "open_ai", model: "gpt-4o" } }; + const speak = { provider: { type: "deepgram", model: "aura-2-thalia-en" } }; + + act(() => { + context!.sendAgentMessage("Please wait", "queue"); + context!.updateListen(listen); + context!.updateThink(think); + context!.updateSpeak(speak); + context!.updatePrompt("Be concise"); + }); + + expect(lastSession.injectAgentMessage).toHaveBeenCalledWith("Please wait", "queue"); + expect(lastSession.updateListen).toHaveBeenCalledWith(listen); + expect(lastSession.updateThink).toHaveBeenCalledWith(think); + expect(lastSession.updateSpeak).toHaveBeenCalledWith(speak); + expect(lastSession.updatePrompt).toHaveBeenCalledWith("Be concise"); + }); + + it("delegates conversation clearing to AgentSession", () => { + let conversation: ReturnType | undefined; + render( + + { conversation = value; }} /> + , + ); + + act(() => conversation!.clearConversation()); + + expect(lastSession.clearConversationHistory).toHaveBeenCalledTimes(1); + }); + }); + + describe("notifications and function tools", () => { + it("forwards protocol notifications to typed callbacks", () => { + const callbacks = { + onError: jest.fn(), + onSdkError: jest.fn(), + onWarning: jest.fn(), + onLatencyReport: jest.fn(), + onInjectionRefused: jest.fn(), + onListenUpdated: jest.fn(), + onHistory: jest.fn(), + }; + render(
); + + const messages = { + error: { type: "Error", message: "bad" }, + sdkError: new Error("socket closed"), + warning: { type: "Warning", message: "careful" }, + latency: { type: "LatencyReport", total_latency: 10 }, + refused: { type: "InjectionRefused", message: "busy" }, + listen: { type: "ListenUpdated" }, + history: { type: "History", role: "assistant", content: "hello" }, + }; + act(() => { + lastSession.emit("error", messages.error); + lastSession.emit("sdk-error", messages.sdkError); + lastSession.emit("warning", messages.warning); + lastSession.emit("latency-report", messages.latency); + lastSession.emit("injection-refused", messages.refused); + lastSession.emit("listen-updated", messages.listen); + lastSession.emit("history", messages.history); + }); + + expect(callbacks.onError).toHaveBeenCalledWith(messages.error); + expect(callbacks.onSdkError).toHaveBeenCalledWith(messages.sdkError); + expect(callbacks.onWarning).toHaveBeenCalledWith(messages.warning); + expect(callbacks.onLatencyReport).toHaveBeenCalledWith(messages.latency); + expect(callbacks.onInjectionRefused).toHaveBeenCalledWith(messages.refused); + expect(callbacks.onListenUpdated).toHaveBeenCalledWith(messages.listen); + expect(callbacks.onHistory).toHaveBeenCalledWith(messages.history); + }); + + it("uses the latest notification and function-call props without replacing the session", async () => { + const firstWarning = jest.fn(); + const latestWarning = jest.fn(); + const firstFunction = jest.fn(() => "old"); + const latestFunction = jest.fn(() => "new"); + const { rerender } = render( +
, + ); + const session = lastSession; + + rerender( +
, + ); + act(() => { + lastSession.emit("warning", { type: "Warning", message: "latest" }); + lastSession.emit("function-call-request", { + type: "FunctionCallRequest", + functions: [{ id: "1", name: "lookup", arguments: "{}", client_side: true }], + }); + }); + + await waitFor(() => { + expect(lastSession.sendFunctionCallResponse).toHaveBeenCalledWith("1", "lookup", "new"); + }); + expect(lastSession).toBe(session); + expect(firstWarning).not.toHaveBeenCalled(); + expect(latestWarning).toHaveBeenCalledTimes(1); + expect(firstFunction).not.toHaveBeenCalled(); + expect(latestFunction).toHaveBeenCalledTimes(1); + }); + }); + + describe("mode and lifecycle safety", () => { + it("tracks AgentThinking before switching to playback-aware speaking", () => { + let context: ReturnType | undefined; + render( + + { context = value; }} /> + , + ); + + act(() => lastSession.emit("agent-thinking", { type: "AgentThinking" })); + expect(context!.mode).toBe("thinking"); + expect(context!.isThinking).toBe(true); + + act(() => lastSession.emit("audio", new ArrayBuffer(32))); + expect(context!.mode).toBe("speaking"); + expect(context!.isSpeaking).toBe(true); + }); + + it("waits for queued playback before returning to listening", async () => { + let context: ReturnType | undefined; + render( + + { context = value; }} /> + , + ); + lastPlayer.getRemainingPlaybackTime.mockReturnValue(0.01); + + act(() => { + lastSession.emit("audio", new ArrayBuffer(32)); + lastSession.emit("agent-audio-done", { type: "AgentAudioDone" }); + }); + expect(context!.mode).toBe("speaking"); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + expect(context!.mode).toBe("listening"); + }); + + it("rolls back the session when microphone startup fails", async () => { + const failure = new Error("permission denied"); + failNextMicrophoneStart(failure); + let context: ReturnType | undefined; + render( + + { context = value; }} /> + , + ); + + let thrown: unknown; + await act(async () => { + try { + await context!.start(); + } catch (error) { + thrown = error; + } + }); + + expect(thrown).toBe(failure); + expect(lastMicrophone.stop).toHaveBeenCalled(); + expect(lastSession.disconnect).toHaveBeenCalled(); + expect(context!.micActive).toBe(false); + expect(context!.micMuted).toBe(false); + expect(context!.mode).toBe("idle"); + }); + + it("stop interrupts playback and resets mic and mode state for restart", async () => { + let context: ReturnType | undefined; + render( + + { context = value; }} /> + , + ); + + await act(async () => context!.start()); + act(() => { + context!.setMicMuted(true); + lastSession.emit("audio", new ArrayBuffer(32)); + }); + lastPlayer.interrupt.mockClear(); + + act(() => context!.stop()); + + expect(lastPlayer.interrupt).toHaveBeenCalledTimes(1); + expect(context!.micActive).toBe(false); + expect(context!.micMuted).toBe(false); + expect(context!.mode).toBe("idle"); + + await act(async () => context!.start()); + expect(microphones).toHaveLength(2); + expect(context!.micActive).toBe(true); + expect(context!.micMuted).toBe(false); + }); + + it("starts again after a pending start is canceled", async () => { + let context: ReturnType | undefined; + render( + + { context = value; }} /> + , + ); + + let resolveFirstConnect!: () => void; + lastSession.connect.mockImplementationOnce( + () => new Promise((resolve) => { resolveFirstConnect = resolve; }), + ); + let firstStart!: Promise; + act(() => { firstStart = context!.start(); }); + act(() => context!.stop()); + + await act(async () => context!.start()); + expect(lastSession.connect).toHaveBeenCalledTimes(2); + expect(microphones).toHaveLength(1); + expect(context!.micActive).toBe(true); + const disconnectCalls = lastSession.disconnect.mock.calls.length; + + await act(async () => { + resolveFirstConnect(); + await firstStart; + }); + expect(lastSession.disconnect).toHaveBeenCalledTimes(disconnectCalls); + expect(context!.micActive).toBe(true); + }); + + it("stops audio resources after a terminal disconnect", async () => { + let context: ReturnType | undefined; + render( + + { context = value; }} /> + , + ); + await act(async () => context!.start()); + act(() => context!.setMicMuted(true)); + lastMicrophone.stop.mockClear(); + lastPlayer.interrupt.mockClear(); + + act(() => { + lastSession.state = "disconnected"; + lastSession.emit("disconnected", "reconnect attempts exhausted"); + }); + + expect(lastMicrophone.stop).toHaveBeenCalledTimes(1); + expect(lastPlayer.interrupt).toHaveBeenCalledTimes(1); + expect(context!.micActive).toBe(false); + expect(context!.micMuted).toBe(false); + expect(context!.mode).toBe("idle"); + }); + + it("stops active resources when microphone and TTS are disabled", async () => { + let context: ReturnType | undefined; + const { rerender } = render( + + { context = value; }} /> + , + ); + await act(async () => context!.start()); + act(() => { + context!.setMicMuted(true); + lastSession.emit("audio", new ArrayBuffer(32)); + }); + lastMicrophone.stop.mockClear(); + lastPlayer.interrupt.mockClear(); + + rerender( + + { context = value; }} /> + , + ); + + expect(lastMicrophone.stop).toHaveBeenCalledTimes(1); + expect(lastPlayer.interrupt).toHaveBeenCalledTimes(1); + expect(context!.micEnabled).toBe(false); + expect(context!.ttsEnabled).toBe(false); + expect(context!.micActive).toBe(false); + expect(context!.micMuted).toBe(false); + + rerender( + + { context = value; }} /> + , + ); + await waitFor(() => expect(microphones).toHaveLength(2)); + expect(context!.micEnabled).toBe(true); + expect(context!.micActive).toBe(true); + }); + + it("ignores a canceled microphone startup failure", async () => { + const deferred = deferNextMicrophoneStart(); + let context: ReturnType | undefined; + const { rerender } = render( + + { context = value; }} /> + , + ); + let startPromise!: Promise; + act(() => { startPromise = context!.start(); }); + await waitFor(() => expect(microphones).toHaveLength(1)); + + rerender( + + { context = value; }} /> + , + ); + deferred.reject(new Error("permission denied after cancellation")); + await act(async () => startPromise); + + expect(lastSession.disconnect).not.toHaveBeenCalled(); + expect(context!.state).toBe("connected"); + expect(context!.micEnabled).toBe(false); + expect(context!.micActive).toBe(false); + }); + + it("shares microphone startup when re-enabled before connect resolves", async () => { + const microphoneStart = deferNextMicrophoneStart(); + let resolveConnect!: () => void; + let context: ReturnType | undefined; + const { rerender } = render( + + { context = value; }} /> + , + ); + lastSession.connect.mockImplementationOnce(async () => { + lastSession.state = "connected"; + lastSession.emit("connecting"); + lastSession.emit("connected"); + await new Promise((resolve) => { resolveConnect = resolve; }); + }); + let startPromise!: Promise; + act(() => { startPromise = context!.start(); }); + + rerender( + + { context = value; }} /> + , + ); + await waitFor(() => expect(microphones).toHaveLength(1)); + act(() => resolveConnect()); + microphoneStart.resolve(); + await act(async () => startPromise); + + expect(microphones).toHaveLength(1); + expect(lastMicrophone.start).toHaveBeenCalledTimes(1); + expect(lastMicrophone.stop).not.toHaveBeenCalled(); + expect(context!.micActive).toBe(true); + }); + + it("waits for a canceled microphone attempt before re-enabling", async () => { + const firstMicrophoneStart = deferNextMicrophoneStart(); + let context: ReturnType | undefined; + const { rerender } = render( + + { context = value; }} /> + , + ); + let startPromise!: Promise; + act(() => { startPromise = context!.start(); }); + await waitFor(() => expect(microphones).toHaveLength(1)); + + rerender( + + { context = value; }} /> + , + ); + rerender( + + { context = value; }} /> + , + ); + expect(microphones).toHaveLength(1); + + firstMicrophoneStart.resolve(); + await act(async () => startPromise); + + expect(microphones).toHaveLength(2); + expect(microphones[0].stop).toHaveBeenCalled(); + expect(microphones[1].start).toHaveBeenCalledTimes(1); + expect(context!.micActive).toBe(true); + }); + + it("starts an enabled microphone when reconnect reaches connected", async () => { + let context: ReturnType | undefined; + const { rerender } = render( + + { context = value; }} /> + , + ); + await act(async () => context!.start()); + + rerender( + + { context = value; }} /> + , + ); + act(() => { + lastSession.state = "reconnecting"; + lastSession.emit("reconnecting", 1, 100); + }); + rerender( + + { context = value; }} /> + , + ); + expect(microphones).toHaveLength(1); + + act(() => { + lastSession.state = "connected"; + lastSession.emit("connected"); + }); + await waitFor(() => expect(microphones).toHaveLength(2)); + + expect(microphones[1].start).toHaveBeenCalledTimes(1); + expect(context!.micActive).toBe(true); + }); + + it("does not send a client-tool result through a replacement session", async () => { + let resolveTool!: (result: string) => void; + const onFunctionCall = jest.fn( + () => new Promise((resolve) => { resolveTool = resolve; }), + ); + let context: ReturnType | undefined; + render( + + { context = value; }} /> + , + ); + await act(async () => context!.start()); + + act(() => { + lastSession.emit("function-call-request", { + type: "FunctionCallRequest", + functions: [{ id: "old-call", name: "lookup", arguments: "{}", client_side: true }], + }); + }); + act(() => context!.stop()); + await act(async () => context!.start()); + + await act(async () => { + resolveTool("old-result"); + await Promise.resolve(); + }); + expect(lastSession.sendFunctionCallResponse).not.toHaveBeenCalled(); + }); + + it("survives StrictMode replay without duplicate auto-start or disposed-player reuse", async () => { + const { unmount } = render( + +
+ , + ); + + await waitFor(() => { + expect(sessions.reduce((count, session) => count + session.connect.mock.calls.length, 0)).toBe(1); + }); + expect(microphones).toHaveLength(1); + expect(players).toHaveLength(1); + expect(lastPlayer.disposed).toBe(false); + + act(() => lastSession.emit("audio", new ArrayBuffer(32))); + expect(lastPlayer.queue).toHaveBeenCalled(); + + const externalWarningListener = jest.fn(); + lastSession.on("warning", externalWarningListener); + await act(async () => { + unmount(); + await Promise.resolve(); + }); + + expect(lastPlayer.dispose).toHaveBeenCalledTimes(1); + expect(players.every((player) => player.dispose.mock.calls.length === 1)).toBe(true); + expect(lastMicrophone.stop).toHaveBeenCalled(); + expect(lastSession.listenerCount("warning")).toBe(1); + lastSession.off("warning", externalWarningListener); + }); }); }); + +function ContextReader({ + onContext, +}: { + onContext: (context: ReturnType) => void; +}) { + onContext(useAgentContext()); + return null; +} diff --git a/packages/react/src/__tests__/useDeepgramAgent.test.tsx b/packages/react/src/__tests__/useDeepgramAgent.test.tsx new file mode 100644 index 0000000..efc543d --- /dev/null +++ b/packages/react/src/__tests__/useDeepgramAgent.test.tsx @@ -0,0 +1,227 @@ +import React from "react"; +import { beforeEach, describe, expect, it, jest } from "bun:test"; +import { act, renderHook, waitFor } from "@testing-library/react"; + +import { + failNextMicrophoneStart, + lastMicrophone, + lastPlayer, + lastSession, + players, + resetMocks, +} from "./helpers/mock-sdk.js"; + +const { useDeepgramAgent } = await import("../hooks/useDeepgramAgent.js"); + +const config = { + auth: { apiKey: "test-key" }, + agent: { think: { provider: { type: "open_ai" as const, model: "gpt-4o-mini" } } }, +}; + +describe("useDeepgramAgent", () => { + beforeEach(resetMocks); + + it("manages a basic session and exposes current controls", async () => { + const { result } = renderHook(() => useDeepgramAgent({ config })); + + await act(async () => result.current.start()); + expect(result.current.state).toBe("connected"); + expect(result.current.micActive).toBe(true); + + const listen = { provider: { type: "deepgram", model: "flux-general-en" } }; + const think = { provider: { type: "open_ai", model: "gpt-4o" } }; + const speak = { provider: { type: "deepgram", model: "aura-2-thalia-en" } }; + act(() => { + result.current.sendUserMessage("Hello"); + result.current.sendAgentMessage("Welcome", "queue"); + result.current.updateListen(listen); + result.current.updateThink(think); + result.current.updateSpeak(speak); + result.current.updatePrompt("Be brief"); + lastSession.emit("agent-thinking", { type: "AgentThinking" }); + }); + + expect(lastSession.injectUserMessage).toHaveBeenCalledWith("Hello"); + expect(lastSession.injectAgentMessage).toHaveBeenCalledWith("Welcome", "queue"); + expect(lastSession.updateListen).toHaveBeenCalledWith(listen); + expect(lastSession.updateThink).toHaveBeenCalledWith(think); + expect(lastSession.updateSpeak).toHaveBeenCalledWith(speak); + expect(lastSession.updatePrompt).toHaveBeenCalledWith("Be brief"); + expect(result.current.mode).toBe("thinking"); + expect(result.current.isThinking).toBe(true); + }); + + it("uses the latest callbacks without replacing its session", async () => { + const firstWarning = jest.fn(); + const latestWarning = jest.fn(); + const firstFunction = jest.fn(() => "old"); + const latestFunction = jest.fn(() => "new"); + const { result, rerender } = renderHook( + ({ warning, fn }) => useDeepgramAgent({ + config, + onWarning: warning, + onFunctionCall: fn, + }), + { initialProps: { warning: firstWarning, fn: firstFunction } }, + ); + const session = lastSession; + + rerender({ warning: latestWarning, fn: latestFunction }); + act(() => { + lastSession.emit("warning", { type: "Warning", message: "latest" }); + lastSession.emit("function-call-request", { + type: "FunctionCallRequest", + functions: [{ id: "1", name: "lookup", arguments: "{}", client_side: true }], + }); + }); + + await waitFor(() => { + expect(lastSession.sendFunctionCallResponse).toHaveBeenCalledWith("1", "lookup", "new"); + }); + expect(result.current.state).toBe("idle"); + expect(lastSession).toBe(session); + expect(firstWarning).not.toHaveBeenCalled(); + expect(latestWarning).toHaveBeenCalledTimes(1); + expect(firstFunction).not.toHaveBeenCalled(); + }); + + it("forwards SDK transport errors", () => { + const onSdkError = jest.fn(); + renderHook(() => useDeepgramAgent({ config, onSdkError })); + const error = new Error("socket closed"); + + act(() => lastSession.emit("sdk-error", error)); + + expect(onSdkError).toHaveBeenCalledWith(error); + }); + + it("rolls back cleanly when microphone startup fails", async () => { + const failure = new Error("microphone unavailable"); + failNextMicrophoneStart(failure); + const { result } = renderHook(() => useDeepgramAgent({ config })); + + let thrown: unknown; + await act(async () => { + try { + await result.current.start(); + } catch (error) { + thrown = error; + } + }); + + expect(thrown).toBe(failure); + expect(lastMicrophone.stop).toHaveBeenCalled(); + expect(lastSession.disconnect).toHaveBeenCalled(); + expect(lastPlayer.interrupt).toHaveBeenCalled(); + expect(result.current.micActive).toBe(false); + expect(result.current.micMuted).toBe(false); + expect(result.current.mode).toBe("idle"); + }); + + it("stop interrupts queued playback and clears mic/mode state", async () => { + const { result } = renderHook(() => useDeepgramAgent({ config })); + await act(async () => result.current.start()); + act(() => { + result.current.setMicMuted(true); + lastSession.emit("audio", new ArrayBuffer(32)); + }); + lastPlayer.interrupt.mockClear(); + + act(() => result.current.stop()); + + expect(lastPlayer.interrupt).toHaveBeenCalledTimes(1); + expect(lastMicrophone.stop).toHaveBeenCalled(); + expect(result.current.micActive).toBe(false); + expect(result.current.micMuted).toBe(false); + expect(result.current.mode).toBe("idle"); + }); + + it("starts again after a pending start is canceled", async () => { + const { result } = renderHook(() => useDeepgramAgent({ config })); + let resolveFirstConnect!: () => void; + lastSession.connect.mockImplementationOnce( + () => new Promise((resolve) => { resolveFirstConnect = resolve; }), + ); + let firstStart!: Promise; + act(() => { firstStart = result.current.start(); }); + act(() => result.current.stop()); + + await act(async () => result.current.start()); + expect(lastSession.connect).toHaveBeenCalledTimes(2); + expect(result.current.micActive).toBe(true); + const disconnectCalls = lastSession.disconnect.mock.calls.length; + + await act(async () => { + resolveFirstConnect(); + await firstStart; + }); + expect(lastSession.disconnect).toHaveBeenCalledTimes(disconnectCalls); + expect(result.current.micActive).toBe(true); + }); + + it("stops audio resources after a terminal disconnect", async () => { + const { result } = renderHook(() => useDeepgramAgent({ config })); + await act(async () => result.current.start()); + act(() => result.current.setMicMuted(true)); + lastMicrophone.stop.mockClear(); + lastPlayer.interrupt.mockClear(); + + act(() => { + lastSession.state = "disconnected"; + lastSession.emit("disconnected", "reconnect attempts exhausted"); + }); + + expect(lastMicrophone.stop).toHaveBeenCalledTimes(1); + expect(lastPlayer.interrupt).toHaveBeenCalledTimes(1); + expect(result.current.micActive).toBe(false); + expect(result.current.micMuted).toBe(false); + expect(result.current.mode).toBe("idle"); + }); + + it("does not send a client-tool result through a replacement session", async () => { + let resolveTool!: (result: string) => void; + const onFunctionCall = jest.fn( + () => new Promise((resolve) => { resolveTool = resolve; }), + ); + const { result } = renderHook(() => useDeepgramAgent({ config, onFunctionCall })); + await act(async () => result.current.start()); + + act(() => { + lastSession.emit("function-call-request", { + type: "FunctionCallRequest", + functions: [{ id: "old-call", name: "lookup", arguments: "{}", client_side: true }], + }); + }); + act(() => result.current.stop()); + await act(async () => result.current.start()); + + await act(async () => { + resolveTool("old-result"); + await Promise.resolve(); + }); + expect(lastSession.sendFunctionCallResponse).not.toHaveBeenCalled(); + }); + + it("does not reuse a disposed player during StrictMode replay", async () => { + const StrictWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + const { unmount } = renderHook(() => useDeepgramAgent({ config }), { + wrapper: StrictWrapper, + }); + + expect(players).toHaveLength(1); + expect(lastPlayer.disposed).toBe(false); + act(() => lastSession.emit("audio", new ArrayBuffer(32))); + expect(lastPlayer.queue).toHaveBeenCalled(); + + await act(async () => { + unmount(); + await Promise.resolve(); + }); + + expect(lastSession.disconnect).toHaveBeenCalled(); + expect(lastPlayer.dispose).toHaveBeenCalledTimes(1); + expect(players.every((player) => player.dispose.mock.calls.length === 1)).toBe(true); + }); +}); diff --git a/packages/react/src/context.ts b/packages/react/src/context.ts index c73bbaf..85ffa6a 100644 --- a/packages/react/src/context.ts +++ b/packages/react/src/context.ts @@ -1,6 +1,13 @@ import { createContext, useContext } from "react"; -import type { AgentSession, AgentState } from "@deepgram/agents"; -import type { FunctionCallItem } from "@deepgram/agents"; +import type { + AgentMessageBehavior, + AgentSession, + AgentState, + FunctionCallItem, + ListenSettings, + SpeakSettings, + ThinkSettings, +} from "@deepgram/agents"; export interface ConversationEntry { id: string; @@ -9,7 +16,7 @@ export interface ConversationEntry { timestamp: number; // ms epoch } -export type AgentMode = "idle" | "listening" | "speaking"; +export type AgentMode = "idle" | "listening" | "thinking" | "speaking"; export interface AgentContextValue { // Raw session — escape hatch for anything not exposed here @@ -20,15 +27,23 @@ export interface AgentContextValue { start: () => Promise; stop: () => void; - // Mode (speaking / listening) + // Mode (speaking / listening / thinking) mode: AgentMode; isSpeaking: boolean; isListening: boolean; + isThinking: boolean; // Conversation conversation: ConversationEntry[]; clearConversation: () => void; sendUserMessage: (text: string) => void; + sendAgentMessage: (message: string, behavior?: AgentMessageBehavior) => void; + + // Runtime settings + updateListen: (listen: ListenSettings) => void; + updateThink: (think: ThinkSettings | ThinkSettings[]) => void; + updateSpeak: (speak: SpeakSettings | SpeakSettings[]) => void; + updatePrompt: (prompt: string) => void; // Microphone micActive: boolean; @@ -46,7 +61,7 @@ export interface AgentContextValue { getOutputVolume: () => number; // Client tools — dynamic registration - registerClientTool: (name: string, handler: (fn: FunctionCallItem) => Promise | string) => void; + registerClientTool: (name: string, handler: (fn: FunctionCallItem) => Promise | string) => () => void; unregisterClientTool: (name: string) => void; } diff --git a/packages/react/src/hooks/useAgentClientTool.ts b/packages/react/src/hooks/useAgentClientTool.ts index 1c99a29..32e1fa6 100644 --- a/packages/react/src/hooks/useAgentClientTool.ts +++ b/packages/react/src/hooks/useAgentClientTool.ts @@ -18,7 +18,7 @@ import type { FunctionCallItem } from "@deepgram/agents"; * }); * * useAgentClientTool("setLocation", (fn) => { - * const params = JSON.parse(fn.input); + * const params = JSON.parse(fn.arguments); * setLocation(params); * return JSON.stringify({ ok: true }); * }); @@ -31,11 +31,10 @@ export function useAgentClientTool( name: string, handler: (fn: FunctionCallItem) => Promise | string, ): void { - const { registerClientTool, unregisterClientTool } = useAgentContext(); + const { registerClientTool } = useAgentContext(); useEffect(() => { - registerClientTool(name, handler); - return () => unregisterClientTool(name); + return registerClientTool(name, handler); // Re-register when handler changes to pick up latest closure - }, [name, handler, registerClientTool, unregisterClientTool]); + }, [name, handler, registerClientTool]); } diff --git a/packages/react/src/hooks/useAgentControls.ts b/packages/react/src/hooks/useAgentControls.ts index b44b6f2..97ca4ba 100644 --- a/packages/react/src/hooks/useAgentControls.ts +++ b/packages/react/src/hooks/useAgentControls.ts @@ -1,27 +1,40 @@ import { useAgentContext } from "../context.js"; +import type { + AgentMessageBehavior, + ListenSettings, + SpeakSettings, + ThinkSettings, +} from "@deepgram/agents"; export interface UseAgentControlsResult { start: () => Promise; stop: () => void; sendUserMessage: (text: string) => void; + sendAgentMessage: (message: string, behavior?: AgentMessageBehavior) => void; + updateListen: (listen: ListenSettings) => void; + updateThink: (think: ThinkSettings | ThinkSettings[]) => void; + updateSpeak: (speak: SpeakSettings | SpeakSettings[]) => void; + updatePrompt: (prompt: string) => void; clearConversation: () => void; setMicMuted: (muted: boolean) => void; setOutputMuted: (muted: boolean) => void; } /** - * Stable action methods — all are useCallback-wrapped refs that never - * change identity. Components using only this hook will never re-render - * due to state changes. - * - * Use this in components that trigger actions but don't display state - * (e.g. a custom "mute all" button). + * Agent action methods grouped for components that trigger controls. This + * hook consumes AgentContext, so its component still re-renders when the + * provider value changes. */ export function useAgentControls(): UseAgentControlsResult { const { start, stop, sendUserMessage, + sendAgentMessage, + updateListen, + updateThink, + updateSpeak, + updatePrompt, clearConversation, setMicMuted, setOutputMuted, @@ -31,6 +44,11 @@ export function useAgentControls(): UseAgentControlsResult { start, stop, sendUserMessage, + sendAgentMessage, + updateListen, + updateThink, + updateSpeak, + updatePrompt, clearConversation, setMicMuted, setOutputMuted, diff --git a/packages/react/src/hooks/useAgentConversation.ts b/packages/react/src/hooks/useAgentConversation.ts index 030e6d6..d78c9b8 100644 --- a/packages/react/src/hooks/useAgentConversation.ts +++ b/packages/react/src/hooks/useAgentConversation.ts @@ -1,13 +1,15 @@ import { useAgentContext, type ConversationEntry } from "../context.js"; +import type { AgentMessageBehavior } from "@deepgram/agents"; export interface UseAgentConversationResult { conversation: ConversationEntry[]; clearConversation: () => void; sendUserMessage: (text: string) => void; + sendAgentMessage: (message: string, behavior?: AgentMessageBehavior) => void; } /** Conversation history and text messaging. */ export function useAgentConversation(): UseAgentConversationResult { - const { conversation, clearConversation, sendUserMessage } = useAgentContext(); - return { conversation, clearConversation, sendUserMessage }; + const { conversation, clearConversation, sendUserMessage, sendAgentMessage } = useAgentContext(); + return { conversation, clearConversation, sendUserMessage, sendAgentMessage }; } diff --git a/packages/react/src/hooks/useAgentMode.ts b/packages/react/src/hooks/useAgentMode.ts index 9ef4c4a..607116d 100644 --- a/packages/react/src/hooks/useAgentMode.ts +++ b/packages/react/src/hooks/useAgentMode.ts @@ -4,15 +4,17 @@ export interface UseAgentModeResult { mode: AgentMode; isSpeaking: boolean; isListening: boolean; + isThinking: boolean; } /** - * Agent speaking/listening mode. + * Agent speaking/listening/thinking mode. * - `"idle"` — not connected * - `"listening"` — agent is listening for user speech + * - `"thinking"` — agent is preparing a response * - `"speaking"` — agent is producing audio */ export function useAgentMode(): UseAgentModeResult { - const { mode, isSpeaking, isListening } = useAgentContext(); - return { mode, isSpeaking, isListening }; + const { mode, isSpeaking, isListening, isThinking } = useAgentContext(); + return { mode, isSpeaking, isListening, isThinking }; } diff --git a/packages/react/src/hooks/useDeepgramAgent.ts b/packages/react/src/hooks/useDeepgramAgent.ts index 66247e3..cd37956 100644 --- a/packages/react/src/hooks/useDeepgramAgent.ts +++ b/packages/react/src/hooks/useDeepgramAgent.ts @@ -3,47 +3,55 @@ import { AgentSession, AgentMicrophone, AgentPlayer, + type AgentErrorMessage, + type AgentMessageBehavior, type AgentSessionConfig, - type MicrophoneOptions, type AgentState, + type AgentWarningMessage, type ConversationTextMessage, + type FunctionCallItem, type FunctionCallRequestMessage, + type HistoryMessage, + type InjectionRefusedMessage, + type LatencyReportMessage, + type ListenSettings, + type ListenUpdatedMessage, + type MicrophoneOptions, + type SpeakSettings, + type ThinkSettings, } from "@deepgram/agents"; -import type { ConversationEntry } from "../context.js"; +import type { AgentMode, ConversationEntry } from "../context.js"; +import type { AgentNotificationCallbacks } from "../provider.js"; -export interface UseDeepgramAgentOptions { +export interface UseDeepgramAgentOptions extends AgentNotificationCallbacks { config: AgentSessionConfig; micOptions?: MicrophoneOptions; playerSampleRate?: number; - /** - * Called when the agent requests a function call. - * Return the result as a string (JSON-serialised). - */ - onFunctionCall?: ( - fn: FunctionCallRequestMessage["functions"][number], - ) => Promise | string; + /** Return the client-side function result as a JSON-serialized string. */ + onFunctionCall?: (fn: FunctionCallItem) => Promise | string; } export interface UseDeepgramAgentResult { - /** Current connection state */ state: AgentState; - /** Whether microphone is active */ + mode: AgentMode; + isSpeaking: boolean; + isListening: boolean; + isThinking: boolean; micActive: boolean; - /** Whether agent audio is muted */ + micMuted: boolean; outputMuted: boolean; - /** Conversation history (user + agent turns) */ conversation: ConversationEntry[]; - /** Start session + microphone */ start: () => Promise; - /** Stop session + microphone */ stop: () => void; - /** Mute/unmute mic input */ setMicMuted: (muted: boolean) => void; - /** Mute/unmute agent audio output */ setOutputMuted: (muted: boolean) => void; - /** Send a text message as the user */ sendUserMessage: (text: string) => void; - /** Interrupt agent speech immediately */ + sendAgentMessage: (message: string, behavior?: AgentMessageBehavior) => void; + updateListen: (listen: ListenSettings) => void; + updateThink: (think: ThinkSettings | ThinkSettings[]) => void; + updateSpeak: (speak: SpeakSettings | SpeakSettings[]) => void; + updatePrompt: (prompt: string) => void; + clearConversation: () => void; interrupt: () => void; } @@ -53,135 +61,351 @@ const nextId = () => String(++entryCounter); /** * All-in-one hook for a Deepgram Voice Agent session. * - * Creates and manages an AgentSession + AgentMicrophone + AgentPlayer. - * Suitable for simple single-widget use cases. For shared session state - * across multiple components, use + useAgentContext(). + * Suitable for a single widget. Use AgentProvider when multiple components + * need to share the same session. */ export function useDeepgramAgent({ config, - micOptions = {}, + micOptions, playerSampleRate = 24_000, onFunctionCall, + onError, + onSdkError, + onWarning, + onLatencyReport, + onInjectionRefused, + onListenUpdated, + onHistory, }: UseDeepgramAgentOptions): UseDeepgramAgentResult { const sessionRef = useRef(null); const micRef = useRef(null); const playerRef = useRef(null); + if (!sessionRef.current) sessionRef.current = new AgentSession(config); + + const latestOptionsRef = useRef({ + micOptions, + onFunctionCall, + onError, + onSdkError, + onWarning, + onLatencyReport, + onInjectionRefused, + onListenUpdated, + onHistory, + }); + latestOptionsRef.current = { + micOptions, + onFunctionCall, + onError, + onSdkError, + onWarning, + onLatencyReport, + onInjectionRefused, + onListenUpdated, + onHistory, + }; + + const audioDoneTimerRef = useRef | null>(null); + const effectCycleRef = useRef(0); + const mountedRef = useRef(true); + const initialPlayerSampleRateRef = useRef(playerSampleRate); + const startGenerationRef = useRef(0); + const startPromiseRef = useRef | null>(null); + const [state, setState] = useState("idle"); + const [mode, setMode] = useState("idle"); const [micActive, setMicActive] = useState(false); + const [micMuted, setMicMutedState] = useState(false); const [outputMuted, setOutputMutedState] = useState(false); const [conversation, setConversation] = useState([]); - const getSession = useCallback((): AgentSession => { - if (!sessionRef.current) { - sessionRef.current = new AgentSession(config); - } - return sessionRef.current; - }, []); // config intentionally not in deps — session lives for hook lifetime - - const getPlayer = useCallback((): AgentPlayer => { + const ensurePlayer = useCallback((): AgentPlayer => { if (!playerRef.current) { - playerRef.current = new AgentPlayer({ sampleRate: playerSampleRate }); + playerRef.current = new AgentPlayer({ + sampleRate: initialPlayerSampleRateRef.current, + }); } return playerRef.current; - }, [playerSampleRate]); + }, []); - // Wire session events once - useEffect(() => { - const session = getSession(); - const player = getPlayer(); + const clearAudioDoneTimer = useCallback(() => { + if (audioDoneTimerRef.current) { + clearTimeout(audioDoneTimerRef.current); + audioDoneTimerRef.current = null; + } + }, []); - const onState = () => setState(session.state); - session.on("connecting", onState); - session.on("connected", onState); - session.on("reconnecting", onState); - session.on("disconnected", onState); + const start = useCallback((): Promise => { + if (startPromiseRef.current) return startPromiseRef.current; - session.on("conversation-text", (msg: ConversationTextMessage) => { - setConversation((prev) => [ - ...prev, - { id: nextId(), role: msg.role as "user" | "assistant", content: msg.content, timestamp: Date.now() }, - ]); - }); + const session = requireSession(sessionRef.current); + const generation = ++startGenerationRef.current; - session.on("audio", (chunk: ArrayBuffer) => { - player.queue(chunk); - }); + clearAudioDoneTimer(); + ensurePlayer().interrupt(); + micRef.current?.stop(); + micRef.current = null; + setMicActive(false); + setMicMutedState(false); + setConversation([]); - // Barge-in: when user starts speaking, interrupt agent audio - session.on("user-started-speaking", () => { - player.interrupt(); - }); + const promise = (async () => { + try { + await session.connect(); + + if (generation !== startGenerationRef.current) { + return; + } + + const mic = new AgentMicrophone( + (data) => session.sendAudio(data), + latestOptionsRef.current.micOptions, + ); + micRef.current = mic; - session.on("function-call-request", async (msg: FunctionCallRequestMessage) => { - if (!onFunctionCall) return; - for (const fn of msg.functions) { - if (!fn.client_side) continue; try { - const result = await onFunctionCall(fn); - session.sendFunctionCallResponse(fn.id, fn.name, result); - } catch (err) { - session.sendFunctionCallResponse( - fn.id, - fn.name, - JSON.stringify({ error: String(err) }), - ); + await mic.start(); + } catch (error) { + mic.stop(); + if (micRef.current === mic) micRef.current = null; + throw error; } - } - }); - return () => { - session.removeAllListeners(); - session.disconnect(); - player.dispose(); - micRef.current?.stop(); - }; - }, []); // run once + if (generation !== startGenerationRef.current) { + mic.stop(); + if (micRef.current === mic) micRef.current = null; + return; + } - const start = useCallback(async () => { - const session = getSession(); - await session.connect(); + if (mountedRef.current) { + setMicActive(true); + setMicMutedState(false); + } + } catch (error) { + if (generation !== startGenerationRef.current) return; + session.disconnect(); + playerRef.current?.interrupt(); + if (mountedRef.current) { + setMicActive(false); + setMicMutedState(false); + setMode("idle"); + } + throw error; + } + })(); - const mic = new AgentMicrophone( - (data) => session.sendAudio(data), - micOptions, + startPromiseRef.current = promise; + void promise.then( + () => { + if (startPromiseRef.current === promise) startPromiseRef.current = null; + }, + () => { + if (startPromiseRef.current === promise) startPromiseRef.current = null; + }, ); - - micRef.current?.stop(); - micRef.current = mic; - await mic.start(); - setMicActive(true); - }, [micOptions]); + return promise; + }, [clearAudioDoneTimer, ensurePlayer]); const stop = useCallback(() => { + ++startGenerationRef.current; + startPromiseRef.current = null; + clearAudioDoneTimer(); sessionRef.current?.disconnect(); - micRef.current?.stop(); - micRef.current = null; - setMicActive(false); - }, []); + }, [clearAudioDoneTimer]); const setMicMuted = useCallback((muted: boolean) => { if (!micRef.current) return; muted ? micRef.current.mute() : micRef.current.unmute(); + setMicMutedState(muted); }, []); const setOutputMuted = useCallback((muted: boolean) => { - const player = getPlayer(); + const player = ensurePlayer(); muted ? player.mute() : player.unmute(); setOutputMutedState(muted); - }, []); + }, [ensurePlayer]); const sendUserMessage = useCallback((text: string) => { sessionRef.current?.injectUserMessage(text); }, []); + const sendAgentMessage = useCallback((message: string, behavior?: AgentMessageBehavior) => { + sessionRef.current?.injectAgentMessage(message, behavior); + }, []); + + const updateListen = useCallback((listen: ListenSettings) => { + sessionRef.current?.updateListen(listen); + }, []); + + const updateThink = useCallback((think: ThinkSettings | ThinkSettings[]) => { + sessionRef.current?.updateThink(think); + }, []); + + const updateSpeak = useCallback((speak: SpeakSettings | SpeakSettings[]) => { + sessionRef.current?.updateSpeak(speak); + }, []); + + const updatePrompt = useCallback((prompt: string) => { + sessionRef.current?.updatePrompt(prompt); + }, []); + + const clearConversation = useCallback(() => { + sessionRef.current?.clearConversationHistory(); + setConversation([]); + }, []); + const interrupt = useCallback(() => { - getPlayer().interrupt(); + playerRef.current?.interrupt(); }, []); + useEffect(() => { + const session = requireSession(sessionRef.current); + const cycle = ++effectCycleRef.current; + mountedRef.current = true; + ensurePlayer(); + + const onState = () => setState(session.state); + const onDisconnected = () => { + ++startGenerationRef.current; + startPromiseRef.current = null; + onState(); + clearAudioDoneTimer(); + micRef.current?.stop(); + micRef.current = null; + playerRef.current?.interrupt(); + setMicActive(false); + setMicMutedState(false); + setMode("idle"); + }; + const onConversationText = (msg: ConversationTextMessage) => { + setConversation((previous) => [ + ...previous, + { + id: nextId(), + role: msg.role as "user" | "assistant", + content: msg.content, + timestamp: Date.now(), + }, + ]); + }; + const onAudio = (chunk: ArrayBuffer) => { + setMode("speaking"); + ensurePlayer().queue(chunk); + }; + const onUserStartedSpeaking = () => { + clearAudioDoneTimer(); + setMode("listening"); + playerRef.current?.interrupt(); + }; + const onAgentStartedSpeaking = () => { + clearAudioDoneTimer(); + setMode("speaking"); + }; + const onAgentAudioDone = () => { + const remaining = playerRef.current?.getRemainingPlaybackTime() ?? 0; + clearAudioDoneTimer(); + audioDoneTimerRef.current = setTimeout(() => { + audioDoneTimerRef.current = null; + setMode("listening"); + }, remaining * 1000); + }; + const onAgentThinking = () => { + clearAudioDoneTimer(); + setMode("thinking"); + }; + const onSettingsApplied = () => setMode("listening"); + const onFunctionCallRequest = async (msg: FunctionCallRequestMessage) => { + const handler = latestOptionsRef.current.onFunctionCall; + if (!handler) return; + const generation = startGenerationRef.current; + for (const fn of msg.functions) { + if (generation !== startGenerationRef.current) return; + if (!fn.client_side) continue; + try { + const result = await handler(fn); + if (generation !== startGenerationRef.current) return; + session.sendFunctionCallResponse(fn.id, fn.name, result); + } catch (error) { + if (generation !== startGenerationRef.current) return; + session.sendFunctionCallResponse(fn.id, fn.name, JSON.stringify({ error: String(error) })); + } + } + }; + const onProtocolError = (message: AgentErrorMessage) => latestOptionsRef.current.onError?.(message); + const onSdkError = (error: Error) => latestOptionsRef.current.onSdkError?.(error); + const onProtocolWarning = (message: AgentWarningMessage) => latestOptionsRef.current.onWarning?.(message); + const onLatency = (message: LatencyReportMessage) => latestOptionsRef.current.onLatencyReport?.(message); + const onRefused = (message: InjectionRefusedMessage) => latestOptionsRef.current.onInjectionRefused?.(message); + const onListen = (message: ListenUpdatedMessage) => latestOptionsRef.current.onListenUpdated?.(message); + const onHistoryMessage = (message: HistoryMessage) => latestOptionsRef.current.onHistory?.(message); + + session.on("connecting", onState); + session.on("connected", onState); + session.on("reconnecting", onState); + session.on("disconnected", onDisconnected); + session.on("conversation-text", onConversationText); + session.on("audio", onAudio); + session.on("user-started-speaking", onUserStartedSpeaking); + session.on("agent-started-speaking", onAgentStartedSpeaking); + session.on("agent-audio-done", onAgentAudioDone); + session.on("agent-thinking", onAgentThinking); + session.on("settings-applied", onSettingsApplied); + session.on("function-call-request", onFunctionCallRequest); + session.on("error", onProtocolError); + session.on("sdk-error", onSdkError); + session.on("warning", onProtocolWarning); + session.on("latency-report", onLatency); + session.on("injection-refused", onRefused); + session.on("listen-updated", onListen); + session.on("history", onHistoryMessage); + + return () => { + mountedRef.current = false; + ++startGenerationRef.current; + startPromiseRef.current = null; + clearAudioDoneTimer(); + + session.off("connecting", onState); + session.off("connected", onState); + session.off("reconnecting", onState); + session.off("disconnected", onDisconnected); + session.off("conversation-text", onConversationText); + session.off("audio", onAudio); + session.off("user-started-speaking", onUserStartedSpeaking); + session.off("agent-started-speaking", onAgentStartedSpeaking); + session.off("agent-audio-done", onAgentAudioDone); + session.off("agent-thinking", onAgentThinking); + session.off("settings-applied", onSettingsApplied); + session.off("function-call-request", onFunctionCallRequest); + session.off("error", onProtocolError); + session.off("sdk-error", onSdkError); + session.off("warning", onProtocolWarning); + session.off("latency-report", onLatency); + session.off("injection-refused", onRefused); + session.off("listen-updated", onListen); + session.off("history", onHistoryMessage); + + queueMicrotask(() => { + if (mountedRef.current || effectCycleRef.current !== cycle) return; + session.disconnect(); + micRef.current?.stop(); + micRef.current = null; + playerRef.current?.interrupt(); + playerRef.current?.dispose(); + playerRef.current = null; + }); + }; + }, [clearAudioDoneTimer, ensurePlayer]); + return { state, + mode, + isSpeaking: mode === "speaking", + isListening: mode === "listening", + isThinking: mode === "thinking", micActive, + micMuted, outputMuted, conversation, start, @@ -189,6 +413,17 @@ export function useDeepgramAgent({ setMicMuted, setOutputMuted, sendUserMessage, + sendAgentMessage, + updateListen, + updateThink, + updateSpeak, + updatePrompt, + clearConversation, interrupt, }; } + +function requireSession(session: AgentSession | null): AgentSession { + if (!session) throw new Error("Agent session is unavailable"); + return session; +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index f6a8cc3..9ee481b 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,12 +1,12 @@ // Provider export { AgentProvider } from "./provider.js"; -export type { AgentProviderProps } from "./provider.js"; +export type { AgentNotificationCallbacks, AgentProviderProps } from "./provider.js"; // Context (escape hatch) export { useAgentContext } from "./context.js"; export type { AgentContextValue, ConversationEntry, AgentMode } from "./context.js"; -// Focused hooks — each subscribes to a specific state slice +// Focused hooks — convenient API slices backed by AgentContext export { useAgentState } from "./hooks/useAgentState.js"; export { useAgentConversation } from "./hooks/useAgentConversation.js"; export { useAgentMicrophone } from "./hooks/useAgentMicrophone.js"; @@ -32,7 +32,32 @@ export type { AuthConfig, TokenFactory, AgentSettingsObject, + AgentMessageBehavior, + ListenSettings, ThinkSettings, + ThinkProvider, SpeakSettings, + SpeakProvider, MicrophoneOptions, + AgentSessionEvents, + AgentState, + WelcomeMessage, + SettingsAppliedMessage, + ConversationTextMessage, + UserStartedSpeakingMessage, + AgentThinkingMessage, + FunctionCallRequestMessage, + FunctionCallItem, + AgentStartedSpeakingMessage, + AgentAudioDoneMessage, + PromptUpdatedMessage, + SpeakUpdatedMessage, + ThinkUpdatedMessage, + ListenUpdatedMessage, + LatencyReportMessage, + HistoryMessage, + InjectionRefusedMessage, + AgentErrorMessage, + AgentWarningMessage, + ServerMessage, } from "@deepgram/agents"; diff --git a/packages/react/src/provider.tsx b/packages/react/src/provider.tsx index 9316d4b..6d724f9 100644 --- a/packages/react/src/provider.tsx +++ b/packages/react/src/provider.tsx @@ -3,14 +3,36 @@ import { AgentSession, AgentMicrophone, AgentPlayer, + type AgentErrorMessage, + type AgentMessageBehavior, type AgentSessionConfig, type AgentState, - type MicrophoneOptions, + type AgentWarningMessage, + type ConversationTextMessage, type FunctionCallItem, + type FunctionCallRequestMessage, + type HistoryMessage, + type InjectionRefusedMessage, + type LatencyReportMessage, + type ListenSettings, + type ListenUpdatedMessage, + type MicrophoneOptions, + type SpeakSettings, + type ThinkSettings, } from "@deepgram/agents"; import { AgentContext, type AgentMode, type ConversationEntry } from "./context.js"; -export interface AgentProviderProps { +export interface AgentNotificationCallbacks { + onError?: (message: AgentErrorMessage) => void; + onSdkError?: (error: Error) => void; + onWarning?: (message: AgentWarningMessage) => void; + onLatencyReport?: (message: LatencyReportMessage) => void; + onInjectionRefused?: (message: InjectionRefusedMessage) => void; + onListenUpdated?: (message: ListenUpdatedMessage) => void; + onHistory?: (message: HistoryMessage) => void; +} + +export interface AgentProviderProps extends AgentNotificationCallbacks { config: AgentSessionConfig; microphone?: boolean; microphoneOptions?: MicrophoneOptions; @@ -32,120 +54,213 @@ export function AgentProvider({ playerSampleRate = 24_000, autoStart = false, onFunctionCall, + onError, + onSdkError, + onWarning, + onLatencyReport, + onInjectionRefused, + onListenUpdated, + onHistory, children, }: AgentProviderProps) { const sessionRef = useRef(null); - const micRef = useRef(null); + const micRef = useRef(null); const playerRef = useRef(null); - // Dynamic client tools — registered/unregistered by useAgentClientTool - const clientToolsRef = useRef Promise | string>>(new Map()); - if (!sessionRef.current) sessionRef.current = new AgentSession(config); - if (tts && !playerRef.current) playerRef.current = new AgentPlayer({ sampleRate: playerSampleRate }); - const [state, setState] = useState("idle"); - const [mode, setMode] = useState("idle"); - const [conversation, setConversation] = useState([]); - const [micActive, setMicActive] = useState(false); - const [micMuted, setMicMutedState] = useState(false); - const [outputMuted, setOutputMutedState] = useState(false); + const latestPropsRef = useRef({ + microphone, + microphoneOptions, + tts, + onFunctionCall, + onError, + onSdkError, + onWarning, + onLatencyReport, + onInjectionRefused, + onListenUpdated, + onHistory, + }); + latestPropsRef.current = { + microphone, + microphoneOptions, + tts, + onFunctionCall, + onError, + onSdkError, + onWarning, + onLatencyReport, + onInjectionRefused, + onListenUpdated, + onHistory, + }; - useEffect(() => { - const session = sessionRef.current!; - const player = playerRef.current; + const clientToolsRef = useRef Promise | string; + }>>>(new Map()); + const audioDoneTimerRef = useRef | null>(null); + const effectCycleRef = useRef(0); + const mountedRef = useRef(true); + const initialPlayerSampleRateRef = useRef(playerSampleRate); + const initialAutoStartRef = useRef(autoStart); + const autoStartRequestedRef = useRef(false); + const startGenerationRef = useRef(0); + const microphoneGenerationRef = useRef(0); + const startPromiseRef = useRef | null>(null); + const microphoneStartPromiseRef = useRef | null>(null); - const onState = () => setState(session.state); - session.on("connecting", onState); - session.on("connected", onState); - session.on("reconnecting", onState); - session.on("disconnected", () => { onState(); setMode("idle"); }); + const [state, setState] = useState("idle"); + const [mode, setMode] = useState("idle"); + const [conversation, setConversation] = useState([]); + const [micActive, setMicActive] = useState(false); + const [micMuted, setMicMutedState] = useState(false); + const [outputMuted, setOutputMutedState] = useState(false); - // Mode tracking: speaking / listening - // The server may skip AgentStartedSpeaking, so we also infer speaking from - // audio chunk arrival. AgentAudioDone fires when the server is done SENDING - // audio, but playback continues — we delay the listening transition until - // the player finishes playing the queued buffers. - let audioDoneTimer: ReturnType | null = null; + const ensurePlayer = useCallback((): AgentPlayer => { + if (!playerRef.current) { + playerRef.current = new AgentPlayer({ + sampleRate: initialPlayerSampleRateRef.current, + }); + } + return playerRef.current; + }, []); - session.on("agent-started-speaking", () => { - if (audioDoneTimer) { clearTimeout(audioDoneTimer); audioDoneTimer = null; } - setMode("speaking"); - }); - session.on("agent-audio-done", () => { - // Wait for the player to finish playing queued audio before switching to listening - const remaining = player?.getRemainingPlaybackTime() ?? 0; - if (audioDoneTimer) clearTimeout(audioDoneTimer); - audioDoneTimer = setTimeout(() => { - audioDoneTimer = null; - setMode("listening"); - }, remaining * 1000); - }); - session.on("user-started-speaking", () => { - if (audioDoneTimer) { clearTimeout(audioDoneTimer); audioDoneTimer = null; } - setMode("listening"); - player?.interrupt(); - }); - session.on("settings-applied", () => setMode("listening")); - - session.on("conversation-text", (msg) => { - setConversation((prev) => [ - ...prev, - { id: nextId(), role: msg.role as "user" | "assistant", content: msg.content, timestamp: Date.now() }, - ]); - }); + const clearAudioDoneTimer = useCallback(() => { + if (audioDoneTimerRef.current) { + clearTimeout(audioDoneTimerRef.current); + audioDoneTimerRef.current = null; + } + }, []); - if (player) { - session.on("audio", (chunk) => { - // Infer speaking mode from audio flow — covers servers that skip AgentStartedSpeaking - setMode("speaking"); - player.queue(chunk); - }); + const startMicrophone = useCallback(function ensureMicrophone( + session: AgentSession, + ): Promise { + if ( + !latestPropsRef.current.microphone + || session.state !== "connected" + ) { + return Promise.resolve(); + } + if (micRef.current && !microphoneStartPromiseRef.current) { + return Promise.resolve(); + } + const inFlight = microphoneStartPromiseRef.current; + if (inFlight) { + return inFlight.then(() => ensureMicrophone(session)); } - session.on("function-call-request", async (msg) => { - for (const fn of msg.functions) { - if (!fn.client_side) continue; - // Check dynamic client tools first, then fall back to prop - const dynamicHandler = clientToolsRef.current.get(fn.name); - const handler = dynamicHandler ?? onFunctionCall; - if (!handler) continue; - try { - const result = await handler(fn); - session.sendFunctionCallResponse(fn.id, fn.name, result); - } catch (err) { - session.sendFunctionCallResponse(fn.id, fn.name, JSON.stringify({ error: String(err) })); + const generation = ++microphoneGenerationRef.current; + const mic = new AgentMicrophone( + (data) => session.sendAudio(data), + latestPropsRef.current.microphoneOptions, + ); + micRef.current = mic; + + const promise = (async () => { + try { + await mic.start(); + } catch (error) { + mic.stop(); + if (micRef.current === mic) micRef.current = null; + if ( + generation !== microphoneGenerationRef.current + || !latestPropsRef.current.microphone + ) { + return; } + throw error; } - }); - if (autoStart) { - _start(session, microphone, microphoneOptions, micRef, setMicActive).catch(console.error); - } + if ( + generation !== microphoneGenerationRef.current + || !latestPropsRef.current.microphone + || micRef.current !== mic + ) { + mic.stop(); + if (micRef.current === mic) micRef.current = null; + return; + } - return () => { - if (audioDoneTimer) clearTimeout(audioDoneTimer); - session.removeAllListeners(); - session.disconnect(); - micRef.current?.stop(); - player?.dispose(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps + if (mountedRef.current) { + setMicActive(true); + setMicMutedState(false); + } + })(); + + microphoneStartPromiseRef.current = promise; + void promise.then( + () => { + if (microphoneStartPromiseRef.current === promise) { + microphoneStartPromiseRef.current = null; + } + }, + () => { + if (microphoneStartPromiseRef.current === promise) { + microphoneStartPromiseRef.current = null; + } + }, + ); + return promise.then(() => ensureMicrophone(session)); }, []); - const start = useCallback(async () => { - const session = sessionRef.current!; - // Fresh connect — reset display history (session.connect() resets conversationHistory too) - setConversation([]); - await _start(session, microphone, microphoneOptions, micRef, setMicActive); - }, [microphone, microphoneOptions]); + const start = useCallback((): Promise => { + if (startPromiseRef.current) return startPromiseRef.current; - const stop = useCallback(() => { - sessionRef.current!.disconnect(); + const session = requireSession(sessionRef.current); + const generation = ++startGenerationRef.current; + const { tts: ttsEnabled } = latestPropsRef.current; + + clearAudioDoneTimer(); + (ttsEnabled ? ensurePlayer() : playerRef.current)?.interrupt(); + ++microphoneGenerationRef.current; micRef.current?.stop(); micRef.current = null; setMicActive(false); - }, []); + setMicMutedState(false); + setConversation([]); + + const promise = (async () => { + try { + await session.connect(); + + if (generation !== startGenerationRef.current) { + return; + } + + await startMicrophone(session); + } catch (error) { + if (generation !== startGenerationRef.current) return; + session.disconnect(); + playerRef.current?.interrupt(); + if (mountedRef.current) { + setMicActive(false); + setMicMutedState(false); + setMode("idle"); + } + throw error; + } + })(); + + startPromiseRef.current = promise; + void promise.then( + () => { + if (startPromiseRef.current === promise) startPromiseRef.current = null; + }, + () => { + if (startPromiseRef.current === promise) startPromiseRef.current = null; + }, + ); + return promise; + }, [clearAudioDoneTimer, ensurePlayer, startMicrophone]); + + const stop = useCallback(() => { + ++startGenerationRef.current; + startPromiseRef.current = null; + clearAudioDoneTimer(); + sessionRef.current?.disconnect(); + }, [clearAudioDoneTimer]); const setMicMuted = useCallback((muted: boolean) => { if (!micRef.current) return; @@ -154,17 +269,40 @@ export function AgentProvider({ }, []); const setOutputMuted = useCallback((muted: boolean) => { - if (!playerRef.current) return; - muted ? playerRef.current.mute() : playerRef.current.unmute(); + const player = playerRef.current ?? ( + latestPropsRef.current.tts ? ensurePlayer() : null + ); + if (!player) return; + muted ? player.mute() : player.unmute(); setOutputMutedState(muted); - }, []); + }, [ensurePlayer]); const sendUserMessage = useCallback((text: string) => { sessionRef.current?.injectUserMessage(text); }, []); + const sendAgentMessage = useCallback((message: string, behavior?: AgentMessageBehavior) => { + sessionRef.current?.injectAgentMessage(message, behavior); + }, []); + + const updateListen = useCallback((listen: ListenSettings) => { + sessionRef.current?.updateListen(listen); + }, []); + + const updateThink = useCallback((think: ThinkSettings | ThinkSettings[]) => { + sessionRef.current?.updateThink(think); + }, []); + + const updateSpeak = useCallback((speak: SpeakSettings | SpeakSettings[]) => { + sessionRef.current?.updateSpeak(speak); + }, []); + + const updatePrompt = useCallback((prompt: string) => { + sessionRef.current?.updatePrompt(prompt); + }, []); + const clearConversation = useCallback(() => { - sessionRef.current!.conversationHistory = []; + sessionRef.current?.clearConversationHistory(); setConversation([]); }, []); @@ -172,14 +310,24 @@ export function AgentProvider({ name: string, handler: (fn: FunctionCallItem) => Promise | string, ) => { - clientToolsRef.current.set(name, handler); + const registration = { id: Symbol(name), handler }; + const registrations = clientToolsRef.current.get(name) ?? []; + registrations.push(registration); + clientToolsRef.current.set(name, registrations); + + return () => { + const current = clientToolsRef.current.get(name); + if (!current) return; + const index = current.findIndex(({ id }) => id === registration.id); + if (index !== -1) current.splice(index, 1); + if (current.length === 0) clientToolsRef.current.delete(name); + }; }, []); const unregisterClientTool = useCallback((name: string) => { clientToolsRef.current.delete(name); }, []); - // Volume getters — stable refs, called per animation frame by visualizers const getInputVolume = useCallback( () => micRef.current?.getInputVolume() ?? 0, [], @@ -189,19 +337,224 @@ export function AgentProvider({ [], ); + useEffect(() => { + if (tts) { + ensurePlayer(); + return; + } + + const wasWaitingForPlayback = audioDoneTimerRef.current !== null; + clearAudioDoneTimer(); + playerRef.current?.interrupt(); + if (wasWaitingForPlayback) setMode("listening"); + }, [clearAudioDoneTimer, ensurePlayer, tts]); + + useEffect(() => { + if (!microphone) { + ++microphoneGenerationRef.current; + micRef.current?.stop(); + micRef.current = null; + setMicActive(false); + setMicMutedState(false); + return; + } + + const session = sessionRef.current; + if (session?.state === "connected" && !micRef.current) { + void startMicrophone(session).catch((error) => { + latestPropsRef.current.onSdkError?.( + error instanceof Error ? error : new Error(String(error)), + ); + }); + } + }, [microphone, startMicrophone]); + + useEffect(() => { + const session = requireSession(sessionRef.current); + const cycle = ++effectCycleRef.current; + mountedRef.current = true; + + const onState = () => setState(session.state); + const onConnected = () => { + onState(); + void startMicrophone(session).catch((error) => { + latestPropsRef.current.onSdkError?.( + error instanceof Error ? error : new Error(String(error)), + ); + }); + }; + const onDisconnected = () => { + ++startGenerationRef.current; + ++microphoneGenerationRef.current; + startPromiseRef.current = null; + onState(); + clearAudioDoneTimer(); + micRef.current?.stop(); + micRef.current = null; + playerRef.current?.interrupt(); + setMicActive(false); + setMicMutedState(false); + setMode("idle"); + }; + const onAgentStartedSpeaking = () => { + clearAudioDoneTimer(); + setMode("speaking"); + }; + const onAgentAudioDone = () => { + const remaining = playerRef.current?.getRemainingPlaybackTime() ?? 0; + clearAudioDoneTimer(); + audioDoneTimerRef.current = setTimeout(() => { + audioDoneTimerRef.current = null; + setMode("listening"); + }, remaining * 1000); + }; + const onUserStartedSpeaking = () => { + clearAudioDoneTimer(); + setMode("listening"); + playerRef.current?.interrupt(); + }; + const onSettingsApplied = () => setMode("listening"); + const onAgentThinking = () => { + clearAudioDoneTimer(); + setMode("thinking"); + }; + const onConversationText = (msg: ConversationTextMessage) => { + setConversation((previous) => [ + ...previous, + { + id: nextId(), + role: msg.role as "user" | "assistant", + content: msg.content, + timestamp: Date.now(), + }, + ]); + }; + const onAudio = (chunk: ArrayBuffer) => { + if (!latestPropsRef.current.tts) return; + setMode("speaking"); + ensurePlayer().queue(chunk); + }; + const onFunctionCallRequest = async (msg: FunctionCallRequestMessage) => { + const generation = startGenerationRef.current; + for (const fn of msg.functions) { + if (generation !== startGenerationRef.current) return; + if (!fn.client_side) continue; + const registrations = clientToolsRef.current.get(fn.name); + const handler = registrations?.[registrations.length - 1]?.handler + ?? latestPropsRef.current.onFunctionCall; + if (!handler) continue; + try { + const result = await handler(fn); + if (generation !== startGenerationRef.current) return; + session.sendFunctionCallResponse(fn.id, fn.name, result); + } catch (error) { + if (generation !== startGenerationRef.current) return; + session.sendFunctionCallResponse(fn.id, fn.name, JSON.stringify({ error: String(error) })); + } + } + }; + const onProtocolError = (message: AgentErrorMessage) => latestPropsRef.current.onError?.(message); + const onSdkError = (error: Error) => latestPropsRef.current.onSdkError?.(error); + const onProtocolWarning = (message: AgentWarningMessage) => latestPropsRef.current.onWarning?.(message); + const onLatency = (message: LatencyReportMessage) => latestPropsRef.current.onLatencyReport?.(message); + const onRefused = (message: InjectionRefusedMessage) => latestPropsRef.current.onInjectionRefused?.(message); + const onListen = (message: ListenUpdatedMessage) => latestPropsRef.current.onListenUpdated?.(message); + const onHistoryMessage = (message: HistoryMessage) => latestPropsRef.current.onHistory?.(message); + + session.on("connecting", onState); + session.on("connected", onConnected); + session.on("reconnecting", onState); + session.on("disconnected", onDisconnected); + session.on("agent-started-speaking", onAgentStartedSpeaking); + session.on("agent-audio-done", onAgentAudioDone); + session.on("user-started-speaking", onUserStartedSpeaking); + session.on("settings-applied", onSettingsApplied); + session.on("agent-thinking", onAgentThinking); + session.on("conversation-text", onConversationText); + session.on("audio", onAudio); + session.on("function-call-request", onFunctionCallRequest); + session.on("error", onProtocolError); + session.on("sdk-error", onSdkError); + session.on("warning", onProtocolWarning); + session.on("latency-report", onLatency); + session.on("injection-refused", onRefused); + session.on("listen-updated", onListen); + session.on("history", onHistoryMessage); + + if (initialAutoStartRef.current) { + queueMicrotask(() => { + if ( + mountedRef.current && + effectCycleRef.current === cycle && + !autoStartRequestedRef.current + ) { + autoStartRequestedRef.current = true; + start().catch(console.error); + } + }); + } + + return () => { + mountedRef.current = false; + ++startGenerationRef.current; + ++microphoneGenerationRef.current; + startPromiseRef.current = null; + clearAudioDoneTimer(); + + session.off("connecting", onState); + session.off("connected", onConnected); + session.off("reconnecting", onState); + session.off("disconnected", onDisconnected); + session.off("agent-started-speaking", onAgentStartedSpeaking); + session.off("agent-audio-done", onAgentAudioDone); + session.off("user-started-speaking", onUserStartedSpeaking); + session.off("settings-applied", onSettingsApplied); + session.off("agent-thinking", onAgentThinking); + session.off("conversation-text", onConversationText); + session.off("audio", onAudio); + session.off("function-call-request", onFunctionCallRequest); + session.off("error", onProtocolError); + session.off("sdk-error", onSdkError); + session.off("warning", onProtocolWarning); + session.off("latency-report", onLatency); + session.off("injection-refused", onRefused); + session.off("listen-updated", onListen); + session.off("history", onHistoryMessage); + + // StrictMode immediately replays effects. Delay final resource disposal so + // the next setup can retain the live session/player, while real unmounts + // still release everything in the following microtask. + queueMicrotask(() => { + if (mountedRef.current || effectCycleRef.current !== cycle) return; + session.disconnect(); + micRef.current?.stop(); + micRef.current = null; + playerRef.current?.interrupt(); + playerRef.current?.dispose(); + playerRef.current = null; + }); + }; + }, [clearAudioDoneTimer, ensurePlayer, start, startMicrophone]); + return ( , - setMicActive: (v: boolean) => void, -): Promise { - await session.connect(); - - if (microphoneEnabled) { - micRef.current?.stop(); - const mic = new AgentMicrophone( - (data) => session.sendAudio(data), - microphoneOptions, - ); - micRef.current = mic; - await mic.start(); - setMicActive(true); - } +function requireSession(session: AgentSession | null): AgentSession { + if (!session) throw new Error("Agent session is unavailable"); + return session; } diff --git a/release-please-config.json b/release-please-config.json index c8836fb..a08b79a 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -16,8 +16,7 @@ "packages": { "packages/react": { "package-name": "@deepgram/react", - "changelog-path": "CHANGELOG.md", - "release-as": "0.1.0" + "changelog-path": "CHANGELOG.md" } } }