Ctrl K
ComunidadGuías…

test/connect-error-toolcall-route-matrix.test.js

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

Archivo original ↗Preguntar a KiBot
// P2b (external Devin audit, SWE-2 Flash) — ROUTE-LEVEL matrix for the
// "explicit upstream ERROR + decodable tool call" turn.
//
// The adapter-level guard is pinned in test/connect-stop-reason-truncation.test.js
// (`terminalError` snapshot → 'error' re-asserted after the tool-call arms). That
// guard only matters if each protocol route actually STOPS presenting the turn as
// a clean, executable tool call — and this repo has a documented history of fixes
// that covered one route and silently missed the other three
// (docs/HANDOFF-2026-07-27.md §v3.9.2-v3.9.3, "修复只覆盖部分路径" ×4).
//
// So every route is driven end-to-end through the REAL connect adapter over a
// mocked transport (no network: setup-env installs the fail-closed guard and
// `__setRequestImpl` replaces the transport). The upstream sends a valid #6
// ChatToolCall followed by stop reason 13 (ERROR).
//
// The invariant, identical on all four: the turn must NOT come back as a
// successful, tool-call-shaped answer. Exact error codes are deliberately not
// pinned — the routes own their shapes (529 / 503 / response.failed); what must
// never regress is "clean tool_use with finish STOP".
//
// Note on the negative direction: a route that returns 200 is checked too, so a
// future change that "fixes" the status but re-labels the turn as a normal
// tool call still fails here.

process.env.DEVIN_CONNECT = '1';
process.env.DEVIN_CONNECT_TOKEN = 'devin-session-token$errmatrix.jwt.sig';
process.env.DEVIN_CONNECT_TOOL_CALL_TAGS = 'outer=6,id=1,name=2,arguments_json=3';

import { afterEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { handleChatCompletions } from '../src/handlers/chat.js';
import { handleMessages } from '../src/handlers/messages.js';
import { handleResponses } from '../src/handlers/responses.js';
import { handleGemini } from '../src/handlers/gemini.js';
import { __setRequestImpl } from '../src/devin-connect.js';
import { writeStringField, writeVarintField, writeMessageField } from '../src/proto.js';
import { wrapEnvelope, endOfStreamEnvelope } from '../src/connect.js';

const MODEL = 'swe-1-6-slow';
const CONTEXT = { callerKey: 'errmatrix:caller' };

// #6 ChatToolCall { id=1, name=2, arguments_json=3 } — a call the proxy can decode.
const toolCallFrame = () => writeMessageField(6, Buffer.concat([
  writeStringField(1, 'call_1'),
  writeStringField(2, 'Read'),
  writeStringField(3, '{}'),
]));

// A complete, well-formed stream: one decodable call, then the upstream's ERROR
// stop reason, then the mandatory end-of-stream trailer.
function errorTurnTransport() {
  return (_opts, cb) => {
    const req = new EventEmitter();
    req.setTimeout = () => req;
    req.write = () => {};
    req.destroy = () => {};
    req.end = () => {
      const res = new EventEmitter();
      res.statusCode = 200;
      setImmediate(() => {
        res.emit('data', wrapEnvelope(toolCallFrame(), { compress: false }));
        res.emit('data', wrapEnvelope(writeVarintField(5, 13), { compress: false }));
        res.emit('data', endOfStreamEnvelope());
        res.emit('end');
      });
      cb(res);
    };
    return req;
  };
}

const openaiTools = [{ type: 'function', function: { name: 'Read', description: 'read', parameters: { type: 'object' } } }];
const anthropicTools = [{ name: 'Read', description: 'read', input_schema: { type: 'object' } }];

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

describe('P2b matrix: an ERROR turn with a tool call is never a clean tool_use', () => {
  it('/v1/chat/completions reports finish_reason "error"', async () => {
    __setRequestImpl(errorTurnTransport());
    const r = await handleChatCompletions({
      model: MODEL,
      stream: false,
      tools: openaiTools,
      messages: [{ role: 'user', content: 'read a.txt' }],
    }, CONTEXT);
    const choice = r.body?.choices?.[0];
    assert.notEqual(choice?.finish_reason, 'tool_calls',
      'a failed turn must not advertise executable arguments');
    assert.equal(choice?.finish_reason, 'error');
  });

  it('/v1/messages does not hand Claude Code a tool_use block', async () => {
    __setRequestImpl(errorTurnTransport());
    const r = await handleMessages({
      model: 'claude-sonnet-4.6',
      max_tokens: 64,
      tools: anthropicTools,
      messages: [{ role: 'user', content: 'read a.txt' }],
    }, CONTEXT);
    if (r.status === 200) {
      const blocks = (r.body?.content || []).map((b) => b?.type);
      assert.equal(blocks.includes('tool_use'), false,
        `a failed turn must not deliver a tool_use block (got ${blocks.join(',')})`);
    } else {
      assert.ok(r.status >= 400, `unexpected status ${r.status}`);
    }
  });

  it('/v1/responses does not close the turn as completed', async () => {
    __setRequestImpl(errorTurnTransport());
    const r = await handleResponses({
      model: MODEL,
      input: [{ role: 'user', content: [{ type: 'input_text', text: 'read a.txt' }] }],
      tools: [{ type: 'function', name: 'Read', description: 'read', parameters: { type: 'object' } }],
    }, CONTEXT);
    if (r.status === 200) {
      assert.notEqual(r.body?.status, 'completed',
        'a failed turn must not be reported as a completed response');
    } else {
      assert.ok(r.status >= 400, `unexpected status ${r.status}`);
    }
  });

  it('/v1beta gemini does not report STOP with a functionCall', async () => {
    __setRequestImpl(errorTurnTransport());
    const r = await handleGemini(MODEL, {
      contents: [{ role: 'user', parts: [{ text: 'read a.txt' }] }],
      tools: [{ functionDeclarations: [{ name: 'Read', description: 'read', parameters: { type: 'object' } }] }],
    }, CONTEXT);
    if (r.status === 200) {
      const cand = r.body?.candidates?.[0];
      assert.notEqual(cand?.finishReason, 'STOP',
        'a failed turn must not report a clean STOP');
    } else {
      assert.ok(r.status >= 400, `unexpected status ${r.status}`);
    }
  });
});