feat: add voice capability status reporting to client
This commit is contained in:
@@ -190,6 +190,57 @@ describe("daemon client E2E", () => {
|
||||
await client.close();
|
||||
}, 15000);
|
||||
|
||||
test("emits disabled voice capability reasons on fresh daemon startup", async () => {
|
||||
const isolatedCtx = await createDaemonTestContext({
|
||||
speech: {
|
||||
providers: {
|
||||
dictationStt: { provider: "local", explicit: true, enabled: false },
|
||||
voiceStt: { provider: "local", explicit: true, enabled: false },
|
||||
voiceTts: { provider: "local", explicit: true, enabled: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${isolatedCtx.daemon.port}/ws`,
|
||||
});
|
||||
|
||||
try {
|
||||
const infoPromise = waitForSignal<{
|
||||
dictationEnabled: boolean;
|
||||
dictationReason: string;
|
||||
voiceEnabled: boolean;
|
||||
voiceReason: string;
|
||||
}>(5000, (resolve) => {
|
||||
const unsubscribe = client.on("status", (message) => {
|
||||
if (message.type !== "status") return;
|
||||
const payload = parseServerInfoStatusPayload(message.payload);
|
||||
if (!payload) return;
|
||||
const voice = payload.capabilities?.voice;
|
||||
if (!voice) return;
|
||||
resolve({
|
||||
dictationEnabled: voice.dictation.enabled,
|
||||
dictationReason: voice.dictation.reason,
|
||||
voiceEnabled: voice.voice.enabled,
|
||||
voiceReason: voice.voice.reason,
|
||||
});
|
||||
});
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
const info = await infoPromise;
|
||||
|
||||
expect(info.dictationEnabled).toBe(false);
|
||||
expect(info.dictationReason).toBe("Dictation is disabled in daemon config.");
|
||||
expect(info.voiceEnabled).toBe(false);
|
||||
expect(info.voiceReason).toBe("Realtime voice is disabled in daemon config.");
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await isolatedCtx.cleanup();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test("handles concurrent filtered agent fetch requests", async () => {
|
||||
const firstRequestId = `fetch-${Date.now()}-a`;
|
||||
const secondRequestId = `fetch-${Date.now()}-b`;
|
||||
|
||||
@@ -197,6 +197,47 @@ function createReadySpeechReadinessSnapshot(): SpeechReadinessSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
function createDownloadInProgressSpeechReadinessSnapshot(): SpeechReadinessSnapshot {
|
||||
return {
|
||||
generatedAt: "2026-02-14T00:00:00.000Z",
|
||||
requiredLocalModelIds: [
|
||||
"sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20",
|
||||
],
|
||||
missingLocalModelIds: [
|
||||
"sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20",
|
||||
],
|
||||
download: {
|
||||
inProgress: true,
|
||||
error: null,
|
||||
},
|
||||
dictation: {
|
||||
enabled: true,
|
||||
available: false,
|
||||
reasonCode: "stt_unavailable",
|
||||
message: "Dictation is unavailable: speech-to-text service is not ready.",
|
||||
retryable: false,
|
||||
missingModelIds: [],
|
||||
},
|
||||
realtimeVoice: {
|
||||
enabled: true,
|
||||
available: false,
|
||||
reasonCode: "stt_unavailable",
|
||||
message: "Realtime voice is unavailable: speech-to-text service is not ready.",
|
||||
retryable: false,
|
||||
missingModelIds: [],
|
||||
},
|
||||
voiceFeature: {
|
||||
enabled: true,
|
||||
available: false,
|
||||
reasonCode: "model_download_in_progress",
|
||||
message:
|
||||
"Voice features are unavailable while models download in the background (sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20).",
|
||||
retryable: true,
|
||||
missingModelIds: ["sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("relay external socket reconnect behavior", () => {
|
||||
beforeEach(() => {
|
||||
sessionMock.instances.length = 0;
|
||||
@@ -318,6 +359,35 @@ describe("relay external socket reconnect behavior", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("includes temporary retry guidance while models are downloading", async () => {
|
||||
const server = createServer();
|
||||
const metadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: "relay:client-server-info-download-guidance",
|
||||
};
|
||||
const socket = new MockSocket();
|
||||
await server.attachExternalSocket(socket, metadata);
|
||||
expect(socket.sent).toHaveLength(1);
|
||||
|
||||
server.publishSpeechReadiness(createDownloadInProgressSpeechReadinessSnapshot());
|
||||
expect(socket.sent).toHaveLength(2);
|
||||
|
||||
const envelope = JSON.parse(socket.sent[1] as string) as {
|
||||
message?: { payload?: unknown };
|
||||
};
|
||||
const payload = parseServerInfoStatusPayload(envelope.message?.payload);
|
||||
expect(payload?.capabilities?.voice?.dictation.enabled).toBe(true);
|
||||
expect(payload?.capabilities?.voice?.voice.enabled).toBe(true);
|
||||
expect(payload?.capabilities?.voice?.dictation.reason).toContain(
|
||||
"Try again in a few minutes."
|
||||
);
|
||||
expect(payload?.capabilities?.voice?.voice.reason).toContain(
|
||||
"Try again in a few minutes."
|
||||
);
|
||||
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("routes inbound binary mux frames to session.handleBinaryFrame", async () => {
|
||||
const server = createServer();
|
||||
const metadata: ExternalSocketMetadata = {
|
||||
|
||||
@@ -55,14 +55,38 @@ type WebSocketServerConfig = {
|
||||
};
|
||||
|
||||
function toServerCapabilityState(
|
||||
state: SpeechReadinessSnapshot["dictation"]
|
||||
params: {
|
||||
state: SpeechReadinessSnapshot["dictation"];
|
||||
reason: string;
|
||||
}
|
||||
): ServerCapabilityState {
|
||||
const { state, reason } = params;
|
||||
return {
|
||||
enabled: state.enabled,
|
||||
reason: state.available ? "" : state.message,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCapabilityReason(params: {
|
||||
state: SpeechReadinessSnapshot["dictation"];
|
||||
readiness: SpeechReadinessSnapshot;
|
||||
}): string {
|
||||
const { state, readiness } = params;
|
||||
if (state.available) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (readiness.voiceFeature.reasonCode === "model_download_in_progress") {
|
||||
const baseMessage = readiness.voiceFeature.message.trim();
|
||||
if (baseMessage.includes("Try again in a few minutes")) {
|
||||
return baseMessage;
|
||||
}
|
||||
return `${baseMessage} Try again in a few minutes.`;
|
||||
}
|
||||
|
||||
return state.message;
|
||||
}
|
||||
|
||||
function buildServerCapabilities(params: {
|
||||
readiness: SpeechReadinessSnapshot | null;
|
||||
}): ServerCapabilities | undefined {
|
||||
@@ -72,8 +96,20 @@ function buildServerCapabilities(params: {
|
||||
}
|
||||
return {
|
||||
voice: {
|
||||
dictation: toServerCapabilityState(readiness.dictation),
|
||||
voice: toServerCapabilityState(readiness.realtimeVoice),
|
||||
dictation: toServerCapabilityState({
|
||||
state: readiness.dictation,
|
||||
reason: resolveCapabilityReason({
|
||||
state: readiness.dictation,
|
||||
readiness,
|
||||
}),
|
||||
}),
|
||||
voice: toServerCapabilityState({
|
||||
state: readiness.realtimeVoice,
|
||||
reason: resolveCapabilityReason({
|
||||
state: readiness.realtimeVoice,
|
||||
readiness,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user