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.
This commit is contained in:
manjunath22466 committed 2026-11-03 22:57:22 +05:30
1 parent 0a8d824124
commit c09de47d19
4 files changed
+109 -14

No files matched your search

+35
View File
@@ -479,6 +479,41 @@ for (const [name, provider] of Object.entries(providers)) {
await f.send(); await f.send();
assert.equal(upstream, 2); 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) => { test(`${name} throttled replies say they are rate limited`, async (t) => {
const local = await fixture(t, provider, undefined, { const local = await fixture(t, provider, undefined, {
resolveRateLimit: () => '1', resolveRateLimit: () => '1',
+8 -1
View File
@@ -219,7 +219,14 @@ test('Gemini composite: chat Stop keeps the conversation and voice start ends ch
requests[2].contents requests[2].contents
.filter((item) => item.parts[0].text) .filter((item) => item.parts[0].text)
.map((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. // Starting voice retires the typed turn in progress.
adapter.controller.start = async () => {}; adapter.controller.start = async () => {};
+20 -8
View File
@@ -20,9 +20,16 @@ const HISTORY_BYTES = 180000;
const REQUEST_BYTES = 512 * 1024; const REQUEST_BYTES = 512 * 1024;
// Results from earlier turns keep their outcome and say line, not their records. // Results from earlier turns keep their outcome and say line, not their records.
const COMPACT_RESULT_BYTES = 1500; 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 NOT_RUN = { ok: false, skipped: true, reason: 'reply-stopped' };
const PARTIAL_NOTE = 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. */ /** Text-only HTTP turns: no Live token, socket, microphone or audio context. */
export function createTextChat({ export function createTextChat({
@@ -157,23 +164,28 @@ export function createTextChat({
: ''; : '';
mapEvents = []; mapEvents = [];
let contents = [protocol.user(text + context)]; let contents = [protocol.user(text + context)];
// A turn that stops after some map actions ran keeps them, closed by a // A turn that stops after map actions started keeps them, closed by a
// note, so the next message knows they happened. // note, so the next message knows what ran.
let settled = 0; let settled = 0;
// The round in progress: its calls, and the results of those that ran. // The round in progress: its calls, and the results of those that ran.
let openRound = null; let openRound = null;
partial = { partial = {
owner, owner,
keep() { keep() {
const ran = openRound?.pairs.length ? contents.length : settled; const ran = openRound ? contents.length : settled;
if (!ran) return; if (!ran) return;
// Calls that never ran are answered as skipped, so every call in the // Every kept call stays paired with a result.
// kept history stays paired with a result. const closing = openRound
const closing = openRound?.pairs.length
? protocol.results( ? protocol.results(
openRound.calls.map( openRound.calls.map(
(call, index) => (call, index) =>
openRound.pairs[index] ?? { call, result: NOT_RUN }, openRound.pairs[index] ?? {
call,
result:
index === openRound.pairs.length
? STOPPED_RUNNING
: NOT_RUN,
},
), ),
) )
: []; : [];
+46 -5
View File
@@ -131,12 +131,15 @@ for (const provider of ['openai', 'gemini']) {
const note = items.at(-2); const note = items.at(-2);
const noteText = provider === 'openai' ? note.content : note.parts[0].text; const noteText = provider === 'openai' ? note.content : note.parts[0].text;
assert.equal(note.role, provider === 'openai' ? 'assistant' : 'model'); 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); assert.equal(items.length, 5);
h.chat.stop(); 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; let runs = 0;
const h = harness( const h = harness(
provider, provider,
@@ -166,11 +169,44 @@ for (const provider of ['openai', 'gemini']) {
); );
assert.equal(results.length, 2); assert.equal(results.length, 2);
assert.equal(results[0].ok, true); 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); const note = items.at(-2);
assert.match( assert.match(
provider === 'openai' ? note.content : note.parts[0].text, 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(); h.chat.stop();
}); });
@@ -203,7 +239,12 @@ for (const provider of ['openai', 'gemini']) {
assert.equal(h.chat.sendText('Third'), true); assert.equal(h.chat.sendText('Third'), true);
await settle(); await settle();
assert.equal(h.requests.length, 3); 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. // Ending the session still starts the next one fresh.
h.chat.stop(); h.chat.stop();
h.chat.start(); h.chat.start();