diff --git a/model/parsers/qwen35.go b/model/parsers/qwen35.go index 6a6f54b69..eeb3ee68f 100644 --- a/model/parsers/qwen35.go +++ b/model/parsers/qwen35.go @@ -190,7 +190,7 @@ func (p *Qwen35Parser) eat() ([]qwen35Event, bool) { p.state = qwen35ParserStateCollectingContent } return events, true - } else if overlapLen := overlap(acc, qwen35ThinkingCloseTag); overlapLen > 0 { + } else if overlapLen := max(overlap(acc, qwen35ThinkingCloseTag), overlap(acc, qwen35ToolCallOpenTag)); overlapLen > 0 { beforePartialTag := acc[:len(acc)-overlapLen] trailingWsLen := trailingWhitespaceLen(beforePartialTag) ambiguousStart := len(beforePartialTag) - trailingWsLen diff --git a/model/parsers/qwen35_test.go b/model/parsers/qwen35_test.go index abedfe8f4..642946b9b 100644 --- a/model/parsers/qwen35_test.go +++ b/model/parsers/qwen35_test.go @@ -158,6 +158,133 @@ SF } } +func TestQwen35ParserToolCallEmittedInThinkingIsParsedWhenToolCallTagIsSplitAcrossChunks(t *testing.T) { + parser := ParserForName("qwen3.5") + if parser == nil { + t.Fatal("expected qwen3.5 parser") + } + + tools := []api.Tool{ + { + Function: api.ToolFunction{ + Name: "get_weather", + Parameters: api.ToolFunctionParameters{ + Properties: func() *api.ToolPropertiesMap { + props := api.NewToolPropertiesMap() + props.Set("location", api.ToolProperty{Type: api.PropertyType{"string"}}) + return props + }(), + }, + }, + }, + } + + parser.Init(tools, nil, &api.ThinkValue{Value: true}) + content, thinking, calls, err := parser.Add("Need weather lookup +SF +`, true) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + if content != "" { + t.Fatalf("expected empty content, got %q", content) + } + if thinking != "" { + t.Fatalf("expected no additional thinking, got %q", thinking) + } + if len(calls) != 1 { + t.Fatalf("expected 1 tool call, got %d", len(calls)) + } + + if calls[0].Function.Name != "get_weather" { + t.Fatalf("expected tool name %q, got %q", "get_weather", calls[0].Function.Name) + } + + location, ok := calls[0].Function.Arguments.Get("location") + if !ok || location != "SF" { + t.Fatalf("expected location %q, got %v", "SF", location) + } +} + +func TestQwen35ParserFakeoutPartialToolCallThenThinkCloseAcrossChunks(t *testing.T) { + parser := ParserForName("qwen3.5") + if parser == nil { + t.Fatal("expected qwen3.5 parser") + } + + tools := []api.Tool{ + { + Function: api.ToolFunction{ + Name: "get_weather", + Parameters: api.ToolFunctionParameters{ + Properties: func() *api.ToolPropertiesMap { + props := api.NewToolPropertiesMap() + props.Set("location", api.ToolProperty{Type: api.PropertyType{"string"}}) + return props + }(), + }, + }, + }, + } + + parser.Init(tools, nil, &api.ThinkValue{Value: true}) + content, thinking, calls, err := parser.Add("Need weather lookup", true) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + if content != "" { + t.Fatalf("expected empty content, got %q", content) + } + if thinking != "" { + t.Fatalf("expected no additional thinking in third chunk, got %q", thinking) + } + if len(calls) != 0 { + t.Fatalf("expected no tool calls in third chunk, got %d", len(calls)) + } +} + func TestQwen35ParserToolCallAfterThinkingCloseIsParsed(t *testing.T) { parser := ParserForName("qwen3.5") if parser == nil {