fix(stream): resolve index mismatch in textual tool-call slicing and deduplicate containsTextualToolCallMarker (#3413)

Integrated into release/v3.8.17
This commit is contained in:
Ardem2025
2026-06-09 00:50:53 +03:00
committed by GitHub
parent dbd70ddd1f
commit da273d37e2
4 changed files with 230 additions and 43 deletions

View File

@@ -3,6 +3,7 @@ import {
buildGeminiThoughtSignatureKey,
storeGeminiThoughtSignature,
} from "../services/geminiThoughtSignatureStore.ts";
import { containsTextualToolCallMarker } from "../utils/textualToolCall.ts";
type JsonRecord = Record<string, unknown>;
@@ -75,17 +76,6 @@ function parseTextualToolCall(text: unknown): { name: string; args: unknown } |
return null;
}
function containsTextualToolCallMarker(text: unknown): boolean {
if (typeof text !== "string") return false;
const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, "");
if (!normalized.includes("[Tool call:")) return false;
if (normalized.includes("Arguments:")) return true;
const trimmed = normalized.trim();
return trimmed.startsWith("[Tool call:") || trimmed.startsWith("(empty)[Tool call:");
}
function extractMessageOutputText(item: JsonRecord): string {
if (!Array.isArray(item.content)) return "";
let text = "";

View File

@@ -4,7 +4,10 @@ import {
buildGeminiThoughtSignatureKey,
storeGeminiThoughtSignature,
} from "../../services/geminiThoughtSignatureStore.ts";
import { parseTextualToolCallCandidate } from "../../utils/textualToolCall.ts";
import {
parseTextualToolCallCandidate,
containsTextualToolCallMarker,
} from "../../utils/textualToolCall.ts";
type GeminiToOpenAIState = {
functionIndex: number;
@@ -37,17 +40,6 @@ function normalizeToolCallArgs(args: unknown): unknown {
}
}
function containsTextualToolCallMarker(text: unknown): boolean {
if (typeof text !== "string") return false;
const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, "");
if (!normalized.includes("[Tool call:")) return false;
if (normalized.includes("Arguments:")) return true;
const trimmed = normalized.trim();
return trimmed.startsWith("[Tool call:") || trimmed.startsWith("(empty)[Tool call:");
}
function buildToolCallId(
functionCall: GeminiFunctionCallPart["functionCall"],
toolName: string,
@@ -236,28 +228,25 @@ export function geminiToOpenAIResponse(chunk, state) {
if (part.text !== undefined && part.text !== "") {
let accumulated = (state.textualToolCallBuffer || "") + part.text;
let candidate = null;
if (!state.hasEmittedContent || state.textualToolCallBuffer) {
candidate = parseTextualToolCallCandidate(accumulated);
}
let candidate = parseTextualToolCallCandidate(accumulated);
if (candidate) {
const normalized = accumulated.replace(/[-]/g, "");
let toolCallIndex = normalized.lastIndexOf("[Tool call:");
accumulated = accumulated.replace(/[\u200B-\u200D\uFEFF]/g, "");
let toolCallIndex = accumulated.lastIndexOf("(empty)[Tool call:");
if (toolCallIndex < 0) {
toolCallIndex = normalized.lastIndexOf("(empty)[Tool call:");
toolCallIndex = accumulated.lastIndexOf("[Tool call:");
}
if (toolCallIndex < 0) {
const lastBracket = normalized.lastIndexOf("[");
if (lastBracket !== -1 && "[Tool call:".startsWith(normalized.slice(lastBracket))) {
toolCallIndex = lastBracket;
const lastParen = accumulated.lastIndexOf("(");
if (
lastParen !== -1 &&
"(empty)[Tool call:".startsWith(accumulated.slice(lastParen))
) {
toolCallIndex = lastParen;
} else {
const lastParen = normalized.lastIndexOf("(");
if (
lastParen !== -1 &&
"(empty)[Tool call:".startsWith(normalized.slice(lastParen))
) {
toolCallIndex = lastParen;
const lastBracket = accumulated.lastIndexOf("[");
if (lastBracket !== -1 && "[Tool call:".startsWith(accumulated.slice(lastBracket))) {
toolCallIndex = lastBracket;
}
}
}

View File

@@ -61,14 +61,14 @@ export function parseTextualToolCallCandidate(
const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, "");
const toolCallIndex = normalized.lastIndexOf("[Tool call:");
if (toolCallIndex < 0) {
const lastBracket = normalized.lastIndexOf("[");
if (lastBracket !== -1 && "[Tool call:".startsWith(normalized.slice(lastBracket))) {
return { kind: "partial" };
}
const lastParen = normalized.lastIndexOf("(");
if (lastParen !== -1 && "(empty)[Tool call:".startsWith(normalized.slice(lastParen))) {
return { kind: "partial" };
}
const lastBracket = normalized.lastIndexOf("[");
if (lastBracket !== -1 && "[Tool call:".startsWith(normalized.slice(lastBracket))) {
return { kind: "partial" };
}
return null;
}
const candidate = normalized.slice(toolCallIndex);
@@ -99,3 +99,14 @@ export function parseTextualToolCallCandidate(
}
return { kind: "partial" };
}
export function containsTextualToolCallMarker(text: unknown): boolean {
if (typeof text !== "string") return false;
const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, "");
if (!normalized.includes("[Tool call:")) return false;
if (normalized.includes("Arguments:")) return true;
const trimmed = normalized.trim();
return trimmed.startsWith("[Tool call:") || trimmed.startsWith("(empty)[Tool call:");
}

View File

@@ -814,3 +814,200 @@ test("Gemini stream: splits mid-stream partial candidate but preserves tool call
assert.equal(toolCall.function.name, "read_file");
assert.equal(toolCall.function.arguments, '{"path":"/tmp/a"}');
});
test("Gemini stream: index mismatch regression test with zero-width characters in prefix", () => {
const state = createStreamingState();
const result = geminiToOpenAIResponse(
{
responseId: "resp-textual-tool-index-mismatch",
modelVersion: "gemini-3.5-flash-low",
candidates: [
{
content: {
parts: [
{
text: "\u200BКак исправить: [Tool call: terminal]\nArguments: {\"command\":\"whoami\"}",
},
],
},
finishReason: "STOP",
},
],
},
state
);
const leakedContent = result.map((event: any) => event.choices?.[0]?.delta?.content || "").join("");
assert.equal(leakedContent, "Как исправить: ");
const toolCalls = result.flatMap((event: any) => event.choices?.[0]?.delta?.tool_calls || []);
assert.equal(toolCalls.length, 1);
assert.equal(toolCalls[0].function.name, "terminal");
assert.equal(toolCalls[0].function.arguments, '{"command":"whoami"}');
});
test("Gemini stream: partial tool call with (empty) prefix check at chunk end does not leak (empty)", () => {
const state = createStreamingState();
const chunk1 = {
responseId: "resp-empty-leak",
modelVersion: "gemini-3.5-flash-low",
candidates: [
{
content: {
parts: [
{
text: "Результат: (empty)[Tool ca",
},
],
},
},
],
};
const chunk2 = {
candidates: [
{
content: {
parts: [
{
text: "ll: terminal]\nArguments: {\"command\":\"whoami\"}",
},
],
},
finishReason: "STOP",
},
],
};
const res1 = geminiToOpenAIResponse(chunk1, state) || [];
const content1 = res1.map((event) => event.choices?.[0]?.delta?.content || "").join("");
assert.equal(content1, "Результат: "); // (empty) must be buffered, not leaked!
const res2 = geminiToOpenAIResponse(chunk2, state) || [];
const content2 = res2.map((event) => event.choices?.[0]?.delta?.content || "").join("");
assert.equal(content2, "");
assert.equal(state.toolCalls.size, 1);
const toolCall: any = Array.from(state.toolCalls.values())[0];
assert.equal(toolCall.function.name, "terminal");
assert.equal(toolCall.function.arguments, '{"command":"whoami"}');
});
test("Gemini stream: parses textual tool call that starts in a subsequent chunk after prose has been emitted", () => {
const state = createStreamingState() as any;
const chunk1 = {
responseId: "resp-test-after-prose",
modelVersion: "gemini-3.5-flash-low",
candidates: [
{
content: {
parts: [
{
text: "Generating response now... ",
},
],
},
},
],
};
const chunk2 = {
candidates: [
{
content: {
parts: [
{
text: '[Tool call: web_search]\nArguments: {"query": "AI news"}',
},
],
},
finishReason: "STOP",
},
],
};
const res1 = geminiToOpenAIResponse(chunk1, state) || [];
const content1 = res1.map((event) => event.choices?.[0]?.delta?.content || "").join("");
assert.equal(content1, "Generating response now... ");
const res2 = geminiToOpenAIResponse(chunk2, state) || [];
const content2 = res2.map((event) => event.choices?.[0]?.delta?.content || "").join("");
assert.equal(content2, "");
assert.equal(state.toolCalls.size, 1);
const toolCall: any = Array.from(state.toolCalls.values())[0];
assert.equal(toolCall.function.name, "web_search");
assert.equal(toolCall.function.arguments, '{"query":"AI news"}');
});
test("Gemini stream: checks lastParen before lastBracket when identifying partial (empty) markers with distinct chuncks", () => {
const state = createStreamingState() as any;
// Имитируем чанк, который кончается на частичный "(empty)[Tool call:" маркер, например "(em"
const chunk1 = {
responseId: "resp-test-empty-partial",
modelVersion: "gemini-3.5-flash-low",
candidates: [
{
content: {
parts: [
{
text: "Result is here: (em",
},
],
},
},
],
};
// Имитируем чанк, который содержит и "(", и "[", кончаясь на "(empty)[Tool"
// Если бы мы проверяли lastBracket первым, мы бы отрезали по "[", оставив "(empty)" утекать пользователю.
const chunk2 = {
candidates: [
{
content: {
parts: [
{
text: "pty)[Tool",
},
],
},
},
],
};
const chunk3 = {
candidates: [
{
content: {
parts: [
{
text: ' call: my_tool]\nArguments: {}',
},
],
},
finishReason: "STOP",
},
],
};
const res1 = geminiToOpenAIResponse(chunk1, state) || [];
const content1 = res1.map((event) => event.choices?.[0]?.delta?.content || "").join("");
assert.equal(content1, "Result is here: "); // (em задерживается в буфере
const res2 = geminiToOpenAIResponse(chunk2, state) || [];
const content2 = res2.map((event) => event.choices?.[0]?.delta?.content || "").join("");
assert.equal(content2, ""); // (empty)[Tool задерживается в буфере полностью, (empty) не утекает
const res3 = geminiToOpenAIResponse(chunk3, state) || [];
const content3 = res3.map((event) => event.choices?.[0]?.delta?.content || "").join("");
assert.equal(content3, "");
assert.equal(state.toolCalls.size, 1);
const toolCall: any = Array.from(state.toolCalls.values())[0];
assert.equal(toolCall.function.name, "my_tool");
assert.equal(toolCall.function.arguments, "{}");
});