test/cc-claude-2-1-306.test.js
Rama master · réplica de lectura del repositorio original.
/**
* Claude Code 2.1.306 compatibility gaps (evidence/ClientSandboxes.md CL1–CL3).
*
* CL1 — thinking blocks: the `signature` key is ALWAYS present ("" when the
* upstream supplied none). This is the intended #278 / PR b60ec3d
* behaviour, not an accident: strict clients deserialize it as a required
* string, so a missing key fails the whole event while "" parses.
* Captured 2.1.306 traffic: 7/7 thinking starts carried `signature:""`,
* 0 signature_delta frames, 11/11 requests HTTP 200 — the real client
* accepts the empty string.
* CL2 — `context_management` (new in 2.1.306, beta context-management-2025-06-27)
* must not be dropped in silence: the edit types this bridge can satisfy
* are accepted, everything else is a 400 `unsupported_parameter` with an
* actionable message.
* CL3 — `defer_loading` (new in 2.1.306): a schema-less deferred placeholder is
* dropped (nothing to load, "never call this tool"), a schema-bearing
* deferred tool is forwarded and COUNTED as `tool_defer_loading_ignored`
* because the upstream has no tool-loading protocol — never silent.
*/
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import {
handleMessages,
openAIToAnthropic,
validateMessagesRequest,
} from '../src/handlers/messages.js';
import { getCcCompatStats, resetCcCompatStats } from '../src/handlers/cc-compat.js';
function chatChunk(chunk) {
return `data: ${JSON.stringify(chunk)}\n\n`;
}
function fakeRes() {
const listeners = new Map();
return {
body: '',
writableEnded: false,
write(chunk) {
this.body += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
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;
},
};
}
function parseAnthropicEvents(raw) {
return raw
.trim()
.split('\n\n')
.filter(Boolean)
.filter(frame => !frame.startsWith(':'))
.map(frame => {
const lines = frame.split('\n');
return {
event: lines.find(line => line.startsWith('event: '))?.slice(7),
data: JSON.parse(lines.find(line => line.startsWith('data: '))?.slice(6) || '{}'),
};
});
}
// The tool list captured from Claude Code 2.1.306 (capture id 37). Schemas are
// trimmed to their shape; the deferred placeholder is byte-for-byte the real
// one (empty object schema + "never call this tool" description).
function capturedTools() {
return [
{
name: 'Bash',
description: 'Executes a bash command',
input_schema: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] },
},
{
name: 'ToolSearch',
description: 'Fetches full schema definitions for deferred tools so they can be called.',
input_schema: {
type: 'object',
properties: { query: { type: 'string' }, max_results: { type: 'number' } },
required: ['query', 'max_results'],
},
},
{
name: 'DeferredToolPlaceholder',
description: 'Reserved placeholder that keeps deferred tool loading active; never call this tool.',
input_schema: { type: 'object', properties: {} },
defer_loading: true,
},
{
name: 'Write',
description: 'Writes a file',
input_schema: {
type: 'object',
properties: { path: { type: 'string' }, content: { type: 'string' } },
required: ['path', 'content'],
},
},
];
}
// The exact request keys Claude Code 2.1.306 sends on every turn (capture id 37).
function capturedBody(overrides = {}) {
return {
model: 'claude-haiku-5-5-medium',
max_tokens: 128000,
stream: false,
thinking: { type: 'adaptive', display: 'updates' },
output_config: { effort: 'medium' },
context_management: { edits: [{ type: 'clear_thinking_20251015', keep: 'all' }] },
tools: capturedTools(),
messages: [{ role: 'user', content: 'go' }],
...overrides,
};
}
function captureForwarded() {
const captured = {};
const handler = async (body) => {
captured.body = body;
return {
status: 200,
body: {
model: body.model,
choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 1, completion_tokens: 1 },
},
};
};
return { captured, handler };
}
beforeEach(() => {
resetCcCompatStats();
});
// ─── CL1 ────────────────────────────────────────────────────────────────────
describe('CL1 thinking signature wire shape (Claude Code 2.1.306)', () => {
it('stream: content_block_start thinking is exactly {type, thinking, signature:""} and no signature_delta is emitted', async () => {
const result = await handleMessages(capturedBody({ stream: true }), {
async handleChatCompletions() {
return {
status: 200,
stream: true,
async handler(res) {
res.write(chatChunk({ choices: [{ index: 0, delta: { reasoning_content: 'pondering' }, finish_reason: null }] }));
res.write(chatChunk({ choices: [{ index: 0, delta: { content: 'done' }, finish_reason: null }] }));
res.write(chatChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] }));
res.end('data: [DONE]\n\n');
},
};
},
});
const res = fakeRes();
await result.handler(res);
const events = parseAnthropicEvents(res.body);
const start = events.find(e => e.event === 'content_block_start' && e.data.content_block?.type === 'thinking');
assert.ok(start, 'a thinking content_block_start must be emitted');
// Exact key set: the signature key must be present (Grok Build/serde treat
// it as required) and must be the empty string when upstream has none.
assert.deepEqual(
Object.keys(start.data.content_block).sort(),
['signature', 'thinking', 'type'],
'thinking content_block_start must carry exactly type/thinking/signature',
);
assert.equal(start.data.content_block.signature, '');
assert.equal(start.data.content_block.thinking, '');
const sigDeltas = events.filter(e => e.event === 'content_block_delta' && e.data.delta?.type === 'signature_delta');
assert.equal(sigDeltas.length, 0, 'no signature_delta without a real upstream signature');
});
it('non-stream: thinking block carries signature:"" when upstream has none', () => {
const message = openAIToAnthropic({
model: 'claude-haiku-5-5-medium',
choices: [{
index: 0,
message: { role: 'assistant', reasoning_content: 'thought', content: 'answer' },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 1, completion_tokens: 1 },
}, 'claude-haiku-5-5-medium', 'msg_test');
const thinking = message.content.find(c => c.type === 'thinking');
assert.deepEqual(thinking, { type: 'thinking', thinking: 'thought', signature: '' });
});
it('stream: a real upstream reasoning_signature still rides signature_delta before content_block_stop', async () => {
const result = await handleMessages(capturedBody({ stream: true }), {
async handleChatCompletions() {
return {
status: 200,
stream: true,
async handler(res) {
res.write(chatChunk({ choices: [{ index: 0, delta: { reasoning_content: 'r', reasoning_signature: 'sig_abc' }, finish_reason: null }] }));
res.write(chatChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] }));
res.end('data: [DONE]\n\n');
},
};
},
});
const res = fakeRes();
await result.handler(res);
const events = parseAnthropicEvents(res.body);
const startIdx = events.findIndex(e => e.event === 'content_block_start' && e.data.content_block?.type === 'thinking');
const stopIdx = events.findIndex((e, i) => i > startIdx && e.event === 'content_block_stop' && e.data.index === events[startIdx].data.index);
assert.ok(startIdx !== -1 && stopIdx !== -1, 'thinking block must open and close');
const beforeStop = events[stopIdx - 1];
assert.equal(beforeStop.event, 'content_block_delta');
assert.deepEqual(beforeStop.data.delta, { type: 'signature_delta', signature: 'sig_abc' });
});
});
// ─── CL2 ────────────────────────────────────────────────────────────────────
describe('CL2 context_management (Claude Code 2.1.306)', () => {
it('accepts the exact captured edit: clear_thinking_20251015 keep:"all"', () => {
const err = validateMessagesRequest(capturedBody());
assert.equal(err, null, 'the real 2.1.306 request must not be rejected');
});
it('accepts keep:"none" and keep:{type:"thinking_turns", value:N}', () => {
assert.equal(validateMessagesRequest(capturedBody({
context_management: { edits: [{ type: 'clear_thinking_20251015', keep: 'none' }] },
})), null);
assert.equal(validateMessagesRequest(capturedBody({
context_management: { edits: [{ type: 'clear_thinking_20251015', keep: { type: 'thinking_turns', value: 3 } }] },
})), null);
});
it('rejects clear_tool_uses_20250919 with 400 unsupported_parameter and an actionable message', () => {
const err = validateMessagesRequest(capturedBody({
context_management: { edits: [{ type: 'clear_tool_uses_20250919' }] },
}));
assert.equal(err.status, 400);
assert.equal(err.body.error.type, 'invalid_request_error');
assert.equal(err.body.error.code, 'unsupported_parameter');
assert.equal(err.body.error.param, 'context_management.edits[0].type');
assert.match(err.body.error.message, /clear_tool_uses_20250919/);
assert.match(err.body.error.message, /clear_thinking_20251015/, 'message must name what IS supported');
assert.match(err.body.error.message, /Remove|disable/i, 'message must tell the caller what to do');
});
it('rejects an unknown edit type with 400 unsupported_parameter', () => {
const err = validateMessagesRequest(capturedBody({
context_management: { edits: [{ type: 'clear_everything_20300101' }] },
}));
assert.equal(err.status, 400);
assert.equal(err.body.error.code, 'unsupported_parameter');
assert.equal(err.body.error.param, 'context_management.edits[0].type');
});
it('rejects malformed context_management shapes with 400 invalid_request_error', () => {
for (const bad of [
[],
{ edits: 'nope' },
{ edits: [null] },
{ edits: [{}] },
{ edits: [{ type: 'clear_thinking_20251015', keep: 'sometimes' }] },
{ edits: [{ type: 'clear_thinking_20251015', keep: { type: 'turns', value: 1 } }] },
{ edits: [{ type: 'clear_thinking_20251015', keep: { type: 'thinking_turns', value: 0 } }] },
]) {
const err = validateMessagesRequest(capturedBody({ context_management: bad }));
assert.ok(err, `expected 400 for ${JSON.stringify(bad)}`);
assert.equal(err.status, 400);
assert.equal(err.body.error.type, 'invalid_request_error');
}
});
it('does not forward context_management upstream, and history thinking stays dropped (the edit is satisfied by that drop)', async () => {
const { captured, handler } = captureForwarded();
await handleMessages(capturedBody({
messages: [
{ role: 'assistant', content: [{ type: 'thinking', thinking: 'SECRET-THOUGHT', signature: '' }, { type: 'text', text: 'hi' }] },
{ role: 'user', content: 'go' },
],
}), { handleChatCompletions: handler });
assert.equal(captured.body.context_management, undefined, 'context_management is an Anthropic-side directive');
assert.equal(captured.body.__contextManagement, undefined, 'no internal carrier leaks to the upstream handler');
assert.ok(!JSON.stringify(captured.body.messages).includes('SECRET-THOUGHT'), 'thinking history is never forwarded');
});
});
// ─── CL3 ────────────────────────────────────────────────────────────────────
describe('CL3 defer_loading (Claude Code 2.1.306)', () => {
it('drops the schema-less deferred placeholder, counts it honored, and never forwards defer_loading', async () => {
const { captured, handler } = captureForwarded();
await handleMessages(capturedBody(), { handleChatCompletions: handler });
const names = captured.body.tools.map(t => t.function.name);
assert.deepEqual(names, ['Bash', 'ToolSearch', 'Write'], 'DeferredToolPlaceholder has no schema and must never be callable');
assert.ok(!JSON.stringify(captured.body.tools).includes('defer_loading'), 'defer_loading is an Anthropic-only key');
const stats = getCcCompatStats();
assert.equal(stats.toolDeferLoadingHonored, 1);
assert.equal(stats.toolDeferLoadingIgnored, 0);
});
it('forwards a schema-bearing deferred tool in full and counts it ignored (tool_defer_loading_ignored, never silent)', async () => {
const { captured, handler } = captureForwarded();
await handleMessages(capturedBody({
tools: [
{
name: 'Lookup',
description: 'deferred but schema-bearing',
input_schema: { type: 'object', properties: { q: { type: 'string' } }, required: ['q'] },
defer_loading: true,
},
],
}), { handleChatCompletions: handler });
const forwarded = captured.body.tools.find(t => t.function.name === 'Lookup');
assert.ok(forwarded, 'a schema-bearing deferred tool must stay callable');
assert.deepEqual(forwarded.function.parameters.properties, { q: { type: 'string' } });
assert.ok(!('defer_loading' in forwarded.function));
const stats = getCcCompatStats();
assert.equal(stats.toolDeferLoadingIgnored, 1, 'ignored deferrals are counted, not silent');
assert.equal(stats.toolDeferLoadingHonored, 0);
});
it('defer_loading:false is an ordinary tool and is not counted', async () => {
const { captured, handler } = captureForwarded();
await handleMessages(capturedBody({
tools: [{
name: 'Plain',
description: 'plain',
input_schema: { type: 'object', properties: { x: { type: 'string' } } },
defer_loading: false,
}],
}), { handleChatCompletions: handler });
assert.deepEqual(captured.body.tools.map(t => t.function.name), ['Plain']);
const stats = getCcCompatStats();
assert.equal(stats.toolDeferLoadingHonored, 0);
assert.equal(stats.toolDeferLoadingIgnored, 0);
});
});