test/chat-tool-choice-parity.test.js
Rama master · réplica de lectura del repositorio original.
// A07/KH-004: the non-stream Cascade path received the RAW request tools[],
// while the stream path received effectiveTools (tool_choice already applied).
// A fake XML/thinking call for a tool the caller excluded therefore surfaced
// only on the non-stream path. These tests pin stream/non-stream parity for
// tool_choice:none and forced choices, plus the original-contract validation
// that turns a forced-but-undeclared tool into a 400 before any upstream call.
import { afterEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { handleChatCompletions } from '../src/handlers/chat.js';
import { addAccountByKey, getApiKey, removeAccount } from '../src/auth.js';
const createdAccountIds = [];
afterEach(() => {
while (createdAccountIds.length) removeAccount(createdAccountIds.pop());
});
function fnTool(name) {
return {
type: 'function',
function: {
name,
description: `${name} tool`,
parameters: { type: 'object', properties: {}, additionalProperties: true },
},
};
}
function newAccount(label) {
const account = addAccountByKey(
`${label}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
label,
);
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,
};
}
class XmlBashClient {
constructor({ text, thinking = '', endReason = 'idle_done' } = {}) {
this.text = text;
this.thinking = thinking;
this.endReason = endReason;
this.calls = 0;
}
async cascadeChat(_messages, _modelEnum, _modelUid, opts = {}) {
this.calls++;
if (opts.onChunk) {
if (this.thinking) await opts.onChunk({ text: '', thinking: this.thinking, isError: false });
if (this.text) await opts.onChunk({ text: this.text, thinking: '', isError: false });
}
const chunks = opts.onChunk
? []
: [{ text: this.text, thinking: this.thinking, isError: false }];
chunks.endReason = this.endReason;
chunks.toolCalls = [];
return chunks;
}
}
function history() {
return [
{ role: 'user', content: 'earlier request' },
{
role: 'assistant',
content: null,
tool_calls: [{
id: 'call_hist_1',
type: 'function',
function: { name: 'Bash', arguments: '{"command":"ls"}' },
}],
},
{ role: 'tool', tool_call_id: 'call_hist_1', content: 'file.txt' },
{ role: 'user', content: 'now echo hi' },
];
}
const BASH_XML = '<tool_call>{"name":"Bash","arguments":{"command":"echo hi"}}</tool_call>';
async function runChat(body, options) {
newAccount('tool-choice');
const holder = { instance: null };
class Client extends XmlBashClient {
constructor(...args) {
super(options);
holder.instance = this;
}
}
const result = await handleChatCompletions(body, chatDeps(Client));
return { result, client: holder.instance };
}
function collectChatToolCalls(bodyText) {
const calls = [];
for (const frame of bodyText.split('\n\n')) {
const data = frame.split('\n').find(line => line.startsWith('data: '))?.slice(6);
if (!data || data === '[DONE]') continue;
let parsed;
try { parsed = JSON.parse(data); } catch { continue; }
for (const choice of parsed.choices || []) {
for (const tc of choice.delta?.tool_calls || []) calls.push(tc);
for (const tc of choice.message?.tool_calls || []) calls.push(tc);
}
}
return calls;
}
describe('A07 tool_choice parity and contract', () => {
it('non-stream tool_choice:none with history emits no tool_calls and matches the stream result', async () => {
const request = {
model: 'gemini-2.5-flash',
tools: [fnTool('Bash'), fnTool('Read')],
tool_choice: 'none',
messages: history(),
};
const { result: nonStream } = await runChat({ ...request, stream: false }, { text: BASH_XML });
assert.equal(nonStream.status, 200);
assert.equal(nonStream.body.choices[0].message.tool_calls, undefined,
'tool_choice:none must drop a fake XML Bash call on the non-stream path');
const { result: stream } = await runChat({ ...request, stream: true }, { text: BASH_XML });
assert.equal(stream.status, 200);
const res = {
body: '', writableEnded: false,
write(chunk) { this.body += String(chunk); return true; },
end(chunk) { if (chunk) this.write(chunk); this.writableEnded = true; },
on() { return this; }, removeListener() {},
};
await stream.handler(res);
assert.equal(collectChatToolCalls(res.body).length, 0,
'the stream path already filters against effectiveTools; non-stream must agree');
});
it('forced choice absent from tools returns 400 before upstream', async () => {
const { result, client } = await runChat({
model: 'gemini-2.5-flash',
stream: false,
tools: [fnTool('Read')],
tool_choice: { type: 'function', function: { name: 'Bash' } },
messages: [{ role: 'user', content: 'run echo hi' }],
}, { text: BASH_XML });
assert.equal(result.status, 400);
assert.equal(result.body.error.type, 'invalid_request_error');
assert.equal(result.body.error.param, 'tool_choice');
assert.equal(result.body.error.code, 'tool_choice_tool_not_declared');
assert.equal(client?.calls || 0, 0, 'a rejected contract must not spend an upstream turn');
});
it('forced Read does not admit a Bash call on either path', async () => {
const request = {
model: 'gemini-2.5-flash',
tools: [fnTool('Bash'), fnTool('Read')],
tool_choice: { type: 'function', function: { name: 'Read' } },
messages: history(),
};
const { result: nonStream } = await runChat({ ...request, stream: false }, { text: BASH_XML });
assert.equal(nonStream.status, 200);
assert.equal(nonStream.body.choices[0].message.tool_calls, undefined,
'a forced Read must not admit the non-forced Bash call');
const { result: stream } = await runChat({ ...request, stream: true }, { text: BASH_XML });
const res = {
body: '', writableEnded: false,
write(chunk) { this.body += String(chunk); return true; },
end(chunk) { if (chunk) this.write(chunk); this.writableEnded = true; },
on() { return this; }, removeListener() {},
};
await stream.handler(res);
assert.equal(collectChatToolCalls(res.body).length, 0);
});
it('a tool_call recovered from thinking is filtered by tool_choice:none too', async () => {
const { result } = await runChat({
model: 'gemini-2.5-flash',
stream: false,
tools: [fnTool('Bash'), fnTool('Read')],
tool_choice: 'none',
messages: history(),
}, { text: 'plain answer', thinking: BASH_XML });
assert.equal(result.status, 200);
assert.equal(result.body.choices[0].message.tool_calls, undefined,
'the thinking-tail recovery must use the effective list');
});
});