Compare commits

..
7 Commits
41 changed files with 1858 additions and 382 deletions
@@ -0,0 +1,223 @@
# ClawX Realtime Talk Design
Date: 2026-08-16
## Goal
Add OpenClaw realtime Talk to ClawX Chat. A user can start a duplex voice conversation from the selected Chat session. ClawX uses OpenClaw's Gateway-managed realtime relay, reuses the selected session key for Agent consults, and keeps OpenClaw as the only durable source of truth.
## Scope
- Support Gateway Relay realtime Talk only.
- Discover the active realtime provider, models, voices, and readiness through `talk.catalog`.
- Add a Talk start/stop control to the Chat composer.
- Capture microphone input, play realtime provider output, show live transcripts, and support barge-in.
- Preserve OpenClaw's default mixed behavior: the realtime model may answer directly or call `openclaw_agent_consult`.
- Add basic Settings > Talk controls for provider, model, speaker voice, and readiness.
- Officially validate on macOS. Windows and Linux use the same Chromium implementation and receive automated coverage, but do not block the first release.
## Non-Goals
- Client-owned WebRTC or provider WebSocket transports.
- Camera/video Talk.
- Push-to-talk dictation or audio-to-composer input.
- Audio recording or saving raw audio.
- A ClawX-owned database, transcript ledger, sidecar JSON history, or any other durable Talk storage.
- Writing OpenClaw session transcript files directly or modifying the OpenClaw repository.
- Forcing all utterances through Agent consult.
## Session Ownership
The persistent user-visible session is the selected OpenClaw `sessionKey`. ACP loads and renders its text, tool, permission, and history semantics, but does not own the underlying session.
Each active call has a temporary Gateway `relaySessionId`:
```text
OpenClaw sessionKey
|- ACP text session and timeline
`- active relaySessionId for one realtime call
```
- Talk always receives the currently selected, non-heartbeat-only `sessionKey`.
- Talk must never fall back to `DEFAULT_SESSION_KEY`.
- If no normal session exists, ClawX first uses the existing ACP load/create path to materialize one, then creates the relay.
- A relay session expires or is closed independently of the text session.
- Only one Talk relay can be active in ClawX at a time.
- While Talk is active for a session, ClawX locks the text composer for that session. It restores the draft after Talk stops, fails, or the Gateway disconnects.
## History Semantics
OpenClaw's realtime relay emits direct provider transcripts through `talk.event`, but does not expose an API that persists those transcript entries as ordinary session messages. ClawX must not create a substitute persistence layer.
### Direct Realtime Answers
When the realtime model answers without an Agent consult:
- Renderer shows partial and final user/assistant transcripts in an ephemeral Live Talk area at the end of Chat.
- The area uses normal chat-bubble styling and shows a microphone source marker.
- These entries are in-memory UI state only.
- They are removed after Talk ends, session changes, a Chat reload, or application restart.
- They do not appear in ACP history or session replay.
### Agent Consult Answers
When the realtime model calls `openclaw_agent_consult`:
1. ClawX invokes `talk.client.toolCall` for the active session key and relay session.
2. OpenClaw invokes its normal `chat.send` path for that session key.
3. OpenClaw persists the normal user and assistant turn in its own session transcript.
4. ClawX returns the completed result using `talk.session.submitToolResult`.
5. The realtime model speaks an answer based on that result.
6. After the consult completes, ClawX reloads the current ACP session. ACP is the only source that renders the durable user and assistant chat bubbles.
The spoken realtime response may be phrased differently from the persisted Agent response. Persistent ACP bubbles do not require a microphone marker because OpenClaw does not persist a stable voice-source annotation for these turns.
## User Interface
### Chat
- Add a microphone button beside the Chat send controls.
- Before starting, validate Gateway connection, Talk readiness, selected session eligibility, and no active ACP prompt.
- When active, replace the microphone control with live level feedback, localized status, and a stop action.
- Disable message send, model selection, attachment selection, agent targeting, and text editing while Talk is active.
- Display an ephemeral Live Talk area immediately above the composer. It presents ordered user and assistant partial/final transcripts.
- Use localized status states: connecting, listening, thinking, speaking, disconnected, and error.
- For provider direct transcripts, render the microphone marker only within Live Talk.
- On failure, retain the transient Live Talk area until the user dismisses the error or exits Talk; never convert it into durable chat history.
- Stop Talk when the user switches sessions, the Chat page unmounts, the window closes, or the Gateway connection is lost.
### Settings
Add a Talk section in Settings with:
- Realtime provider picker.
- Realtime model picker.
- Speaker voice picker.
- Gateway readiness and unavailable-reason display.
- Link to Advanced Developer configuration for API keys, VAD settings, and provider-specific raw configuration.
Picker options come exclusively from `talk.catalog`. Writes update only:
- `talk.realtime.provider`
- `talk.realtime.model`
- `talk.realtime.speakerVoice`
The first release fixes runtime transport to `gateway-relay`; Settings does not expose transport selection.
## Architecture
### Typed Host Boundary
Renderer does not call Gateway RPCs or Gateway HTTP endpoints directly. Add a dedicated `hostApi.talk` module backed by Electron Main:
```ts
hostApi.talk.catalog()
hostApi.talk.startRelay({ sessionKey })
hostApi.talk.appendAudio({ relaySessionId, audioBase64, timestamp })
hostApi.talk.cancelOutput({ relaySessionId })
hostApi.talk.submitToolResult({ relaySessionId, callId, result })
hostApi.talk.acknowledgeMark({ relaySessionId, markName })
hostApi.talk.stopRelay({ relaySessionId })
```
Add a typed `hostEvents.onTalkEvent` subscription. Electron Main owns event routing and only emits an event to the active renderer controller when its `relaySessionId` matches the active relay.
The generic `hostApi.gateway.rpc` remains an internal compatibility surface and is not used by Talk Renderer code.
### Gateway Protocol
Start a relay with:
```ts
talk.session.create({
sessionKey,
mode: 'realtime',
transport: 'gateway-relay',
brain: 'agent-consult',
})
```
The result supplies the relay session identifier and audio contract. The first release accepts the Gateway Relay PCM16 contract. Renderer sends base64 PCM16 frames through `talk.session.appendAudio` and receives provider events through `talk.event`.
The Main-process event dispatcher recognizes `talk.event` as a dedicated protocol event instead of treating it as a generic Gateway notification.
### Audio Pipeline
```text
Renderer microphone
-> getUserMedia
-> AudioWorklet PCM frames
-> hostApi.talk.appendAudio
-> Main GatewayManager RPC
-> OpenClaw Gateway relay
-> realtime provider
realtime provider output
-> Gateway talk.event
-> Main typed Talk event
-> Renderer Web Audio PCM queue
-> speakers
```
- Renderer uses `getUserMedia` for microphone access and `AudioWorklet` for sample handling.
- Input requests use a bounded in-flight queue. If Gateway cannot keep up, discard stale microphone frames rather than accumulating latency.
- Renderer decodes returned PCM16 into a bounded Web Audio playback queue.
- While playback is active, detected user speech invokes `talk.session.cancelOutput` to support barge-in.
- Renderer sends mark acknowledgements only after the corresponding queued output reaches playback completion.
### Agent Consult Bridge
Port the Gateway Relay client behavior from OpenClaw Control UI rather than defining a new Agent protocol.
- For `openclaw_agent_consult`, invoke `talk.client.toolCall` with the selected session key, call id, arguments, and relay session id.
- Submit progress and terminal results through `talk.session.submitToolResult`.
- Handle provider cancellation, Gateway run cancellation, duplicate call ids, and late results idempotently.
- Handle OpenClaw's realtime Agent control tool with the same Gateway Relay protocol behavior.
- ACP is never used to issue a Talk consult. ACP is reloaded only to display the OpenClaw-owned transcript produced by the consult's normal `chat.send` turn.
## Failure and Lifecycle Handling
- Missing or unready Talk provider: do not create a relay; show a localized error and Settings link.
- Microphone permission denial, no input device, AudioWorklet failure, or unsupported audio contract: stop local resources and show a localized error.
- Gateway RPC failure, provider error, playback queue overflow, or relay close: stop the relay, release microphone and audio contexts, restore the composer, and retain transient content only until user dismissal or Talk exit.
- Gateway reconnect does not replay microphone frames or resurrect an old relay. It ends the active Talk session with a reconnect error.
- Session switch, component unmount, application shutdown, and window close close the relay best-effort and immediately release local audio resources.
- An ACP reload failure after a successful consult does not create an alternate transcript projection. It exposes a retryable synchronization message and leaves OpenClaw history authoritative.
## Localization and Styling
- All Talk user-facing text is localized in `en`, `zh`, `ja`, and `ru` locale files.
- New UI follows the repository token rules in `src/styles/globals.css`.
- Status and error colors use existing semantic tokens.
- Talk controls are keyboard-accessible and expose non-audio status updates through appropriate live regions.
## Validation
### Unit Tests
- Talk lifecycle state transitions and relay ownership.
- Session eligibility and composer lock behavior.
- Gateway event filtering by relay session id.
- PCM conversion, input backpressure, playback bounds, and barge-in requests.
- Consult tool-call sequencing, cancellation, idempotency, and ACP reload trigger.
- Catalog parsing, Settings mapping, and readiness failures.
- No durable ClawX Talk transcript store is introduced.
### Electron E2E Tests
- Start button readiness and unavailable state.
- Active Talk locks the text composer and restores its draft after stop.
- Live direct transcript bubbles appear with microphone source markers.
- Permission denial and Gateway disconnect cleanly end Talk.
- Consult completion causes ACP-native messages to appear after reload.
- Direct realtime transcript bubbles do not survive Chat reload.
### Manual macOS Acceptance
- Validate microphone permission, input level, provider output, barge-in, direct provider reply, Agent consult reply, stop, and Gateway reconnect against an installed configured provider.
## Documentation and Harness
- Update `README.md`, `README.zh-CN.md`, and `README.ja-JP.md` to describe Talk availability and its Gateway Relay architecture.
- Add a Talk scenario, rule, and task specification under `harness/specs/`.
- The task specification references `gateway-backend-communication` and is validated before implementation.
+6
View File
@@ -6,6 +6,7 @@ import type {
GatewayChatMessageEvent,
GatewayRuntimePayload,
} from '@shared/host-events/contract';
import type { TalkRelayEvent } from '@shared/talk/types';
type GatewayEventEmitter = {
emit: (event: string, payload: unknown) => boolean;
@@ -43,6 +44,9 @@ export function dispatchProtocolEvent(
case 'presence':
emitter.emit('gateway:presence', payload as GatewayRuntimePayload);
break;
case 'talk.event':
emitter.emit('talk:event', payload as TalkRelayEvent);
break;
default:
emitter.emit('notification', { method: event, params: payload });
}
@@ -57,6 +61,8 @@ export function dispatchJsonRpcNotification(
if (normalized) {
emitter.emit('chat:runtime-event', normalized);
}
} else if (notification.method === 'talk.event') {
emitter.emit('talk:event', notification.params as TalkRelayEvent);
} else {
emitter.emit('notification', notification);
}
+2
View File
@@ -79,6 +79,7 @@ import type {
GatewayRuntimePayload,
} from '@shared/host-events/contract';
import type { ChatRuntimeEvent } from '@shared/chat-runtime-events';
import type { TalkRelayEvent } from '@shared/talk/types';
export interface GatewayStatus {
state: GatewayLifecycleState;
@@ -162,6 +163,7 @@ export interface GatewayManagerEvents {
'channel:status': (data: GatewayChannelStatusEvent) => void;
'chat:message': (data: GatewayChatMessageEvent) => void;
'chat:runtime-event': (data: ChatRuntimeEvent) => void;
'talk:event': (data: TalkRelayEvent) => void;
}
/**
+8 -1
View File
@@ -54,6 +54,7 @@ import { deviceOAuthManager } from '../utils/device-oauth';
import { browserOAuthManager } from '../utils/browser-oauth';
import { whatsAppLoginManager } from '../utils/whatsapp-login';
import { syncAllProviderAuthToRuntime } from '../services/providers/provider-runtime-sync';
import { forwardActiveTalkEvent } from '../services/talk-api';
const WINDOWS_APP_USER_MODEL_ID = 'app.clawx.desktop';
const isE2EMode = process.env.CLAWX_E2E === '1';
@@ -355,7 +356,7 @@ async function initialize(): Promise<void> {
);
// Register IPC handlers
registerIpcHandlers(
const talkRelayOwnership = registerIpcHandlers(
gatewayManager,
clawHubService,
window,
@@ -483,6 +484,12 @@ async function initialize(): Promise<void> {
sendMainWindowEvent('chat:runtime-event', data);
});
gatewayManager.on('talk:event', (data) => {
forwardActiveTalkEvent(talkRelayOwnership, data, (event) => {
sendMainWindowEvent('talk:event', event);
});
});
gatewayManager.on('channel:status', (data) => {
sendMainWindowEvent('gateway:channel-status', data);
});
+9 -3
View File
@@ -65,6 +65,7 @@ import { createSessionsApi } from '../services/sessions-api';
import { createSkillsApi } from '../services/skills-api';
import { createUsageApi } from '../services/usage-api';
import { createWebBrowserApi } from '../services/web-browser-api';
import { createTalkApi, createTalkRelayOwnership, type TalkRelayOwnership } from '../services/talk-api';
import type { WebBrowserGuestRegistry } from './web-browser-policy';
import {
isLaunchAtStartupKey,
@@ -85,12 +86,12 @@ export function registerIpcHandlers(
hostApiRegistry: HostApiRegistry,
browserSession: Session,
registry: WebBrowserGuestRegistry,
): void {
): TalkRelayOwnership {
// Unified request protocol (non-breaking: legacy channels remain available)
registerUnifiedRequestHandlers(gatewayManager);
// Typed host invoke handlers (new renderer facade; legacy channels remain available)
registerTypedHostHandlers(
const talkRelayOwnership = registerTypedHostHandlers(
gatewayManager,
clawHubService,
mainWindow,
@@ -134,6 +135,8 @@ export function registerIpcHandlers(
// File preview handlers (sandboxed read/write/list for inline viewer)
registerFilePreviewHandlers();
return talkRelayOwnership;
}
function registerTypedHostHandlers(
@@ -143,7 +146,7 @@ function registerTypedHostHandlers(
hostApiRegistry: HostApiRegistry,
browserSession: Session,
registry: WebBrowserGuestRegistry,
): void {
): TalkRelayOwnership {
const acpSessionAccessRegistry = new AcpSessionAccessRegistry();
const stagedAttachments = new StagedAttachmentRegistry();
const attachmentOpenWith = createAttachmentOpenWithService();
@@ -152,6 +155,7 @@ function registerTypedHostHandlers(
stagedAttachments,
openWith: attachmentOpenWith,
});
const talkRelayOwnership = createTalkRelayOwnership();
hostApiRegistry.registerCoreServices({
app: createAppApi(),
openclaw: createOpenClawApi(),
@@ -163,6 +167,7 @@ function registerTypedHostHandlers(
uv: createUvApi(),
settings: createSettingsApi(gatewayManager),
gateway: createGatewayApi(gatewayManager),
talk: createTalkApi(gatewayManager, talkRelayOwnership),
logs: createLogsApi(),
channels: createChannelsApi({ gatewayManager, mainWindow }),
agents: createAgentsApi({ gatewayManager }),
@@ -180,6 +185,7 @@ function registerTypedHostHandlers(
usage: createUsageApi(),
});
registerHostInvokeHandler(hostApiRegistry);
return talkRelayOwnership;
}
function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
-96
View File
@@ -1,96 +0,0 @@
/**
* Window Management Utilities
* Handles window state persistence and multi-window management
*/
import { BrowserWindow, screen } from 'electron';
interface WindowState {
x?: number;
y?: number;
width: number;
height: number;
isMaximized: boolean;
}
// Lazy-load electron-store (ESM module)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let windowStateStore: any = null;
async function getStore() {
if (!windowStateStore) {
const Store = (await import('electron-store')).default;
windowStateStore = new Store<{ windowState: WindowState }>({
name: 'window-state',
defaults: {
windowState: {
width: 1280,
height: 800,
isMaximized: false,
},
},
});
}
return windowStateStore;
}
/**
* Get saved window state with bounds validation
*/
export async function getWindowState(): Promise<WindowState> {
const store = await getStore();
const state = store.get('windowState');
// Validate that the window is visible on a screen
if (state.x !== undefined && state.y !== undefined) {
const displays = screen.getAllDisplays();
const isVisible = displays.some((display) => {
const { x, y, width, height } = display.bounds;
return (
state.x! >= x &&
state.x! < x + width &&
state.y! >= y &&
state.y! < y + height
);
});
if (!isVisible) {
// Reset position if not visible
delete state.x;
delete state.y;
}
}
return state;
}
/**
* Save window state
*/
export async function saveWindowState(win: BrowserWindow): Promise<void> {
const store = await getStore();
const isMaximized = win.isMaximized();
if (!isMaximized) {
const bounds = win.getBounds();
store.set('windowState', {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
isMaximized,
});
} else {
store.set('windowState.isMaximized', true);
}
}
/**
* Track window state changes
*/
export function trackWindowState(win: BrowserWindow): void {
// Save state on window events
['resize', 'move', 'close'].forEach((event) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
win.on(event as any, () => saveWindowState(win));
});
}
+175
View File
@@ -0,0 +1,175 @@
import type { GatewayManager } from '../gateway/manager';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { TalkRelayEvent } from '@shared/talk/types';
import { isRecord } from './payload-utils';
export const TALK_RPC_TIMEOUT_MS = 8_000;
export type TalkRelayOwnership = {
activate: (relaySessionId: string) => void;
clear: (relaySessionId?: string) => void;
isActive: (relaySessionId: string) => boolean;
};
export function createTalkRelayOwnership(): TalkRelayOwnership {
let activeRelaySessionId: string | undefined;
return {
activate: (relaySessionId) => {
activeRelaySessionId = relaySessionId;
},
clear: (relaySessionId) => {
if (relaySessionId === undefined || activeRelaySessionId === relaySessionId) {
activeRelaySessionId = undefined;
}
},
isActive: (relaySessionId) => activeRelaySessionId === relaySessionId,
};
}
export function forwardActiveTalkEvent(
ownership: TalkRelayOwnership,
event: TalkRelayEvent,
sendToRenderer: (event: TalkRelayEvent) => void,
): void {
if (!ownership.isActive(event.relaySessionId)) return;
if (event.type === 'close') {
ownership.clear(event.relaySessionId);
}
sendToRenderer(event);
}
function requiredString(value: unknown, message: string): string {
if (typeof value !== 'string' || !value.trim()) {
throw new Error(message);
}
return value.trim();
}
function parseRelayId(value: unknown): string {
return requiredString(value, 'Invalid Talk relay session id');
}
function parsePcm16Base64(value: unknown): string {
if (
typeof value !== 'string'
|| !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)
) {
throw new Error('Invalid Talk PCM16 audio');
}
const bytes = Buffer.from(value, 'base64');
if (bytes.length === 0 || bytes.length % 2 !== 0) {
throw new Error('Invalid Talk PCM16 audio');
}
return value;
}
function parseTimestamp(value: unknown): number | undefined {
if (value === undefined) return undefined;
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
throw new Error('Invalid Talk audio timestamp');
}
return Math.floor(value);
}
function parseToolResultOptions(value: unknown): { suppressResponse?: boolean; willContinue?: boolean } | undefined {
if (value === undefined) return undefined;
if (!isRecord(value)
|| (value.suppressResponse !== undefined && typeof value.suppressResponse !== 'boolean')
|| (value.willContinue !== undefined && typeof value.willContinue !== 'boolean')) {
throw new Error('Invalid Talk tool result options');
}
return {
...(typeof value.suppressResponse === 'boolean' ? { suppressResponse: value.suppressResponse } : {}),
...(typeof value.willContinue === 'boolean' ? { willContinue: value.willContinue } : {}),
};
}
export function createTalkApi(
gatewayManager: GatewayManager,
ownership: TalkRelayOwnership = createTalkRelayOwnership(),
): CompleteHostServiceRegistry['talk'] {
let ownershipGeneration = 0;
return {
catalog: async () => await gatewayManager.rpc('talk.catalog', {}, TALK_RPC_TIMEOUT_MS),
startRelay: async (payload) => {
const body: Record<string, unknown> = isRecord(payload) ? payload : {};
const sessionKey = requiredString(body.sessionKey, 'Invalid Talk session key');
const generation = ++ownershipGeneration;
ownership.clear();
const relay = await gatewayManager.rpc('talk.session.create', {
sessionKey,
mode: 'realtime',
transport: 'gateway-relay',
brain: 'agent-consult',
}, TALK_RPC_TIMEOUT_MS);
if (!isRecord(relay)) {
throw new Error('Invalid Talk relay session');
}
const relaySessionId = parseRelayId(relay.relaySessionId);
if (generation !== ownershipGeneration) {
void gatewayManager.rpc('talk.session.close', { sessionId: relaySessionId }, TALK_RPC_TIMEOUT_MS)
.catch(() => undefined);
return relay as never;
}
ownership.activate(relaySessionId);
return relay as never;
},
appendAudio: async (payload) => {
const body: Record<string, unknown> = isRecord(payload) ? payload : {};
const timestamp = parseTimestamp(body.timestamp);
return await gatewayManager.rpc('talk.session.appendAudio', {
sessionId: parseRelayId(body.relaySessionId),
audioBase64: parsePcm16Base64(body.audioBase64),
...(timestamp === undefined ? {} : { timestamp }),
}, TALK_RPC_TIMEOUT_MS);
},
cancelOutput: async (payload) => {
const body: Record<string, unknown> = isRecord(payload) ? payload : {};
return await gatewayManager.rpc('talk.session.cancelOutput', {
sessionId: parseRelayId(body.relaySessionId),
}, TALK_RPC_TIMEOUT_MS);
},
submitToolResult: async (payload) => {
const body: Record<string, unknown> = isRecord(payload) ? payload : {};
if (!('result' in body)) {
throw new Error('Invalid Talk tool result');
}
const options = parseToolResultOptions(body.options);
return await gatewayManager.rpc('talk.session.submitToolResult', {
sessionId: parseRelayId(body.relaySessionId),
callId: requiredString(body.callId, 'Invalid Talk tool call id'),
result: body.result,
...(options && Object.keys(options).length > 0 ? { options } : {}),
}, TALK_RPC_TIMEOUT_MS);
},
acknowledgeMark: async (payload) => {
const body: Record<string, unknown> = isRecord(payload) ? payload : {};
return await gatewayManager.rpc('talk.session.acknowledgeMark', {
sessionId: parseRelayId(body.relaySessionId),
markName: requiredString(body.markName, 'Invalid Talk mark name'),
}, TALK_RPC_TIMEOUT_MS);
},
stopRelay: async (payload) => {
const body: Record<string, unknown> = isRecord(payload) ? payload : {};
const relaySessionId = parseRelayId(body.relaySessionId);
ownershipGeneration += 1;
ownership.clear(relaySessionId);
return await gatewayManager.rpc('talk.session.close', {
sessionId: relaySessionId,
}, TALK_RPC_TIMEOUT_MS);
},
startAgentConsult: async (payload) => {
const body: Record<string, unknown> = isRecord(payload) ? payload : {};
if (!isRecord(body.args)) {
throw new Error('Invalid Talk tool arguments');
}
return await gatewayManager.rpc('talk.client.toolCall', {
relaySessionId: parseRelayId(body.relaySessionId),
sessionKey: requiredString(body.sessionKey, 'Invalid Talk session key'),
callId: requiredString(body.callId, 'Invalid Talk tool call id'),
name: 'openclaw_agent_consult',
args: body.args,
}, TALK_RPC_TIMEOUT_MS);
},
};
}
+1
View File
@@ -95,6 +95,7 @@
"electron-updater": "^6.8.3",
"json5": "2.2.3",
"node-machine-id": "^1.1.12",
"pino": "^9.14.0",
"posthog-node": "^5.28.0",
"tar": "^6.2.1",
"timeago.js": "^4.0.2",
+7 -4
View File
@@ -31,6 +31,9 @@ importers:
node-machine-id:
specifier: ^1.1.12
version: 1.1.12
pino:
specifier: ^9.14.0
version: 9.14.0
posthog-node:
specifier: ^5.28.0
version: 5.28.5
@@ -4677,8 +4680,8 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
libsignal@git+https://git@github.com:whiskeysockets/libsignal-node.git#bcea72df9ec34d9d9140ab30619cf479c7c144c7:
resolution: {commit: bcea72df9ec34d9d9140ab30619cf479c7c144c7, repo: git@github.com:whiskeysockets/libsignal-node.git, type: git}
libsignal@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7:
resolution: {gitHosted: true, tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7}
version: 6.0.0
lie@3.3.0:
@@ -9571,7 +9574,7 @@ snapshots:
'@cacheable/node-cache': 1.7.6
'@hapi/boom': 9.1.4
async-mutex: 0.5.0
libsignal: git+https://git@github.com:whiskeysockets/libsignal-node.git#bcea72df9ec34d9d9140ab30619cf479c7c144c7
libsignal: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7
lru-cache: 11.2.7
music-metadata: 11.12.3
p-queue: 9.1.0
@@ -11829,7 +11832,7 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
libsignal@git+https://git@github.com:whiskeysockets/libsignal-node.git#bcea72df9ec34d9d9140ab30619cf479c7c144c7:
libsignal@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7:
dependencies:
curve25519-js: 0.0.4
protobufjs: 7.5.8
+22
View File
@@ -11,6 +11,18 @@ import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/c
import type { GatewayHealth, GatewayStatus } from '../types/gateway';
import type { MarketplaceSkill, QuickAccessSkill, Skill } from '../types/skill';
import type { WebBrowserNavigatePayload } from '../web-browser';
import type {
TalkAcknowledgeMarkPayload,
TalkAgentConsultResult,
TalkAppendAudioPayload,
TalkCatalog,
TalkOperationResult,
TalkRelayIdPayload,
TalkRelaySession,
TalkStartAgentConsultPayload,
TalkStartRelayPayload,
TalkSubmitToolResultPayload,
} from '../talk/types';
export type JsonRecord = Record<string, unknown>;
export type HostSuccess = { success: boolean; error?: string };
@@ -865,6 +877,16 @@ export type HostApiContract = {
controlUi: () => GatewayControlUiResult;
rpc: (payload: GatewayRpcPayload) => unknown;
};
talk: {
catalog: () => TalkCatalog;
startRelay: (payload: TalkStartRelayPayload) => TalkRelaySession;
appendAudio: (payload: TalkAppendAudioPayload) => TalkOperationResult;
cancelOutput: (payload: TalkRelayIdPayload) => TalkOperationResult;
submitToolResult: (payload: TalkSubmitToolResultPayload) => TalkOperationResult;
acknowledgeMark: (payload: TalkAcknowledgeMarkPayload) => TalkOperationResult;
stopRelay: (payload: TalkRelayIdPayload) => TalkOperationResult;
startAgentConsult: (payload: TalkStartAgentConsultPayload) => TalkAgentConsultResult;
};
logs: {
recent: (payload?: LogRecentPayload) => LogContentResult;
memory: (payload?: LogMemoryPayload) => string[];
+7
View File
@@ -4,6 +4,7 @@ import type {
} from '../acp-chat/types';
import type { UpdateStatusSnapshot } from '../host-api/contract';
import type { ChatRuntimeEvent } from '../chat-runtime-events';
import type { TalkRelayEvent } from '../talk/types';
import type {
GatewayNotification,
GatewayRuntimePayload,
@@ -82,6 +83,9 @@ export type HostEventContract = {
acpSessionUpdate: (payload: AcpSessionUpdateEnvelope) => void;
acpPermissionRequest: (payload: AcpPermissionRequestEnvelope) => void;
};
talk: {
event: (payload: TalkRelayEvent) => void;
};
oauth: {
code: (payload: OAuthCodeEvent) => void;
success: (payload: OAuthSuccessEvent) => void;
@@ -131,6 +135,9 @@ export const HOST_EVENT_CHANNELS = {
acpSessionUpdate: 'chat:acp-session-update',
acpPermissionRequest: 'chat:acp-permission-request',
},
talk: {
event: 'talk:event',
},
oauth: {
code: 'oauth:code',
success: 'oauth:success',
+81
View File
@@ -0,0 +1,81 @@
export type TalkTerminalReason = 'completed' | 'cancelled' | 'error' | 'disconnected' | (string & {});
export type TalkCatalogReadiness =
| { ready: true }
| { ready: false; reason?: string }
| { ready?: undefined };
export type TalkCatalogProvider = {
id: string;
label: string;
configured: boolean;
models?: string[];
voices?: string[];
transports?: string[];
brains?: string[];
};
export type TalkCatalog = {
modes: string[];
transports: string[];
brains: string[];
realtime: TalkCatalogReadiness & {
activeProvider?: string;
providers: TalkCatalogProvider[];
};
};
export type TalkPcm16AudioContract = {
inputEncoding: 'pcm16';
inputSampleRateHz: number;
outputEncoding: 'pcm16';
outputSampleRateHz: number;
};
export type TalkRelaySession = {
relaySessionId: string;
provider: string;
transport: 'gateway-relay';
audio: TalkPcm16AudioContract;
model?: string;
voice?: string;
expiresAt?: number;
};
export type TalkRelayEventBase = {
relaySessionId: string;
talkEvent?: unknown;
};
export type TalkRelayEvent = TalkRelayEventBase & (
| { type: 'ready' }
| { type: 'audio'; audioBase64: string }
| { type: 'clear'; reason?: 'barge-in' }
| { type: 'mark'; markName: string }
| { type: 'transcript'; role: 'user' | 'assistant'; text: string; final: boolean }
| { type: 'toolCall'; callId: string; name: string; args: Record<string, unknown>; forced?: boolean }
| { type: 'toolCallCancelled'; callId: string }
| { type: 'toolResult'; callId: string; final?: boolean }
| { type: 'error'; message: string }
| { type: 'close'; reason: TalkTerminalReason }
);
export type TalkStartRelayPayload = { sessionKey: string };
export type TalkRelayIdPayload = { relaySessionId: string };
export type TalkAppendAudioPayload = TalkRelayIdPayload & {
audioBase64: string;
timestamp?: number;
};
export type TalkSubmitToolResultPayload = TalkRelayIdPayload & {
callId: string;
result: unknown;
options?: { suppressResponse?: boolean; willContinue?: boolean };
};
export type TalkAcknowledgeMarkPayload = TalkRelayIdPayload & { markName: string };
export type TalkStartAgentConsultPayload = TalkRelayIdPayload & {
sessionKey: string;
callId: string;
args: Record<string, unknown>;
};
export type TalkOperationResult = { ok: true };
export type TalkAgentConsultResult = { runId: string; idempotencyKey: string };
-74
View File
@@ -1,74 +0,0 @@
/**
* Error Boundary Component
* Catches and displays errors in the component tree
*/
import { Component, ReactNode } from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
}
handleReset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="flex h-full items-center justify-center p-6">
<Card className="max-w-md">
<CardHeader>
<div className="flex items-center gap-2">
<AlertTriangle className="h-6 w-6 text-destructive" />
<CardTitle>Something went wrong</CardTitle>
</div>
<CardDescription>
An unexpected error occurred. Please try again.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{this.state.error && (
<pre className="rounded-lg bg-surface-input p-4 text-sm overflow-auto max-h-40">
{this.state.error.message}
</pre>
)}
<Button onClick={this.handleReset} className="w-full">
<RefreshCw className="mr-2 h-4 w-4" />
Try Again
</Button>
</CardContent>
</Card>
</div>
);
}
return this.props.children;
}
}
-47
View File
@@ -1,47 +0,0 @@
/**
* Status Badge Component
* Displays connection/state status with color coding
*/
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
export type Status = 'connected' | 'disconnected' | 'connecting' | 'error' | 'running' | 'stopped' | 'starting' | 'reconnecting';
interface StatusBadgeProps {
status: Status;
label?: string;
showDot?: boolean;
}
const statusConfig: Record<Status, { label: string; variant: 'success' | 'secondary' | 'warning' | 'destructive' }> = {
connected: { label: 'Connected', variant: 'success' },
running: { label: 'Running', variant: 'success' },
disconnected: { label: 'Disconnected', variant: 'secondary' },
stopped: { label: 'Stopped', variant: 'secondary' },
connecting: { label: 'Connecting', variant: 'warning' },
starting: { label: 'Starting', variant: 'warning' },
reconnecting: { label: 'Reconnecting', variant: 'warning' },
error: { label: 'Error', variant: 'destructive' },
};
export function StatusBadge({ status, label, showDot = true }: StatusBadgeProps) {
const config = statusConfig[status];
const displayLabel = label || config.label;
return (
<Badge variant={config.variant} className="gap-1.5">
{showDot && (
<span
className={cn(
'h-1.5 w-1.5 rounded-full',
config.variant === 'success' && 'bg-green-600',
config.variant === 'secondary' && 'bg-muted-foreground',
config.variant === 'warning' && 'bg-yellow-600 animate-pulse',
config.variant === 'destructive' && 'bg-red-600'
)}
/>
)}
{displayLabel}
</Badge>
);
}
-49
View File
@@ -1,49 +0,0 @@
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-44 overflow-hidden rounded-md border border-border bg-surface-modal p-1 text-popover-foreground shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors',
'focus:bg-black/5 focus:text-foreground dark:focus:bg-white/10',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
export {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
};
-1
View File
@@ -1,5 +1,4 @@
export { rendererExtensionRegistry } from './registry';
export { registerRendererExtensionModule, loadRendererExtensions } from './loader';
export type {
RendererExtension,
NavItemDef,
-34
View File
@@ -1,34 +0,0 @@
import { rendererExtensionRegistry } from './registry';
import type { RendererExtension } from './types';
interface RendererExtensionManifest {
extensions?: {
renderer?: string[];
};
}
const registeredModules = new Map<string, () => RendererExtension>();
export function registerRendererExtensionModule(id: string, factory: () => RendererExtension): void {
registeredModules.set(id, factory);
}
export function loadRendererExtensions(manifest?: RendererExtensionManifest): void {
const extensionIds = manifest?.extensions?.renderer;
if (!extensionIds || extensionIds.length === 0) {
for (const [, factory] of registeredModules) {
rendererExtensionRegistry.register(factory());
}
return;
}
for (const id of extensionIds) {
const factory = registeredModules.get(id);
if (factory) {
rendererExtensionRegistry.register(factory());
} else {
console.warn(`[extensions] Renderer extension "${id}" not found in registered modules`);
}
}
}
-45
View File
@@ -1,45 +0,0 @@
import { useState, useRef, useEffect } from 'react';
/**
* A hook that ensures a loading state remains true for at least a minimum duration (e.g., 1000ms),
* preventing flickering for very fast loading times.
*
* @param isLoading - The actual loading state from the data source
* @param minDurationMs - Minimum duration to show loading (default: 1000)
*/
export function useMinLoading(isLoading: boolean, minDurationMs: number = 500) {
const [showLoading, setShowLoading] = useState(isLoading);
const startTime = useRef<number>(0);
// Guarantee that the loading UI activates immediately without any asynchronous delay
if (isLoading && !showLoading) {
setShowLoading(true);
}
// Record the actual timestamp in an effect to respect React purity rules
useEffect(() => {
if (isLoading && startTime.current === 0) {
startTime.current = Date.now();
}
}, [isLoading]);
useEffect(() => {
let timeout: NodeJS.Timeout;
if (!isLoading && showLoading) {
const elapsed = startTime.current > 0 ? Date.now() - startTime.current : 0;
const remaining = Math.max(0, minDurationMs - elapsed);
timeout = setTimeout(() => {
setShowLoading(false);
startTime.current = 0;
}, remaining);
}
return () => {
if (timeout) clearTimeout(timeout);
};
}, [isLoading, showLoading, minDurationMs]);
return isLoading || showLoading;
}
+25
View File
@@ -47,6 +47,14 @@ import type {
AcpChatRespondPermissionPayload,
} from '@shared/acp-chat/types';
import type { CronJobCreateInput, CronJobUpdateInput } from '@shared/types/cron';
import type {
TalkAcknowledgeMarkPayload,
TalkAppendAudioPayload,
TalkRelayIdPayload,
TalkStartAgentConsultPayload,
TalkStartRelayPayload,
TalkSubmitToolResultPayload,
} from '@shared/talk/types';
import { invokeHost } from './host-api-client';
export type {
@@ -102,6 +110,13 @@ export type {
WorkspaceNativeFileResult,
WorkspaceOpenHandlersResult,
} from '@shared/host-api/contract';
export type {
TalkAgentConsultResult,
TalkCatalog,
TalkOperationResult,
TalkRelayEvent,
TalkRelaySession,
} from '@shared/talk/types';
export const hostApi = {
app: {
@@ -172,6 +187,16 @@ export const hostApi = {
invokeHost('gateway', 'rpc', { method, params, timeoutMs }) as Promise<T>
),
},
talk: {
catalog: () => invokeHost('talk', 'catalog'),
startRelay: (input: TalkStartRelayPayload) => invokeHost('talk', 'startRelay', input),
appendAudio: (input: TalkAppendAudioPayload) => invokeHost('talk', 'appendAudio', input),
cancelOutput: (input: TalkRelayIdPayload) => invokeHost('talk', 'cancelOutput', input),
submitToolResult: (input: TalkSubmitToolResultPayload) => invokeHost('talk', 'submitToolResult', input),
acknowledgeMark: (input: TalkAcknowledgeMarkPayload) => invokeHost('talk', 'acknowledgeMark', input),
stopRelay: (input: TalkRelayIdPayload) => invokeHost('talk', 'stopRelay', input),
startAgentConsult: (input: TalkStartAgentConsultPayload) => invokeHost('talk', 'startAgentConsult', input),
},
logs: {
recent: (tailLines = 100) => invokeHost('logs', 'recent', { tailLines }),
dir: () => invokeHost('logs', 'dir'),
+6
View File
@@ -40,6 +40,11 @@ const onChatEvent = <E extends HostEventName<'chat'>>(
handler: HostEventHandler<'chat', E>,
) => onIpc(HOST_EVENT_CHANNELS.chat[event], handler);
const onTalkEvent = <E extends HostEventName<'talk'>>(
event: E,
handler: HostEventHandler<'talk', E>,
) => onIpc(HOST_EVENT_CHANNELS.talk[event], handler);
const onOAuthEvent = <E extends HostEventName<'oauth'>>(
event: E,
handler: HostEventHandler<'oauth', E>,
@@ -98,6 +103,7 @@ export const hostEvents = {
onAcpPermissionRequest: (handler: HostEventHandler<'chat', 'acpPermissionRequest'>) => (
onChatEvent('acpPermissionRequest', handler)
),
onTalkEvent: (handler: HostEventHandler<'talk', 'event'>) => onTalkEvent('event', handler),
onOAuthCode: (handler: HostEventHandler<'oauth', 'code'>) => onOAuthEvent('code', handler),
onOAuthSuccess: (handler: HostEventHandler<'oauth', 'success'>) => onOAuthEvent('success', handler),
onOAuthError: (handler: HostEventHandler<'oauth', 'error'>) => onOAuthEvent('error', handler),
+52
View File
@@ -0,0 +1,52 @@
export type MicrophoneBridge = {
stop: () => void;
};
const processorSource = `
class ClawXTalkMicProcessor extends AudioWorkletProcessor {
process(inputs) {
const samples = inputs[0] && inputs[0][0];
if (samples) {
const copy = samples.slice();
this.port.postMessage(copy, [copy.buffer]);
}
return true;
}
}
registerProcessor('clawx-talk-mic', ClawXTalkMicProcessor);
`;
export async function createMicrophoneBridge(
context: AudioContext,
onSamples: (samples: Float32Array) => void,
): Promise<MicrophoneBridge> {
if (!navigator.mediaDevices?.getUserMedia || !context.audioWorklet) {
throw new Error('Realtime microphone audio is not supported');
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const url = URL.createObjectURL(new Blob([processorSource], { type: 'text/javascript' }));
try {
await context.audioWorklet.addModule(url);
const source = context.createMediaStreamSource(stream);
const worklet = new AudioWorkletNode(context, 'clawx-talk-mic');
const silentOutput = context.createGain();
silentOutput.gain.value = 0;
worklet.port.onmessage = (event: MessageEvent<Float32Array>) => onSamples(event.data);
source.connect(worklet);
worklet.connect(silentOutput);
silentOutput.connect(context.destination);
return {
stop: () => {
source.disconnect();
worklet.disconnect();
silentOutput.disconnect();
for (const track of stream.getTracks()) track.stop();
},
};
} catch (error) {
for (const track of stream.getTracks()) track.stop();
throw error;
} finally {
URL.revokeObjectURL(url);
}
}
+118
View File
@@ -0,0 +1,118 @@
export function float32ToPcm16(samples: Float32Array): Int16Array {
const pcm = new Int16Array(samples.length);
for (let index = 0; index < samples.length; index += 1) {
const sample = Math.max(-1, Math.min(1, samples[index] ?? 0));
pcm[index] = Math.trunc(sample < 0 ? sample * 0x8000 : sample * 0x7fff);
}
return pcm;
}
function pcm16ToBytes(samples: Int16Array): Uint8Array {
const bytes = new Uint8Array(samples.length * 2);
const view = new DataView(bytes.buffer);
for (let index = 0; index < samples.length; index += 1) {
view.setInt16(index * 2, samples[index] ?? 0, true);
}
return bytes;
}
export function pcm16ToBase64(samples: Int16Array): string {
const bytes = pcm16ToBytes(samples);
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
export function decodePcm16Base64(audioBase64: string): Int16Array {
if (
typeof audioBase64 !== 'string'
|| !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(audioBase64)
) throw new Error('Invalid PCM16 base64 audio');
let binary: string;
try {
binary = atob(audioBase64);
} catch {
throw new Error('Invalid PCM16 base64 audio');
}
if (binary.length === 0 || binary.length % 2 !== 0) throw new Error('Invalid PCM16 byte length');
const pcm = new Int16Array(binary.length / 2);
for (let index = 0; index < pcm.length; index += 1) {
const low = binary.charCodeAt(index * 2);
const high = binary.charCodeAt(index * 2 + 1);
pcm[index] = (high << 8) | low;
}
return pcm;
}
export function pcm16ToFloat32(samples: Int16Array): Float32Array {
const output = new Float32Array(samples.length);
for (let index = 0; index < samples.length; index += 1) output[index] = (samples[index] ?? 0) / 0x8000;
return output;
}
export function inputLevel(samples: Float32Array): number {
if (samples.length === 0) return 0;
let sum = 0;
for (const sample of samples) sum += sample * sample;
return Math.min(1, Math.sqrt(sum / samples.length));
}
export function resampleFloat32(
samples: Float32Array,
sourceSampleRateHz: number,
targetSampleRateHz: number,
): Float32Array {
if (
!Number.isInteger(sourceSampleRateHz)
|| !Number.isInteger(targetSampleRateHz)
|| sourceSampleRateHz < 8_000
|| sourceSampleRateHz > 192_000
|| targetSampleRateHz < 8_000
|| targetSampleRateHz > 192_000
) throw new Error('Unsupported Talk audio sample rate');
if (sourceSampleRateHz === targetSampleRateHz) return samples;
const length = Math.max(1, Math.round(samples.length * targetSampleRateHz / sourceSampleRateHz));
const output = new Float32Array(length);
for (let index = 0; index < length; index += 1) {
const position = index * sourceSampleRateHz / targetSampleRateHz;
const before = Math.floor(position);
const after = Math.min(before + 1, samples.length - 1);
const fraction = position - before;
output[index] = (samples[before] ?? 0) * (1 - fraction) + (samples[after] ?? 0) * fraction;
}
return output;
}
export class Pcm16PlaybackQueue {
private nextStartAt = 0;
private sources = new Set<AudioBufferSourceNode>();
constructor(private readonly context: AudioContext, private readonly maxQueuedItems = 24) {}
enqueue(samples: Int16Array, sampleRate: number): Promise<void> {
if (this.sources.size >= this.maxQueuedItems) return Promise.reject(new Error('Talk playback queue is full'));
const buffer = this.context.createBuffer(1, samples.length, sampleRate);
buffer.copyToChannel(new Float32Array(pcm16ToFloat32(samples)), 0);
const source = this.context.createBufferSource();
source.buffer = buffer;
source.connect(this.context.destination);
const startAt = Math.max(this.context.currentTime, this.nextStartAt);
this.nextStartAt = startAt + buffer.duration;
this.sources.add(source);
return new Promise((resolve) => {
source.onended = () => {
this.sources.delete(source);
resolve();
};
source.start(startAt);
});
}
clear(): void {
for (const source of this.sources) source.stop();
this.sources.clear();
this.nextStartAt = this.context.currentTime;
}
}
+419
View File
@@ -0,0 +1,419 @@
import type { TalkRelayEvent, TalkRelaySession } from '@shared/talk/types';
import { DEFAULT_SESSION_KEY, type ChatSession } from '@shared/chat/types';
import type { GatewayStatus } from '@shared/types/gateway';
import { hostApi } from '@/lib/host-api';
import { hostEvents } from '@/lib/host-events';
import { useAcpChatSessionStore } from '@/stores/acp-chat-session';
import { useChatStore } from '@/stores/chat';
import { isOpenClawHeartbeatOnlySession } from '@/stores/chat/session-key-utils';
import {
registerRealtimeTalkCleanup,
useRealtimeTalkStore,
} from '@/stores/realtime-talk';
import {
decodePcm16Base64,
float32ToPcm16,
inputLevel,
Pcm16PlaybackQueue,
pcm16ToBase64,
resampleFloat32,
} from './audio';
import { createMicrophoneBridge, type MicrophoneBridge } from './audio-worklet';
type TalkApi = Pick<typeof hostApi.talk,
'startRelay' | 'appendAudio' | 'cancelOutput' | 'acknowledgeMark' | 'stopRelay' | 'startAgentConsult' | 'submitToolResult'>;
type TalkAudioTransport = {
start: (onSamples: (samples: Float32Array, sampleRateHz: number) => void) => Promise<void>;
stop: () => void;
enqueueOutput: (samples: Int16Array, sampleRate: number) => Promise<void>;
clearOutput: () => void;
};
type ControllerDependencies = {
talk: TalkApi;
subscribeTalk: (listener: (event: TalkRelayEvent) => void) => () => void;
subscribeGatewayStatus: (listener: (status: GatewayStatus) => void) => () => void;
createAudio: () => TalkAudioTransport;
getAcpState: () => { sending: boolean; activeSessionKey: string | null };
/** Test seam; production derives heartbeat status from the selected Chat session. */
isHeartbeatSession?: (sessionKey: string) => boolean;
reloadAcpSession: () => Promise<boolean>;
};
type StartInput = { sessionKey: string; heartbeatOnly?: boolean };
type StartAttempt = { controller: RealtimeTalkController; id: number };
let nextStartAttemptId = 0;
let currentStartAttempt: StartAttempt | null = null;
function isSupportedSampleRate(sampleRateHz: number): boolean {
return Number.isInteger(sampleRateHz) && sampleRateHz >= 8_000 && sampleRateHz <= 192_000;
}
function isSupportedAudio(session: TalkRelaySession): boolean {
const { audio } = session;
return audio.inputEncoding === 'pcm16'
&& audio.outputEncoding === 'pcm16'
&& isSupportedSampleRate(audio.inputSampleRateHz)
&& isSupportedSampleRate(audio.outputSampleRateHz);
}
class BrowserTalkAudioTransport implements TalkAudioTransport {
private context: AudioContext | null = null;
private microphone: MicrophoneBridge | null = null;
private playback: Pcm16PlaybackQueue | null = null;
async start(onSamples: (samples: Float32Array, sampleRateHz: number) => void): Promise<void> {
const AudioContextConstructor = window.AudioContext;
if (!AudioContextConstructor) throw new Error('Realtime audio is not supported');
this.context = new AudioContextConstructor();
this.playback = new Pcm16PlaybackQueue(this.context);
this.microphone = await createMicrophoneBridge(this.context, (samples) => {
onSamples(samples, this.context?.sampleRate ?? 0);
});
}
stop(): void {
this.microphone?.stop();
this.microphone = null;
this.playback?.clear();
this.playback = null;
void this.context?.close();
this.context = null;
}
enqueueOutput(samples: Int16Array, sampleRate: number): Promise<void> {
if (!this.playback) return Promise.reject(new Error('Talk playback is unavailable'));
return this.playback.enqueue(samples, sampleRate);
}
clearOutput(): void {
this.playback?.clear();
}
}
export class RealtimeTalkController {
private relaySessionId: string | null = null;
private sessionKey: string | null = null;
private unsubscribeTalk: (() => void) | null = null;
private unsubscribeGateway: (() => void) | null = null;
private unregisterCleanup: (() => void) | null = null;
private cleanupAttempt: StartAttempt | null = null;
private audio: TalkAudioTransport | null = null;
private appendQueue = Promise.resolve();
private queuedAppendCount = 0;
private inputSampleRateHz = 24_000;
private outputSampleRateHz = 24_000;
private outputPlayback = Promise.resolve();
private outputTurnActive = false;
private outputTurnGeneration = 0;
private outputCancelled = false;
private consultCallIds = new Set<string>();
constructor(private readonly dependencies: ControllerDependencies) {}
async start(input: StartInput): Promise<boolean> {
const sessionKey = input.sessionKey.trim();
const acp = this.dependencies.getAcpState();
const heartbeat = input.heartbeatOnly || this.dependencies.isHeartbeatSession?.(sessionKey) || false;
if (
useRealtimeTalkStore.getState().isActive
|| !sessionKey
|| sessionKey === DEFAULT_SESSION_KEY
|| sessionKey !== acp.activeSessionKey
|| heartbeat
) {
useRealtimeTalkStore.getState().finish('error', 'Talk requires the selected non-heartbeat session');
return false;
}
if (acp.sending) {
useRealtimeTalkStore.getState().finish('error', 'An ACP prompt is already in progress');
return false;
}
const attempt: StartAttempt = { controller: this, id: nextStartAttemptId += 1 };
this.clearCleanupAttempt();
this.unregisterCleanup = registerRealtimeTalkCleanup(() => this.stop());
this.cleanupAttempt = attempt;
currentStartAttempt = attempt;
useRealtimeTalkStore.getState().setStatus('connecting');
let relay: TalkRelaySession | null = null;
let audio: TalkAudioTransport | null = null;
let unsubscribeTalk: (() => void) | null = null;
let unsubscribeGateway: (() => void) | null = null;
try {
relay = await this.dependencies.talk.startRelay({ sessionKey });
if (!this.isCurrentAttempt(attempt)) {
await this.closeRelay(relay.relaySessionId);
this.clearCleanupAttempt(attempt);
return false;
}
if (!isSupportedAudio(relay)) throw new Error('Unsupported Talk audio contract');
audio = this.dependencies.createAudio();
unsubscribeTalk = this.dependencies.subscribeTalk((event) => this.handleEvent(event));
unsubscribeGateway = this.dependencies.subscribeGatewayStatus((status) => {
if (status.state !== 'running' || status.gatewayReady === false) void this.handleGatewayDisconnect();
});
await audio.start((samples, sampleRateHz) => this.handleInput(samples, sampleRateHz));
if (!this.isCurrentAttempt(attempt)) {
unsubscribeTalk();
unsubscribeGateway();
audio.stop();
await this.closeRelay(relay.relaySessionId);
this.clearCleanupAttempt(attempt);
return false;
}
this.relaySessionId = relay.relaySessionId;
this.sessionKey = sessionKey;
this.inputSampleRateHz = relay.audio.inputSampleRateHz;
this.outputSampleRateHz = relay.audio.outputSampleRateHz;
this.audio = audio;
this.unsubscribeTalk = unsubscribeTalk;
this.unsubscribeGateway = unsubscribeGateway;
currentStartAttempt = null;
useRealtimeTalkStore.getState().begin(relay.relaySessionId, sessionKey);
return true;
} catch (error) {
unsubscribeTalk?.();
unsubscribeGateway?.();
audio?.stop();
if (relay) await this.closeRelay(relay.relaySessionId);
if (this.isCurrentAttempt(attempt)) {
currentStartAttempt = null;
this.clearCleanupAttempt(attempt);
useRealtimeTalkStore.getState().finish('error', error instanceof Error ? error.message : 'Talk could not start');
} else {
this.clearCleanupAttempt(attempt);
}
return false;
}
}
async stop(): Promise<void> {
await this.cleanup('cancelled');
}
async handleSessionChange(sessionKey: string): Promise<void> {
if (this.sessionKey && this.sessionKey !== sessionKey) await this.cleanup('cancelled');
}
async handleGatewayDisconnect(): Promise<void> {
await this.cleanup('disconnected');
}
private isCurrentAttempt(attempt: StartAttempt): boolean {
return currentStartAttempt?.controller === attempt.controller && currentStartAttempt.id === attempt.id;
}
private clearCleanupAttempt(attempt?: StartAttempt): void {
if (attempt && this.cleanupAttempt !== attempt) return;
this.unregisterCleanup?.();
this.unregisterCleanup = null;
this.cleanupAttempt = null;
}
private handleInput(samples: Float32Array, captureSampleRateHz: number): void {
const relaySessionId = this.relaySessionId;
if (!relaySessionId) return;
const level = inputLevel(samples);
useRealtimeTalkStore.getState().setInputLevel(level);
if (this.outputTurnActive && !this.outputCancelled && level >= 0.05) {
this.outputCancelled = true;
this.clearOutputTurn();
void this.dependencies.talk.cancelOutput({ relaySessionId }).catch(() => undefined);
}
if (this.queuedAppendCount >= 4) return;
let resampled: Float32Array;
try {
resampled = resampleFloat32(samples, captureSampleRateHz, this.inputSampleRateHz);
} catch (error) {
void this.cleanup('error', error instanceof Error ? error.message : 'Unsupported Talk audio sample rate');
return;
}
this.queuedAppendCount += 1;
const audioBase64 = pcm16ToBase64(float32ToPcm16(resampled));
this.appendQueue = this.appendQueue
.then(async () => {
if (this.relaySessionId !== relaySessionId) return;
await this.dependencies.talk.appendAudio({ relaySessionId, audioBase64, timestamp: Date.now() });
})
.catch(() => undefined)
.finally(() => { this.queuedAppendCount -= 1; });
}
private handleEvent(event: TalkRelayEvent): void {
if (event.relaySessionId !== this.relaySessionId) return;
switch (event.type) {
case 'ready':
useRealtimeTalkStore.getState().setStatus('listening');
break;
case 'transcript':
useRealtimeTalkStore.getState().appendTranscript(event);
break;
case 'audio':
this.playOutput(event.audioBase64);
break;
case 'clear':
this.clearOutputTurn();
useRealtimeTalkStore.getState().setStatus('listening');
break;
case 'mark':
this.completeOutputTurn(event.markName);
break;
case 'toolCall':
void this.startAgentConsult(event);
break;
case 'toolResult':
if (event.final && this.consultCallIds.delete(event.callId)) void this.dependencies.reloadAcpSession();
break;
case 'toolCallCancelled':
this.consultCallIds.delete(event.callId);
break;
case 'error':
void this.cleanup('error', event.message);
break;
case 'close':
void this.cleanup(event.reason, event.reason === 'error' ? 'Talk relay closed with an error' : undefined);
break;
}
}
private playOutput(audioBase64: string): void {
const relaySessionId = this.relaySessionId;
if (!relaySessionId || !this.audio) return;
let samples: Int16Array;
try {
samples = decodePcm16Base64(audioBase64);
} catch (error) {
void this.cleanup('error', error instanceof Error ? error.message : 'Invalid Talk audio');
return;
}
if (!this.outputTurnActive) {
this.outputTurnActive = true;
this.outputCancelled = false;
this.outputTurnGeneration += 1;
useRealtimeTalkStore.getState().setStatus('speaking');
}
const generation = this.outputTurnGeneration;
this.outputPlayback = this.outputPlayback
.catch(() => undefined)
.then(async () => {
if (!this.isCurrentOutputTurn(generation, relaySessionId)) return;
await this.audio?.enqueueOutput(samples, this.outputSampleRateHz);
});
}
private completeOutputTurn(markName: string): void {
const relaySessionId = this.relaySessionId;
const generation = this.outputTurnGeneration;
if (!relaySessionId) return;
if (!this.outputTurnActive) {
void this.outputPlayback
.catch(() => undefined)
.then(() => this.acknowledgeMark(markName));
return;
}
void this.outputPlayback
.catch(() => undefined)
.then(async () => {
if (!this.isCurrentOutputTurn(generation, relaySessionId)) return;
await this.acknowledgeMark(markName);
if (!this.isCurrentOutputTurn(generation, relaySessionId)) return;
this.outputTurnActive = false;
useRealtimeTalkStore.getState().setStatus('listening');
});
}
private isCurrentOutputTurn(generation: number, relaySessionId: string): boolean {
return this.outputTurnActive
&& this.outputTurnGeneration === generation
&& this.relaySessionId === relaySessionId;
}
private clearOutputTurn(): void {
this.outputTurnGeneration += 1;
this.outputTurnActive = false;
this.outputCancelled = false;
this.audio?.clearOutput();
}
private async acknowledgeMark(markName: string): Promise<void> {
const relaySessionId = this.relaySessionId;
if (!relaySessionId) return;
await this.dependencies.talk.acknowledgeMark({ relaySessionId, markName }).catch(() => undefined);
}
private async startAgentConsult(event: Extract<TalkRelayEvent, { type: 'toolCall' }>): Promise<void> {
const relaySessionId = this.relaySessionId;
const sessionKey = this.sessionKey;
if (!relaySessionId || !sessionKey) return;
this.consultCallIds.add(event.callId);
useRealtimeTalkStore.getState().setStatus('thinking');
try {
const result = await this.dependencies.talk.startAgentConsult({ relaySessionId, sessionKey, callId: event.callId, args: event.args });
await this.dependencies.talk.submitToolResult({
relaySessionId,
callId: event.callId,
result,
options: { suppressResponse: true, willContinue: true },
});
} catch (error) {
this.consultCallIds.delete(event.callId);
await this.cleanup('error', error instanceof Error ? error.message : 'Agent consult failed');
}
}
private async cleanup(reason: 'cancelled' | 'completed' | 'error' | 'disconnected' | (string & {}), error?: string): Promise<void> {
if (currentStartAttempt?.controller === this) currentStartAttempt = null;
const relaySessionId = this.relaySessionId;
this.releaseLocalResources();
useRealtimeTalkStore.getState().finish(reason, error);
if (relaySessionId) await this.closeRelay(relaySessionId);
}
private async closeRelay(relaySessionId: string): Promise<void> {
await this.dependencies.talk.stopRelay({ relaySessionId }).catch(() => undefined);
}
private releaseLocalResources(): void {
this.clearCleanupAttempt();
this.unsubscribeTalk?.();
this.unsubscribeTalk = null;
this.unsubscribeGateway?.();
this.unsubscribeGateway = null;
this.clearOutputTurn();
this.audio?.stop();
this.audio = null;
this.relaySessionId = null;
this.sessionKey = null;
this.appendQueue = Promise.resolve();
this.inputSampleRateHz = 24_000;
this.outputSampleRateHz = 24_000;
this.queuedAppendCount = 0;
this.consultCallIds.clear();
}
}
function isSelectedHeartbeatSession(sessionKey: string): boolean {
const session = useChatStore.getState().sessions.find((candidate: ChatSession) => candidate.key === sessionKey);
return !!session && isOpenClawHeartbeatOnlySession(session);
}
export function createRealtimeTalkController(dependencies: ControllerDependencies): RealtimeTalkController {
return new RealtimeTalkController(dependencies);
}
export const realtimeTalkController = createRealtimeTalkController({
talk: hostApi.talk,
subscribeTalk: hostEvents.onTalkEvent,
subscribeGatewayStatus: hostEvents.onGatewayStatus,
createAudio: () => new BrowserTalkAudioTransport(),
getAcpState: () => {
const state = useAcpChatSessionStore.getState();
return { sending: state.sending, activeSessionKey: state.activeSessionKey };
},
isHeartbeatSession: isSelectedHeartbeatSession,
reloadAcpSession: () => useAcpChatSessionStore.getState().reloadActiveSession(),
});
+13
View File
@@ -45,6 +45,7 @@ import { hostApi } from '@/lib/host-api';
import { hostEvents } from '@/lib/host-events';
import type { AcpTimelineSnapshot, MessageSegmentItem, PermissionItem, RenderPart } from '@/lib/acp/timeline-types';
import { isCronSessionKey } from './chat/cron-session-utils';
import { stopActiveRealtimeTalk, useRealtimeTalkStore } from './realtime-talk';
const EMPTY_SESSION_ID = '';
const CANCEL_PERMISSION_OPTION_ID = '__cancelled__';
@@ -147,6 +148,7 @@ export type AcpChatSessionState = {
turnTimingsByUserMessageId: Record<string, AcpTurnTiming>;
prepareLocalSession: (input: AcpChatLoadPayload) => void;
loadSession: (input: AcpChatLoadPayload) => Promise<boolean>;
reloadActiveSession: () => Promise<boolean>;
sendPrompt: (input: AcpChatPromptPayload) => Promise<boolean>;
cancel: () => Promise<void>;
respondPermission: (requestId: string, optionId: string) => Promise<void>;
@@ -1016,6 +1018,8 @@ export const useAcpChatSessionStore = create<AcpChatSessionState>((set, get) =>
turnTimingsByUserMessageId: {},
prepareLocalSession(input) {
void stopActiveRealtimeTalk();
useRealtimeTalkStore.getState().reset();
captureLiveSession(get());
loadRequestSeq += 1;
pendingLoadUpdates.clear();
@@ -1061,6 +1065,8 @@ export const useAcpChatSessionStore = create<AcpChatSessionState>((set, get) =>
timeline: liveSnapshot?.timeline ?? createEmptyAcpTimeline(input.sessionKey, generation),
turnTimingsByUserMessageId: liveSnapshot?.turnTimingsByUserMessageId ?? {},
});
await stopActiveRealtimeTalk();
useRealtimeTalkStore.getState().reset();
try {
let result = await hostApi.chat.loadAcpSession(input);
@@ -1223,11 +1229,18 @@ export const useAcpChatSessionStore = create<AcpChatSessionState>((set, get) =>
}
},
async reloadActiveSession() {
const { activeSessionKey, workspaceRoot, cwd } = get();
if (!activeSessionKey || !workspaceRoot || !cwd) return false;
return await get().loadSession({ sessionKey: activeSessionKey, workspaceRoot, cwd });
},
async sendPrompt(input) {
const startState = get();
const sessionKey = input.sessionKey;
const generation = startState.generation;
if (startState.activeSessionKey !== sessionKey) return false;
if (useRealtimeTalkStore.getState().isActive) return false;
const messageId = input.messageId ?? createOptimisticMessageId();
const payload = { ...input, messageId };
+80
View File
@@ -0,0 +1,80 @@
import { create } from 'zustand';
import type { TalkTerminalReason } from '@shared/talk/types';
let activeRelayCleanup: (() => Promise<void>) | null = null;
// ACP owns reload boundaries, but the controller owns the browser resources it must release.
export function registerRealtimeTalkCleanup(cleanup: () => Promise<void>): () => void {
activeRelayCleanup = cleanup;
return () => {
if (activeRelayCleanup === cleanup) activeRelayCleanup = null;
};
}
export async function stopActiveRealtimeTalk(): Promise<void> {
await activeRelayCleanup?.();
}
export type RealtimeTalkStatus = 'idle' | 'connecting' | 'listening' | 'thinking' | 'speaking' | 'disconnected' | 'error';
export type LiveTalkTranscript = {
role: 'user' | 'assistant';
text: string;
final: boolean;
};
type RealtimeTalkState = {
status: RealtimeTalkStatus;
relaySessionId: string | null;
sessionKey: string | null;
inputLevel: number;
transcripts: LiveTalkTranscript[];
error: string | null;
isActive: boolean;
begin: (relaySessionId: string, sessionKey: string) => void;
setStatus: (status: RealtimeTalkStatus) => void;
setInputLevel: (inputLevel: number) => void;
appendTranscript: (entry: LiveTalkTranscript) => void;
finish: (reason: TalkTerminalReason, error?: string) => void;
reset: () => void;
};
const initialState = {
status: 'idle' as const,
relaySessionId: null,
sessionKey: null,
inputLevel: 0,
transcripts: [] as LiveTalkTranscript[],
error: null,
isActive: false,
};
export const useRealtimeTalkStore = create<RealtimeTalkState>((set) => ({
...initialState,
begin: (relaySessionId, sessionKey) => set({
relaySessionId,
sessionKey,
status: 'listening',
error: null,
inputLevel: 0,
transcripts: [],
isActive: true,
}),
setStatus: (status) => set({ status }),
setInputLevel: (inputLevel) => set({ inputLevel: Math.max(0, Math.min(1, inputLevel)) }),
appendTranscript: (entry) => set((state) => {
const last = state.transcripts.at(-1);
const transcripts = !entry.final && last && last.role === entry.role && !last.final
? [...state.transcripts.slice(0, -1), entry]
: entry.final && last && last.role === entry.role && !last.final
? [...state.transcripts.slice(0, -1), entry]
: [...state.transcripts, entry];
return { transcripts };
}),
finish: (reason, error) => set({
...initialState,
status: error ? 'error' : reason === 'disconnected' ? 'disconnected' : 'idle',
error: error ?? null,
}),
reset: () => set(initialState),
}));
+30
View File
@@ -166,6 +166,36 @@ describe('ACP Chat store', () => {
hostEventsMock.onChatRuntimeEvent.mockClear();
});
it('does not send an ACP prompt while a realtime Talk relay is active', async () => {
const { useAcpChatSessionStore } = await importStore();
const { useRealtimeTalkStore } = await import('@/stores/realtime-talk');
useAcpChatSessionStore.getState().prepareLocalSession({
sessionKey: 'agent:pi:s1', workspaceRoot: '/repo-a', cwd: '/repo-a',
});
useRealtimeTalkStore.getState().begin('relay-1', 'agent:pi:s1');
await expect(useAcpChatSessionStore.getState().sendPrompt({
sessionKey: 'agent:pi:s1', cwd: '/repo-a', message: 'blocked',
})).resolves.toBe(false);
expect(hostApiMock.sendAcpPrompt).not.toHaveBeenCalled();
});
it('stops the active realtime Talk controller before reloading ACP state', async () => {
const { useAcpChatSessionStore } = await importStore();
const { registerRealtimeTalkCleanup, useRealtimeTalkStore } = await import('@/stores/realtime-talk');
const stopRelay = vi.fn().mockResolvedValue(undefined);
const unregister = registerRealtimeTalkCleanup(stopRelay);
useRealtimeTalkStore.getState().begin('relay-1', 'agent:pi:s1');
await useAcpChatSessionStore.getState().loadSession({
sessionKey: 'agent:pi:s1', workspaceRoot: '/repo-a', cwd: '/repo-a',
});
expect(stopRelay).toHaveBeenCalledOnce();
expect(useRealtimeTalkStore.getState().isActive).toBe(false);
unregister();
});
it('projects cron history when ACP replay is empty', async () => {
hostApiMock.cronSessionHistory.mockResolvedValue({
messages: [
@@ -115,10 +115,6 @@ vi.mock('@/hooks/use-stick-to-bottom-instant', () => ({
})),
}));
vi.mock('@/hooks/use-min-loading', () => ({
useMinLoading: () => false,
}));
vi.mock('@/pages/Chat/ChatToolbar', () => ({
ChatToolbar: () => null,
}));
-4
View File
@@ -137,10 +137,6 @@ vi.mock('@/hooks/use-stick-to-bottom-instant', () => ({
}),
}));
vi.mock('@/hooks/use-min-loading', () => ({
useMinLoading: () => false,
}));
vi.mock('@/pages/Chat/ChatToolbar', () => ({
ChatToolbar: () => <div data-testid="mock-chat-toolbar" />,
}));
@@ -71,10 +71,6 @@ vi.mock('@/hooks/use-stick-to-bottom-instant', () => ({
}),
}));
vi.mock('@/hooks/use-min-loading', () => ({
useMinLoading: (value: boolean) => value,
}));
vi.mock('@/pages/Chat/ChatInput', () => ({
ChatInput: () => <div data-testid="chat-input" />,
}));
@@ -97,10 +97,6 @@ vi.mock('@/hooks/use-stick-to-bottom-instant', () => ({
})),
}));
vi.mock('@/hooks/use-min-loading', () => ({
useMinLoading: () => false,
}));
vi.mock('@/pages/Chat/ChatToolbar', () => ({ ChatToolbar: () => null }));
vi.mock('@/pages/Chat/ChatInput', () => ({ ChatInput: () => null }));
@@ -102,10 +102,6 @@ vi.mock('@/hooks/use-stick-to-bottom-instant', () => ({
})),
}));
vi.mock('@/hooks/use-min-loading', () => ({
useMinLoading: () => false,
}));
vi.mock('@/pages/Chat/ChatToolbar', () => ({ ChatToolbar: () => null }));
vi.mock('@/pages/Chat/ChatInput', () => ({ ChatInput: () => null }));
@@ -102,10 +102,6 @@ vi.mock('@/hooks/use-stick-to-bottom-instant', () => ({
}),
}));
vi.mock('@/hooks/use-min-loading', () => ({
useMinLoading: () => false,
}));
vi.mock('@/pages/Chat/ChatInput', () => ({
ChatInput: () => null,
}));
@@ -102,10 +102,6 @@ vi.mock('@/hooks/use-stick-to-bottom-instant', () => ({
})),
}));
vi.mock('@/hooks/use-min-loading', () => ({
useMinLoading: () => false,
}));
vi.mock('@/pages/Chat/ChatToolbar', () => ({
ChatToolbar: () => null,
}));
+10
View File
@@ -48,6 +48,16 @@ describe('dispatchProtocolEvent', () => {
expect(emitter.emit).toHaveBeenCalledWith('chat:message', { message: { text: 'hello' } });
});
it('dispatches talk.event to talk:event without the generic notification path', () => {
const emitter = createMockEmitter();
const payload = { relaySessionId: 'relay-1', type: 'transcript', role: 'assistant', text: 'Hello' };
dispatchProtocolEvent(emitter, 'talk.event', payload);
expect(emitter.emit).toHaveBeenCalledWith('talk:event', payload);
expect(emitter.emit).not.toHaveBeenCalledWith('notification', expect.anything());
});
it('does not normalize non-terminal lifecycle phase=end as run.ended', () => {
const emitter = createMockEmitter();
const payload = {
+30
View File
@@ -472,6 +472,36 @@ describe('hostApi facade', () => {
}));
});
it('routes Talk actions through the typed host facade', async () => {
hostInvoke.mockResolvedValue({ id: 'req', ok: true, data: { ok: true } });
const { hostApi } = await import('@/lib/host-api');
await hostApi.talk.catalog();
await hostApi.talk.startRelay({ sessionKey: 'agent:main:session-1' });
await hostApi.talk.appendAudio({ relaySessionId: 'relay-1', audioBase64: 'AQI=' });
await hostApi.talk.cancelOutput({ relaySessionId: 'relay-1' });
await hostApi.talk.submitToolResult({ relaySessionId: 'relay-1', callId: 'call-1', result: { ok: true } });
await hostApi.talk.acknowledgeMark({ relaySessionId: 'relay-1', markName: 'mark-1' });
await hostApi.talk.stopRelay({ relaySessionId: 'relay-1' });
await hostApi.talk.startAgentConsult({
relaySessionId: 'relay-1',
sessionKey: 'agent:main:session-1',
callId: 'call-1',
args: {},
});
expect(hostInvoke.mock.calls.map(([request]) => request)).toEqual(expect.arrayContaining([
expect.objectContaining({ module: 'talk', action: 'catalog' }),
expect.objectContaining({ module: 'talk', action: 'startRelay', payload: { sessionKey: 'agent:main:session-1' } }),
expect.objectContaining({ module: 'talk', action: 'appendAudio', payload: { relaySessionId: 'relay-1', audioBase64: 'AQI=' } }),
expect.objectContaining({ module: 'talk', action: 'cancelOutput' }),
expect.objectContaining({ module: 'talk', action: 'submitToolResult' }),
expect.objectContaining({ module: 'talk', action: 'acknowledgeMark' }),
expect.objectContaining({ module: 'talk', action: 'stopRelay' }),
expect.objectContaining({ module: 'talk', action: 'startAgentConsult' }),
]));
});
it('calls sessions.summaries through hostInvoke', async () => {
hostInvoke.mockResolvedValueOnce({ id: 'req', ok: true, data: { success: true, summaries: [] } });
const { hostApi } = await import('@/lib/host-api');
+12
View File
@@ -74,6 +74,18 @@ describe('hostEvents', () => {
expect(on).toHaveBeenCalledWith('channel:wechat-qr', expect.any(Function));
});
it('subscribes to Talk relay events over the dedicated IPC channel', async () => {
const { hostEvents } = await import('@/lib/host-events');
const handler = vi.fn();
hostEvents.onTalkEvent(handler);
const callback = on.mock.calls[0]?.[1] as ((payload: unknown) => void) | undefined;
callback?.({ relaySessionId: 'relay-1', type: 'ready' });
expect(on).toHaveBeenCalledWith('talk:event', expect.any(Function));
expect(handler).toHaveBeenCalledWith({ relaySessionId: 'relay-1', type: 'ready' });
});
it('does not create EventSource fallback', async () => {
const eventSource = vi.fn();
vi.stubGlobal('EventSource', eventSource);
+34
View File
@@ -1278,6 +1278,40 @@ describe('host services', () => {
]);
});
it('registers the typed Talk service with Main-owned relay ownership and no legacy direct IPC channel', () => {
const source = readFileSync(join(process.cwd(), 'electron/main/ipc-handlers.ts'), 'utf8');
const mainSource = readFileSync(join(process.cwd(), 'electron/main/index.ts'), 'utf8');
const registerIpcHandlersSource = source.slice(
source.indexOf('export function registerIpcHandlers('),
source.indexOf('function registerTypedHostHandlers('),
);
expect(source).toContain('const talkRelayOwnership = createTalkRelayOwnership();');
expect(source).toContain('talk: createTalkApi(gatewayManager, talkRelayOwnership)');
expect(source).not.toMatch(/ipcMain\.handle\(\s*['"]talk:/);
expect(mainSource).toContain('forwardActiveTalkEvent(talkRelayOwnership, data');
const returnIndex = registerIpcHandlersSource.indexOf('return talkRelayOwnership;');
expect(returnIndex).toBeGreaterThan(-1);
[
'registerGatewayHandlers(gatewayManager);',
'registerOpenClawHandlers();',
'registerProviderHandlers(gatewayManager);',
'registerShellHandlers();',
'registerDialogHandlers();',
'registerAppHandlers();',
'registerSettingsHandlers(gatewayManager);',
'registerUsageHandlers();',
'registerCronHandlers(gatewayManager);',
'registerWindowHandlers(mainWindow);',
'registerWhatsAppHandlers(mainWindow);',
'registerFilePreviewHandlers();',
].forEach((registration) => {
const registrationIndex = registerIpcHandlersSource.indexOf(registration);
expect(registrationIndex).toBeGreaterThan(-1);
expect(registrationIndex).toBeLessThan(returnIndex);
});
});
it('loads session summaries and transcript history through the typed sessions service', async () => {
const sessionsDir = join(testOpenClawConfigDir, 'agents', 'main', 'sessions');
mkdirSync(sessionsDir, { recursive: true });
+250
View File
@@ -0,0 +1,250 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { TalkRelayEvent, TalkRelaySession } from '@shared/talk/types';
import { createRealtimeTalkController } from '@/lib/talk/realtime-talk-controller';
import { stopActiveRealtimeTalk, useRealtimeTalkStore } from '@/stores/realtime-talk';
const relay: TalkRelaySession = {
relaySessionId: 'relay-1',
provider: 'openai',
transport: 'gateway-relay',
audio: {
inputEncoding: 'pcm16',
inputSampleRateHz: 24_000,
outputEncoding: 'pcm16',
outputSampleRateHz: 24_000,
},
};
function setup(overrides: Partial<{
sending: boolean;
activeSessionKey: string | null;
heartbeatOnly: boolean;
startAudio: () => Promise<void>;
relay: TalkRelaySession;
}> = {}) {
let eventListener: ((event: TalkRelayEvent) => void) | null = null;
let gatewayStatusListener: ((status: { state: string; gatewayReady?: boolean }) => void) | null = null;
let activeSessionKey = overrides.activeSessionKey ?? 'agent:main:session-1';
const talk = {
startRelay: vi.fn().mockResolvedValue(overrides.relay ?? relay),
appendAudio: vi.fn().mockResolvedValue({ ok: true }),
cancelOutput: vi.fn().mockResolvedValue({ ok: true }),
acknowledgeMark: vi.fn().mockResolvedValue({ ok: true }),
stopRelay: vi.fn().mockResolvedValue({ ok: true }),
startAgentConsult: vi.fn().mockResolvedValue({ runId: 'run-1' }),
submitToolResult: vi.fn().mockResolvedValue({ ok: true }),
};
const audio = {
start: vi.fn(overrides.startAudio ?? (async () => {})),
stop: vi.fn(),
enqueueOutput: vi.fn().mockResolvedValue(undefined),
clearOutput: vi.fn(),
};
const unsubscribe = vi.fn();
const reload = vi.fn().mockResolvedValue(true);
const controller = createRealtimeTalkController({
talk,
subscribeTalk: (listener) => {
eventListener = listener;
return unsubscribe;
},
subscribeGatewayStatus: (listener) => {
gatewayStatusListener = listener;
return vi.fn();
},
createAudio: () => audio,
getAcpState: () => ({
sending: overrides.sending ?? false,
activeSessionKey,
}),
isHeartbeatSession: () => overrides.heartbeatOnly ?? false,
reloadAcpSession: reload,
});
return {
audio,
controller,
emit: (event: TalkRelayEvent) => eventListener?.(event),
emitGatewayStatus: (status: { state: string; gatewayReady?: boolean }) => gatewayStatusListener?.(status),
reload,
setActiveSessionKey: (sessionKey: string | null) => { activeSessionKey = sessionKey; },
talk,
unsubscribe,
};
}
describe('realtime Talk controller', () => {
beforeEach(() => {
useRealtimeTalkStore.getState().reset();
});
it('starts one selected relay and serializes local PCM input through the typed Talk API', async () => {
const { audio, controller, talk } = setup();
await expect(controller.start({ sessionKey: 'agent:main:session-1' })).resolves.toBe(true);
audio.start.mock.calls[0]?.[0](new Float32Array([0, 1, -1, 0]), 48_000);
await vi.waitFor(() => expect(talk.appendAudio).toHaveBeenCalledWith(expect.objectContaining({
relaySessionId: 'relay-1',
audioBase64: 'AAAAgA==',
})));
expect(useRealtimeTalkStore.getState()).toMatchObject({
status: 'listening',
relaySessionId: 'relay-1',
sessionKey: 'agent:main:session-1',
});
});
it('rejects stale relay events and clears direct transcript state on terminal paths', async () => {
const { controller, emit, talk } = setup();
await controller.start({ sessionKey: 'agent:main:session-1' });
emit({ relaySessionId: 'stale', type: 'transcript', role: 'assistant', text: 'ignore', final: true });
emit({ relaySessionId: 'relay-1', type: 'transcript', role: 'assistant', text: 'live', final: true });
expect(useRealtimeTalkStore.getState().transcripts).toHaveLength(1);
emit({ relaySessionId: 'relay-1', type: 'close', reason: 'completed' });
await vi.waitFor(() => expect(talk.stopRelay).toHaveBeenCalledWith({ relaySessionId: 'relay-1' }));
expect(useRealtimeTalkStore.getState().transcripts).toEqual([]);
});
it('cleans tracks, events, and relay ownership on session switch and gateway disconnect', async () => {
const { audio, controller, emitGatewayStatus, setActiveSessionKey, talk, unsubscribe } = setup();
await controller.start({ sessionKey: 'agent:main:session-1' });
await controller.handleSessionChange('agent:main:session-2');
expect(audio.stop).toHaveBeenCalledOnce();
expect(unsubscribe).toHaveBeenCalledOnce();
expect(talk.stopRelay).toHaveBeenCalledWith({ relaySessionId: 'relay-1' });
setActiveSessionKey('agent:main:session-2');
await controller.start({ sessionKey: 'agent:main:session-2' });
emitGatewayStatus({ state: 'stopped' });
await vi.waitFor(() => expect(talk.stopRelay).toHaveBeenCalledTimes(2));
expect(useRealtimeTalkStore.getState().status).toBe('disconnected');
});
it('keeps only the latest overlapping start and closes superseded relays before they can own audio', async () => {
let resolveFirst!: (value: TalkRelaySession) => void;
let resolveSecond!: (value: TalkRelaySession) => void;
const firstRelay = new Promise<TalkRelaySession>((resolve) => { resolveFirst = resolve; });
const secondRelay = new Promise<TalkRelaySession>((resolve) => { resolveSecond = resolve; });
const { audio, controller, talk } = setup();
talk.startRelay.mockReturnValueOnce(firstRelay).mockReturnValueOnce(secondRelay);
const first = controller.start({ sessionKey: 'agent:main:session-1' });
const second = controller.start({ sessionKey: 'agent:main:session-1' });
resolveFirst({ ...relay, relaySessionId: 'relay-old' });
await vi.waitFor(() => expect(talk.stopRelay).toHaveBeenCalledWith({ relaySessionId: 'relay-old' }));
resolveSecond({ ...relay, relaySessionId: 'relay-new' });
await expect(first).resolves.toBe(false);
await expect(second).resolves.toBe(true);
expect(audio.start).toHaveBeenCalledOnce();
expect(useRealtimeTalkStore.getState().relaySessionId).toBe('relay-new');
});
it('invalidates a pending start when ACP lifecycle cleanup runs before relay creation resolves', async () => {
let resolveRelay!: (value: TalkRelaySession) => void;
const pendingRelay = new Promise<TalkRelaySession>((resolve) => { resolveRelay = resolve; });
const { audio, controller, talk } = setup();
talk.startRelay.mockReturnValueOnce(pendingRelay);
const starting = controller.start({ sessionKey: 'agent:main:session-1' });
await stopActiveRealtimeTalk();
resolveRelay(relay);
await expect(starting).resolves.toBe(false);
expect(talk.stopRelay).toHaveBeenCalledWith({ relaySessionId: 'relay-1' });
expect(audio.start).not.toHaveBeenCalled();
});
it('cancels once for barge-in and acknowledges marks after queued playback completes', async () => {
let finishPlayback!: () => void;
const playback = new Promise<void>((resolve) => { finishPlayback = resolve; });
const { audio, controller, emit, talk } = setup();
audio.enqueueOutput.mockReturnValue(playback);
await controller.start({ sessionKey: 'agent:main:session-1' });
emit({ relaySessionId: 'relay-1', type: 'audio', audioBase64: 'AAD/fw==' });
await vi.waitFor(() => expect(audio.enqueueOutput).toHaveBeenCalledOnce());
audio.start.mock.calls[0]?.[0](new Float32Array([0.2]), 24_000);
audio.start.mock.calls[0]?.[0](new Float32Array([0.3]), 24_000);
expect(talk.cancelOutput).toHaveBeenCalledOnce();
emit({ relaySessionId: 'relay-1', type: 'mark', markName: 'end-turn' });
expect(talk.acknowledgeMark).not.toHaveBeenCalled();
finishPlayback();
await vi.waitFor(() => expect(talk.acknowledgeMark).toHaveBeenCalledWith({
relaySessionId: 'relay-1', markName: 'end-turn',
}));
});
it('drops queued output continuations after local barge-in', async () => {
let finishFirst!: () => void;
const firstPlayback = new Promise<void>((resolve) => { finishFirst = resolve; });
const { audio, controller, emit, talk } = setup();
audio.enqueueOutput.mockReturnValueOnce(firstPlayback).mockResolvedValue(undefined);
await controller.start({ sessionKey: 'agent:main:session-1' });
emit({ relaySessionId: 'relay-1', type: 'audio', audioBase64: 'AAD/fw==' });
emit({ relaySessionId: 'relay-1', type: 'audio', audioBase64: 'AAD/fw==' });
await vi.waitFor(() => expect(audio.enqueueOutput).toHaveBeenCalledOnce());
audio.start.mock.calls[0]?.[0](new Float32Array([0.2]), 24_000);
expect(talk.cancelOutput).toHaveBeenCalledOnce();
finishFirst();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(audio.enqueueOutput).toHaveBeenCalledOnce();
});
it('fails safely for a pending ACP prompt, microphone denial, and unsupported audio', async () => {
const pending = setup({ sending: true });
await expect(pending.controller.start({ sessionKey: 'agent:main:session-1' })).resolves.toBe(false);
expect(pending.talk.startRelay).not.toHaveBeenCalled();
const denied = setup({ startAudio: async () => { throw new Error('Permission denied'); } });
await expect(denied.controller.start({ sessionKey: 'agent:main:session-1' })).resolves.toBe(false);
expect(denied.talk.stopRelay).toHaveBeenCalledWith({ relaySessionId: 'relay-1' });
const unsupported = setup({ relay: { ...relay, audio: { ...relay.audio, inputEncoding: 'pcm16', outputEncoding: 'pcm16', inputSampleRateHz: 0 } } });
await expect(unsupported.controller.start({ sessionKey: 'agent:main:session-1' })).resolves.toBe(false);
expect(unsupported.talk.stopRelay).toHaveBeenCalledWith({ relaySessionId: 'relay-1' });
});
it('requires the ACP-selected non-heartbeat session and never accepts the default fallback', async () => {
const arbitrary = setup({ activeSessionKey: 'agent:main:selected' });
await expect(arbitrary.controller.start({ sessionKey: 'agent:main:other' })).resolves.toBe(false);
expect(arbitrary.talk.startRelay).not.toHaveBeenCalled();
const heartbeat = setup({ heartbeatOnly: true });
await expect(heartbeat.controller.start({ sessionKey: 'agent:main:session-1' })).resolves.toBe(false);
expect(heartbeat.talk.startRelay).not.toHaveBeenCalled();
const fallback = setup({ activeSessionKey: 'agent:main:main' });
await expect(fallback.controller.start({ sessionKey: 'agent:main:main' })).resolves.toBe(false);
expect(fallback.talk.startRelay).not.toHaveBeenCalled();
});
it('maps an error close event to explicit error state without a separate error message', async () => {
const { controller, emit } = setup();
await controller.start({ sessionKey: 'agent:main:session-1' });
emit({ relaySessionId: 'relay-1', type: 'close', reason: 'error' });
await vi.waitFor(() => expect(useRealtimeTalkStore.getState().status).toBe('error'));
expect(useRealtimeTalkStore.getState().error).toBeTruthy();
});
it('starts Agent consults through Talk and reloads the same ACP session only after terminal completion', async () => {
const { controller, emit, reload, talk } = setup();
await controller.start({ sessionKey: 'agent:main:session-1' });
emit({ relaySessionId: 'relay-1', type: 'toolCall', callId: 'call-1', name: 'agent_consult', args: { prompt: 'review' } });
await vi.waitFor(() => expect(talk.startAgentConsult).toHaveBeenCalledWith({
relaySessionId: 'relay-1', sessionKey: 'agent:main:session-1', callId: 'call-1', args: { prompt: 'review' },
}));
expect(reload).not.toHaveBeenCalled();
emit({ relaySessionId: 'relay-1', type: 'toolResult', callId: 'call-1', final: true });
await vi.waitFor(() => expect(reload).toHaveBeenCalledOnce());
});
});
+40
View File
@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useRealtimeTalkStore } from '@/stores/realtime-talk';
describe('realtime Talk store', () => {
beforeEach(() => {
useRealtimeTalkStore.getState().reset();
});
it('keeps direct partial and final transcripts only in ordered live memory', () => {
const store = useRealtimeTalkStore.getState();
store.begin('relay-1', 'agent:main:session-1');
store.appendTranscript({ role: 'user', text: 'hel', final: false });
store.appendTranscript({ role: 'user', text: 'hello', final: true });
store.appendTranscript({ role: 'assistant', text: 'hi', final: true });
expect(useRealtimeTalkStore.getState().transcripts).toEqual([
{ role: 'user', text: 'hello', final: true },
{ role: 'assistant', text: 'hi', final: true },
]);
});
it('clears live transcript state on terminal, session reset, and app reset paths', () => {
const store = useRealtimeTalkStore.getState();
store.begin('relay-1', 'agent:main:session-1');
store.appendTranscript({ role: 'assistant', text: 'temporary', final: true });
store.finish('completed');
expect(useRealtimeTalkStore.getState()).toMatchObject({
status: 'idle',
relaySessionId: null,
transcripts: [],
});
store.begin('relay-2', 'agent:main:session-2');
store.appendTranscript({ role: 'assistant', text: 'temporary again', final: true });
store.reset();
expect(useRealtimeTalkStore.getState().transcripts).toEqual([]);
});
});
+167
View File
@@ -0,0 +1,167 @@
import { describe, expect, it, vi } from 'vitest';
import {
createTalkApi,
createTalkRelayOwnership,
forwardActiveTalkEvent,
} from '@electron/services/talk-api';
const TALK_RPC_TIMEOUT_MS = 8_000;
describe('talk API', () => {
it('uses only the expected Gateway RPCs with normalized relay parameters', async () => {
const gatewayManager = {
rpc: vi.fn()
.mockResolvedValueOnce({ realtime: { ready: true, providers: [] } })
.mockResolvedValueOnce({
relaySessionId: 'relay-1',
provider: 'openai',
transport: 'gateway-relay',
audio: {
inputEncoding: 'pcm16',
inputSampleRateHz: 24000,
outputEncoding: 'pcm16',
outputSampleRateHz: 24000,
},
})
.mockResolvedValue({ ok: true }),
};
const talkApi = createTalkApi(gatewayManager as never);
await expect(talkApi.catalog()).resolves.toEqual({ realtime: { ready: true, providers: [] } });
await expect(talkApi.startRelay({ sessionKey: ' agent:main:session-1 ' })).resolves.toMatchObject({
relaySessionId: 'relay-1',
});
await talkApi.appendAudio({ relaySessionId: ' relay-1 ', audioBase64: 'AQI=', timestamp: 12.8 });
await talkApi.cancelOutput({ relaySessionId: ' relay-1 ' });
await talkApi.submitToolResult({ relaySessionId: ' relay-1 ', callId: ' call-1 ', result: { ok: true } });
await talkApi.acknowledgeMark({ relaySessionId: ' relay-1 ', markName: ' mark-1 ' });
await talkApi.stopRelay({ relaySessionId: ' relay-1 ' });
await expect(talkApi.startAgentConsult({
relaySessionId: ' relay-1 ',
sessionKey: ' agent:main:session-1 ',
callId: ' call-2 ',
args: { prompt: 'hello' },
})).resolves.toEqual({ ok: true });
expect(gatewayManager.rpc.mock.calls).toEqual([
['talk.catalog', {}, TALK_RPC_TIMEOUT_MS],
['talk.session.create', {
sessionKey: 'agent:main:session-1',
mode: 'realtime',
transport: 'gateway-relay',
brain: 'agent-consult',
}, TALK_RPC_TIMEOUT_MS],
['talk.session.appendAudio', { sessionId: 'relay-1', audioBase64: 'AQI=', timestamp: 12 }, TALK_RPC_TIMEOUT_MS],
['talk.session.cancelOutput', { sessionId: 'relay-1' }, TALK_RPC_TIMEOUT_MS],
['talk.session.submitToolResult', { sessionId: 'relay-1', callId: 'call-1', result: { ok: true } }, TALK_RPC_TIMEOUT_MS],
['talk.session.acknowledgeMark', { sessionId: 'relay-1', markName: 'mark-1' }, TALK_RPC_TIMEOUT_MS],
['talk.session.close', { sessionId: 'relay-1' }, TALK_RPC_TIMEOUT_MS],
['talk.client.toolCall', {
relaySessionId: 'relay-1',
sessionKey: 'agent:main:session-1',
callId: 'call-2',
name: 'openclaw_agent_consult',
args: { prompt: 'hello' },
}, TALK_RPC_TIMEOUT_MS],
]);
});
it('rejects malformed relay requests before invoking the Gateway', async () => {
const gatewayManager = { rpc: vi.fn() };
const talkApi = createTalkApi(gatewayManager as never);
await expect(talkApi.startRelay({ sessionKey: ' ' })).rejects.toThrow('Invalid Talk session key');
await expect(talkApi.appendAudio({ relaySessionId: ' ', audioBase64: 'AQI=' })).rejects.toThrow('Invalid Talk relay session id');
await expect(talkApi.appendAudio({ relaySessionId: 'relay-1', audioBase64: 'not base64' })).rejects.toThrow('Invalid Talk PCM16 audio');
await expect(talkApi.acknowledgeMark({ relaySessionId: 'relay-1', markName: ' ' })).rejects.toThrow('Invalid Talk mark name');
await expect(talkApi.startAgentConsult({
relaySessionId: 'relay-1',
sessionKey: 'agent:main:session-1',
callId: ' ',
args: {},
})).rejects.toThrow('Invalid Talk tool call id');
expect(gatewayManager.rpc).not.toHaveBeenCalled();
});
it('does not forward late events from stopped or replaced relays', async () => {
const gatewayManager = {
rpc: vi.fn()
.mockResolvedValueOnce({ relaySessionId: 'relay-old' })
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ relaySessionId: 'relay-old' })
.mockResolvedValueOnce({ relaySessionId: 'relay-new' }),
};
const ownership = createTalkRelayOwnership();
const talkApi = createTalkApi(gatewayManager as never, ownership);
const sendToRenderer = vi.fn();
const oldEvent = {
relaySessionId: 'relay-old',
type: 'transcript' as const,
role: 'assistant' as const,
text: 'late',
final: true,
};
const newEvent = { ...oldEvent, relaySessionId: 'relay-new' };
await talkApi.startRelay({ sessionKey: 'agent:main:session-1' });
forwardActiveTalkEvent(ownership, oldEvent, sendToRenderer);
await talkApi.stopRelay({ relaySessionId: 'relay-old' });
forwardActiveTalkEvent(ownership, oldEvent, sendToRenderer);
await talkApi.startRelay({ sessionKey: 'agent:main:session-1' });
await talkApi.startRelay({ sessionKey: 'agent:main:session-2' });
forwardActiveTalkEvent(ownership, oldEvent, sendToRenderer);
forwardActiveTalkEvent(ownership, newEvent, sendToRenderer);
expect(sendToRenderer).toHaveBeenCalledTimes(2);
expect(sendToRenderer).toHaveBeenNthCalledWith(1, oldEvent);
expect(sendToRenderer).toHaveBeenNthCalledWith(2, newEvent);
});
it('keeps the latest relay active when concurrent starts resolve out of order', async () => {
let resolveFirst!: (value: unknown) => void;
let resolveSecond!: (value: unknown) => void;
const firstResponse = new Promise<unknown>((resolve) => {
resolveFirst = resolve;
});
const secondResponse = new Promise<unknown>((resolve) => {
resolveSecond = resolve;
});
const gatewayManager = {
rpc: vi.fn()
.mockReturnValueOnce(firstResponse)
.mockReturnValueOnce(secondResponse)
.mockResolvedValueOnce({ ok: true }),
};
const ownership = createTalkRelayOwnership();
const talkApi = createTalkApi(gatewayManager as never, ownership);
const sendToRenderer = vi.fn();
const oldEvent = {
relaySessionId: 'relay-old',
type: 'transcript' as const,
role: 'assistant' as const,
text: 'late',
final: true,
};
const newEvent = { ...oldEvent, relaySessionId: 'relay-new' };
const firstStart = talkApi.startRelay({ sessionKey: 'agent:main:session-1' });
const secondStart = talkApi.startRelay({ sessionKey: 'agent:main:session-2' });
resolveSecond({ relaySessionId: 'relay-new' });
await secondStart;
resolveFirst({ relaySessionId: 'relay-old' });
await firstStart;
forwardActiveTalkEvent(ownership, oldEvent, sendToRenderer);
forwardActiveTalkEvent(ownership, newEvent, sendToRenderer);
expect(sendToRenderer).toHaveBeenCalledTimes(1);
expect(sendToRenderer).toHaveBeenCalledWith(newEvent);
expect(gatewayManager.rpc).toHaveBeenLastCalledWith(
'talk.session.close',
{ sessionId: 'relay-old' },
TALK_RPC_TIMEOUT_MS,
);
});
});
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import {
decodePcm16Base64,
float32ToPcm16,
pcm16ToBase64,
pcm16ToFloat32,
} from '@/lib/talk/audio';
describe('Talk audio codec', () => {
it('converts Float32 microphone samples to clipped little-endian PCM16', () => {
expect([...float32ToPcm16(new Float32Array([-1, -0.5, 0, 0.5, 1, 2]))])
.toEqual([-32768, -16384, 0, 16383, 32767, 32767]);
});
it('round-trips PCM16 bytes through base64', () => {
const pcm = new Int16Array([-32768, 0, 32767]);
expect(pcm16ToBase64(pcm)).toBe('AIAAAP9/');
});
it('rejects malformed and odd-length PCM16 base64 payloads', () => {
expect(() => decodePcm16Base64('not base64')).toThrow('Invalid PCM16 base64 audio');
expect(() => decodePcm16Base64('AQ==')).toThrow('Invalid PCM16 byte length');
});
it('decodes little-endian PCM16 output for playback', () => {
const decoded = pcm16ToFloat32(decodePcm16Base64('AIAAAP9/'));
expect([...decoded]).toEqual([-1, 0, 32767 / 32768]);
});
});