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

@@ -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, "{}");
});