// A06/KH-018: Cascade's poll loop returns `max_wait` / `stall_warm` / // `idle_empty` as a NORMAL array. Before this, both Chat builders chose only // `stop` or `tool_calls`, so a half answer looked complete; /v1/responses then // committed it to the store as the next turn's context. // // The contract this file pins: // - a poll-window/stall exit WITH output is a typed `length` truncation on // Chat, and an `upstream_incomplete` Responses turn that is NOT stored; // - an exit with NO output (`idle_empty`) is a typed upstream error, never a // 200 empty completion; // - the stream paths never say `finish_reason:"stop"` for either shape. import { afterEach, beforeEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { handleChatCompletions } from '../src/handlers/chat.js'; import { handleMessages } from '../src/handlers/messages.js'; import { handleResponses } from '../src/handlers/responses.js'; import * as store from '../src/response-store.js'; import { addAccountByKey, getApiKey, removeAccount } from '../src/auth.js'; const CALLER = 'api:a06:user:u1'; const createdAccountIds = []; afterEach(() => { while (createdAccountIds.length) removeAccount(createdAccountIds.pop()); }); beforeEach(() => store.resetResponseStore()); function newAccount() { const account = addAccountByKey( `a06-${Date.now()}-${Math.random().toString(36).slice(2)}`, 'a06', ); createdAccountIds.push(account.id); return account; } function chatDeps(Client) { return { waitForAccount(tried, _signal, _maxWaitMs, modelKey) { return tried.length === 0 ? getApiKey(tried, modelKey) : null; }, ensureLs: async () => {}, getLsFor: () => ({ port: 17777, csrfToken: 'csrf', generation: 1 }), WindsurfClient: Client, callerKey: CALLER, }; } class TruncatingClient { constructor(options = {}) { this.options = options; this.calls = 0; } async cascadeChat(_messages, _modelEnum, _modelUid, opts = {}) { this.calls++; const { text = '', thinking = '', endReason = 'max_wait' } = this.options; if (opts.onChunk) { if (thinking) await opts.onChunk({ text: '', thinking, isError: false }); if (text) await opts.onChunk({ text, thinking: '', isError: false }); } const chunks = opts.onChunk ? [] : (text || thinking ? [{ text, thinking, isError: false }] : []); chunks.endReason = endReason; chunks.toolCalls = []; chunks.totalYielded = text.length; chunks.totalThinking = thinking.length; return chunks; } } async function runChat(body, options) { newAccount(); const holder = { instance: null }; class Client extends TruncatingClient { constructor(...args) { super(options); holder.instance = this; } } const result = await handleChatCompletions(body, chatDeps(Client)); return { result, client: holder.instance }; } function fakeRes() { const listeners = new Map(); return { body: '', writableEnded: false, write(chunk) { this.body += String(chunk); return true; }, end(chunk) { if (chunk) this.write(chunk); this.writableEnded = true; for (const cb of listeners.get('close') || []) cb(); }, on(event, cb) { if (!listeners.has(event)) listeners.set(event, []); listeners.get(event).push(cb); return this; }, removeListener() {}, }; } function chatFrames(body) { return body.split('\n\n') .filter(Boolean) .filter(frame => !frame.startsWith(':')) .map(frame => frame.split('\n').find(line => line.startsWith('data: '))?.slice(6)) .filter(Boolean); } function terminalChunk(body) { for (const raw of chatFrames(body)) { if (raw === '[DONE]') continue; let parsed; try { parsed = JSON.parse(raw); } catch { continue; } const choice = parsed.choices?.[0]; if (choice?.finish_reason) return { parsed, choice }; } return null; } function eventNames(body) { return [...body.matchAll(/^event: (\S+)$/gm)].map(m => m[1]); } function lastEvent(body) { return eventNames(body).at(-1); } function eventData(body, name) { const m = body.match(new RegExp(`^event: ${name}\\ndata: (.*)$`, 'm')); return m ? JSON.parse(m[1]) : null; } async function runResponses(body, options) { newAccount(); const holder = { instance: null }; class Client extends TruncatingClient { constructor(...args) { super(options); holder.instance = this; } } const result = await handleResponses(body, { context: chatDeps(Client), }); return { result, client: holder.instance }; } async function runMessages(body, options) { newAccount(); const holder = { instance: null }; class Client extends TruncatingClient { constructor(...args) { super(options); holder.instance = this; } } const result = await handleMessages(body, { callerKey: CALLER, async handleChatCompletions(chatBody, context) { return handleChatCompletions(chatBody, { ...context, ...chatDeps(Client) }); }, }); return { result, client: holder.instance }; } describe('A06 Cascade poll truncation terminal', () => { it('max_wait/stall_warm/idle_empty end with typed length/error and are not committed as completed', async () => { for (const endReason of ['max_wait', 'stall_warm']) { const { result: nonStream } = await runChat({ model: 'gemini-2.5-flash', stream: false, messages: [{ role: 'user', content: 'write a long answer' }], }, { text: 'partial answer', endReason }); assert.equal(nonStream.status, 200, `${endReason}: a partial answer is still delivered`); assert.equal(nonStream.body.choices[0].finish_reason, 'length', `${endReason}: truncation must be typed length, never stop`); assert.equal(nonStream.body.choices[0]._windsurf_end_reason, 'upstream_truncated'); assert.equal(nonStream.body.choices[0].message.content, 'partial answer'); const { result: stream } = await runChat({ model: 'gemini-2.5-flash', stream: true, messages: [{ role: 'user', content: 'write a long answer' }], }, { text: 'partial answer', endReason }); assert.equal(stream.status, 200); const res = fakeRes(); await stream.handler(res); const terminal = terminalChunk(res.body); assert.equal(terminal?.choice.finish_reason, 'length', `${endReason}: stream truncation must be typed length, never stop`); assert.equal(terminal?.parsed.__windsurf_end_reason, 'upstream_truncated'); assert.equal(res.body.includes('"finish_reason":"stop"'), false); // /v1/responses: incomplete + upstream_incomplete, and NOT chainable. const { result: responsesNonStream } = await runResponses({ model: 'gemini-2.5-flash', stream: false, store: true, input: [{ role: 'user', content: [{ type: 'input_text', text: 'write a long answer' }] }], }, { text: 'partial answer', endReason }); assert.equal(responsesNonStream.status, 200); assert.equal(responsesNonStream.body.status, 'incomplete'); assert.equal(responsesNonStream.body.incomplete_details.reason, 'upstream_incomplete'); assert.equal(store.getResponse(responsesNonStream.body.id, CALLER).ok, false, `${endReason}: an interrupted turn must not be committed to the response store`); const { result: responsesStream } = await runResponses({ model: 'gemini-2.5-flash', stream: true, store: true, input: [{ role: 'user', content: [{ type: 'input_text', text: 'write a long answer' }] }], }, { text: 'partial answer', endReason }); assert.equal(responsesStream.status, 200); const responsesRes = fakeRes(); await responsesStream.handler(responsesRes); assert.equal(lastEvent(responsesRes.body), 'response.incomplete'); assert.equal(eventData(responsesRes.body, 'response.incomplete').response.incomplete_details.reason, 'upstream_incomplete'); const responseId = responsesRes.body.match(/"id":"(resp_[^"]+)"/)?.[1]; assert.ok(responseId, 'precondition: a response id was minted'); assert.equal(store.getResponse(responseId, CALLER).ok, false, `${endReason}: a streamed interrupted turn must not be chainable`); assert.equal(responsesRes.body.trimEnd().endsWith('data: [DONE]'), false, `${endReason}: an upstream truncation must not get the clean-end sentinel`); // /v1/messages stream: the Anthropic translator must emit an error event, // not a normal end_turn/message_stop for a fabricated length terminal. const { result: messagesStream } = await runMessages({ model: 'claude-sonnet-4.6', stream: true, max_tokens: 100, messages: [{ role: 'user', content: 'write a long answer' }], }, { text: 'partial answer', endReason }); assert.equal(messagesStream.status, 200); const messagesRes = fakeRes(); await messagesStream.handler(messagesRes); assert.equal(/"stop_reason":"end_turn"/.test(messagesRes.body), false, `${endReason}: a truncated turn must not be reported as a natural end_turn`); assert.ok(/event: error/.test(messagesRes.body), `${endReason}: the truncation must surface as an Anthropic error event`); } // idle_empty: no output at all is an upstream error, never a 200 empty stop. const { result: emptyNonStream } = await runChat({ model: 'gemini-2.5-flash', stream: false, messages: [{ role: 'user', content: 'hi' }], }, { text: '', endReason: 'idle_empty' }); assert.equal(emptyNonStream.status, 502); assert.equal(emptyNonStream.body.error.type, 'upstream_empty_response'); assert.equal(emptyNonStream.body.error.code, 'EMPTY_RESPONSE'); const { result: emptyStream } = await runChat({ model: 'gemini-2.5-flash', stream: true, messages: [{ role: 'user', content: 'hi' }], }, { text: '', endReason: 'idle_empty' }); const emptyRes = fakeRes(); await emptyStream.handler(emptyRes); assert.match(emptyRes.body, /"error"/); assert.equal(emptyRes.body.includes('"finish_reason":"stop"'), false); assert.equal(chatFrames(emptyRes.body).includes('[DONE]'), false, 'an error stream must not claim a clean finish'); const { result: emptyResponses } = await runResponses({ model: 'gemini-2.5-flash', stream: false, store: true, input: 'hi', }, { text: '', endReason: 'idle_empty' }); assert.equal(emptyResponses.status, 502); assert.equal(emptyResponses.body.error.type, 'upstream_empty_response'); }); it('a legitimate upstream stop is still a normal completion and stays chainable', async () => { const { result } = await runChat({ model: 'gemini-2.5-flash', stream: false, messages: [{ role: 'user', content: 'hi' }], }, { text: 'complete answer', endReason: 'idle_done' }); assert.equal(result.status, 200); assert.equal(result.body.choices[0].finish_reason, 'stop'); assert.equal(result.body.choices[0]._windsurf_end_reason, undefined); const { result: responses } = await runResponses({ model: 'gemini-2.5-flash', stream: false, store: true, input: 'hi', }, { text: 'complete answer', endReason: 'idle_done' }); assert.equal(responses.status, 200); assert.equal(responses.body.status, 'completed'); assert.equal(store.getResponse(responses.body.id, CALLER).ok, true, 'a clean turn must remain chainable'); }); });