mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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, "{}");
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user