Ctrl K
ComunidadGuías…

test/cascade-poll-truncation-terminal.test.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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');
  });

});