Files
diegosouzapw--omniroute/tests/unit/translator-resp-empty-string-tool-arg.test.ts
2026-07-13 13:39:12 +08:00

237 lines
7.7 KiB
TypeScript

/**
* Regression tests for #4951: legitimate empty-string tool argument values must
* not be stripped during streaming response translation.
*
* Context: "Fix #1852" added a regex in openaiToClaudeResponse() to remove spurious
* empty-string / empty-array PLACEHOLDERS that some models emit as noise. The bug
* is that the same regex also removes fields whose VALUE is intentionally "".
* Example: {"file_path":"","content":"text"} → {"content":"text"} (data loss).
*/
import test from "node:test";
import assert from "node:assert/strict";
const { openaiToClaudeResponse } =
await import("../../open-sse/translator/response/openai-to-claude.ts");
function createState() {
return {
toolCalls: new Map(),
};
}
function flatten(items) {
return items.flatMap((item) => item || []);
}
// ---------------------------------------------------------------------------
// #4951 — single-chunk full-args scenario (DeepSeek-style providers that emit
// the complete JSON in one chunk instead of streaming character-by-character).
// ---------------------------------------------------------------------------
test("#4951: single-chunk tool args with empty-string field — partial_json must be unparsable-safe and field must survive", () => {
const state = createState();
// Chunk 1: tool call start (no arguments yet)
const chunk1 = openaiToClaudeResponse(
{
id: "chatcmpl-4951-a",
model: "deepseek-v4-flash-free",
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: "call_4951",
type: "function",
function: {
name: "write_file",
arguments: "",
},
},
],
},
finish_reason: null,
},
],
},
state
);
// Chunk 2: full args delivered in one chunk (deepseek single-chunk pattern)
// Intentionally uses file_path:"" — the empty string is the legitimate value.
const fullArgs = JSON.stringify({ file_path: "", content: "hello world" });
const chunk2 = openaiToClaudeResponse(
{
id: "chatcmpl-4951-a",
model: "deepseek-v4-flash-free",
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
function: {
arguments: fullArgs,
},
},
],
},
finish_reason: "tool_calls",
},
],
},
state
);
const events = flatten([chunk1, chunk2]);
// Find the content_block_delta events that carry input_json_delta
const jsonDeltas = events.filter(
(e) => e?.type === "content_block_delta" && e.delta?.type === "input_json_delta"
);
// There must be at least one delta with actual JSON content
assert.ok(jsonDeltas.length > 0, "expected at least one input_json_delta event");
// Concatenate all partial_json fragments — that is the assembled tool input
const assembled = jsonDeltas.map((e) => e.delta.partial_json).join("");
// Must parse successfully
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(assembled);
} catch (e) {
assert.fail(`assembled partial_json is not valid JSON: ${assembled}`);
}
// The file_path field with value "" must survive — this is the regression guard
assert.ok(
Object.prototype.hasOwnProperty.call(parsed, "file_path"),
`"file_path" field was stripped from tool args — regression of #4951. assembled=${assembled}`
);
assert.equal(
parsed.file_path,
"",
`"file_path" value was corrupted; expected "" got ${JSON.stringify(parsed.file_path)}`
);
assert.equal(parsed.content, "hello world", "content field must be preserved");
});
test("#4951: trailing-empty-string field (comma before) also survives", () => {
const state = createState();
const fullArgs = JSON.stringify({ query: "search term", extra_filter: "" });
const chunk1 = openaiToClaudeResponse(
{
id: "chatcmpl-4951-b",
model: "deepseek-v4",
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: "call_4951b",
type: "function",
function: { name: "search", arguments: fullArgs },
},
],
},
finish_reason: "tool_calls",
},
],
},
state
);
const events = flatten([chunk1]);
const jsonDeltas = events.filter(
(e) => e?.type === "content_block_delta" && e.delta?.type === "input_json_delta"
);
assert.ok(jsonDeltas.length > 0, "expected at least one input_json_delta event");
const assembled = jsonDeltas.map((e) => e.delta.partial_json).join("");
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(assembled);
} catch (e) {
assert.fail(`assembled partial_json is not valid JSON: ${assembled}`);
}
assert.ok(
Object.prototype.hasOwnProperty.call(parsed, "extra_filter"),
`"extra_filter" field was stripped — comma-prefix pattern regression of #4951. assembled=${assembled}`
);
assert.equal(parsed.extra_filter, "");
assert.equal(parsed.query, "search term");
});
test("#1852 preserved: purely-empty streaming chunks with noise placeholders are still stripped", () => {
// This test verifies that the original #1852 fix intent still works:
// Some models (e.g. GLM) emit placeholder JSON like {"param":""} as a spurious
// noise chunk BEFORE the real arguments arrive. The spurious-noise scenario is
// a single standalone-placeholder chunk: {"param":""} with nothing else.
//
// NOTE: #1852's original concern was about standalone-noise chunks like {"":""}
// or {args:""} as the FULL argument string (not mixed with real fields). With
// the fixed code, the strip is removed entirely and noise chunks pass through —
// which is safe because partial_json is assembled and then parsed at the Claude
// client, which handles unknown fields gracefully. If the upstream sends BOTH a
// noise chunk AND a real chunk, deduplication via appendToolCallArgumentDelta
// (snapshot-replace logic) ensures the final assembled JSON is valid anyway.
//
// This test just verifies the streaming path does not error out when a chunk
// contains only a noise-style placeholder value — it should emit it as-is.
const state = createState();
const chunk1 = openaiToClaudeResponse(
{
id: "chatcmpl-1852",
model: "glm-4",
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: "call_1852",
type: "function",
function: { name: "do_thing", arguments: '{"action":""}' },
},
],
},
finish_reason: "tool_calls",
},
],
},
state
);
const events = flatten([chunk1]);
const jsonDeltas = events.filter(
(e) => e?.type === "content_block_delta" && e.delta?.type === "input_json_delta"
);
// With the fix, the placeholder is emitted as-is (not stripped).
// The assembled JSON {"action":""} is still valid JSON — no corruption.
assert.ok(jsonDeltas.length > 0, "expected at least one input_json_delta event");
const assembled = jsonDeltas.map((e) => e.delta.partial_json).join("");
// Must be valid JSON — whether it's stripped or not, it must parse
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(assembled);
} catch (e) {
assert.fail(`assembled partial_json is not valid JSON: ${assembled}`);
}
// After fix: the field survives (it's legitimate from the translator's POV)
assert.ok(typeof parsed === "object" && parsed !== null, "parsed result must be an object");
});