feat: add voice capability status reporting to client
This commit is contained in:
@@ -32,7 +32,9 @@ import { DictationOverlay } from "./dictation-controls";
|
|||||||
import { RealtimeVoiceOverlay } from "./realtime-voice-overlay";
|
import { RealtimeVoiceOverlay } from "./realtime-voice-overlay";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useVoiceOptional } from "@/contexts/voice-context";
|
import { useVoiceOptional } from "@/contexts/voice-context";
|
||||||
|
import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
|
|
||||||
@@ -167,6 +169,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
const overlayTransition = useSharedValue(0);
|
const overlayTransition = useSharedValue(0);
|
||||||
const sendAfterTranscriptRef = useRef(false);
|
const sendAfterTranscriptRef = useRef(false);
|
||||||
const valueRef = useRef(value);
|
const valueRef = useRef(value);
|
||||||
|
const serverInfo = useSessionStore(
|
||||||
|
useCallback(
|
||||||
|
(state) => {
|
||||||
|
if (!voiceServerId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return state.sessions[voiceServerId]?.serverInfo ?? null;
|
||||||
|
},
|
||||||
|
[voiceServerId]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
valueRef.current = value;
|
valueRef.current = value;
|
||||||
@@ -212,10 +225,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
console.error("[MessageInput] Dictation error:", error);
|
console.error("[MessageInput] Dictation error:", error);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const dictationUnavailableMessage = resolveVoiceUnavailableMessage({
|
||||||
|
serverInfo,
|
||||||
|
mode: "dictation",
|
||||||
|
});
|
||||||
|
|
||||||
const canStartDictation = useCallback(() => {
|
const canStartDictation = useCallback(() => {
|
||||||
const socketConnected = client?.isConnected ?? false;
|
const socketConnected = client?.isConnected ?? false;
|
||||||
return socketConnected && !disabled;
|
return socketConnected && !disabled && !dictationUnavailableMessage;
|
||||||
}, [client, disabled]);
|
}, [client, disabled, dictationUnavailableMessage]);
|
||||||
|
|
||||||
const canConfirmDictation = useCallback(() => {
|
const canConfirmDictation = useCallback(() => {
|
||||||
const socketConnected = client?.isConnected ?? false;
|
const socketConnected = client?.isConnected ?? false;
|
||||||
@@ -268,6 +286,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
sendAfterTranscriptRef.current = false;
|
sendAfterTranscriptRef.current = false;
|
||||||
}, [dictationStatus, isDictating, isDictationProcessing]);
|
}, [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
|
// Cmd+D to start/submit dictation, Cmd+Shift+D toggles realtime voice, Escape cancels dictation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!IS_WEB) return;
|
if (!IS_WEB) return;
|
||||||
@@ -298,7 +324,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
}
|
}
|
||||||
void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => {
|
void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => {
|
||||||
console.error("[MessageInput] Failed to start realtime voice", 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);
|
Alert.alert("Voice unavailable", message);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -348,7 +374,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
sendAfterTranscriptRef.current = true;
|
sendAfterTranscriptRef.current = true;
|
||||||
confirmDictation();
|
confirmDictation();
|
||||||
} else {
|
} else {
|
||||||
startDictation();
|
void startDictationIfAvailable();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -369,7 +395,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
isConnected,
|
isConnected,
|
||||||
isRealtimeVoiceForCurrentAgent,
|
isRealtimeVoiceForCurrentAgent,
|
||||||
isScreenFocused,
|
isScreenFocused,
|
||||||
startDictation,
|
startDictationIfAvailable,
|
||||||
voiceAgentId,
|
voiceAgentId,
|
||||||
voiceServerId,
|
voiceServerId,
|
||||||
voice,
|
voice,
|
||||||
@@ -400,13 +426,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
if (isDictating) {
|
if (isDictating) {
|
||||||
await cancelDictation();
|
await cancelDictation();
|
||||||
} else {
|
} else {
|
||||||
await startDictation();
|
await startDictationIfAvailable();
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
cancelDictation,
|
cancelDictation,
|
||||||
isDictating,
|
isDictating,
|
||||||
isRealtimeVoiceForCurrentAgent,
|
isRealtimeVoiceForCurrentAgent,
|
||||||
startDictation,
|
startDictationIfAvailable,
|
||||||
voice,
|
voice,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -464,7 +490,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
}
|
}
|
||||||
void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => {
|
void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => {
|
||||||
console.error("[MessageInput] Failed to start realtime voice", 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);
|
Alert.alert("Voice unavailable", message);
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
@@ -623,7 +649,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
sendAfterTranscriptRef.current = true;
|
sendAfterTranscriptRef.current = true;
|
||||||
confirmDictation();
|
confirmDictation();
|
||||||
} else {
|
} else {
|
||||||
startDictation();
|
void startDictationIfAvailable();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
AgentStreamEventPayload,
|
AgentStreamEventPayload,
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
} from "@server/shared/messages";
|
} from "@server/shared/messages";
|
||||||
|
import { parseServerInfoStatusPayload } from "@server/shared/messages";
|
||||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||||
import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types";
|
import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types";
|
||||||
import type { DaemonClient, ConnectionState } from "@server/client/daemon-client";
|
import type { DaemonClient, ConnectionState } from "@server/client/daemon-client";
|
||||||
@@ -984,15 +985,14 @@ export function SessionProvider({
|
|||||||
|
|
||||||
const unsubStatus = client.on("status", (message) => {
|
const unsubStatus = client.on("status", (message) => {
|
||||||
if (message.type !== "status") return;
|
if (message.type !== "status") return;
|
||||||
const status = message.payload.status;
|
const serverInfo = parseServerInfoStatusPayload(message.payload);
|
||||||
if (status === "server_info") {
|
if (serverInfo) {
|
||||||
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() : "";
|
|
||||||
updateSessionServerInfo(serverId, {
|
updateSessionServerInfo(serverId, {
|
||||||
serverId: rawServerId,
|
serverId: serverInfo.serverId,
|
||||||
hostname: rawHostname.length > 0 ? rawHostname : null,
|
hostname: serverInfo.hostname,
|
||||||
|
...(serverInfo.capabilities
|
||||||
|
? { capabilities: serverInfo.capabilities }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createContext, useContext, useState, ReactNode, useCallback, useEffect,
|
|||||||
import { useSpeechmaticsAudio } from "@/hooks/use-speechmatics-audio";
|
import { useSpeechmaticsAudio } from "@/hooks/use-speechmatics-audio";
|
||||||
import type { SessionState } from "@/stores/session-store";
|
import type { SessionState } from "@/stores/session-store";
|
||||||
import { useSessionStore } 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 { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||||
import { REALTIME_VOICE_VAD_CONFIG } from "@/voice/realtime-voice-config";
|
import { REALTIME_VOICE_VAD_CONFIG } from "@/voice/realtime-voice-config";
|
||||||
|
|
||||||
@@ -334,6 +335,13 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
|||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error(`Host ${serverId} is not connected`);
|
throw new Error(`Host ${serverId} is not connected`);
|
||||||
}
|
}
|
||||||
|
const unavailableMessage = resolveVoiceUnavailableMessage({
|
||||||
|
serverInfo: session.serverInfo,
|
||||||
|
mode: "voice",
|
||||||
|
});
|
||||||
|
if (unavailableMessage) {
|
||||||
|
throw new Error(unavailableMessage);
|
||||||
|
}
|
||||||
|
|
||||||
setIsVoiceSwitching(true);
|
setIsVoiceSwitching(true);
|
||||||
voiceTransportReadyRef.current = false;
|
voiceTransportReadyRef.current = false;
|
||||||
|
|||||||
@@ -86,7 +86,11 @@ export class DictationStreamSender {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!this.dictationId) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,9 +159,11 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
|||||||
if (!isRecordingRef.current) {
|
if (!isRecordingRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void startNewStream("reconnect");
|
void startNewStream("reconnect").catch((error) => {
|
||||||
|
reportError(error, "Failed to restart dictation stream after reconnect");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}, [client, startNewStream]);
|
}, [client, reportError, startNewStream]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
@@ -260,7 +262,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
|||||||
try {
|
try {
|
||||||
await audio.start();
|
await audio.start();
|
||||||
if (client?.isConnected) {
|
if (client?.isConnected) {
|
||||||
void startNewStream("start");
|
await startNewStream("start");
|
||||||
}
|
}
|
||||||
isRecordingRef.current = true;
|
isRecordingRef.current = true;
|
||||||
setIsRecording(true);
|
setIsRecording(true);
|
||||||
@@ -268,9 +270,11 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
|||||||
startDurationTracking();
|
startDurationTracking();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
await audio.stop().catch(() => undefined);
|
||||||
stopDurationTracking();
|
stopDurationTracking();
|
||||||
isRecordingRef.current = false;
|
isRecordingRef.current = false;
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
|
setStatus("idle");
|
||||||
reportError(err, "Failed to start dictation");
|
reportError(err, "Failed to start dictation");
|
||||||
} finally {
|
} finally {
|
||||||
actionGateRef.current.starting = false;
|
actionGateRef.current.starting = false;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
FileDownloadTokenResponse,
|
FileDownloadTokenResponse,
|
||||||
GitSetupOptions,
|
GitSetupOptions,
|
||||||
ProjectPlacementPayload,
|
ProjectPlacementPayload,
|
||||||
|
ServerCapabilities,
|
||||||
} from "@server/shared/messages";
|
} from "@server/shared/messages";
|
||||||
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
||||||
|
|
||||||
@@ -155,6 +156,7 @@ export interface DaemonConnectionSnapshot {
|
|||||||
export type DaemonServerInfo = {
|
export type DaemonServerInfo = {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
hostname: string | null;
|
hostname: string | null;
|
||||||
|
capabilities?: ServerCapabilities;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AgentTimelineCursorState {
|
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>()(
|
export const useSessionStore = create<SessionStore>()(
|
||||||
subscribeWithSelector((set, get) => ({
|
subscribeWithSelector((set, get) => ({
|
||||||
sessions: {},
|
sessions: {},
|
||||||
@@ -446,8 +455,14 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
|
|
||||||
const nextHostname = info.hostname?.trim() || null;
|
const nextHostname = info.hostname?.trim() || null;
|
||||||
const prevHostname = session.serverInfo?.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;
|
return prev;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,7 +477,11 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
...prev.sessions,
|
...prev.sessions,
|
||||||
[serverId]: {
|
[serverId]: {
|
||||||
...session,
|
...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 { DaemonClient } from "@server/client/daemon-client";
|
||||||
import type { DaemonClientConfig } 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 type { HostConnection } from "@/contexts/daemon-registry-context";
|
||||||
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
|
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
|
||||||
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport";
|
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport";
|
||||||
@@ -116,15 +117,10 @@ function connectAndProbe(
|
|||||||
|
|
||||||
unsubscribeStatus = client.on("status", (message) => {
|
unsubscribeStatus = client.on("status", (message) => {
|
||||||
if (message.type !== "status") return;
|
if (message.type !== "status") return;
|
||||||
const payload = message.payload as { status?: unknown; serverId?: unknown; hostname?: unknown };
|
const payload = parseServerInfoStatusPayload(message.payload);
|
||||||
if (payload?.status !== "server_info") return;
|
if (!payload) return;
|
||||||
const raw = typeof payload.serverId === "string" ? payload.serverId.trim() : "";
|
serverId = payload.serverId;
|
||||||
if (!raw) return;
|
hostname = payload.hostname;
|
||||||
serverId = raw;
|
|
||||||
hostname = typeof payload.hostname === "string" ? payload.hostname.trim() : null;
|
|
||||||
if (hostname && hostname.length === 0) {
|
|
||||||
hostname = null;
|
|
||||||
}
|
|
||||||
maybeFinishOk();
|
maybeFinishOk();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -299,6 +299,7 @@ export async function createPaseoDaemon(
|
|||||||
);
|
);
|
||||||
let wsServer: VoiceAssistantWebSocketServer | null = null;
|
let wsServer: VoiceAssistantWebSocketServer | null = null;
|
||||||
let voiceMcpBridgeManager: VoiceMcpSocketBridgeManager | 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)
|
// Create in-memory transport for Session's Agent MCP client (voice assistant tools)
|
||||||
const createInMemoryAgentMcpTransport = async (): Promise<InMemoryTransport> => {
|
const createInMemoryAgentMcpTransport = async (): Promise<InMemoryTransport> => {
|
||||||
@@ -461,6 +462,7 @@ export async function createPaseoDaemon(
|
|||||||
resolveVoiceTts,
|
resolveVoiceTts,
|
||||||
resolveDictationStt,
|
resolveDictationStt,
|
||||||
getSpeechReadiness,
|
getSpeechReadiness,
|
||||||
|
subscribeSpeechReadiness,
|
||||||
cleanup: cleanupSpeechRuntime,
|
cleanup: cleanupSpeechRuntime,
|
||||||
localModelConfig,
|
localModelConfig,
|
||||||
} = await initializeSpeechRuntime({
|
} = await initializeSpeechRuntime({
|
||||||
@@ -503,6 +505,9 @@ export async function createPaseoDaemon(
|
|||||||
},
|
},
|
||||||
config.agentProviderSettings
|
config.agentProviderSettings
|
||||||
);
|
);
|
||||||
|
unsubscribeSpeechReadiness = subscribeSpeechReadiness((snapshot) => {
|
||||||
|
wsServer?.publishSpeechReadiness(snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
// Start main HTTP server
|
// Start main HTTP server
|
||||||
@@ -582,6 +587,8 @@ export async function createPaseoDaemon(
|
|||||||
runtimeSettings: config.agentProviderSettings,
|
runtimeSettings: config.agentProviderSettings,
|
||||||
});
|
});
|
||||||
terminalManager.killAll();
|
terminalManager.killAll();
|
||||||
|
unsubscribeSpeechReadiness?.();
|
||||||
|
unsubscribeSpeechReadiness = null;
|
||||||
cleanupSpeechRuntime();
|
cleanupSpeechRuntime();
|
||||||
await relayTransport?.stop().catch(() => undefined);
|
await relayTransport?.stop().catch(() => undefined);
|
||||||
if (wsServer) {
|
if (wsServer) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
DaemonClient,
|
DaemonClient,
|
||||||
} from "./test-utils/index.js";
|
} from "./test-utils/index.js";
|
||||||
import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-configs.js";
|
import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-configs.js";
|
||||||
|
import { parseServerInfoStatusPayload } from "./messages.js";
|
||||||
import {
|
import {
|
||||||
chunkPcm16,
|
chunkPcm16,
|
||||||
parsePcm16MonoWav,
|
parsePcm16MonoWav,
|
||||||
@@ -175,10 +176,9 @@ describe("daemon client E2E", () => {
|
|||||||
const infoPromise = waitForSignal<{ serverId: string }>(5000, (resolve) => {
|
const infoPromise = waitForSignal<{ serverId: string }>(5000, (resolve) => {
|
||||||
const unsubscribe = client.on("status", (message) => {
|
const unsubscribe = client.on("status", (message) => {
|
||||||
if (message.type !== "status") return;
|
if (message.type !== "status") return;
|
||||||
const payload = message.payload as { status?: unknown; serverId?: unknown };
|
const payload = parseServerInfoStatusPayload(message.payload);
|
||||||
if (payload.status !== "server_info") return;
|
if (!payload) return;
|
||||||
if (typeof payload.serverId !== "string" || payload.serverId.trim().length === 0) return;
|
resolve({ serverId: payload.serverId });
|
||||||
resolve({ serverId: payload.serverId.trim() });
|
|
||||||
});
|
});
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -308,6 +308,9 @@ export type InitializedSpeechRuntime = {
|
|||||||
resolveVoiceTts: () => TextToSpeechProvider | null;
|
resolveVoiceTts: () => TextToSpeechProvider | null;
|
||||||
resolveDictationStt: () => SpeechToTextProvider | null;
|
resolveDictationStt: () => SpeechToTextProvider | null;
|
||||||
getSpeechReadiness: () => SpeechReadinessSnapshot;
|
getSpeechReadiness: () => SpeechReadinessSnapshot;
|
||||||
|
subscribeSpeechReadiness: (
|
||||||
|
listener: (snapshot: SpeechReadinessSnapshot) => void
|
||||||
|
) => () => void;
|
||||||
cleanup: () => void;
|
cleanup: () => void;
|
||||||
localModelConfig: {
|
localModelConfig: {
|
||||||
modelsDir: string;
|
modelsDir: string;
|
||||||
@@ -359,6 +362,9 @@ export async function initializeSpeechRuntime(params: {
|
|||||||
let stopped = false;
|
let stopped = false;
|
||||||
let monitorTimeout: ReturnType<typeof setTimeout> | null = null;
|
let monitorTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
let reconcileInFlight: Promise<void> | 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 computeReadinessSnapshot = (): SpeechReadinessSnapshot => {
|
||||||
const realtimeVoice = buildRealtimeVoiceReadiness({
|
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> => {
|
const refreshMissingLocalModels = async (): Promise<void> => {
|
||||||
missingLocalModelIds = await findMissingRequiredLocalModels({
|
missingLocalModelIds = await findMissingRequiredLocalModels({
|
||||||
modelsDir: localModelConfig?.modelsDir ?? null,
|
modelsDir: localModelConfig?.modelsDir ?? null,
|
||||||
@@ -470,12 +524,14 @@ export async function initializeSpeechRuntime(params: {
|
|||||||
const runReconcile = async (params: { modelEnsureAutoDownload: boolean }): Promise<void> => {
|
const runReconcile = async (params: { modelEnsureAutoDownload: boolean }): Promise<void> => {
|
||||||
if (reconcileInFlight) {
|
if (reconcileInFlight) {
|
||||||
await reconcileInFlight;
|
await reconcileInFlight;
|
||||||
|
publishReadinessIfChanged();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
reconcileInFlight = reconcileServices(params.modelEnsureAutoDownload).finally(() => {
|
reconcileInFlight = reconcileServices(params.modelEnsureAutoDownload).finally(() => {
|
||||||
reconcileInFlight = null;
|
reconcileInFlight = null;
|
||||||
});
|
});
|
||||||
await reconcileInFlight;
|
await reconcileInFlight;
|
||||||
|
publishReadinessIfChanged();
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduleMonitor = (): void => {
|
const scheduleMonitor = (): void => {
|
||||||
@@ -500,6 +556,7 @@ export async function initializeSpeechRuntime(params: {
|
|||||||
|
|
||||||
backgroundDownloadInProgress = true;
|
backgroundDownloadInProgress = true;
|
||||||
backgroundDownloadError = null;
|
backgroundDownloadError = null;
|
||||||
|
publishReadinessIfChanged();
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
@@ -521,6 +578,7 @@ export async function initializeSpeechRuntime(params: {
|
|||||||
backgroundDownloadError = null;
|
backgroundDownloadError = null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
backgroundDownloadError = error instanceof Error ? error.message : String(error);
|
backgroundDownloadError = error instanceof Error ? error.message : String(error);
|
||||||
|
publishReadinessIfChanged();
|
||||||
logger.error(
|
logger.error(
|
||||||
{
|
{
|
||||||
err: error,
|
err: error,
|
||||||
@@ -533,6 +591,7 @@ export async function initializeSpeechRuntime(params: {
|
|||||||
await refreshMissingLocalModels().catch((error) => {
|
await refreshMissingLocalModels().catch((error) => {
|
||||||
logger.warn({ err: error }, "Failed to refresh local speech model status after download");
|
logger.warn({ err: error }, "Failed to refresh local speech model status after download");
|
||||||
});
|
});
|
||||||
|
publishReadinessIfChanged();
|
||||||
scheduleMonitor();
|
scheduleMonitor();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -565,6 +624,7 @@ export async function initializeSpeechRuntime(params: {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn({ err: error }, "Speech runtime monitor tick failed");
|
logger.warn({ err: error }, "Speech runtime monitor tick failed");
|
||||||
} finally {
|
} finally {
|
||||||
|
publishReadinessIfChanged();
|
||||||
scheduleMonitor();
|
scheduleMonitor();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -591,7 +651,9 @@ export async function initializeSpeechRuntime(params: {
|
|||||||
resolveVoiceStt: () => sttService,
|
resolveVoiceStt: () => sttService,
|
||||||
resolveVoiceTts: () => ttsService,
|
resolveVoiceTts: () => ttsService,
|
||||||
resolveDictationStt: () => dictationSttService,
|
resolveDictationStt: () => dictationSttService,
|
||||||
getSpeechReadiness: () => computeReadinessSnapshot(),
|
getSpeechReadiness: () =>
|
||||||
|
lastPublishedReadinessSnapshot ?? computeReadinessSnapshot(),
|
||||||
|
subscribeSpeechReadiness,
|
||||||
cleanup,
|
cleanup,
|
||||||
localModelConfig,
|
localModelConfig,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ import {
|
|||||||
VoiceAssistantWebSocketServer,
|
VoiceAssistantWebSocketServer,
|
||||||
type ExternalSocketMetadata,
|
type ExternalSocketMetadata,
|
||||||
} from "./websocket-server";
|
} from "./websocket-server";
|
||||||
|
import { parseServerInfoStatusPayload } from "./messages.js";
|
||||||
|
import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js";
|
||||||
|
|
||||||
class MockSocket {
|
class MockSocket {
|
||||||
readyState = 1;
|
readyState = 1;
|
||||||
@@ -133,7 +135,8 @@ function createLogger() {
|
|||||||
return logger;
|
return logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createServer() {
|
function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | null }) {
|
||||||
|
const speechReadiness = options?.speechReadiness ?? null;
|
||||||
return new VoiceAssistantWebSocketServer(
|
return new VoiceAssistantWebSocketServer(
|
||||||
{} as any,
|
{} as any,
|
||||||
createLogger() as any,
|
createLogger() as any,
|
||||||
@@ -146,10 +149,54 @@ function createServer() {
|
|||||||
{} as any,
|
{} as any,
|
||||||
"/tmp/paseo-test",
|
"/tmp/paseo-test",
|
||||||
async () => ({} as any),
|
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", () => {
|
describe("relay external socket reconnect behavior", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sessionMock.instances.length = 0;
|
sessionMock.instances.length = 0;
|
||||||
@@ -205,6 +252,72 @@ describe("relay external socket reconnect behavior", () => {
|
|||||||
await server.close();
|
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 () => {
|
test("routes inbound binary mux frames to session.handleBinaryFrame", async () => {
|
||||||
const server = createServer();
|
const server = createServer();
|
||||||
const metadata: ExternalSocketMetadata = {
|
const metadata: ExternalSocketMetadata = {
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import type { DownloadTokenStore } from "./file-download/token-store.js";
|
|||||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
import {
|
import {
|
||||||
|
type ServerInfoStatusPayload,
|
||||||
WSInboundMessageSchema,
|
WSInboundMessageSchema,
|
||||||
|
type ServerCapabilityState,
|
||||||
|
type ServerCapabilities,
|
||||||
type WSOutboundMessage,
|
type WSOutboundMessage,
|
||||||
wrapSessionMessage,
|
wrapSessionMessage,
|
||||||
} from "./messages.js";
|
} from "./messages.js";
|
||||||
@@ -51,6 +54,37 @@ type WebSocketServerConfig = {
|
|||||||
allowedHosts?: AllowedHostsConfig;
|
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 {
|
function bufferFromWsData(data: Buffer | ArrayBuffer | Buffer[] | string): Buffer {
|
||||||
if (typeof data === "string") return Buffer.from(data, "utf8");
|
if (typeof data === "string") return Buffer.from(data, "utf8");
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
@@ -123,6 +157,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
>();
|
>();
|
||||||
private readonly voiceCallerContexts = new Map<string, VoiceCallerContext>();
|
private readonly voiceCallerContexts = new Map<string, VoiceCallerContext>();
|
||||||
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
|
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
|
||||||
|
private serverCapabilities: ServerCapabilities | undefined;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
server: HTTPServer,
|
server: HTTPServer,
|
||||||
@@ -168,6 +203,9 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
this.voice = voice ?? null;
|
this.voice = voice ?? null;
|
||||||
this.dictation = dictation ?? null;
|
this.dictation = dictation ?? null;
|
||||||
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
|
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
|
||||||
|
this.serverCapabilities = buildServerCapabilities({
|
||||||
|
readiness: this.dictation?.getSpeechReadiness?.() ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
const pushLogger = this.logger.child({ module: "push" });
|
const pushLogger = this.logger.child({ module: "push" });
|
||||||
this.pushTokenStore = new PushTokenStore(
|
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(
|
public async attachExternalSocket(
|
||||||
ws: WebSocketLike,
|
ws: WebSocketLike,
|
||||||
metadata?: ExternalSocketMetadata
|
metadata?: ExternalSocketMetadata
|
||||||
@@ -410,17 +463,31 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
this.bindSocketHandlers(ws, connection);
|
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 {
|
private sendServerInfo(ws: WebSocketLike): void {
|
||||||
// Advertise stable server identity immediately on connect (used for URL/shareable IDs).
|
// Advertise stable server identity immediately on connect (used for URL/shareable IDs).
|
||||||
this.sendToClient(
|
this.sendToClient(
|
||||||
ws,
|
ws,
|
||||||
wrapSessionMessage({
|
wrapSessionMessage({
|
||||||
type: "status",
|
type: "status",
|
||||||
payload: {
|
payload: this.buildServerInfoStatusPayload(),
|
||||||
status: "server_info",
|
|
||||||
serverId: this.serverId,
|
|
||||||
hostname: getHostname(),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
export const StatusMessageSchema = z.object({
|
||||||
type: z.literal("status"),
|
type: z.literal("status"),
|
||||||
payload: z
|
payload: z
|
||||||
@@ -1975,6 +2028,10 @@ export type AssistantChunkMessage = z.infer<typeof AssistantChunkMessageSchema>;
|
|||||||
export type AudioOutputMessage = z.infer<typeof AudioOutputMessageSchema>;
|
export type AudioOutputMessage = z.infer<typeof AudioOutputMessageSchema>;
|
||||||
export type TranscriptionResultMessage = z.infer<typeof TranscriptionResultMessageSchema>;
|
export type TranscriptionResultMessage = z.infer<typeof TranscriptionResultMessageSchema>;
|
||||||
export type StatusMessage = z.infer<typeof StatusMessageSchema>;
|
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 RpcErrorMessage = z.infer<typeof RpcErrorMessageSchema>;
|
||||||
export type ArtifactMessage = z.infer<typeof ArtifactMessageSchema>;
|
export type ArtifactMessage = z.infer<typeof ArtifactMessageSchema>;
|
||||||
export type AgentUpdateMessage = z.infer<typeof AgentUpdateMessageSchema>;
|
export type AgentUpdateMessage = z.infer<typeof AgentUpdateMessageSchema>;
|
||||||
@@ -2189,3 +2246,13 @@ export function wrapSessionMessage(
|
|||||||
message: sessionMsg,
|
message: sessionMsg,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseServerInfoStatusPayload(
|
||||||
|
payload: unknown
|
||||||
|
): ServerInfoStatusPayload | null {
|
||||||
|
const parsed = ServerInfoStatusPayloadSchema.safeParse(payload);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
|
||||||
cd "${REPO_ROOT}"
|
|
||||||
|
|
||||||
SLEEP_SECONDS="${SLEEP_SECONDS:-600}"
|
|
||||||
MAX_ITERATIONS="${MAX_ITERATIONS:-0}" # 0 means run forever
|
|
||||||
CODEX_MODEL="${CODEX_MODEL:-gpt-5.3-codex}"
|
|
||||||
CODEX_REASONING_EFFORT="${CODEX_REASONING_EFFORT:-medium}"
|
|
||||||
|
|
||||||
read -r -d '' PROMPT <<'EOF' || true
|
|
||||||
1. load the refactor skill and read its SKILL.md fully from top to bottom (do not skim or partially read)
|
|
||||||
2. check previous commits to see what other agents have worked on
|
|
||||||
3. identify and work on a single large improvement based on the refactor skill (can be a file system reorg, file splitting, refactor, add a new test, harden a test, deflakify a test, fix a test, improve control flow, remove unused code)
|
|
||||||
4. commit with a description of what was done, your reasoning, and document accomplishments or challenges for the next agent
|
|
||||||
|
|
||||||
guidelines:
|
|
||||||
- focus on the server and app code, it's the most hairy
|
|
||||||
EOF
|
|
||||||
|
|
||||||
iteration=1
|
|
||||||
while true; do
|
|
||||||
if [[ "${MAX_ITERATIONS}" -gt 0 && "${iteration}" -gt "${MAX_ITERATIONS}" ]]; then
|
|
||||||
echo "Reached MAX_ITERATIONS=${MAX_ITERATIONS}; exiting."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] Starting codex exec iteration ${iteration}"
|
|
||||||
|
|
||||||
cmd=(
|
|
||||||
codex exec
|
|
||||||
--dangerously-bypass-approvals-and-sandbox
|
|
||||||
--cd "${REPO_ROOT}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if [[ -n "${CODEX_MODEL}" ]]; then
|
|
||||||
cmd+=(--model "${CODEX_MODEL}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
cmd+=(-c "model_reasoning_effort=\"${CODEX_REASONING_EFFORT}\"")
|
|
||||||
|
|
||||||
if "${cmd[@]}" "${PROMPT}"; then
|
|
||||||
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] Iteration ${iteration} completed successfully"
|
|
||||||
else
|
|
||||||
status=$?
|
|
||||||
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] Iteration ${iteration} failed with exit code ${status}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Sleeping ${SLEEP_SECONDS}s before next iteration..."
|
|
||||||
sleep "${SLEEP_SECONDS}"
|
|
||||||
iteration=$((iteration + 1))
|
|
||||||
done
|
|
||||||
Reference in New Issue
Block a user