Files
diegosouzapw--omniroute/tests/unit/cc-bridge-tool-pair-guard.test.ts
2026-07-13 13:39:12 +08:00

194 lines
6.5 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
const { buildAndSignClaudeCodeRequest } =
await import("../../open-sse/services/claudeCodeCompatible.ts");
const { fixToolPairs, stripTrailingAssistantOrphanToolUse } =
await import("../../open-sse/services/contextManager.ts");
// Regression for the Anthropic 400:
// `messages.N: tool_use ids were found without tool_result blocks
// immediately after: toolu_...`
// The CC bridge now invokes fixToolPairs in step 5c before serialization
// so orphan tool_use blocks from mid-tool-call truncated histories are
// stripped before reaching Anthropic.
test("fixToolPairs strips orphan tool_use blocks from non-final assistant messages", () => {
const messages = [
{ role: "user", content: "hi" },
{
role: "assistant",
content: [
{ type: "text", text: "calling" },
{ type: "tool_use", id: "toolu_orphan", name: "Bash", input: {} },
],
},
{ role: "user", content: "no tool result here" },
{
role: "assistant",
content: [
{ type: "text", text: "done" },
{ type: "tool_use", id: "toolu_kept", name: "Bash", input: {} },
],
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_kept", content: "ok" }],
},
];
const fixed = fixToolPairs(messages as never);
const text = JSON.stringify(fixed);
assert.ok(!text.includes("toolu_orphan"), "orphan tool_use must be stripped");
assert.ok(text.includes("toolu_kept"), "paired tool_use must survive");
});
test("fixToolPairs is idempotent on clean histories", () => {
const cleanMessages = [
{ role: "user", content: "hi" },
{
role: "assistant",
content: [
{ type: "text", text: "calling" },
{ type: "tool_use", id: "toolu_a", name: "Bash", input: {} },
],
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_a", content: "ok" }],
},
];
const once = fixToolPairs(cleanMessages as never);
const twice = fixToolPairs(once);
assert.deepEqual(once, twice, "idempotent on clean histories");
});
test("buildAndSignClaudeCodeRequest invokes fixToolPairs via step 5c", async () => {
// Pass messages via claudeBody (BuildRequestOptions accepts sourceBody/
// normalizedBody/claudeBody — claudeBody is the path that preserves the
// shape we expect for an Anthropic-format upstream).
const result = await buildAndSignClaudeCodeRequest({
model: "claude-opus-4-7",
apiKey: "test-key",
claudeBody: {
model: "claude-opus-4-7",
max_tokens: 32,
messages: [
{ role: "user", content: "hi" },
{
role: "assistant",
content: [
{ type: "text", text: "calling" },
{ type: "tool_use", id: "toolu_orphan", name: "Bash", input: {} },
],
},
{ role: "user", content: "no tool result here" },
{
role: "assistant",
content: [
{ type: "text", text: "done" },
{ type: "tool_use", id: "toolu_kept", name: "Bash", input: {} },
],
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_kept", content: "ok" }],
},
],
},
});
const body = JSON.parse(result.bodyString);
const text = JSON.stringify(body.messages);
assert.ok(!text.includes("toolu_orphan"), "orphan tool_use must be stripped before send");
assert.ok(text.includes("toolu_kept"), "paired tool_use must survive");
});
test("stripTrailingAssistantOrphanToolUse strips trailing assistant tool_use blocks", () => {
const messages = [
{ role: "user", content: "hi" },
{
role: "assistant",
content: [
{ type: "text", text: "thinking" },
{ type: "tool_use", id: "toolu_trailing", name: "Bash", input: {} },
],
},
];
const stripped = stripTrailingAssistantOrphanToolUse(messages as never);
const text = JSON.stringify(stripped);
assert.ok(!text.includes("toolu_trailing"), "trailing tool_use must be removed");
// Text content survives — message kept, only tool_use blocks removed.
assert.ok(text.includes("thinking"), "non-tool_use content must survive");
});
test("stripTrailingAssistantOrphanToolUse drops final message if it becomes empty", () => {
const messages = [
{ role: "user", content: "hi" },
{
role: "assistant",
content: [{ type: "tool_use", id: "toolu_only", name: "Bash", input: {} }],
},
];
const stripped = stripTrailingAssistantOrphanToolUse(messages as never);
assert.equal(stripped.length, 1, "now-empty assistant message dropped entirely");
assert.equal(stripped[0].role, "user");
});
test("stripTrailingAssistantOrphanToolUse is a no-op when last message is a user turn", () => {
const messages = [
{ role: "user", content: "hi" },
{ role: "assistant", content: [{ type: "text", text: "hello" }] },
{ role: "user", content: "follow up" },
];
const stripped = stripTrailingAssistantOrphanToolUse(messages as never);
assert.deepEqual(stripped, messages);
});
test("stripTrailingAssistantOrphanToolUse is a no-op on text-only trailing assistant", () => {
const messages = [
{ role: "user", content: "hi" },
{ role: "assistant", content: [{ type: "text", text: "answer" }] },
];
const stripped = stripTrailingAssistantOrphanToolUse(messages as never);
assert.deepEqual(stripped, messages);
});
test("buildAndSignClaudeCodeRequest strips trailing assistant tool_use (gemini HIGH review)", async () => {
// Anthropic rejects a body whose LAST message is assistant(tool_use)
// with no matching tool_result in the next user message — by definition
// there is no next message. fixToolPairs alone preserves the trailing
// tool_use (intentional for context pruning), so the guard pipeline
// pairs it with stripTrailingAssistantOrphanToolUse.
const result = await buildAndSignClaudeCodeRequest({
model: "claude-opus-4-7",
apiKey: "test-key",
claudeBody: {
model: "claude-opus-4-7",
max_tokens: 32,
messages: [
{ role: "user", content: "hi" },
{
role: "assistant",
content: [
{ type: "text", text: "calling" },
{ type: "tool_use", id: "toolu_trailing", name: "Bash", input: {} },
],
},
],
},
});
const body = JSON.parse(result.bodyString);
const text = JSON.stringify(body.messages);
assert.ok(
!text.includes("toolu_trailing"),
"trailing assistant tool_use must be stripped before upstream send"
);
});