feat: speak a progress update during long voice turns
CI / Verify and package (ubuntu-latest) (push) Canceled after 0s
CI / Verify and package (windows-latest) (push) Canceled after 0s

After two seconds of agent work, send one agent.progress frame with a
synthesized "Hang on while I work on that." clip over the same binary
audio channel (shared output-sequence counter), at most once per turn;
screen-only agent.working heartbeats continue every 15 seconds. This is
the change the 15:11 rebuild already shipped to the running server —
committing it pins the protocol addition to the client that now
tolerates and plays it.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
snowspeeder
2026-08-06 23:10:07 -04:00
co-authored by Claude Fable 5
parent a71cb8334a
commit a7033f9076
3 changed files with 45 additions and 7 deletions
+20 -3
View File
@@ -119,6 +119,20 @@ describe("voice API", () => {
expect(frame.subarray(8)).toEqual(Buffer.from([1, 2, 3, 4]));
});
it("does not announce progress for a quick completed turn", () => {
vi.useFakeTimers();
try {
const socket: Pick<WebSocket, "readyState" | "send"> = { readyState: WebSocket.OPEN, send: () => {} };
const turn: { closed: boolean; workingTimer: ReturnType<typeof setInterval> | undefined; progressTimer?: ReturnType<typeof setTimeout>; progressAnnounced?: boolean } = { closed: false, workingTimer: undefined };
const progress = vi.fn();
startWorkingProgress(socket, turn, progress);
vi.advanceTimersByTime(1_000);
clearWorkingProgress(turn);
vi.advanceTimersByTime(5_000);
expect(progress).not.toHaveBeenCalled();
} finally { vi.useRealTimers(); }
});
it("repeats working progress every 15 seconds and stops it before final/audio", () => {
vi.useFakeTimers();
try {
@@ -130,16 +144,19 @@ describe("voice API", () => {
sent.push(JSON.parse(value));
},
};
const turn: { closed: boolean; workingTimer: ReturnType<typeof setInterval> | undefined } = {
const turn: { closed: boolean; workingTimer: ReturnType<typeof setInterval> | undefined; progressTimer?: ReturnType<typeof setTimeout>; progressAnnounced?: boolean } = {
closed: false,
workingTimer: undefined,
};
const progress = vi.fn();
startWorkingProgress(socket, turn);
vi.advanceTimersByTime(45_000);
startWorkingProgress(socket, turn, progress);
vi.advanceTimersByTime(2_000);
vi.advanceTimersByTime(43_000);
clearWorkingProgress(turn);
vi.advanceTimersByTime(30_000);
expect(progress).toHaveBeenCalledTimes(1);
expect(sent).toEqual([
{ type: "agent.working" },
{ type: "agent.working" },
+24 -3
View File
@@ -30,6 +30,7 @@ const OUTPUT_CHUNK = 16 * 1024;
const EVENT_OPEN_TIMEOUT_MS = 10_000;
const TURN_TIMEOUT_MS = 120_000;
const WORKING_PROGRESS_INTERVAL_MS = 15_000;
const SPOKEN_PROGRESS_DELAY_MS = 2_000;
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 {
@@ -98,6 +99,8 @@ interface SocketTurn {
recognizer?: VoiceRecognizer;
eventSocket: WebSocket | undefined;
workingTimer: ReturnType<typeof setInterval> | undefined;
progressTimer: ReturnType<typeof setTimeout> | undefined;
progressAnnounced: boolean | undefined;
/** VAD/STT result only; never use it as an assistant response. */
transcript: string;
/** Assistant output collected from Pi's session event stream only. */
@@ -415,6 +418,8 @@ function wireVoiceSocket(
closed: false,
eventSocket: undefined,
workingTimer: undefined,
progressTimer: undefined,
progressAnnounced: false,
};
sendJson(socket, {
type: "hello",
@@ -583,7 +588,12 @@ async function submitText(
conversation.status = "working";
turn.assistant = "";
sendJson(socket, { type: "agent.working" });
startWorkingProgress(socket, turn);
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);
});
});
try {
await withTimeout(
runTurn(socket, turn, conversation, deps, speech, text),
@@ -721,7 +731,8 @@ function waitForWebSocketOpen(socket: WebSocket): Promise<void> {
export function startWorkingProgress(
socket: Pick<WebSocket, "readyState" | "send">,
turn: Pick<SocketTurn, "closed" | "workingTimer">
turn: Pick<SocketTurn, "closed" | "workingTimer"> & Partial<Pick<SocketTurn, "progressTimer" | "progressAnnounced">>,
onSpokenProgress?: () => void
): void {
clearWorkingProgress(turn);
const timer = setInterval(() => {
@@ -730,13 +741,23 @@ export function startWorkingProgress(
}, WORKING_PROGRESS_INTERVAL_MS);
timer.unref();
turn.workingTimer = timer;
if (onSpokenProgress !== undefined) {
turn.progressTimer = setTimeout(() => {
if (turn.closed || turn.progressAnnounced === true) return;
turn.progressAnnounced = true;
onSpokenProgress();
}, SPOKEN_PROGRESS_DELAY_MS);
turn.progressTimer.unref();
}
}
export function clearWorkingProgress(
turn: Pick<SocketTurn, "workingTimer">
turn: Pick<SocketTurn, "workingTimer"> & Partial<Pick<SocketTurn, "progressTimer">>
): void {
if (turn.workingTimer !== undefined) clearInterval(turn.workingTimer);
if (turn.progressTimer !== undefined) clearTimeout(turn.progressTimer);
turn.workingTimer = undefined;
turn.progressTimer = undefined;
}
function closeEventSocket(turn: SocketTurn): void {