Ctrl K
ComunidadGuías…

test/connect-stop-reason-truncation.test.js

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

Archivo original ↗Preguntar a KiBot
// G3 (Q1 audit §7/§8.2) — "text deltas, then a clean EOF with no stopReason" is
// TRUNCATION, not a normal end.
//
// S01 measured this as the silent-corruption class of the corpus: their decoder
// raises a stream error rather than synthesizing `end_turn`, because a coding
// agent that receives a half answer labelled complete marks the task done
// (docs/upstream-protocol.md:106 — "缺 stopReason 的干净 EOF = 截断不是正常结束",
// the real incident being Codex reporting task_complete on a truncated turn).
//
// WindsurfAPI already raises STREAM_TRUNCATED when the Connect end-of-stream
// frame is MISSING (see test/responses-stream-truncation.test.js). It did not
// cover the other half: the trailer arrives, the answer is delivered, and no
// frame ever carried #5. `lastFinish` stayed null, `mapFinishReason(null)` is
// null ("no reason yet"), and every consumer defaults a null reason to 'stop' —
// so the truncated turn was reported as a completed one on all four surfaces.
//
// The rule pinned here: a turn that DELIVERED a payload but never named a stop
// reason reports finish_reason 'error' (never 'stop', never null). A turn that
// delivered NOTHING keeps its null reason — that is the empty-turn shape the
// adapter's empty/rescue retry owns, and it must not be reclassified.

import { afterEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { streamChat, chat, __setRequestImpl } from '../src/devin-connect.js';
import { toChatCompletion, streamChatCompletion } from '../src/devin-connect-openai.js';
import { writeStringField, writeVarintField, writeMessageField } from '../src/proto.js';
import { wrapEnvelope, endOfStreamEnvelope } from '../src/connect.js';

const TOKEN = 'devin-session-token$truncation.test.jwt.sig';
const MODEL = 'claude-sonnet-4.6';
// Calibrated native tool-call decode tags — a #6 ChatToolCall is the payload shape
// whose finish_reason the adapter used to force to 'tool_calls'.
const CALL_TAGS = 'outer=6,id=1,name=2,arguments_json=3';

// Connect-RPC always terminates a stream with an end-of-stream frame, so the
// transport below ALWAYS sends one: the only variable is whether any data frame
// carried the terminal stopReason (#5).
function mockTransport(payloads) {
  return (_opts, cb) => {
    const req = new EventEmitter();
    req.setTimeout = () => req;
    req.write = () => {};
    req.destroy = () => {};
    req.end = () => {
      const res = new EventEmitter();
      res.statusCode = 200;
      setImmediate(() => {
        for (const p of payloads) res.emit('data', wrapEnvelope(p, { compress: false }));
        res.emit('data', endOfStreamEnvelope());
        res.emit('end');
      });
      cb(res);
    };
    return req;
  };
}

const params = (extraEnv = {}) => ({
  messages: [{ role: 'user', content: 'what is 2+2' }],
  model: MODEL,
  token: TOKEN,
  tools: [{ type: 'function', function: { name: 'Read', parameters: { type: 'object' } } }],
  env: { DEVIN_CONNECT_TOKEN: TOKEN, ...extraEnv },
});

async function drain(payloads) {
  __setRequestImpl(mockTransport(payloads));
  const events = [];
  try {
    for await (const ev of streamChat(params())) events.push(ev);
  } finally {
    __setRequestImpl(null);
  }
  return events;
}

afterEach(() => __setRequestImpl(null));

describe('G3: a delivered turn with no stopReason is not a normal completion', () => {
  it('reports finish_reason "error" when text arrived but #5 never did', async () => {
    const events = await drain([writeStringField(3, 'The answer is 4')]);
    const finish = events.find((e) => e.type === 'finish');
    assert.ok(finish, 'the stream must still terminate with a finish event');
    assert.notEqual(finish.reason, 'stop', 'a truncated turn must never look like a clean stop');
    assert.notEqual(finish.reason, null, 'null is what every consumer turns into "stop"');
    assert.equal(finish.reason, 'error');
    assert.equal(finish.unterminated, true, 'the shape is named, not inferred from a missing enum');
  });

  it('still delivers every byte the upstream sent before the gap', async () => {
    // Reporting the gap must not withhold the partial answer: the client asked for
    // a stream and is entitled to what actually arrived.
    const events = await drain([writeStringField(3, 'The answer '), writeStringField(3, 'is 4')]);
    assert.equal(events.filter((e) => e.type === 'content').map((e) => e.text).join(''), 'The answer is 4');
  });

  it('the non-stream collector inherits the same verdict', async () => {
    __setRequestImpl(mockTransport([writeStringField(3, 'half an answer')]));
    try {
      const out = await chat(params());
      assert.equal(out.content, 'half an answer');
      assert.equal(out.finish_reason, 'error');
    } finally {
      __setRequestImpl(null);
    }
  });

  it('the OpenAI completion body reports finish_reason "error", not "stop"', async () => {
    // The client-visible claim: devin-connect-openai keeps 'stop' as its default
    // and only overwrites it from a truthy event reason, so the reason has to come
    // from the stream layer — this asserts the two are actually wired.
    __setRequestImpl(mockTransport([writeStringField(3, 'The capital of France is Par')]));
    try {
      const out = await toChatCompletion(params(), { displayModel: MODEL });
      assert.equal(out.status, 200);
      assert.equal(out.body?.choices?.[0]?.finish_reason, 'error');
    } finally {
      __setRequestImpl(null);
    }
  });
});

describe('G3 control: turns that DID terminate are unchanged', () => {
  it('an explicit stopReason still reports its mapped reason', async () => {
    const events = await drain([Buffer.concat([writeStringField(3, 'done'), writeVarintField(5, 2)])]);
    const finish = events.find((e) => e.type === 'finish');
    assert.equal(finish.reason, 'stop');
    assert.equal(finish.unterminated, undefined, 'a terminated turn carries no gap marker');
  });

  it('MAX_TOKENS (3) still reports length', async () => {
    const events = await drain([Buffer.concat([writeStringField(3, 'cut'), writeVarintField(5, 3)])]);
    assert.equal(events.find((e) => e.type === 'finish').reason, 'length');
  });

  it('a stream that delivered NOTHING and named no reason keeps its null reason', async () => {
    // The empty-turn shape: the adapter's empty/rescue retry keys off a null/stop
    // reason, and reclassifying it as an error would disable that machinery for a
    // shape this fix was never about.
    const events = await drain([]);
    const finish = events.find((e) => e.type === 'finish');
    assert.equal(finish.reason, null);
    assert.equal(finish.unterminated, undefined);
  });
});

// ─── P2 (review): a TOOL-CALL payload must not launder an unterminated turn ───
//
// The adapter used to force `finish_reason:'tool_calls'` whenever a call was
// decoded (non-stream: three arms; stream: `toolParser.flush`, native #6, promoted
// reasoning). With no #5 that reported arguments the upstream may have been cut
// mid-write as a clean, executable finish — and OpenAI clients execute whatever
// `tool_calls` they receive. The producer's 'error'/'unterminated' verdict now
// survives; the calls are still delivered (the bytes are real), only the verdict
// is honest.

function toolCallFrame({ id = 'call_abc', name = 'Read', args = '{"path":"a.txt"}' } = {}) {
  return writeMessageField(6, Buffer.concat([
    writeStringField(1, id),
    writeStringField(2, name),
    writeStringField(3, args),
  ]));
}

describe('P2: a tool-call turn with no stopReason is not a clean tool_calls finish', () => {
  it('non-stream: finish_reason is "error", and the call is still delivered', async () => {
    __setRequestImpl(mockTransport([toolCallFrame()]));
    try {
      const out = await toChatCompletion(params({ DEVIN_CONNECT_TOOL_CALL_TAGS: CALL_TAGS }), { displayModel: MODEL });
      assert.equal(out.status, 200);
      assert.equal(out.body?.choices?.[0]?.finish_reason, 'error',
        'unvalidated tool arguments must not be labelled a clean tool_calls finish');
      const call = out.body?.choices?.[0]?.message?.tool_calls?.[0];
      assert.equal(call?.function?.name, 'Read', 'the call itself is still delivered');
      assert.equal(call?.function?.arguments, '{"path":"a.txt"}');
    } finally {
      __setRequestImpl(null);
    }
  });

  it('non-stream control: an explicit FUNCTION_CALL (10) still reports tool_calls', async () => {
    __setRequestImpl(mockTransport([toolCallFrame(), writeVarintField(5, 10)]));
    try {
      const out = await toChatCompletion(params({ DEVIN_CONNECT_TOOL_CALL_TAGS: CALL_TAGS }), { displayModel: MODEL });
      assert.equal(out.body?.choices?.[0]?.finish_reason, 'tool_calls');
    } finally {
      __setRequestImpl(null);
    }
  });

  it('stream: the terminal frame reports "error" after the call deltas', async () => {
    const frames = [];
    __setRequestImpl(mockTransport([toolCallFrame()]));
    try {
      await streamChatCompletion(
        params({ DEVIN_CONNECT_TOOL_CALL_TAGS: CALL_TAGS }),
        (f) => { frames.push(f); },
        { displayModel: MODEL },
      );
    } finally {
      __setRequestImpl(null);
    }
    const deltas = frames.flatMap((f) => f?.choices || []);
    const call = deltas.find((c) => c?.delta?.tool_calls?.length);
    assert.equal(call?.delta?.tool_calls?.[0]?.function?.name, 'Read', 'the call still streams');
    const terminal = deltas.filter((c) => c?.finish_reason).at(-1);
    assert.equal(terminal?.finish_reason, 'error',
      'the terminal frame is the only place a stream can say "these arguments are unvalidated"');
  });

  it('stream control: FUNCTION_CALL (10) still terminates as tool_calls', async () => {
    const frames = [];
    __setRequestImpl(mockTransport([toolCallFrame(), writeVarintField(5, 10)]));
    try {
      await streamChatCompletion(
        params({ DEVIN_CONNECT_TOOL_CALL_TAGS: CALL_TAGS }),
        (f) => { frames.push(f); },
        { displayModel: MODEL },
      );
    } finally {
      __setRequestImpl(null);
    }
    const terminal = frames.flatMap((f) => f?.choices || []).filter((c) => c?.finish_reason).at(-1);
    assert.equal(terminal?.finish_reason, 'tool_calls');
  });
});

// ─── P2b (external audit, SWE-2 Flash): an EXPLICIT error must survive too ────
//
// The first guard only covered "no stopReason at all". A turn that ends with the
// upstream's own ERROR enum (13 → 'error') and ALSO carries a decodable tool call
// was still laundered into `tool_calls` by the arms above, so a FAILED turn
// reported a clean, executable finish. The snapshot (`terminalError`) is taken
// before those arms and re-asserted after them.
describe('P2b: an explicit ERROR (13) plus a tool call stays an error', () => {
  it('non-stream: finish_reason is "error", the call is still delivered', async () => {
    __setRequestImpl(mockTransport([toolCallFrame(), writeVarintField(5, 13)]));
    try {
      const out = await toChatCompletion(params({ DEVIN_CONNECT_TOOL_CALL_TAGS: CALL_TAGS }), { displayModel: MODEL });
      assert.equal(out.body?.choices?.[0]?.finish_reason, 'error',
        'an upstream ERROR turn must not be reported as a clean tool_calls finish');
      assert.equal(out.body?.choices?.[0]?.message?.tool_calls?.[0]?.function?.name, 'Read');
    } finally {
      __setRequestImpl(null);
    }
  });

  it('stream: the terminal frame reports "error" after the call deltas', async () => {
    const frames = [];
    __setRequestImpl(mockTransport([toolCallFrame(), writeVarintField(5, 13)]));
    try {
      await streamChatCompletion(
        params({ DEVIN_CONNECT_TOOL_CALL_TAGS: CALL_TAGS }),
        (f) => { frames.push(f); },
        { displayModel: MODEL },
      );
    } finally {
      __setRequestImpl(null);
    }
    const choices = frames.flatMap((f) => f?.choices || []);
    assert.equal(choices.find((c) => c?.delta?.tool_calls?.length)?.delta?.tool_calls?.[0]?.function?.name, 'Read');
    assert.equal(choices.filter((c) => c?.finish_reason).at(-1)?.finish_reason, 'error');
  });
});