feat: speak answers sentence by sentence and never speak reasoning
CI / Verify and package (ubuntu-latest) (push) Canceled after 0s
CI / Verify and package (windows-latest) (push) Canceled after 0s

Segment the final answer as it streams and synthesize each completed
sentence immediately, so a listener hears the reply begin while the model
is still writing it. Sentences share the progress clips' FIFO chain and
output-sequence counter; only text left unspoken (a hit budget) is
synthesized after settle, so audio is never sent twice. A message that
grows a tool call stops answer-streaming without re-speaking what already
played.

Reasoning models inline their chain of thought as <think> blocks in the
same delta stream. Strip it while streaming -- carrying a partial tag
across delta boundaries -- so it is neither spoken nor shown as answer
text.

The fixed "Hang on while I work on that." is now a rotation of short
natural phrases that never repeats twice running, and a long silent think
gets a spoken "still on it" roughly every 45 seconds until real speech
begins. Turn timeout rises to five minutes to match the client.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
snowspeeder
2026-08-07 14:20:21 -04:00
co-authored by Claude Fable 5
parent a7033f9076
commit 28a2629c43
3 changed files with 1233 additions and 19 deletions
+673
View File
@@ -13,6 +13,11 @@ import {
revokeVoiceToken,
clearWorkingProgress,
startWorkingProgress,
extractCompleteSentences,
isSpeakableFragment,
pickFallbackPhrase,
stripThinkingStream,
stripThinkingAll,
} from "./voiceApi.js";
const temporary: string[] = [];
@@ -119,6 +124,81 @@ describe("voice API", () => {
expect(frame.subarray(8)).toEqual(Buffer.from([1, 2, 3, 4]));
});
it("segments complete sentences, holds back an incomplete trailing fragment, and does not split a decimal like 3.5", () => {
expect(extractCompleteSentences("First one. Second one! Third one?", false)).toEqual([
{ text: "First one.", rawLength: 11 },
{ text: "Second one!", rawLength: 12 },
]);
// "Third one?" has no trailing whitespace yet (it's the end of the
// buffer) so it is held back as an incomplete fragment, not emitted.
const held = extractCompleteSentences("Third one?", false);
expect(held).toEqual([]);
// A mid-number decimal point is never mistaken for a sentence boundary,
// because the period is not followed by whitespace.
const decimal = extractCompleteSentences("It costs 3.5 dollars. Thanks. ", false);
expect(decimal.map((sentence) => sentence.text)).toEqual([
"It costs 3.5 dollars.",
"Thanks.",
]);
});
it("strips streamed thinking blocks, even split across delta boundaries", () => {
const turn = { thinkDepth: 0, thinkCarry: "" };
const out = [
stripThinkingStream(turn, "<think>step one, "),
stripThinkingStream(turn, "step two</th"),
stripThinkingStream(turn, "ink>The answer "),
stripThinkingStream(turn, "is 42."),
].join("");
expect(out).toBe("The answer is 42.");
expect(turn.thinkDepth).toBe(0);
// A tag split exactly at a chunk edge is carried, never leaked.
const carry = { thinkDepth: 0, thinkCarry: "" };
expect(stripThinkingStream(carry, "Hello <")).toBe("Hello ");
expect(stripThinkingStream(carry, "think>hidden</think> world")).toBe(" world");
expect(stripThinkingAll("<think>abc</think>Answer. <thinking>more</thinking>Done.")).toBe(
"Answer. Done."
);
// An unclosed trailing block (still thinking at message end) is dropped.
expect(stripThinkingAll("Answer first. <think>unfinished")).toBe("Answer first. ");
});
it("flushes the trailing incomplete fragment only when explicitly told the message ended", () => {
const pending = "Here is the tail with no terminal punctuation";
expect(extractCompleteSentences(pending, false)).toEqual([]);
expect(extractCompleteSentences(pending, true)).toEqual([
{ text: pending, rawLength: pending.length },
]);
// A purely-whitespace remainder is never flushed as an empty "sentence".
expect(extractCompleteSentences(" ", true)).toEqual([]);
expect(extractCompleteSentences("", true)).toEqual([]);
});
it("treats markdown-decoration-only fragments as unspeakable, but keeps ordinary punctuation-bearing text", () => {
expect(isSpeakableFragment("---")).toBe(false);
expect(isSpeakableFragment("```")).toBe(false);
expect(isSpeakableFragment("**")).toBe(false);
expect(isSpeakableFragment("> ")).toBe(false);
expect(isSpeakableFragment(" ")).toBe(false);
expect(isSpeakableFragment("")).toBe(false);
expect(isSpeakableFragment("Okay - let's continue.")).toBe(true);
expect(isSpeakableFragment("It costs 3.5 dollars.")).toBe(true);
});
it("never repeats the same fallback phrase twice in a row", () => {
const picks = Array.from({ length: 200 }, () => pickFallbackPhrase());
for (let index = 1; index < picks.length; index++) {
expect(picks[index]).not.toBe(picks[index - 1]);
}
// Every pick is one of the known short, natural phrases.
const known = new Set(picks);
for (const phrase of known) expect(phrase.length).toBeGreaterThan(0);
expect(known.size).toBeGreaterThan(1);
});
it("does not announce progress for a quick completed turn", () => {
vi.useFakeTimers();
try {
@@ -392,6 +472,599 @@ describe("voice API", () => {
await app.close();
});
it("speaks intermediate assistant messages between tool calls as ordered progress clips before the final answer", async () => {
const directory = mkdtempSync(join(tmpdir(), "pi-web-voice-api-"));
temporary.push(directory);
const events = Object.assign(new EventEmitter(), {
close: () => undefined,
readyState: websocketConnectingState(),
});
const longMessage = `${"Sentence number one is here. ".repeat(15)}Trailing tail text without a stop.`;
const daemon = {
request: (method: string, path: string) => {
if (method === "POST" && path === "/sessions")
return Promise.resolve(response(200, { id: "session-1" }));
if (method === "POST" && path.endsWith("/prompt")) {
queueMicrotask(() => {
events.emit(
"message",
Buffer.from(JSON.stringify({
type: "message.end",
message: { role: "assistant", content: [
{ type: "text", text: "Checking the first file now." },
{ type: "toolCall", id: "1", name: "read_file", input: {} },
] },
}))
);
events.emit(
"message",
Buffer.from(JSON.stringify({
type: "message.end",
message: { role: "assistant", content: [
{ type: "text", text: longMessage },
{ type: "toolCall", id: "2", name: "read_file", input: {} },
] },
}))
);
events.emit(
"message",
Buffer.from(JSON.stringify({
type: "message.end",
message: { role: "assistant", content: [
{ type: "text", text: "Here is the final answer." },
] },
}))
);
events.emit("message", Buffer.from(JSON.stringify({ type: "agent.settled" })));
});
return Promise.resolve(response(200, { accepted: true }));
}
return Promise.resolve(response(200, {}));
},
connectWebSocket: () => {
queueMicrotask(() => {
events.readyState = WebSocket.OPEN;
events.emit("open");
});
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return events as unknown as WebSocket;
},
};
const synthesizeCalls: string[] = [];
const speech = {
recognize: () => ({ write: () => undefined, end: () => Promise.resolve(), close: () => undefined }),
synthesize: (text: string, onAudio: (pcm: Buffer) => void) => {
synthesizeCalls.push(text);
onAudio(Buffer.from([synthesizeCalls.length]));
return Promise.resolve();
},
};
const app = Fastify();
await app.register(fastifyWebsocket);
const configPath = join(directory, "voice-api.json");
registerVoiceApiRoutes(app, {
configPath,
daemon,
speech,
projects: { list: () => Promise.resolve([{ id: "p", name: "P", path: "/tmp", createdAt: "" }]) },
workspaces: { list: () => Promise.resolve([{ id: "w", projectId: "p", path: "/tmp", label: "W", isMain: true, isGitRepo: false, isGitWorktree: false }]) },
});
const token = createVoiceToken({}, configPath).token;
const created = await app.inject({ method: "POST", url: "/api/v1/voice/conversations", headers: { authorization: `Bearer ${token}` }, payload: { workspaceId: "w" } });
const address = await app.listen({ port: 0, host: "127.0.0.1" });
const frames: ({ json: Record<string, unknown> } | { binary: Buffer })[] = [];
try {
await new Promise<void>((resolve, reject) => {
const client = new WebSocket(address.replace("http", "ws") + "/api/v1/voice/stream", { headers: { authorization: `Bearer ${token}` } });
client.on("open", () => {
client.send(JSON.stringify({ type: "attach", conversationId: responseId(created.body) }));
client.send(JSON.stringify({ type: "input.text", text: "look into this" }));
});
client.on("message", (data, binary) => {
if (binary) {
frames.push({ binary: rawDataToBuffer(data) });
return;
}
const value: unknown = JSON.parse(rawDataToBuffer(data).toString());
if (typeof value !== "object" || value === null) return;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const record = value as Record<string, unknown>;
frames.push({ json: record });
if (record["type"] === "audio.end") {
client.close();
resolve();
}
});
client.on("error", reject);
});
} finally {
await app.close();
}
const jsonFrames = frames.flatMap((frame) => ("json" in frame ? [frame.json] : []));
const progressFrames = jsonFrames.filter((frame) => frame["type"] === "agent.progress");
expect(progressFrames).toEqual([
{ type: "agent.progress", text: "Checking the first file now.", audio: true },
{ type: "agent.progress", text: longMessage, audio: true },
]);
// The final answer is not also spoken as progress, and never repeats.
expect(jsonFrames).toContainEqual({ type: "assistant.final", text: "Here is the final answer." });
expect(jsonFrames.filter((frame) => frame["type"] === "assistant.final")).toHaveLength(1);
// Full text goes out over JSON; only the long message is shortened for speech.
expect(synthesizeCalls).toHaveLength(3);
const [firstClip, secondClip, thirdClip] = synthesizeCalls;
expect(firstClip).toBe("Checking the first file now.");
expect(secondClip).toBeDefined();
expect((secondClip ?? "").length).toBeLessThan(longMessage.length);
expect((secondClip ?? "").length).toBeLessThanOrEqual(300);
expect((secondClip ?? "").endsWith(".")).toBe(true);
expect(thirdClip).toBe("Here is the final answer.");
// Every clip's binary frame(s) are sent before the next JSON frame that
// follows them, i.e. progress clips never interleave or reorder.
const order = frames.map((frame) => ("json" in frame ? frame.json["type"] : "binary"));
const firstProgress = order.indexOf("agent.progress");
const firstBinary = order.indexOf("binary");
const finalIndex = order.indexOf("assistant.final");
expect(firstProgress).toBeGreaterThanOrEqual(0);
expect(firstBinary).toBeGreaterThan(firstProgress);
expect(finalIndex).toBeGreaterThan(firstBinary);
// 3 synthesized clips (2 progress + 1 final) => 3 binary frames.
expect(order.filter((type) => type === "binary")).toHaveLength(3);
});
it("streams the final answer as sentences complete, before agent.settled, then sends assistant.final and audio.end without re-synthesizing already-spoken audio", async () => {
const directory = mkdtempSync(join(tmpdir(), "pi-web-voice-api-"));
temporary.push(directory);
const events = Object.assign(new EventEmitter(), {
close: () => undefined,
readyState: websocketConnectingState(),
});
const sentence1 = "First sentence. ";
const sentence2 = "Second sentence. ";
const sentence3 = "Third sentence.";
const fullText = sentence1 + sentence2 + sentence3;
const daemon = {
request: (method: string, path: string) => {
if (method === "POST" && path === "/sessions")
return Promise.resolve(response(200, { id: "session-1" }));
if (method === "POST" && path.endsWith("/prompt")) {
queueMicrotask(() => {
events.emit("message", Buffer.from(JSON.stringify({ type: "assistant.delta", text: sentence1 })));
events.emit("message", Buffer.from(JSON.stringify({ type: "assistant.delta", text: sentence2 })));
events.emit(
"message",
Buffer.from(JSON.stringify({
type: "message.end",
message: { role: "assistant", content: [{ type: "text", text: fullText }] },
}))
);
events.emit("message", Buffer.from(JSON.stringify({ type: "agent.settled" })));
});
return Promise.resolve(response(200, { accepted: true }));
}
return Promise.resolve(response(200, {}));
},
connectWebSocket: () => {
queueMicrotask(() => {
events.readyState = WebSocket.OPEN;
events.emit("open");
});
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return events as unknown as WebSocket;
},
};
const synthesizeCalls: string[] = [];
const speech = {
recognize: () => ({ write: () => undefined, end: () => Promise.resolve(), close: () => undefined }),
synthesize: (text: string, onAudio: (pcm: Buffer) => void) => {
synthesizeCalls.push(text);
onAudio(Buffer.from([synthesizeCalls.length]));
return Promise.resolve();
},
};
const app = Fastify();
await app.register(fastifyWebsocket);
const configPath = join(directory, "voice-api.json");
registerVoiceApiRoutes(app, {
configPath,
daemon,
speech,
projects: { list: () => Promise.resolve([{ id: "p", name: "P", path: "/tmp", createdAt: "" }]) },
workspaces: { list: () => Promise.resolve([{ id: "w", projectId: "p", path: "/tmp", label: "W", isMain: true, isGitRepo: false, isGitWorktree: false }]) },
});
const token = createVoiceToken({}, configPath).token;
const created = await app.inject({ method: "POST", url: "/api/v1/voice/conversations", headers: { authorization: `Bearer ${token}` }, payload: { workspaceId: "w" } });
const address = await app.listen({ port: 0, host: "127.0.0.1" });
const frames: ({ json: Record<string, unknown> } | { binary: Buffer })[] = [];
try {
await new Promise<void>((resolve, reject) => {
const client = new WebSocket(address.replace("http", "ws") + "/api/v1/voice/stream", { headers: { authorization: `Bearer ${token}` } });
client.on("open", () => {
client.send(JSON.stringify({ type: "attach", conversationId: responseId(created.body) }));
client.send(JSON.stringify({ type: "input.text", text: "explain it" }));
});
client.on("message", (data, binary) => {
if (binary) {
frames.push({ binary: rawDataToBuffer(data) });
return;
}
const value: unknown = JSON.parse(rawDataToBuffer(data).toString());
if (typeof value !== "object" || value === null) return;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const record = value as Record<string, unknown>;
frames.push({ json: record });
if (record["type"] === "audio.end") {
client.close();
resolve();
}
});
client.on("error", reject);
});
} finally {
await app.close();
}
const jsonFrames = frames.flatMap((frame) => ("json" in frame ? [frame.json] : []));
const sentenceFrames = jsonFrames.filter((frame) => frame["type"] === "answer.sentence");
expect(sentenceFrames).toEqual([
{ type: "answer.sentence", text: "First sentence." },
{ type: "answer.sentence", text: "Second sentence." },
{ type: "answer.sentence", text: "Third sentence." },
]);
// The third sentence has no trailing punctuation+whitespace of its own
// (it is the end of the message) so it is only known once message.end
// arrives, well before agent.settled — it is not deferred to the
// post-settle path.
expect(synthesizeCalls).toEqual(["First sentence.", "Second sentence.", "Third sentence."]);
expect(jsonFrames).toContainEqual({ type: "assistant.final", text: fullText.trim() });
// Everything was already spoken live: no post-settle audio is sent.
const order = frames.map((frame) => ("json" in frame ? frame.json["type"] : "binary"));
expect(order.filter((type) => type === "binary")).toHaveLength(3);
// Every answer.sentence's own binary frame(s) are sent before the next
// JSON frame, and all sentence audio precedes assistant.final/audio.end
// — the shared FIFO chain (also used by agent.progress) never
// interleaves or reorders clips. (Filtered to the frame types this
// assertion cares about; agent.working/assistant.delta/etc. also
// appear in `order` but are irrelevant to sequencing answer audio.)
const relevant = order.filter((type) =>
type === "answer.sentence" || type === "binary" || type === "assistant.final" || type === "audio.end"
);
expect(relevant).toEqual([
"answer.sentence", "binary",
"answer.sentence", "binary",
"answer.sentence", "binary",
"assistant.final",
"audio.end",
]);
// All three answer clips share the turn's single monotonic output
// sequence counter (same as agent.progress/final audio).
const binaries = frames.flatMap((frame) => ("binary" in frame ? [frame.binary] : []));
const sequences = binaries.map((buffer) => buffer.readUInt32BE(4));
expect(sequences).toEqual([0, 1, 2]);
});
it("sends only the unspoken tail post-settle when the answer sentence cap is hit", async () => {
const directory = mkdtempSync(join(tmpdir(), "pi-web-voice-api-"));
temporary.push(directory);
const events = Object.assign(new EventEmitter(), {
close: () => undefined,
readyState: websocketConnectingState(),
});
// 45 short, complete sentences (every one, including the last, is
// followed by whitespace) so all are eligible for live streaming, but
// the ~40-sentence cap leaves the last 5 unspoken.
const sentences = Array.from({ length: 45 }, (_, index) => `Sentence ${String(index + 1)}.`);
const fullText = `${sentences.join(" ")} `;
const daemon = {
request: (method: string, path: string) => {
if (method === "POST" && path === "/sessions")
return Promise.resolve(response(200, { id: "session-1" }));
if (method === "POST" && path.endsWith("/prompt")) {
queueMicrotask(() => {
events.emit("message", Buffer.from(JSON.stringify({ type: "assistant.delta", text: fullText })));
events.emit(
"message",
Buffer.from(JSON.stringify({
type: "message.end",
message: { role: "assistant", content: [{ type: "text", text: fullText }] },
}))
);
events.emit("message", Buffer.from(JSON.stringify({ type: "agent.settled" })));
});
return Promise.resolve(response(200, { accepted: true }));
}
return Promise.resolve(response(200, {}));
},
connectWebSocket: () => {
queueMicrotask(() => {
events.readyState = WebSocket.OPEN;
events.emit("open");
});
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return events as unknown as WebSocket;
},
};
const synthesizeCalls: string[] = [];
const speech = {
recognize: () => ({ write: () => undefined, end: () => Promise.resolve(), close: () => undefined }),
synthesize: (text: string, onAudio: (pcm: Buffer) => void) => {
synthesizeCalls.push(text);
onAudio(Buffer.from([1]));
return Promise.resolve();
},
};
const app = Fastify();
await app.register(fastifyWebsocket);
const configPath = join(directory, "voice-api.json");
registerVoiceApiRoutes(app, {
configPath,
daemon,
speech,
projects: { list: () => Promise.resolve([{ id: "p", name: "P", path: "/tmp", createdAt: "" }]) },
workspaces: { list: () => Promise.resolve([{ id: "w", projectId: "p", path: "/tmp", label: "W", isMain: true, isGitRepo: false, isGitWorktree: false }]) },
});
const token = createVoiceToken({}, configPath).token;
const created = await app.inject({ method: "POST", url: "/api/v1/voice/conversations", headers: { authorization: `Bearer ${token}` }, payload: { workspaceId: "w" } });
const address = await app.listen({ port: 0, host: "127.0.0.1" });
const jsonFrames: Record<string, unknown>[] = [];
try {
await new Promise<void>((resolve, reject) => {
const client = new WebSocket(address.replace("http", "ws") + "/api/v1/voice/stream", { headers: { authorization: `Bearer ${token}` } });
client.on("open", () => {
client.send(JSON.stringify({ type: "attach", conversationId: responseId(created.body) }));
client.send(JSON.stringify({ type: "input.text", text: "list them all" }));
});
client.on("message", (data, binary) => {
if (binary) return;
const value: unknown = JSON.parse(rawDataToBuffer(data).toString());
if (typeof value !== "object" || value === null) return;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const record = value as Record<string, unknown>;
jsonFrames.push(record);
if (record["type"] === "audio.end") {
client.close();
resolve();
}
});
client.on("error", reject);
});
} finally {
await app.close();
}
const sentenceFrames = jsonFrames.filter((frame) => frame["type"] === "answer.sentence");
expect(sentenceFrames).toHaveLength(40);
expect(sentenceFrames.map((frame) => frame["text"])).toEqual(sentences.slice(0, 40).map((text) => text));
expect(jsonFrames).toContainEqual({ type: "assistant.final", text: fullText.trim() });
// 40 live sentence clips, plus exactly one post-settle clip for the
// unspoken tail (sentences 41-45) — never re-synthesizing sentences 1-40.
expect(synthesizeCalls).toHaveLength(41);
expect(synthesizeCalls.slice(0, 40)).toEqual(sentences.slice(0, 40));
expect(synthesizeCalls[40]).toBe(sentences.slice(40).join(" "));
});
it("stops answer-sentence streaming once a message grows a toolCall, and does not double-speak the part already spoken live", async () => {
const directory = mkdtempSync(join(tmpdir(), "pi-web-voice-api-"));
temporary.push(directory);
const events = Object.assign(new EventEmitter(), {
close: () => undefined,
readyState: websocketConnectingState(),
});
const spokenLive = "Let me check that.";
const spokenAfterToolStart = "Actually reading more now.";
const messageText = `${spokenLive} ${spokenAfterToolStart}`;
const daemon = {
request: (method: string, path: string) => {
if (method === "POST" && path === "/sessions")
return Promise.resolve(response(200, { id: "session-1" }));
if (method === "POST" && path.endsWith("/prompt")) {
queueMicrotask(() => {
// "Let me check that." completes (trailing space) and is
// spoken live before the tool call is known to exist.
events.emit("message", Buffer.from(JSON.stringify({ type: "assistant.delta", text: `${spokenLive} ` })));
events.emit("message", Buffer.from(JSON.stringify({ type: "tool.start", toolName: "read_file", toolCallId: "1" })));
// More text after the tool call starts must not be live-streamed.
events.emit("message", Buffer.from(JSON.stringify({ type: "assistant.delta", text: spokenAfterToolStart })));
events.emit(
"message",
Buffer.from(JSON.stringify({
type: "message.end",
message: { role: "assistant", content: [
{ type: "text", text: messageText },
{ type: "toolCall", id: "1", name: "read_file", input: {} },
] },
}))
);
events.emit(
"message",
Buffer.from(JSON.stringify({
type: "message.end",
message: { role: "assistant", content: [{ type: "text", text: "Done." }] },
}))
);
events.emit("message", Buffer.from(JSON.stringify({ type: "agent.settled" })));
});
return Promise.resolve(response(200, { accepted: true }));
}
return Promise.resolve(response(200, {}));
},
connectWebSocket: () => {
queueMicrotask(() => {
events.readyState = WebSocket.OPEN;
events.emit("open");
});
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return events as unknown as WebSocket;
},
};
const synthesizeCalls: string[] = [];
const speech = {
recognize: () => ({ write: () => undefined, end: () => Promise.resolve(), close: () => undefined }),
synthesize: (text: string, onAudio: (pcm: Buffer) => void) => {
synthesizeCalls.push(text);
onAudio(Buffer.from([1]));
return Promise.resolve();
},
};
const app = Fastify();
await app.register(fastifyWebsocket);
const configPath = join(directory, "voice-api.json");
registerVoiceApiRoutes(app, {
configPath,
daemon,
speech,
projects: { list: () => Promise.resolve([{ id: "p", name: "P", path: "/tmp", createdAt: "" }]) },
workspaces: { list: () => Promise.resolve([{ id: "w", projectId: "p", path: "/tmp", label: "W", isMain: true, isGitRepo: false, isGitWorktree: false }]) },
});
const token = createVoiceToken({}, configPath).token;
const created = await app.inject({ method: "POST", url: "/api/v1/voice/conversations", headers: { authorization: `Bearer ${token}` }, payload: { workspaceId: "w" } });
const address = await app.listen({ port: 0, host: "127.0.0.1" });
const jsonFrames: Record<string, unknown>[] = [];
try {
await new Promise<void>((resolve, reject) => {
const client = new WebSocket(address.replace("http", "ws") + "/api/v1/voice/stream", { headers: { authorization: `Bearer ${token}` } });
client.on("open", () => {
client.send(JSON.stringify({ type: "attach", conversationId: responseId(created.body) }));
client.send(JSON.stringify({ type: "input.text", text: "look into it" }));
});
client.on("message", (data, binary) => {
if (binary) return;
const value: unknown = JSON.parse(rawDataToBuffer(data).toString());
if (typeof value !== "object" || value === null) return;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const record = value as Record<string, unknown>;
jsonFrames.push(record);
if (record["type"] === "audio.end") {
client.close();
resolve();
}
});
client.on("error", reject);
});
} finally {
await app.close();
}
// "Let me check that." streams live before the tool call is known
// about; "Done." is the turn's separate, subsequent final message and
// is legitimately spoken too, once it ends the turn.
expect(jsonFrames.filter((frame) => frame["type"] === "answer.sentence")).toEqual([
{ type: "answer.sentence", text: spokenLive },
{ type: "answer.sentence", text: "Done." },
]);
// The intermediate-progress clip's JSON text is still the message's
// full, untruncated text, matching every other agent.progress frame.
expect(jsonFrames.filter((frame) => frame["type"] === "agent.progress")).toEqual([
{ type: "agent.progress", text: messageText, audio: true },
]);
// Only the part never spoken live was synthesized for the progress
// clip — the already-spoken opening sentence is never repeated.
expect(synthesizeCalls).toEqual([spokenLive, spokenAfterToolStart, "Done."]);
});
it("caps spoken progress at 8 clips per turn and never speaks after the turn has settled", async () => {
const directory = mkdtempSync(join(tmpdir(), "pi-web-voice-api-"));
temporary.push(directory);
const events = Object.assign(new EventEmitter(), {
close: () => undefined,
readyState: websocketConnectingState(),
});
const intermediateMessage = (index: number) => ({
type: "message.end",
message: { role: "assistant", content: [
{ type: "text", text: `Update number ${String(index)}.` },
{ type: "toolCall", id: String(index), name: "read_file", input: {} },
] },
});
const daemon = {
request: (method: string, path: string) => {
if (method === "POST" && path === "/sessions")
return Promise.resolve(response(200, { id: "session-1" }));
if (method === "POST" && path.endsWith("/prompt")) {
queueMicrotask(() => {
for (let index = 1; index <= 10; index++) {
events.emit("message", Buffer.from(JSON.stringify(intermediateMessage(index))));
}
events.emit(
"message",
Buffer.from(JSON.stringify({
type: "message.end",
message: { role: "assistant", content: [{ type: "text", text: "Done." }] },
}))
);
events.emit("message", Buffer.from(JSON.stringify({ type: "agent.settled" })));
// A stray message.end arriving after agent.settled must never be spoken.
events.emit("message", Buffer.from(JSON.stringify(intermediateMessage(11))));
});
return Promise.resolve(response(200, { accepted: true }));
}
return Promise.resolve(response(200, {}));
},
connectWebSocket: () => {
queueMicrotask(() => {
events.readyState = WebSocket.OPEN;
events.emit("open");
});
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return events as unknown as WebSocket;
},
};
const speech = {
recognize: () => ({ write: () => undefined, end: () => Promise.resolve(), close: () => undefined }),
synthesize: (_text: string, onAudio: (pcm: Buffer) => void) => {
onAudio(Buffer.from([1]));
return Promise.resolve();
},
};
const app = Fastify();
await app.register(fastifyWebsocket);
const configPath = join(directory, "voice-api.json");
registerVoiceApiRoutes(app, {
configPath,
daemon,
speech,
projects: { list: () => Promise.resolve([{ id: "p", name: "P", path: "/tmp", createdAt: "" }]) },
workspaces: { list: () => Promise.resolve([{ id: "w", projectId: "p", path: "/tmp", label: "W", isMain: true, isGitRepo: false, isGitWorktree: false }]) },
});
const token = createVoiceToken({}, configPath).token;
const created = await app.inject({ method: "POST", url: "/api/v1/voice/conversations", headers: { authorization: `Bearer ${token}` }, payload: { workspaceId: "w" } });
const address = await app.listen({ port: 0, host: "127.0.0.1" });
const jsonFrames: Record<string, unknown>[] = [];
try {
await new Promise<void>((resolve, reject) => {
const client = new WebSocket(address.replace("http", "ws") + "/api/v1/voice/stream", { headers: { authorization: `Bearer ${token}` } });
client.on("open", () => {
client.send(JSON.stringify({ type: "attach", conversationId: responseId(created.body) }));
client.send(JSON.stringify({ type: "input.text", text: "do many things" }));
});
client.on("message", (data, binary) => {
if (binary) return;
const value: unknown = JSON.parse(rawDataToBuffer(data).toString());
if (typeof value !== "object" || value === null) return;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const record = value as Record<string, unknown>;
jsonFrames.push(record);
if (record["type"] === "audio.end") {
client.close();
resolve();
}
});
client.on("error", reject);
});
} finally {
await app.close();
}
const progressFrames = jsonFrames.filter((frame) => frame["type"] === "agent.progress");
expect(progressFrames).toHaveLength(8);
expect(progressFrames.map((frame) => frame["text"])).toEqual([
"Update number 1.",
"Update number 2.",
"Update number 3.",
"Update number 4.",
"Update number 5.",
"Update number 6.",
"Update number 7.",
"Update number 8.",
]);
expect(jsonFrames).toContainEqual({ type: "assistant.final", text: "Done." });
});
it("requires a hashed bearer token and only returns registered workspaces", async () => {
const { app, configPath } = setup();
const created = createVoiceToken(
+553 -18
View File
@@ -28,9 +28,124 @@ const BINARY_VERSION = 1;
const BINARY_AUDIO = 1;
const OUTPUT_CHUNK = 16 * 1024;
const EVENT_OPEN_TIMEOUT_MS = 10_000;
const TURN_TIMEOUT_MS = 120_000;
// Long-thinking models can take minutes before the first answer token; the
// voice client's receive budget is raised in lockstep (MAX_TURN_RECEIVE_SECONDS).
const TURN_TIMEOUT_MS = 300_000;
const WORKING_PROGRESS_INTERVAL_MS = 15_000;
const SPOKEN_PROGRESS_DELAY_MS = 2_000;
// Per-turn cap on distinct spoken progress clips (the "Hang on..." fallback
// plus intermediate assistant messages spoken between tool calls). Keeps a
// chatty multi-tool-call turn from talking over itself indefinitely.
const MAX_SPOKEN_PROGRESS_CLIPS = 8;
// Per-turn cap on combined spoken progress audio, in milliseconds (~60s).
// Checked before starting a new clip, so it bounds total narration time
// without truncating a clip that is already playing.
const MAX_SPOKEN_PROGRESS_AUDIO_MS = 60_000;
// Azure progress/final clips are 24 kHz mono 16-bit PCM: 24000 * 2 bytes/sec.
const PROGRESS_AUDIO_BYTES_PER_MS = 48;
// Intermediate assistant messages are truncated to about this many
// characters (at a sentence boundary where possible) before being sent to
// speech synthesis. The full message text is still sent in the JSON frame.
const PROGRESS_SPEECH_MAX_CHARS = 300;
// Per-turn cap on combined spoken *answer* audio (sentences streamed live
// as the final message is generated, plus any post-settle unspoken tail).
// Higher than MAX_SPOKEN_PROGRESS_AUDIO_MS because this is the actual
// answer, not filler narration.
const MAX_SPOKEN_ANSWER_AUDIO_MS = 120_000;
// Guard against a pathological number of tiny "sentences" (e.g. a message
// that is mostly punctuation) turning into an endless run of small clips.
// Anything beyond this count is left for the post-settle unspoken-tail path.
const MAX_SPOKEN_ANSWER_SENTENCES = 40;
// Rotation of short, natural fallback phrases spoken ~2 seconds into a turn
// when nothing else has been said yet (see SPOKEN_PROGRESS_DELAY_MS). Picked
// pseudo-randomly per turn; consecutive turns never repeat the same phrase
// (see pickFallbackPhrase's module-scope lastFallbackPhrase).
const FALLBACK_PROGRESS_PHRASES: readonly string[] = [
"One sec.",
"Let me take a look.",
"Hmm, checking now.",
"Working on it.",
"Give me a moment.",
"Let me look into that.",
];
// Spoken every ~45s while a long-thinking model has produced nothing
// speakable yet; rotates without immediate repeats like the fallback set.
const STILL_WORKING_PHRASES: readonly string[] = [
"Still on it.",
"Still thinking this through.",
"Almost there, still working.",
"This one needs a bit more thought.",
"Bear with me, still working on it.",
];
// Reasoning models (e.g. qwen "thinking" variants) inline their chain of
// thought as <think>/<thinking> blocks in the same delta stream as the
// answer. That content must never be spoken or shown as answer text.
const THINK_OPEN_RE = /<think(?:ing)?>/;
const THINK_CLOSE_RE = /<\/think(?:ing)?>/;
const THINK_TAG_MAX = "</thinking>".length;
export function stripThinkingStream(
turn: Pick<SocketTurn, "thinkDepth" | "thinkCarry">,
text: string
): string {
let pending = turn.thinkCarry + text;
turn.thinkCarry = "";
let visible = "";
while (pending !== "") {
const match = (turn.thinkDepth > 0 ? THINK_CLOSE_RE : THINK_OPEN_RE).exec(pending);
const opposite = (turn.thinkDepth > 0 ? THINK_OPEN_RE : THINK_CLOSE_RE).exec(pending);
// Nested opens inside reasoning are treated as plain text by Pi's own
// renderers; track only the matching tag for the current depth, but a
// stray close while at depth 0 is dropped as noise rather than shown.
const hit = match ?? (turn.thinkDepth === 0 ? opposite : null);
if (hit === null) {
// No complete tag: emit/discard all but a possible partial tag suffix.
const keep = partialTagSuffix(pending);
const emit = pending.slice(0, pending.length - keep.length);
if (turn.thinkDepth === 0) visible += emit;
turn.thinkCarry = keep;
break;
}
const before = pending.slice(0, hit.index);
if (turn.thinkDepth === 0 && match !== null) visible += before;
if (match !== null) turn.thinkDepth = turn.thinkDepth > 0 ? 0 : 1;
pending = pending.slice(hit.index + hit[0].length);
}
return visible;
}
function partialTagSuffix(text: string): string {
const window = text.slice(-THINK_TAG_MAX);
const start = window.lastIndexOf("<");
if (start === -1) return "";
const candidate = window.slice(start);
return "</thinking>".startsWith(candidate) || "<thinking>".startsWith(candidate) ||
"</think>".startsWith(candidate) || "<think>".startsWith(candidate)
? candidate
: "";
}
export function stripThinkingAll(text: string): string {
return text
.replace(/<think(?:ing)?>[\s\S]*?(?:<\/think(?:ing)?>|$)/g, "")
.replace(/^\s+/, "");
}
let lastStillWorkingPhrase: string | undefined;
export function pickStillWorkingPhrase(): string {
const pool = STILL_WORKING_PHRASES.filter(
(phrase) => phrase !== lastStillWorkingPhrase
);
const source = pool.length > 0 ? pool : STILL_WORKING_PHRASES;
const phrase =
source[Math.floor(Math.random() * source.length)] ?? source[0] ?? "Still on it.";
lastStillWorkingPhrase = phrase;
return phrase;
}
// Splits streamed answer text into complete sentences: one or more
// sentence-final marks (. ! ?), optionally followed by closing quotes or
// brackets, followed by required whitespace. Requiring the trailing
// whitespace is what keeps a mid-number decimal point like "3.5" (period
// immediately followed by a digit, no space) from being treated as a
// sentence boundary, with no special-cased abbreviation list needed.
const SENTENCE_BOUNDARY_RE = /[.!?]+[)\]"'”’]*\s+/;
const DEVICE_WORKSPACE_ROOT = "/home/hope/workspaces";
const VOICE_CONVERSATION_INSTRUCTIONS = "This is a voice conversation. Respond in natural, concise spoken language suitable for Azure DragonHD text-to-speech. Do not use Markdown, headings, tables, bullet lists, code fences, URLs unless explicitly requested, or visual-only references. Speak punctuation naturally and expand ambiguous symbols when useful.";
interface TokenRecord {
@@ -100,7 +215,68 @@ interface SocketTurn {
eventSocket: WebSocket | undefined;
workingTimer: ReturnType<typeof setInterval> | undefined;
progressTimer: ReturnType<typeof setTimeout> | undefined;
/** True once any spoken progress clip (fallback or intermediate message) has started for the active turn. */
progressAnnounced: boolean | undefined;
/**
* True once Pi's agent.settled event has been observed for the active
* turn. The final answer supersedes any further spoken progress, so
* queued/in-flight progress clips check this and stop announcing.
*/
settled: boolean;
/**
* Serializes spoken progress clip synthesis and sending so clips are
* never interleaved or reordered on the shared audio output. The final
* answer's synthesis also waits on this chain before it starts, so it
* never overlaps a still-playing progress clip.
*/
progressChain: Promise<void>;
/** Count of spoken progress clips sent so far this turn; see MAX_SPOKEN_PROGRESS_CLIPS. */
progressClipsSent: number;
/** Combined spoken progress audio sent so far this turn, in ms; see MAX_SPOKEN_PROGRESS_AUDIO_MS. */
progressAudioMs: number;
/**
* Delta-accumulated text of the assistant message currently streaming
* (i.e. since the last message.end, or since the turn started). Reset at
* every message boundary; see streamSpokenLength/streamToolCall.
*/
streamText: string;
/**
* Streaming <think>/<thinking> filter state for the current message:
* nesting depth (text inside any depth > 0 is reasoning, never shown or
* spoken) and a carried fragment that might be the start of a tag split
* across delta boundaries. Reset per turn and at every message.end.
*/
thinkDepth: number;
thinkCarry: string;
/**
* How many leading characters of the current message's text (streamText,
* or the message.end/settle-time authoritative text once the message has
* ended) have already been queued as spoken answer.sentence clips. The
* remainder is what still needs to be spoken, whether live, at
* message.end, or as the post-settle unspoken tail.
*/
streamSpokenLength: number;
/**
* True once a toolCall part has been observed for the message currently
* streaming (via a tool.start event, or defensively at its message.end).
* While true, further delta text for that message is not treated as
* answer sentences — it was narration ahead of a tool call, not the
* final answer.
*/
streamToolCall: boolean;
/**
* True once the turn's final message (the one with no toolCall) has had
* its trailing remainder flushed — either at its own message.end, or, if
* agent.settled arrived without an explicit message.end, defensively in
* runTurn. Prevents flushing the same trailing text twice.
*/
finalFlushDone: boolean;
/** True once any real (non-noise) answer.sentence clip has been queued this turn. */
answerStreamingStarted: boolean;
/** Count of answer.sentence clips queued so far this turn; see MAX_SPOKEN_ANSWER_SENTENCES. */
answerSentencesSent: number;
/** Combined spoken answer audio sent so far this turn, in ms; see MAX_SPOKEN_ANSWER_AUDIO_MS. */
answerAudioMs: number;
/** VAD/STT result only; never use it as an assistant response. */
transcript: string;
/** Assistant output collected from Pi's session event stream only. */
@@ -420,6 +596,19 @@ function wireVoiceSocket(
workingTimer: undefined,
progressTimer: undefined,
progressAnnounced: false,
settled: false,
progressChain: Promise.resolve(),
progressClipsSent: 0,
progressAudioMs: 0,
streamText: "",
thinkDepth: 0,
thinkCarry: "",
streamSpokenLength: 0,
streamToolCall: false,
finalFlushDone: false,
answerStreamingStarted: false,
answerSentencesSent: 0,
answerAudioMs: 0,
};
sendJson(socket, {
type: "hello",
@@ -587,13 +776,36 @@ async function submitText(
if (turn.closed) return;
conversation.status = "working";
turn.assistant = "";
turn.settled = false;
turn.progressAnnounced = false;
turn.progressChain = Promise.resolve();
turn.progressClipsSent = 0;
turn.progressAudioMs = 0;
turn.streamText = "";
turn.streamSpokenLength = 0;
turn.streamToolCall = false;
turn.thinkDepth = 0;
turn.thinkCarry = "";
turn.finalFlushDone = false;
turn.answerStreamingStarted = false;
turn.answerSentencesSent = 0;
turn.answerAudioMs = 0;
sendJson(socket, { type: "agent.working" });
startWorkingProgress(socket, turn, () => {
sendJson(socket, { type: "agent.progress", text: "Hang on while I work on that.", audio: true });
void speech.synthesize("Hang on while I work on that.", (pcm) => {
if (!turn.closed) sendAudio(socket, turn, pcm);
});
});
startWorkingProgress(
socket,
turn,
() => {
const phrase = pickFallbackPhrase();
void speakProgress(socket, turn, speech, phrase, phrase);
},
() => {
// Quiet down as soon as the real answer (or any narration mid-flow)
// has audibly begun; only fill genuinely dead air.
if (turn.settled || turn.answerStreamingStarted) return;
const phrase = pickStillWorkingPhrase();
void speakProgress(socket, turn, speech, phrase, phrase);
}
);
try {
await withTimeout(
runTurn(socket, turn, conversation, deps, speech, text),
@@ -630,7 +842,8 @@ async function runTurn(
deps.daemon,
conversation.sessionId,
turn,
socket
socket,
speech
);
try {
await daemonJson(
@@ -650,10 +863,32 @@ async function runTurn(
}
sendJson(socket, { type: "agent.accepted" });
await settled;
// Most runs get a message.end for the final (no-toolCall) message before
// agent.settled, and its handler in startSettledWaiter already flushed
// the trailing remainder live. But agent.settled can arrive without one
// (e.g. a very fast turn); this is the backstop for that case, using
// turn.assistant (the settled final text) as the authoritative source.
if (!turn.finalFlushDone) {
commitAnswerSentences(
turn,
socket,
speech,
extractCompleteSentences(turn.assistant.slice(turn.streamSpokenLength), true)
);
}
// The final answer must never overlap or reorder against a spoken
// progress/answer clip that was still synthesizing/sending when the turn
// settled; draining the shared chain first guarantees strict ordering
// on the single audio output stream.
await turn.progressChain;
const finalText = turn.assistant.trim();
sendJson(socket, { type: "assistant.final", text: finalText });
if (finalText !== "") {
await speech.synthesize(finalText, (pcm) => {
// Anything not already streamed live (e.g. the sentence/audio budget was
// hit) is synthesized once here, as ordinary post-settle audio. When
// everything streamed live this is empty and no audio frame is sent.
const unspokenTail = turn.assistant.slice(turn.streamSpokenLength).trim();
if (unspokenTail !== "") {
await speech.synthesize(prepareForSpeech(unspokenTail), (pcm) => {
if (!turn.closed) sendAudio(socket, turn, pcm);
});
}
@@ -664,7 +899,8 @@ async function startSettledWaiter(
daemon: SessionProxyDaemon,
sessionId: string,
turn: SocketTurn,
socket: WebSocket
socket: WebSocket,
speech: VoiceSpeechGateway
): Promise<{ settled: Promise<void> }> {
const events = daemon.connectWebSocket(
`/sessions/${encodeURIComponent(sessionId)}/events`
@@ -691,16 +927,82 @@ async function startSettledWaiter(
event["type"] === "assistant.delta" &&
typeof event["text"] === "string"
) {
turn.assistant += event["text"];
sendJson(socket, { type: "assistant.delta", text: event["text"] });
const visible = stripThinkingStream(turn, event["text"]);
if (visible === "") return;
turn.assistant += visible;
sendJson(socket, { type: "assistant.delta", text: visible });
// Which message will turn out to be "the final answer" isn't
// knowable mid-stream, so every message is optimistically
// streamed as answer sentences until either a toolCall part
// appears in it (see tool.start below) or it ends.
if (!turn.settled && !turn.streamToolCall) {
turn.streamText += visible;
processAnswerDelta(turn, socket, speech);
}
}
if (event["type"] === "message.end") {
const text = assistantText(event["message"]);
// Pi emits tool.start once a message's tool call begins, which (for
// a message with narration before it) arrives after that
// narration's text deltas but before the message's message.end.
// It is the earliest signal that this message will not be the
// turn's final answer, so live sentence-streaming for it stops
// here rather than waiting for message.end to find out.
if (event["type"] === "tool.start" && !turn.settled) {
turn.streamToolCall = true;
}
// A stray message.end after agent.settled (the daemon should not
// send one, but nothing prevents it) must not overwrite the final
// answer already captured, nor be queued for spoken progress.
if (event["type"] === "message.end" && !turn.settled) {
const text = stripThinkingAll(assistantText(event["message"]));
turn.thinkDepth = 0;
turn.thinkCarry = "";
if (text !== "") turn.assistant = text;
// A message that includes a tool call is not the turn's final
// answer: Pi's agent loop only settles once an assistant message
// has no pending tool call, so a toolCall part here means more
// work (and likely another assistant message) is still coming.
if (messageHasToolCall(event["message"])) {
// Only the part of this message that was never spoken live (see
// assistant.delta above) still needs narrating — track the
// offset so a message spoken in full live is never re-spoken,
// and skip the clip entirely when nothing is left unspoken.
const spokenThisMessage = turn.streamSpokenLength > 0;
const unspoken = text.slice(turn.streamSpokenLength).trim();
// Once the turn's answer has audibly begun, a brand-new message
// that had no live narration of its own (spokenThisMessage
// false) is left quiet rather than talking over it; a message
// that *was* mid-narration when the tool call appeared still
// gets its own leftover finished, so it isn't cut off abruptly.
const suppressed = turn.answerStreamingStarted && !spokenThisMessage;
if (unspoken !== "" && !suppressed) {
void speakProgress(socket, turn, speech, text, truncateForSpeech(unspoken, PROGRESS_SPEECH_MAX_CHARS));
}
turn.streamText = "";
turn.streamSpokenLength = 0;
turn.streamToolCall = false;
} else {
// No toolCall part: Pi's agent loop only settles once a
// message like this is reached, so this is definitively the
// turn's final answer. Flush its trailing remainder (held back
// live because it lacked trailing punctuation+whitespace) as a
// last answer.sentence; leave streamSpokenLength as-is (no
// "next message" is coming) so runTurn's post-settle tail
// computation and the finalFlushDone guard below both see it.
if (text !== "") {
commitAnswerSentences(turn, socket, speech, extractCompleteSentences(text.slice(turn.streamSpokenLength), true));
}
turn.finalFlushDone = true;
}
}
// This is Pi's native session-level event, forwarded by
// PiSessionService/sessiond without deriving it from agent_end.
if (event["type"] === "agent.settled") finish();
if (event["type"] === "agent.settled") {
// Mark settled before finish() so any progress clip already
// queued on turn.progressChain (but not yet started) skips
// itself instead of speaking after the turn has concluded.
turn.settled = true;
finish();
}
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)));
}
@@ -732,12 +1034,20 @@ function waitForWebSocketOpen(socket: WebSocket): Promise<void> {
export function startWorkingProgress(
socket: Pick<WebSocket, "readyState" | "send">,
turn: Pick<SocketTurn, "closed" | "workingTimer"> & Partial<Pick<SocketTurn, "progressTimer" | "progressAnnounced">>,
onSpokenProgress?: () => void
onSpokenProgress?: () => void,
onPeriodicSpokenUpdate?: () => void
): void {
clearWorkingProgress(turn);
let ticks = 0;
const timer = setInterval(() => {
if (!turn.closed && turn.workingTimer === timer && socket.readyState === 1)
if (!turn.closed && turn.workingTimer === timer && socket.readyState === 1) {
socket.send(JSON.stringify({ type: "agent.working" }));
// A long-thinking model can produce nothing speakable for minutes; a
// spoken "still on it" every third heartbeat (~45s) keeps the room from
// going dead between the 2s opener and the first real answer sentence.
ticks += 1;
if (onPeriodicSpokenUpdate !== undefined && ticks % 3 === 0) onPeriodicSpokenUpdate();
}
}, WORKING_PROGRESS_INTERVAL_MS);
timer.unref();
turn.workingTimer = timer;
@@ -940,6 +1250,231 @@ function rawDataToBuffer(data: RawData): Buffer {
if (data instanceof ArrayBuffer) return Buffer.from(data);
return Buffer.concat(data);
}
/**
* Queue one spoken progress clip (the "Hang on..." fallback or an
* intermediate assistant message) behind any clip already in flight. Callers
* do not need to await the result; runTurn awaits turn.progressChain once
* before the final answer so every queued clip finishes, in order, first.
*/
function speakProgress(
socket: WebSocket,
turn: SocketTurn,
speech: VoiceSpeechGateway,
fullText: string,
speechText: string
): Promise<void> {
turn.progressChain = turn.progressChain.then(() =>
sendProgressClip(socket, turn, speech, fullText, speechText)
);
return turn.progressChain;
}
async function sendProgressClip(
socket: WebSocket,
turn: SocketTurn,
speech: VoiceSpeechGateway,
fullText: string,
speechText: string
): Promise<void> {
// Whether this particular message should be spoken at all (including the
// "already settled" rule) is decided once, synchronously, by the caller at
// the moment its triggering event arrived — see the message.end handler in
// startSettledWaiter. Re-checking turn.settled here would be wrong: this
// function runs on the progressChain microtask queue, so a same-tick burst
// of message.end + agent.settled events (a single WebSocket read can
// deliver several frames back to back) could otherwise flip turn.settled
// to true before an already-approved clip gets to run, silently dropping
// it. A closed turn still has nowhere to send audio, so that check stays.
if (turn.closed) return;
if (turn.progressClipsSent >= MAX_SPOKEN_PROGRESS_CLIPS) return;
if (turn.progressAudioMs >= MAX_SPOKEN_PROGRESS_AUDIO_MS) return;
turn.progressClipsSent += 1;
turn.progressAnnounced = true;
sendJson(socket, { type: "agent.progress", text: fullText, audio: true });
try {
await speech.synthesize(speechText, (pcm) => {
if (turn.closed) return;
turn.progressAudioMs += pcm.length / PROGRESS_AUDIO_BYTES_PER_MS;
sendAudio(socket, turn, pcm);
});
} catch {
// Best-effort narration: a failed progress clip must not abort the
// turn, nor block progress clips already queued behind it.
}
}
let lastFallbackPhrase: string | undefined;
/**
* Pick the 2-second "still working" fallback phrase for a turn, pseudo-
* randomly, but never the same phrase spoken by the immediately preceding
* turn (tracked at module scope, since a fresh SocketTurn is created per
* turn and would otherwise have no memory of it).
*/
export function pickFallbackPhrase(): string {
const candidates = FALLBACK_PROGRESS_PHRASES.filter(
(phrase) => phrase !== lastFallbackPhrase
);
const pool = candidates.length > 0 ? candidates : FALLBACK_PROGRESS_PHRASES;
const phrase =
pool[Math.floor(Math.random() * pool.length)] ?? pool[0] ?? "One sec.";
lastFallbackPhrase = phrase;
return phrase;
}
export interface ExtractedSentence {
text: string;
/** Length, in characters of the original (untrimmed) pending text, consumed by this sentence. */
rawLength: number;
}
/**
* Split the not-yet-spoken tail of a streaming answer into complete
* sentences, holding back any incomplete trailing fragment (text with no
* sentence-ending punctuation+whitespace yet) for the next call. Pass
* `flushRemainder: true` at the point the source message is known to have
* ended (message.end, or turn settle with no message.end) to also emit
* that trailing fragment as one final "sentence".
*/
export function extractCompleteSentences(
pending: string,
flushRemainder: boolean
): ExtractedSentence[] {
const sentences: ExtractedSentence[] = [];
let rest = pending;
for (;;) {
const match = SENTENCE_BOUNDARY_RE.exec(rest);
if (match === null) break;
const end = match.index + match[0].length;
sentences.push({ text: rest.slice(0, end).trim(), rawLength: end });
rest = rest.slice(end);
}
if (flushRemainder && rest.trim() !== "") {
sentences.push({ text: rest.trim(), rawLength: rest.length });
}
return sentences;
}
/**
* False for a fragment that, once simple markdown decoration is stripped
* (heading/bullet/emphasis/code/table/link punctuation), has no actual
* word content left — e.g. "---", "```", "**", "> ". These are a normal
* byproduct of segmenting a markdown-ish stream into "sentences" and
* should be silently skipped rather than spoken.
*/
export function isSpeakableFragment(text: string): boolean {
const trimmed = text.trim();
if (trimmed === "") return false;
const withoutMarkdownSyntax = trimmed
.replace(/[#*_`~=\-•>|[\]()]+/g, "")
.trim();
return withoutMarkdownSyntax !== "";
}
/**
* Text preparation immediately before handing a spoken answer fragment to
* speech synthesis. Matches how the final answer's text is prepared for
* TTS today (a trim; the server already instructs Pi not to use Markdown
* in voice turns, see VOICE_CONVERSATION_INSTRUCTIONS) so streamed
* sentences and the final/tail answer audio are prepared identically.
*/
function prepareForSpeech(text: string): string {
return text.trim();
}
/** Segment newly streamed answer text and speak any complete sentences found. */
function processAnswerDelta(
turn: SocketTurn,
socket: WebSocket,
speech: VoiceSpeechGateway
): void {
if (turn.streamToolCall) return;
commitAnswerSentences(
turn,
socket,
speech,
extractCompleteSentences(turn.streamText.slice(turn.streamSpokenLength), false)
);
}
/**
* Advance turn.streamSpokenLength over each sentence and queue it to be
* spoken, in order, unless the per-turn answer sentence/audio budget is
* already exhausted — in which case this (and every later) sentence is
* left unconsumed so it becomes part of the post-settle unspoken tail.
* Noise-only fragments (see isSpeakableFragment) are consumed (skipped
* silently) without being queued, since they carry nothing to say.
*/
function commitAnswerSentences(
turn: SocketTurn,
socket: WebSocket,
speech: VoiceSpeechGateway,
sentences: ExtractedSentence[]
): void {
for (const sentence of sentences) {
if (turn.answerSentencesSent >= MAX_SPOKEN_ANSWER_SENTENCES) break;
if (turn.answerAudioMs >= MAX_SPOKEN_ANSWER_AUDIO_MS) break;
turn.streamSpokenLength += sentence.rawLength;
if (!isSpeakableFragment(sentence.text)) continue;
turn.answerSentencesSent += 1;
turn.answerStreamingStarted = true;
turn.progressAnnounced = true;
void speakAnswerSentence(socket, turn, speech, sentence.text);
}
}
/**
* Queue one spoken answer sentence behind any progress/answer clip already
* in flight, on the same FIFO chain as speakProgress, so answer sentences
* and progress clips are never interleaved or reordered on the shared
* audio output.
*/
function speakAnswerSentence(
socket: WebSocket,
turn: SocketTurn,
speech: VoiceSpeechGateway,
text: string
): Promise<void> {
turn.progressChain = turn.progressChain.then(() =>
sendAnswerSentenceClip(socket, turn, speech, text)
);
return turn.progressChain;
}
async function sendAnswerSentenceClip(
socket: WebSocket,
turn: SocketTurn,
speech: VoiceSpeechGateway,
text: string
): Promise<void> {
if (turn.closed) return;
sendJson(socket, { type: "answer.sentence", text });
try {
await speech.synthesize(prepareForSpeech(text), (pcm) => {
if (turn.closed) return;
turn.answerAudioMs += pcm.length / PROGRESS_AUDIO_BYTES_PER_MS;
sendAudio(socket, turn, pcm);
});
} catch {
// Best-effort narration: a failed sentence clip must not abort the
// turn, nor block sentence clips already queued behind it.
}
}
/** True if an assistant message includes a tool call content part. */
function messageHasToolCall(value: unknown): boolean {
if (!isRecord(value) || !Array.isArray(value["content"])) return false;
return value["content"].some(
(part) => isRecord(part) && part["type"] === "toolCall"
);
}
/**
* Truncate long intermediate text for speech, preferring a sentence
* boundary within the limit and otherwise a word boundary. The full text is
* always sent separately in the JSON agent.progress frame; only the audio is
* shortened.
*/
function truncateForSpeech(text: string, maxChars: number): string {
const trimmed = text.trim();
if (trimmed.length <= maxChars) return trimmed;
const window = trimmed.slice(0, maxChars);
let sentenceEnd = -1;
for (const punctuation of [". ", "! ", "? "]) {
sentenceEnd = Math.max(sentenceEnd, window.lastIndexOf(punctuation));
}
if (/[.!?]$/.test(window)) sentenceEnd = Math.max(sentenceEnd, window.length - 1);
if (sentenceEnd > maxChars * 0.4) return window.slice(0, sentenceEnd + 1).trim();
const spaceIndex = window.lastIndexOf(" ");
return (spaceIndex > 0 ? window.slice(0, spaceIndex) : window).trim();
}
function assistantText(value: unknown): string {
if (
!isRecord(value) ||