From c09de47d19772a3ff0e731bbbaa36d2cf176b58a Mon Sep 17 00:00:00 2001 From: manjunath22466 <301209272+manjunath22466@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:57:22 +0530 Subject: [PATCH] fix(voice): mark a stopped reply's running action as cancelled The action under way when a reply stops is answered as cancelled, since it may have taken effect, and only later calls as skipped; a stop during a round's first action keeps that round too. The closing note no longer claims the actions succeeded. Tests cover the refusal body read cap and the release of an unread refusal body. --- src/textChatServer.test.mjs | 35 +++++++++++++++++++++ src/voice/geminiTextChat.test.mjs | 9 +++++- src/voice/textChat.js | 28 ++++++++++++----- src/voice/textChat.test.mjs | 51 ++++++++++++++++++++++++++++--- 4 files changed, 109 insertions(+), 14 deletions(-) diff --git a/src/textChatServer.test.mjs b/src/textChatServer.test.mjs index e334cf0f0..bd1dddbfb 100644 --- a/src/textChatServer.test.mjs +++ b/src/textChatServer.test.mjs @@ -479,6 +479,41 @@ for (const [name, provider] of Object.entries(providers)) { await f.send(); assert.equal(upstream, 2); }); + test(`${name} refusal bodies are read within 16 KB and released when unread`, async (t) => { + // An oversized error body is not parsed, so its reason does not count. + const padded = await fixture(t, provider, async () => + Response.json( + { + error: { + message: 'x'.repeat(20 * 1024), + details: [{ reason: 'API_KEY_INVALID' }], + }, + }, + { status: 400 }, + ), + ); + const reply = await padded.send(); + assert.equal(reply.status, 502); + assert.equal((await reply.json()).code, undefined); + // A 401 body is never read, and is cancelled rather than left open. + let cancelled = false; + const unread = await fixture( + t, + provider, + async () => + new Response( + new ReadableStream({ + pull: () => {}, + cancel: () => { + cancelled = true; + }, + }), + { status: 401 }, + ), + ); + assert.equal((await (await unread.send()).json()).code, 'API_KEY_REJECTED'); + assert.equal(cancelled, true); + }); test(`${name} throttled replies say they are rate limited`, async (t) => { const local = await fixture(t, provider, undefined, { resolveRateLimit: () => '1', diff --git a/src/voice/geminiTextChat.test.mjs b/src/voice/geminiTextChat.test.mjs index 8a952009d..32291f8dc 100644 --- a/src/voice/geminiTextChat.test.mjs +++ b/src/voice/geminiTextChat.test.mjs @@ -219,7 +219,14 @@ test('Gemini composite: chat Stop keeps the conversation and voice start ends ch requests[2].contents .filter((item) => item.parts[0].text) .map((item) => item.parts[0].text), - ['First', 'Done', 'Third'], + // The stopped turn stays, closed by its note. + [ + 'First', + 'Done', + 'Second', + 'This reply stopped before it finished; the results above are final.', + 'Third', + ], ); // Starting voice retires the typed turn in progress. adapter.controller.start = async () => {}; diff --git a/src/voice/textChat.js b/src/voice/textChat.js index 72332467a..4f52da664 100644 --- a/src/voice/textChat.js +++ b/src/voice/textChat.js @@ -20,9 +20,16 @@ const HISTORY_BYTES = 180000; const REQUEST_BYTES = 512 * 1024; // Results from earlier turns keep their outcome and say line, not their records. const COMPACT_RESULT_BYTES = 1500; +// How a stopped reply answers its calls: the one under way may have taken +// effect, the ones after it never started. +const STOPPED_RUNNING = { + ok: false, + cancelled: true, + reason: 'reply-stopped-while-running', +}; const NOT_RUN = { ok: false, skipped: true, reason: 'reply-stopped' }; const PARTIAL_NOTE = - 'This reply stopped before it finished. The map actions above already ran.'; + 'This reply stopped before it finished; the results above are final.'; /** Text-only HTTP turns: no Live token, socket, microphone or audio context. */ export function createTextChat({ @@ -157,23 +164,28 @@ export function createTextChat({ : ''; mapEvents = []; let contents = [protocol.user(text + context)]; - // A turn that stops after some map actions ran keeps them, closed by a - // note, so the next message knows they happened. + // A turn that stops after map actions started keeps them, closed by a + // note, so the next message knows what ran. let settled = 0; // The round in progress: its calls, and the results of those that ran. let openRound = null; partial = { owner, keep() { - const ran = openRound?.pairs.length ? contents.length : settled; + const ran = openRound ? contents.length : settled; if (!ran) return; - // Calls that never ran are answered as skipped, so every call in the - // kept history stays paired with a result. - const closing = openRound?.pairs.length + // Every kept call stays paired with a result. + const closing = openRound ? protocol.results( openRound.calls.map( (call, index) => - openRound.pairs[index] ?? { call, result: NOT_RUN }, + openRound.pairs[index] ?? { + call, + result: + index === openRound.pairs.length + ? STOPPED_RUNNING + : NOT_RUN, + }, ), ) : []; diff --git a/src/voice/textChat.test.mjs b/src/voice/textChat.test.mjs index 4b705a8ec..0d5876394 100644 --- a/src/voice/textChat.test.mjs +++ b/src/voice/textChat.test.mjs @@ -131,12 +131,15 @@ for (const provider of ['openai', 'gemini']) { const note = items.at(-2); const noteText = provider === 'openai' ? note.content : note.parts[0].text; assert.equal(note.role, provider === 'openai' ? 'assistant' : 'model'); - assert.match(noteText, /map actions above already ran/); + assert.match( + noteText, + /stopped before it finished; the results above are final/, + ); assert.equal(items.length, 5); h.chat.stop(); }); - test(`${provider}: Stop during the second action keeps the first, pairing the rest as skipped`, async () => { + test(`${provider}: Stop during the second action keeps the first and marks the second cancelled`, async () => { let runs = 0; const h = harness( provider, @@ -166,11 +169,44 @@ for (const provider of ['openai', 'gemini']) { ); assert.equal(results.length, 2); assert.equal(results[0].ok, true); - assert.equal(results[1].skipped, true); + assert.equal(results[1].cancelled, true, 'the action under way'); const note = items.at(-2); assert.match( provider === 'openai' ? note.content : note.parts[0].text, - /map actions above already ran/, + /stopped before it finished; the results above are final/, + ); + h.chat.stop(); + }); + + test(`${provider}: Stop during the first of three actions keeps the round`, async () => { + const h = harness( + provider, + (n) => (n === 1 ? w.calls(3) : w.final('Done')), + { + runAction: () => new Promise(() => {}), + }, + ); + h.chat.start(); + h.chat.sendText('Do three'); + await settle(); + assert.equal(h.chat.cancelTurn(), true); + h.chat.sendText('Continue'); + await settle(); + const items = w.items(h.requests[1]); + assert.deepEqual(w.userTexts(items), ['Do three', 'Continue']); + const results = + provider === 'openai' + ? items + .filter((item) => item.role === 'tool') + .map((item) => JSON.parse(item.content)) + : items.flatMap((item) => + item.parts + .filter((part) => part.functionResponse) + .map((part) => part.functionResponse.response), + ); + assert.deepEqual( + results.map((result) => result.reason), + ['reply-stopped-while-running', 'reply-stopped', 'reply-stopped'], ); h.chat.stop(); }); @@ -203,7 +239,12 @@ for (const provider of ['openai', 'gemini']) { assert.equal(h.chat.sendText('Third'), true); await settle(); assert.equal(h.requests.length, 3); - assert.deepEqual(w.userTexts(w.items(h.requests[2])), ['First', 'Third']); + // The stopped turn stays, with its action answered as cancelled. + assert.deepEqual(w.userTexts(w.items(h.requests[2])), [ + 'First', + 'Second', + 'Third', + ]); // Ending the session still starts the next one fresh. h.chat.stop(); h.chat.start();