feat: add voice capability status reporting to client

This commit is contained in:
Mohamed Boudra
2026-02-14 13:14:57 +07:00
parent c4bebb97ab
commit a644adbf9b
16 changed files with 585 additions and 98 deletions
+35 -9
View File
@@ -32,7 +32,9 @@ import { DictationOverlay } from "./dictation-controls";
import { RealtimeVoiceOverlay } from "./realtime-voice-overlay";
import type { DaemonClient } from "@server/client/daemon-client";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { useVoiceOptional } from "@/contexts/voice-context";
import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
@@ -167,6 +169,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const overlayTransition = useSharedValue(0);
const sendAfterTranscriptRef = useRef(false);
const valueRef = useRef(value);
const serverInfo = useSessionStore(
useCallback(
(state) => {
if (!voiceServerId) {
return null;
}
return state.sessions[voiceServerId]?.serverInfo ?? null;
},
[voiceServerId]
)
);
useEffect(() => {
valueRef.current = value;
@@ -212,10 +225,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
console.error("[MessageInput] Dictation error:", error);
}, []);
const dictationUnavailableMessage = resolveVoiceUnavailableMessage({
serverInfo,
mode: "dictation",
});
const canStartDictation = useCallback(() => {
const socketConnected = client?.isConnected ?? false;
return socketConnected && !disabled;
}, [client, disabled]);
return socketConnected && !disabled && !dictationUnavailableMessage;
}, [client, disabled, dictationUnavailableMessage]);
const canConfirmDictation = useCallback(() => {
const socketConnected = client?.isConnected ?? false;
@@ -268,6 +286,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
sendAfterTranscriptRef.current = false;
}, [dictationStatus, isDictating, isDictationProcessing]);
const startDictationIfAvailable = useCallback(async () => {
if (dictationUnavailableMessage) {
Alert.alert("Dictation unavailable", dictationUnavailableMessage);
return;
}
await startDictation();
}, [dictationUnavailableMessage, startDictation]);
// Cmd+D to start/submit dictation, Cmd+Shift+D toggles realtime voice, Escape cancels dictation
useEffect(() => {
if (!IS_WEB) return;
@@ -298,7 +324,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
}
void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => {
console.error("[MessageInput] Failed to start realtime voice", error);
const message = error instanceof Error ? error.message : "Voice features are not available right now.";
const message = error instanceof Error ? error.message : String(error);
Alert.alert("Voice unavailable", message);
});
};
@@ -348,7 +374,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
sendAfterTranscriptRef.current = true;
confirmDictation();
} else {
startDictation();
void startDictationIfAvailable();
}
return;
}
@@ -369,7 +395,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isConnected,
isRealtimeVoiceForCurrentAgent,
isScreenFocused,
startDictation,
startDictationIfAvailable,
voiceAgentId,
voiceServerId,
voice,
@@ -400,13 +426,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
if (isDictating) {
await cancelDictation();
} else {
await startDictation();
await startDictationIfAvailable();
}
}, [
cancelDictation,
isDictating,
isRealtimeVoiceForCurrentAgent,
startDictation,
startDictationIfAvailable,
voice,
]);
@@ -464,7 +490,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
}
void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => {
console.error("[MessageInput] Failed to start realtime voice", error);
const message = error instanceof Error ? error.message : "Voice features are not available right now.";
const message = error instanceof Error ? error.message : String(error);
Alert.alert("Voice unavailable", message);
});
}, [
@@ -623,7 +649,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
sendAfterTranscriptRef.current = true;
confirmDictation();
} else {
startDictation();
void startDictationIfAvailable();
}
return;
}
@@ -18,6 +18,7 @@ import type {
AgentStreamEventPayload,
SessionOutboundMessage,
} from "@server/shared/messages";
import { parseServerInfoStatusPayload } from "@server/shared/messages";
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types";
import type { DaemonClient, ConnectionState } from "@server/client/daemon-client";
@@ -984,15 +985,14 @@ export function SessionProvider({
const unsubStatus = client.on("status", (message) => {
if (message.type !== "status") return;
const status = message.payload.status;
if (status === "server_info") {
const payload = message.payload as any;
const rawServerId = typeof payload.serverId === "string" ? payload.serverId.trim() : "";
if (!rawServerId) return;
const rawHostname = typeof payload.hostname === "string" ? payload.hostname.trim() : "";
const serverInfo = parseServerInfoStatusPayload(message.payload);
if (serverInfo) {
updateSessionServerInfo(serverId, {
serverId: rawServerId,
hostname: rawHostname.length > 0 ? rawHostname : null,
serverId: serverInfo.serverId,
hostname: serverInfo.hostname,
...(serverInfo.capabilities
? { capabilities: serverInfo.capabilities }
: {}),
});
return;
}
@@ -2,6 +2,7 @@ import { createContext, useContext, useState, ReactNode, useCallback, useEffect,
import { useSpeechmaticsAudio } from "@/hooks/use-speechmatics-audio";
import type { SessionState } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import { REALTIME_VOICE_VAD_CONFIG } from "@/voice/realtime-voice-config";
@@ -334,6 +335,13 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
if (!session) {
throw new Error(`Host ${serverId} is not connected`);
}
const unavailableMessage = resolveVoiceUnavailableMessage({
serverInfo: session.serverInfo,
mode: "voice",
});
if (unavailableMessage) {
throw new Error(unavailableMessage);
}
setIsVoiceSwitching(true);
voiceTransportReadyRef.current = false;
@@ -86,7 +86,11 @@ export class DictationStreamSender {
}
if (!this.dictationId) {
void this.restartStream("enqueue");
if (!this.startPromise) {
void this.restartStream("enqueue").catch((error) => {
console.error("[DictationStreamSender] Failed to start stream from enqueue", error);
});
}
return;
}
+7 -3
View File
@@ -159,9 +159,11 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
if (!isRecordingRef.current) {
return;
}
void startNewStream("reconnect");
void startNewStream("reconnect").catch((error) => {
reportError(error, "Failed to restart dictation stream after reconnect");
});
});
}, [client, startNewStream]);
}, [client, reportError, startNewStream]);
useEffect(() => {
if (!client) {
@@ -260,7 +262,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
try {
await audio.start();
if (client?.isConnected) {
void startNewStream("start");
await startNewStream("start");
}
isRecordingRef.current = true;
setIsRecording(true);
@@ -268,9 +270,11 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
startDurationTracking();
}
} catch (err) {
await audio.stop().catch(() => undefined);
stopDurationTracking();
isRecordingRef.current = false;
setIsRecording(false);
setStatus("idle");
reportError(err, "Failed to start dictation");
} finally {
actionGateRef.current.starting = false;
+21 -2
View File
@@ -21,6 +21,7 @@ import type {
FileDownloadTokenResponse,
GitSetupOptions,
ProjectPlacementPayload,
ServerCapabilities,
} from "@server/shared/messages";
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
@@ -155,6 +156,7 @@ export interface DaemonConnectionSnapshot {
export type DaemonServerInfo = {
serverId: string;
hostname: string | null;
capabilities?: ServerCapabilities;
};
export interface AgentTimelineCursorState {
@@ -345,6 +347,13 @@ function createInitialSessionState(serverId: string, client: DaemonClient, audio
};
}
function areServerCapabilitiesEqual(
current: ServerCapabilities | undefined,
next: ServerCapabilities | undefined
): boolean {
return JSON.stringify(current ?? null) === JSON.stringify(next ?? null);
}
export const useSessionStore = create<SessionStore>()(
subscribeWithSelector((set, get) => ({
sessions: {},
@@ -446,8 +455,14 @@ export const useSessionStore = create<SessionStore>()(
const nextHostname = info.hostname?.trim() || null;
const prevHostname = session.serverInfo?.hostname?.trim() || null;
const nextCapabilities = info.capabilities;
const prevCapabilities = session.serverInfo?.capabilities;
if (session.serverInfo?.serverId === info.serverId && prevHostname === nextHostname) {
if (
session.serverInfo?.serverId === info.serverId &&
prevHostname === nextHostname &&
areServerCapabilitiesEqual(prevCapabilities, nextCapabilities)
) {
return prev;
}
@@ -462,7 +477,11 @@ export const useSessionStore = create<SessionStore>()(
...prev.sessions,
[serverId]: {
...session,
serverInfo: { serverId: info.serverId, hostname: nextHostname },
serverInfo: {
serverId: info.serverId,
hostname: nextHostname,
...(nextCapabilities ? { capabilities: nextCapabilities } : {}),
},
},
},
};
@@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import type { ServerCapabilities } from "@server/shared/messages";
import type { DaemonServerInfo } from "@/stores/session-store";
import {
getServerCapabilities,
getVoiceReadinessState,
resolveVoiceUnavailableMessage,
} from "./server-info-capabilities";
function buildServerInfo(capabilities?: ServerCapabilities): DaemonServerInfo {
return {
serverId: "srv-1",
hostname: "test-host",
...(capabilities ? { capabilities } : {}),
};
}
describe("server-info-capabilities", () => {
it("returns null capabilities when server_info does not include capability metadata", () => {
const serverInfo = buildServerInfo();
expect(getServerCapabilities({ serverInfo })).toBeNull();
});
it("returns the matching voice capability state by mode", () => {
const capabilities: ServerCapabilities = {
voice: {
dictation: {
enabled: true,
reason: "Dictation is warming up.",
},
voice: {
enabled: false,
reason: "Voice is disabled in daemon config.",
},
},
};
const serverInfo = buildServerInfo(capabilities);
expect(
getVoiceReadinessState({
serverInfo,
mode: "dictation",
})
).toEqual(capabilities.voice?.dictation);
expect(
getVoiceReadinessState({
serverInfo,
mode: "voice",
})
).toEqual(capabilities.voice?.voice);
});
it("returns null when capability is enabled and has no reason", () => {
const serverInfo = buildServerInfo({
voice: {
dictation: {
enabled: true,
reason: "",
},
voice: {
enabled: true,
reason: "",
},
},
});
expect(
resolveVoiceUnavailableMessage({
serverInfo,
mode: "dictation",
})
).toBeNull();
});
it("returns capability reason when present", () => {
const serverInfo = buildServerInfo({
voice: {
dictation: {
enabled: true,
reason: "Dictation models are still downloading.",
},
voice: {
enabled: true,
reason: "",
},
},
});
expect(
resolveVoiceUnavailableMessage({
serverInfo,
mode: "dictation",
})
).toBe("Dictation models are still downloading.");
});
it("returns null when capability reason is blank", () => {
const serverInfo = buildServerInfo({
voice: {
dictation: {
enabled: false,
reason: " ",
},
voice: {
enabled: true,
reason: "",
},
},
});
expect(
resolveVoiceUnavailableMessage({
serverInfo,
mode: "dictation",
})
).toBeNull();
});
});
@@ -0,0 +1,50 @@
import type { ServerCapabilityState } from "@server/shared/messages";
import type { DaemonServerInfo } from "@/stores/session-store";
export type VoiceReadinessMode = "dictation" | "voice";
export function getServerCapabilities(params: {
serverInfo: DaemonServerInfo | null | undefined;
}): DaemonServerInfo["capabilities"] | null {
const capabilities = params.serverInfo?.capabilities;
if (!capabilities) {
return null;
}
return capabilities;
}
export function getVoiceReadinessState(params: {
serverInfo: DaemonServerInfo | null | undefined;
mode: VoiceReadinessMode;
}): ServerCapabilityState | null {
const capabilities = getServerCapabilities({ serverInfo: params.serverInfo });
const voice = capabilities?.voice;
if (!voice) {
return null;
}
if (params.mode === "dictation") {
return voice.dictation;
}
return voice.voice;
}
export function resolveVoiceUnavailableMessage(params: {
serverInfo: DaemonServerInfo | null | undefined;
mode: VoiceReadinessMode;
}): string | null {
const readiness = getVoiceReadinessState({
serverInfo: params.serverInfo,
mode: params.mode,
});
if (!readiness) {
return null;
}
if (readiness.enabled && readiness.reason.trim().length === 0) {
return null;
}
const message = readiness.reason.trim();
if (message.length > 0) {
return message;
}
return null;
}
@@ -1,5 +1,6 @@
import { DaemonClient } from "@server/client/daemon-client";
import type { DaemonClientConfig } from "@server/client/daemon-client";
import { parseServerInfoStatusPayload } from "@server/shared/messages";
import type { HostConnection } from "@/contexts/daemon-registry-context";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport";
@@ -116,15 +117,10 @@ function connectAndProbe(
unsubscribeStatus = client.on("status", (message) => {
if (message.type !== "status") return;
const payload = message.payload as { status?: unknown; serverId?: unknown; hostname?: unknown };
if (payload?.status !== "server_info") return;
const raw = typeof payload.serverId === "string" ? payload.serverId.trim() : "";
if (!raw) return;
serverId = raw;
hostname = typeof payload.hostname === "string" ? payload.hostname.trim() : null;
if (hostname && hostname.length === 0) {
hostname = null;
}
const payload = parseServerInfoStatusPayload(message.payload);
if (!payload) return;
serverId = payload.serverId;
hostname = payload.hostname;
maybeFinishOk();
});
+7
View File
@@ -299,6 +299,7 @@ export async function createPaseoDaemon(
);
let wsServer: VoiceAssistantWebSocketServer | null = null;
let voiceMcpBridgeManager: VoiceMcpSocketBridgeManager | null = null;
let unsubscribeSpeechReadiness: (() => void) | null = null;
// Create in-memory transport for Session's Agent MCP client (voice assistant tools)
const createInMemoryAgentMcpTransport = async (): Promise<InMemoryTransport> => {
@@ -461,6 +462,7 @@ export async function createPaseoDaemon(
resolveVoiceTts,
resolveDictationStt,
getSpeechReadiness,
subscribeSpeechReadiness,
cleanup: cleanupSpeechRuntime,
localModelConfig,
} = await initializeSpeechRuntime({
@@ -503,6 +505,9 @@ export async function createPaseoDaemon(
},
config.agentProviderSettings
);
unsubscribeSpeechReadiness = subscribeSpeechReadiness((snapshot) => {
wsServer?.publishSpeechReadiness(snapshot);
});
const start = async () => {
// Start main HTTP server
@@ -582,6 +587,8 @@ export async function createPaseoDaemon(
runtimeSettings: config.agentProviderSettings,
});
terminalManager.killAll();
unsubscribeSpeechReadiness?.();
unsubscribeSpeechReadiness = null;
cleanupSpeechRuntime();
await relayTransport?.stop().catch(() => undefined);
if (wsServer) {
@@ -11,6 +11,7 @@ import {
DaemonClient,
} from "./test-utils/index.js";
import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-configs.js";
import { parseServerInfoStatusPayload } from "./messages.js";
import {
chunkPcm16,
parsePcm16MonoWav,
@@ -175,10 +176,9 @@ describe("daemon client E2E", () => {
const infoPromise = waitForSignal<{ serverId: string }>(5000, (resolve) => {
const unsubscribe = client.on("status", (message) => {
if (message.type !== "status") return;
const payload = message.payload as { status?: unknown; serverId?: unknown };
if (payload.status !== "server_info") return;
if (typeof payload.serverId !== "string" || payload.serverId.trim().length === 0) return;
resolve({ serverId: payload.serverId.trim() });
const payload = parseServerInfoStatusPayload(message.payload);
if (!payload) return;
resolve({ serverId: payload.serverId });
});
return unsubscribe;
});
@@ -308,6 +308,9 @@ export type InitializedSpeechRuntime = {
resolveVoiceTts: () => TextToSpeechProvider | null;
resolveDictationStt: () => SpeechToTextProvider | null;
getSpeechReadiness: () => SpeechReadinessSnapshot;
subscribeSpeechReadiness: (
listener: (snapshot: SpeechReadinessSnapshot) => void
) => () => void;
cleanup: () => void;
localModelConfig: {
modelsDir: string;
@@ -359,6 +362,9 @@ export async function initializeSpeechRuntime(params: {
let stopped = false;
let monitorTimeout: ReturnType<typeof setTimeout> | null = null;
let reconcileInFlight: Promise<void> | null = null;
const readinessListeners = new Set<(snapshot: SpeechReadinessSnapshot) => void>();
let lastReadinessFingerprint: string | null = null;
let lastPublishedReadinessSnapshot: SpeechReadinessSnapshot | null = null;
const computeReadinessSnapshot = (): SpeechReadinessSnapshot => {
const realtimeVoice = buildRealtimeVoiceReadiness({
@@ -398,6 +404,54 @@ export async function initializeSpeechRuntime(params: {
};
};
const readinessFingerprint = (snapshot: SpeechReadinessSnapshot): string =>
JSON.stringify({
...snapshot,
generatedAt: "",
});
const publishReadinessIfChanged = (): void => {
const snapshot = computeReadinessSnapshot();
const fingerprint = readinessFingerprint(snapshot);
if (fingerprint === lastReadinessFingerprint) {
return;
}
lastReadinessFingerprint = fingerprint;
lastPublishedReadinessSnapshot = snapshot;
for (const listener of readinessListeners) {
try {
listener(snapshot);
} catch (error) {
logger.warn(
{ err: error },
"Speech readiness listener threw an error"
);
}
}
};
const subscribeSpeechReadiness = (
listener: (snapshot: SpeechReadinessSnapshot) => void
): (() => void) => {
readinessListeners.add(listener);
const snapshot = lastPublishedReadinessSnapshot ?? computeReadinessSnapshot();
if (!lastPublishedReadinessSnapshot) {
lastPublishedReadinessSnapshot = snapshot;
lastReadinessFingerprint = readinessFingerprint(snapshot);
}
try {
listener(snapshot);
} catch (error) {
logger.warn(
{ err: error },
"Speech readiness listener threw an error during subscribe"
);
}
return () => {
readinessListeners.delete(listener);
};
};
const refreshMissingLocalModels = async (): Promise<void> => {
missingLocalModelIds = await findMissingRequiredLocalModels({
modelsDir: localModelConfig?.modelsDir ?? null,
@@ -470,12 +524,14 @@ export async function initializeSpeechRuntime(params: {
const runReconcile = async (params: { modelEnsureAutoDownload: boolean }): Promise<void> => {
if (reconcileInFlight) {
await reconcileInFlight;
publishReadinessIfChanged();
return;
}
reconcileInFlight = reconcileServices(params.modelEnsureAutoDownload).finally(() => {
reconcileInFlight = null;
});
await reconcileInFlight;
publishReadinessIfChanged();
};
const scheduleMonitor = (): void => {
@@ -500,6 +556,7 @@ export async function initializeSpeechRuntime(params: {
backgroundDownloadInProgress = true;
backgroundDownloadError = null;
publishReadinessIfChanged();
logger.info(
{
@@ -521,6 +578,7 @@ export async function initializeSpeechRuntime(params: {
backgroundDownloadError = null;
} catch (error) {
backgroundDownloadError = error instanceof Error ? error.message : String(error);
publishReadinessIfChanged();
logger.error(
{
err: error,
@@ -533,6 +591,7 @@ export async function initializeSpeechRuntime(params: {
await refreshMissingLocalModels().catch((error) => {
logger.warn({ err: error }, "Failed to refresh local speech model status after download");
});
publishReadinessIfChanged();
scheduleMonitor();
}
})();
@@ -565,6 +624,7 @@ export async function initializeSpeechRuntime(params: {
} catch (error) {
logger.warn({ err: error }, "Speech runtime monitor tick failed");
} finally {
publishReadinessIfChanged();
scheduleMonitor();
}
};
@@ -591,7 +651,9 @@ export async function initializeSpeechRuntime(params: {
resolveVoiceStt: () => sttService,
resolveVoiceTts: () => ttsService,
resolveDictationStt: () => dictationSttService,
getSpeechReadiness: () => computeReadinessSnapshot(),
getSpeechReadiness: () =>
lastPublishedReadinessSnapshot ?? computeReadinessSnapshot(),
subscribeSpeechReadiness,
cleanup,
localModelConfig,
};
@@ -76,6 +76,8 @@ import {
VoiceAssistantWebSocketServer,
type ExternalSocketMetadata,
} from "./websocket-server";
import { parseServerInfoStatusPayload } from "./messages.js";
import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js";
class MockSocket {
readyState = 1;
@@ -133,7 +135,8 @@ function createLogger() {
return logger;
}
function createServer() {
function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | null }) {
const speechReadiness = options?.speechReadiness ?? null;
return new VoiceAssistantWebSocketServer(
{} as any,
createLogger() as any,
@@ -146,10 +149,54 @@ function createServer() {
{} as any,
"/tmp/paseo-test",
async () => ({} as any),
{ allowedOrigins: new Set() }
{ allowedOrigins: new Set() },
undefined,
undefined,
undefined,
speechReadiness
? {
getSpeechReadiness: () => speechReadiness,
}
: undefined
);
}
function createReadySpeechReadinessSnapshot(): SpeechReadinessSnapshot {
return {
generatedAt: "2026-02-14T00:00:00.000Z",
requiredLocalModelIds: [],
missingLocalModelIds: [],
download: {
inProgress: false,
error: null,
},
dictation: {
enabled: true,
available: true,
reasonCode: "ready",
message: "Dictation is ready.",
retryable: false,
missingModelIds: [],
},
realtimeVoice: {
enabled: true,
available: true,
reasonCode: "ready",
message: "Realtime voice is ready.",
retryable: false,
missingModelIds: [],
},
voiceFeature: {
enabled: true,
available: true,
reasonCode: "ready",
message: "Voice features are ready.",
retryable: false,
missingModelIds: [],
},
};
}
describe("relay external socket reconnect behavior", () => {
beforeEach(() => {
sessionMock.instances.length = 0;
@@ -205,6 +252,72 @@ describe("relay external socket reconnect behavior", () => {
await server.close();
});
test("includes voice capabilities in server_info when speech readiness exists", async () => {
const speechReadiness = createReadySpeechReadinessSnapshot();
const server = createServer({ speechReadiness });
const metadata: ExternalSocketMetadata = {
transport: "relay",
externalSessionKey: "relay:client-server-info-capabilities",
};
const socket = new MockSocket();
await server.attachExternalSocket(socket, metadata);
expect(socket.sent).toHaveLength(1);
expect(typeof socket.sent[0]).toBe("string");
const envelope = JSON.parse(socket.sent[0] as string) as {
type?: unknown;
message?: {
type?: unknown;
payload?: unknown;
};
};
expect(envelope.type).toBe("session");
expect(envelope.message?.type).toBe("status");
const payload = parseServerInfoStatusPayload(envelope.message?.payload);
expect(payload?.status).toBe("server_info");
expect(payload?.capabilities?.voice?.dictation.enabled).toBe(
speechReadiness.dictation.enabled
);
expect(payload?.capabilities?.voice?.dictation.reason).toBe("");
expect(payload?.capabilities?.voice?.voice.enabled).toBe(
speechReadiness.realtimeVoice.enabled
);
expect(payload?.capabilities?.voice?.voice.reason).toBe("");
await server.close();
});
test("broadcasts updated server_info when capabilities change", async () => {
const server = createServer();
const metadata: ExternalSocketMetadata = {
transport: "relay",
externalSessionKey: "relay:client-server-info-broadcast",
};
const socket = new MockSocket();
await server.attachExternalSocket(socket, metadata);
expect(socket.sent).toHaveLength(1);
const speechReadiness = createReadySpeechReadinessSnapshot();
server.publishSpeechReadiness(speechReadiness);
expect(socket.sent).toHaveLength(2);
const secondEnvelope = JSON.parse(socket.sent[1] as string) as {
message?: { payload?: unknown };
};
const secondPayload = parseServerInfoStatusPayload(secondEnvelope.message?.payload);
expect(secondPayload?.capabilities?.voice?.dictation.enabled).toBe(true);
expect(secondPayload?.capabilities?.voice?.voice.enabled).toBe(true);
// Same readiness should not produce another server_info broadcast.
server.publishSpeechReadiness(speechReadiness);
expect(socket.sent).toHaveLength(2);
await server.close();
});
test("routes inbound binary mux frames to session.handleBinaryFrame", async () => {
const server = createServer();
const metadata: ExternalSocketMetadata = {
+72 -5
View File
@@ -9,7 +9,10 @@ import type { DownloadTokenStore } from "./file-download/token-store.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type pino from "pino";
import {
type ServerInfoStatusPayload,
WSInboundMessageSchema,
type ServerCapabilityState,
type ServerCapabilities,
type WSOutboundMessage,
wrapSessionMessage,
} from "./messages.js";
@@ -51,6 +54,37 @@ type WebSocketServerConfig = {
allowedHosts?: AllowedHostsConfig;
};
function toServerCapabilityState(
state: SpeechReadinessSnapshot["dictation"]
): ServerCapabilityState {
return {
enabled: state.enabled,
reason: state.available ? "" : state.message,
};
}
function buildServerCapabilities(params: {
readiness: SpeechReadinessSnapshot | null;
}): ServerCapabilities | undefined {
const readiness = params.readiness;
if (!readiness) {
return undefined;
}
return {
voice: {
dictation: toServerCapabilityState(readiness.dictation),
voice: toServerCapabilityState(readiness.realtimeVoice),
},
};
}
function areServerCapabilitiesEqual(
current: ServerCapabilities | undefined,
next: ServerCapabilities | undefined
): boolean {
return JSON.stringify(current ?? null) === JSON.stringify(next ?? null);
}
function bufferFromWsData(data: Buffer | ArrayBuffer | Buffer[] | string): Buffer {
if (typeof data === "string") return Buffer.from(data, "utf8");
if (Array.isArray(data)) {
@@ -123,6 +157,7 @@ export class VoiceAssistantWebSocketServer {
>();
private readonly voiceCallerContexts = new Map<string, VoiceCallerContext>();
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
private serverCapabilities: ServerCapabilities | undefined;
constructor(
server: HTTPServer,
@@ -168,6 +203,9 @@ export class VoiceAssistantWebSocketServer {
this.voice = voice ?? null;
this.dictation = dictation ?? null;
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
this.serverCapabilities = buildServerCapabilities({
readiness: this.dictation?.getSpeechReadiness?.() ?? null,
});
const pushLogger = this.logger.child({ module: "push" });
this.pushTokenStore = new PushTokenStore(
@@ -230,6 +268,21 @@ export class VoiceAssistantWebSocketServer {
}
}
public publishSpeechReadiness(readiness: SpeechReadinessSnapshot | null): void {
this.updateServerCapabilities(buildServerCapabilities({ readiness }));
}
public updateServerCapabilities(
capabilities: ServerCapabilities | null | undefined
): void {
const next = capabilities ?? undefined;
if (areServerCapabilitiesEqual(this.serverCapabilities, next)) {
return;
}
this.serverCapabilities = next;
this.broadcastServerInfo();
}
public async attachExternalSocket(
ws: WebSocketLike,
metadata?: ExternalSocketMetadata
@@ -410,17 +463,31 @@ export class VoiceAssistantWebSocketServer {
this.bindSocketHandlers(ws, connection);
}
private buildServerInfoStatusPayload(): ServerInfoStatusPayload {
return {
status: "server_info",
serverId: this.serverId,
hostname: getHostname(),
...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}),
};
}
private broadcastServerInfo(): void {
this.broadcast(
wrapSessionMessage({
type: "status",
payload: this.buildServerInfoStatusPayload(),
})
);
}
private sendServerInfo(ws: WebSocketLike): void {
// Advertise stable server identity immediately on connect (used for URL/shareable IDs).
this.sendToClient(
ws,
wrapSessionMessage({
type: "status",
payload: {
status: "server_info",
serverId: this.serverId,
hostname: getHostname(),
},
payload: this.buildServerInfoStatusPayload(),
})
);
}
+67
View File
@@ -1168,6 +1168,59 @@ export const DictationStreamErrorMessageSchema = z.object({
}),
});
export const ServerCapabilityStateSchema = z.object({
enabled: z.boolean(),
reason: z.string(),
});
export const ServerVoiceCapabilitiesSchema = z.object({
dictation: ServerCapabilityStateSchema,
voice: ServerCapabilityStateSchema,
});
export const ServerCapabilitiesSchema = z
.object({
voice: ServerVoiceCapabilitiesSchema.optional(),
})
.passthrough();
const ServerInfoHostnameSchema = z
.unknown()
.transform((value): string | null => {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
});
const ServerCapabilitiesFromUnknownSchema = z
.unknown()
.optional()
.transform((value): z.infer<typeof ServerCapabilitiesSchema> | undefined => {
if (value === undefined) {
return undefined;
}
const parsed = ServerCapabilitiesSchema.safeParse(value);
if (!parsed.success) {
return undefined;
}
return parsed.data;
});
export const ServerInfoStatusPayloadSchema = z
.object({
status: z.literal("server_info"),
serverId: z.string().trim().min(1),
hostname: ServerInfoHostnameSchema.optional(),
capabilities: ServerCapabilitiesFromUnknownSchema,
})
.passthrough()
.transform((payload) => ({
...payload,
hostname: payload.hostname ?? null,
}));
export const StatusMessageSchema = z.object({
type: z.literal("status"),
payload: z
@@ -1975,6 +2028,10 @@ export type AssistantChunkMessage = z.infer<typeof AssistantChunkMessageSchema>;
export type AudioOutputMessage = z.infer<typeof AudioOutputMessageSchema>;
export type TranscriptionResultMessage = z.infer<typeof TranscriptionResultMessageSchema>;
export type StatusMessage = z.infer<typeof StatusMessageSchema>;
export type ServerCapabilityState = z.infer<typeof ServerCapabilityStateSchema>;
export type ServerVoiceCapabilities = z.infer<typeof ServerVoiceCapabilitiesSchema>;
export type ServerCapabilities = z.infer<typeof ServerCapabilitiesSchema>;
export type ServerInfoStatusPayload = z.infer<typeof ServerInfoStatusPayloadSchema>;
export type RpcErrorMessage = z.infer<typeof RpcErrorMessageSchema>;
export type ArtifactMessage = z.infer<typeof ArtifactMessageSchema>;
export type AgentUpdateMessage = z.infer<typeof AgentUpdateMessageSchema>;
@@ -2189,3 +2246,13 @@ export function wrapSessionMessage(
message: sessionMsg,
};
}
export function parseServerInfoStatusPayload(
payload: unknown
): ServerInfoStatusPayload | null {
const parsed = ServerInfoStatusPayloadSchema.safeParse(payload);
if (!parsed.success) {
return null;
}
return parsed.data;
}