package parsers import ( "reflect" "testing" "github.com/ollama/ollama/api" ) func TestQwen3VLThinkingParserStreaming(t *testing.T) { type step struct { input string wantEvents []qwenEvent } cases := []struct { desc string steps []step only bool }{ { desc: "simple thinking", steps: []step{ {input: "abc", wantEvents: []qwenEvent{qwenEventThinkingContent{content: "abc"}}}, }, }, { desc: "simple trip thinking", steps: []step{ {input: "abc", wantEvents: []qwenEvent{qwenEventThinkingContent{content: "abc"}}}, }, }, { desc: "thinking with split tags", steps: []step{ {input: "abc", wantEvents: []qwenEvent{qwenEventThinkingContent{content: "abc"}}}, {input: "", wantEvents: []qwenEvent{}}, }, }, { desc: "multiple think tags", steps: []step{ {input: "abcactually, is not thinking", wantEvents: []qwenEvent{qwenEventThinkingContent{content: "abcactually, is not thinking"}}}, }, }, { desc: "thinking and tool call", steps: []step{ { input: "I'm thinkingI'm tool calling", wantEvents: []qwenEvent{ qwenEventThinkingContent{content: "I'm thinking"}, qwenEventRawToolCall{raw: "I'm tool calling"}, }, }, }, }, { desc: "thinking and content", steps: []step{ { input: "I'm thinkingI'm content", wantEvents: []qwenEvent{ qwenEventThinkingContent{content: "I'm thinking"}, qwenEventContent{content: "I'm content"}, }, }, }, }, { desc: "thinking and tool call and content", }, { desc: "nested thinking (outside thinking, inside thinking)", steps: []step{ { input: "I'm thinkingI'm nested thinking", wantEvents: []qwenEvent{ qwenEventThinkingContent{content: "I'm thinkingI'm nested thinking"}, qwenEventContent{content: ""}, }, }, }, }, { desc: "interleaved thinking", steps: []step{ { input: "I'm thinkingI'm actually content", wantEvents: []qwenEvent{ qwenEventThinkingContent{content: "I'm thinking"}, qwenEventContent{content: "I'm actually content"}, }, }, }, }, { desc: "nested thinking and tool call (outside thinking, inside tool call)", steps: []step{ { input: "I'm thinkingI'm nested tool call", wantEvents: []qwenEvent{qwenEventThinkingContent{content: "I'm thinkingI'm nested tool call"}}, }, }, }, { desc: "nested thinking and tool call (outside tool call, inside thinking)", steps: []step{ { input: "I'm nested tool callI'm thinking", wantEvents: []qwenEvent{ qwenEventThinkingContent{content: "I'm nested tool callI'm thinking"}, qwenEventContent{content: ""}, }, }, }, }, { desc: "interleaved thinking and tool call", steps: []step{ { input: "I'm thinkingI'm NOT a nested tool callI'm nested tool call 2", wantEvents: []qwenEvent{ qwenEventThinkingContent{content: "I'm thinkingI'm NOT a nested tool call"}, qwenEventContent{content: ""}, qwenEventRawToolCall{raw: "I'm nested tool call 2"}, qwenEventContent{content: ""}, }, }, }, }, { desc: "partial thinking tag fakeout", steps: []step{ { input: "abcunfinishedunfinished"}}, }, }, }, { desc: "test with split thinking and content", steps: []step{ { input: "abcunfinishedunfinished"}}, }, { input: "ink> def", wantEvents: []qwenEvent{ qwenEventContent{content: "def"}, }, }, }, }, { desc: "thinking with no tags", steps: []step{ { input: "Hello I am thinking", wantEvents: []qwenEvent{ qwenEventThinkingContent{content: "Hello I am thinking"}, }, }, { input: "Hello I am thinking some more", wantEvents: []qwenEvent{ qwenEventThinkingContent{content: "Hello I am thinking some more"}, }, }, { input: "Hello I am think NOT", wantEvents: []qwenEvent{ qwenEventThinkingContent{content: "Hello I am think"}, qwenEventContent{content: "NOT"}, }, }, }, }, } anyOnlies := false for _, tc := range cases { if tc.only { anyOnlies = true } } for _, tc := range cases { if anyOnlies && !tc.only { continue } t.Run(tc.desc, func(t *testing.T) { parser := Qwen3VLParser{hasThinkingSupport: true} parser.Init([]api.Tool{}, nil) // parser.state = CollectingThinkingContent for i, step := range tc.steps { parser.buffer.WriteString(step.input) gotEvents := parser.parseEvents() if len(gotEvents) == 0 && len(step.wantEvents) == 0 { // avoid deep equal on empty vs. nil slices continue } if !reflect.DeepEqual(gotEvents, step.wantEvents) { t.Errorf("step %d: input %q: got events %#v, want %#v", i, step.input, gotEvents, step.wantEvents) } } }) } } func TestQwen3VLThinkingToolParser(t *testing.T) { type step struct { name string rawToolCall string tools []api.Tool wantToolCall api.ToolCall } steps := []step{ { name: "simple tool call", tools: []api.Tool{}, rawToolCall: `{"name": "get-current-weather", "arguments": {"location": "San Francisco, CA", "unit": "fahrenheit"}}`, wantToolCall: api.ToolCall{ Function: api.ToolCallFunction{ Name: "get-current-weather", Arguments: map[string]any{ "location": "San Francisco, CA", "unit": "fahrenheit", }, }, }, }, { name: "names with spaces", tools: []api.Tool{}, rawToolCall: `{"name": "get current temperature", "arguments": {"location with spaces": "San Francisco", "unit with spaces": "celsius"}}`, wantToolCall: api.ToolCall{ Function: api.ToolCallFunction{ Name: "get current temperature", Arguments: map[string]any{ "location with spaces": "San Francisco", "unit with spaces": "celsius", }, }, }, }, { name: "names with quotes", tools: []api.Tool{}, rawToolCall: `{"name": "\"get current temperature\"", "arguments": {"\"location with spaces\"": "San Francisco", "\"unit with spaces\"": "\"celsius\""}}`, wantToolCall: api.ToolCall{ Function: api.ToolCallFunction{ Name: "\"get current temperature\"", Arguments: map[string]any{ "\"location with spaces\"": "San Francisco", "\"unit with spaces\"": "\"celsius\"", }, }, }, }, { name: "tool call with typed parameters (json types)", tools: []api.Tool{}, rawToolCall: `{"name": "calculate", "arguments": {"x": 3.14, "y": 42, "enabled": true, "items": ["a", "b", "c"]}}`, wantToolCall: api.ToolCall{ Function: api.ToolCallFunction{ Name: "calculate", Arguments: map[string]any{ "x": 3.14, "y": float64(42), "enabled": true, "items": []any{"a", "b", "c"}, }, }, }, }, { name: "ampersands in parameter values", tools: []api.Tool{}, rawToolCall: `{"name": "exec", "arguments": {"command": "ls && echo \"done\""}}`, wantToolCall: api.ToolCall{ Function: api.ToolCallFunction{ Name: "exec", Arguments: map[string]any{ "command": "ls && echo \"done\"", }, }, }, }, { name: "angle brackets in parameter values", tools: []api.Tool{}, rawToolCall: `{"name": "exec", "arguments": {"command": "ls && echo \"a > b and a < b\""}}`, wantToolCall: api.ToolCall{ Function: api.ToolCallFunction{ Name: "exec", Arguments: map[string]any{ "command": "ls && echo \"a > b and a < b\"", }, }, }, }, { name: "unicode in function names and parameters", tools: []api.Tool{}, rawToolCall: `{"name": "获取天气", "arguments": {"城市": "北京", "message": "Hello! 你好! 🌟 مرحبا"}}`, wantToolCall: api.ToolCall{ Function: api.ToolCallFunction{ Name: "获取天气", Arguments: map[string]any{ "城市": "北京", "message": "Hello! 你好! 🌟 مرحبا", }, }, }, }, } for i, step := range steps { gotToolCall, err := parseJSONToolCall(qwenEventRawToolCall{raw: step.rawToolCall}, step.tools) if err != nil { t.Errorf("step %d (%s): %v", i, step.name, err) } if !reflect.DeepEqual(gotToolCall, step.wantToolCall) { t.Errorf("step %d (%s): got tool call %#v, want %#v", i, step.name, gotToolCall, step.wantToolCall) } } } func TestQwen3VLParserState(t *testing.T) { cases := []struct { desc string hasThinking bool last *api.Message wantState qwenParserState }{ { desc: "no thinking support => CollectingContent", hasThinking: false, last: nil, wantState: CollectingContent, }, { desc: "thinking support, no last message => CollectingThinkingContent", hasThinking: true, last: nil, wantState: CollectingThinkingContent, }, { desc: "thinking support, last assistant with empty content => CollectingThinkingContent", hasThinking: true, last: &api.Message{Role: "assistant", Content: ""}, wantState: CollectingThinkingContent, }, { desc: "thinking support, last assistant with content => CollectingContent", hasThinking: true, last: &api.Message{Role: "assistant", Content: "hello"}, wantState: CollectingContent, }, { desc: "thinking support, last is user => CollectingThinkingContent", hasThinking: true, last: &api.Message{Role: "user", Content: "hi"}, wantState: CollectingThinkingContent, }, } for _, tc := range cases { parser := Qwen3VLParser{hasThinkingSupport: tc.hasThinking} parser.Init(nil, tc.last) if parser.state != tc.wantState { t.Errorf("%s: got state %v, want %v", tc.desc, parser.state, tc.wantState) } } } func TestQwen3VLThinkingParserWithThinkingPrefill(t *testing.T) { type step struct { input string wantEvents []qwenEvent } cases := []struct { desc string steps []step only bool }{ { desc: "thinking prefill", steps: []step{ {input: "abc", wantEvents: []qwenEvent{qwenEventThinkingContent{content: "abc"}}}, }, }, { desc: "thinking prefill with content", steps: []step{ {input: "abc def", wantEvents: []qwenEvent{qwenEventContent{content: "def"}}}, }, }, { desc: "thinking prefill with fakeout", steps: []step{ {input: "abc", wantEvents: []qwenEvent{}}, }, }, { desc: "thinking prefill with spaces", steps: []step{ {input: " starting content", wantEvents: []qwenEvent{qwenEventContent{content: "starting content"}}}, }, }, } last := &api.Message{Role: "assistant", Thinking: "i am thinking"} // so if there is thinking the test is still thinking for _, tc := range cases { t.Run(tc.desc, func(t *testing.T) { parser := Qwen3VLParser{hasThinkingSupport: true} parser.Init([]api.Tool{}, last) for i, step := range tc.steps { parser.buffer.WriteString(step.input) gotEvents := parser.parseEvents() if len(gotEvents) == 0 && len(step.wantEvents) == 0 { // avoid deep equal on empty vs. nil slices continue } if !reflect.DeepEqual(gotEvents, step.wantEvents) { t.Errorf("step %d: input %q: got events %#v, want %#v", i, step.input, gotEvents, step.wantEvents) } } }) } } func TestQwen3VLThinkingParserWithNonThinkingPrefill(t *testing.T) { type step struct { input string wantEvents []qwenEvent } cases := []struct { desc string steps []step only bool }{ { desc: "thinking prefill", steps: []step{ {input: "abc", wantEvents: []qwenEvent{qwenEventContent{content: "abc"}}}, }, }, { desc: "thinking prefill with content", steps: []step{ {input: "abc def", wantEvents: []qwenEvent{qwenEventContent{content: "ink> def"}}}, }, }, { desc: "thinking prefill with fakeout", steps: []step{ {input: "abc", wantEvents: []qwenEvent{qwenEventContent{content: ">"}}}, }, }, { desc: "thinking prefill with spaces", steps: []step{ {input: " starting content", wantEvents: []qwenEvent{qwenEventContent{content: " starting content"}}}, }, }, } last := &api.Message{Role: "assistant", Thinking: "i am thinking", Content: "i am content"} // so if there is thinking the test is still thinking for _, tc := range cases { t.Run(tc.desc, func(t *testing.T) { parser := Qwen3VLParser{hasThinkingSupport: true} parser.Init([]api.Tool{}, last) for i, step := range tc.steps { parser.buffer.WriteString(step.input) gotEvents := parser.parseEvents() if len(gotEvents) == 0 && len(step.wantEvents) == 0 { // avoid deep equal on empty vs. nil slices continue } if !reflect.DeepEqual(gotEvents, step.wantEvents) { t.Errorf("step %d: input %q: got events %#v, want %#v", i, step.input, gotEvents, step.wantEvents) } } }) } } func TestQwen3VLThinkingParserStreamingAssistantPrefillContent(t *testing.T) { // last message is assistant with content ⇒ start in CollectingContent last := &api.Message{Role: "assistant", Content: "has content"} parser := Qwen3VLParser{hasThinkingSupport: true} parser.Init([]api.Tool{}, last) type step struct { input string wantEvents []qwenEvent } steps := []step{ {input: "abc", wantEvents: []qwenEvent{qwenEventContent{content: "abc"}}}, {input: "{\"name\": \"x\", \"arguments\": {}}", wantEvents: []qwenEvent{qwenEventRawToolCall{raw: "{\"name\": \"x\", \"arguments\": {}}"}}}, } for i, s := range steps { parser.buffer.WriteString(s.input) gotEvents := parser.parseEvents() if len(gotEvents) == 0 && len(s.wantEvents) == 0 { continue } if !reflect.DeepEqual(gotEvents, s.wantEvents) { t.Fatalf("step %d: input %q: got %#v, want %#v", i, s.input, gotEvents, s.wantEvents) } } } func TestQwen3VLThinkingWhitespaceHandling(t *testing.T) { type step struct { input string wantEvents []qwenEvent } cases := []struct { desc string steps []step only bool }{ { desc: "whitespace after thinking tag is trimmed", steps: []step{ { input: "thinking content \n\t content starts here", wantEvents: []qwenEvent{ qwenEventThinkingContent{content: "thinking content"}, qwenEventContent{content: "content starts here"}, }, }, }, }, { desc: "whitespace after thinking tag split across chunks", steps: []step{ { input: "thinking content ", wantEvents: []qwenEvent{qwenEventThinkingContent{content: "thinking content"}}, }, { input: " \n\t", wantEvents: []qwenEvent{}, }, { input: "content", wantEvents: []qwenEvent{ qwenEventContent{content: "content"}, }, }, }, }, { desc: "only whitespace after thinking tag", steps: []step{ { input: "thinking content \n\t ", wantEvents: []qwenEvent{qwenEventThinkingContent{content: "thinking content"}}, }, }, }, { desc: "multiple spaces and tabs after thinking", steps: []step{ { input: "think \t\t\n\n text", wantEvents: []qwenEvent{ qwenEventThinkingContent{content: "think"}, qwenEventContent{content: "text"}, }, }, }, }, { desc: "trailing whitespace before thinking tag is preserved in content", steps: []step{ { input: "thinking with spaces text", wantEvents: []qwenEvent{ qwenEventThinkingContent{content: "thinking with spaces"}, qwenEventContent{content: "text"}, }, }, }, }, { desc: "whitespace between thinking and tool call", steps: []step{ { input: "thinking \n {\"name\":\"test\"}", wantEvents: []qwenEvent{ qwenEventThinkingContent{content: "thinking"}, qwenEventRawToolCall{raw: "{\"name\":\"test\"}"}, }, }, }, }, { desc: "no whitespace after thinking tag", steps: []step{ { input: "thinkingcontent", wantEvents: []qwenEvent{ qwenEventThinkingContent{content: "thinking"}, qwenEventContent{content: "content"}, }, }, }, }, { desc: "unicode whitespace after thinking tag", steps: []step{ { input: "thinking\u00a0\u3000content", wantEvents: []qwenEvent{ qwenEventThinkingContent{content: "thinking"}, qwenEventContent{content: "content"}, }, }, }, }, { desc: "whitespace split with partial thinking tag", steps: []step{ { input: "thinking \n", wantEvents: []qwenEvent{}, }, { input: " content", wantEvents: []qwenEvent{ qwenEventContent{content: "content"}, }, }, }, }, { desc: "empty thinking tag with whitespace after", steps: []step{ { input: " \ncontent", wantEvents: []qwenEvent{ qwenEventContent{content: "content"}, }, }, }, }, { desc: "whitespace inside tool call preserves trailing space", steps: []step{ { input: "bruh \n \n \n \n \n \n blahhhhhhhhhh blahhhh blahhhh \n\n\n\t\t tool content \n\n\n\n\n\n\n after", wantEvents: []qwenEvent{ qwenEventThinkingContent{content: "bruh"}, qwenEventContent{content: "blahhhhhhhhhh blahhhh blahhhh"}, qwenEventRawToolCall{raw: " tool content "}, qwenEventContent{content: "after"}, }, }, }, }, { desc: "whitespace inside tool call preserves trailing space", steps: []step{ { input: "bruh shdjfhksdhfj ", wantEvents: []qwenEvent{ qwenEventThinkingContent{content: "bruh"}, qwenEventContent{content: "shdjfhksdhfj"}, }, }, { input: "another word ", wantEvents: []qwenEvent{ qwenEventContent{content: " another word"}, }, }, { input: " tool content ", wantEvents: []qwenEvent{ qwenEventRawToolCall{raw: " tool content "}, }, }, { input: "\n \n \n \n \n \n blahhhhhhhhhh blahhhh blahhhh \n\n\n\t\t anotha one \n\n\n\n\n\n\n after \n\n\n\n\n\n blep", wantEvents: []qwenEvent{ qwenEventContent{content: "blahhhhhhhhhh blahhhh blahhhh"}, qwenEventRawToolCall{raw: " anotha one "}, qwenEventContent{content: "after \n\n\n\n\n\n blep"}, }, }, }, }, } anyOnlies := false for _, tc := range cases { if tc.only { anyOnlies = true } } for _, tc := range cases { if anyOnlies && !tc.only { continue } t.Run(tc.desc, func(t *testing.T) { parser := Qwen3VLParser{hasThinkingSupport: true} parser.Init([]api.Tool{}, nil) for i, step := range tc.steps { parser.buffer.WriteString(step.input) gotEvents := parser.parseEvents() if len(gotEvents) == 0 && len(step.wantEvents) == 0 { continue } if !reflect.DeepEqual(gotEvents, step.wantEvents) { t.Errorf("step %d: input %q: got events %#v, want %#v", i, step.input, gotEvents, step.wantEvents) } } }) } } func TestQwen3VLToolCallWhitespaceHandling(t *testing.T) { type step struct { input string wantEvents []qwenEvent } cases := []struct { desc string steps []step only bool prefillMsg *api.Message // allows starting in content mode instead of thinking mode }{ { desc: "whitespace inside tool call is fully preserved (with content prefill)", prefillMsg: &api.Message{Role: "assistant", Content: "prefill"}, steps: []step{ { input: "before tool content \n after", wantEvents: []qwenEvent{ qwenEventContent{content: "before"}, qwenEventRawToolCall{raw: " tool content "}, qwenEventContent{content: "after"}, }, }, }, }, { desc: "whitespace after tool call trimmed across chunks (with content prefill)", prefillMsg: &api.Message{Role: "assistant", Content: "prefill"}, steps: []step{ { input: "beforetool ", wantEvents: []qwenEvent{ qwenEventContent{content: "before"}, qwenEventRawToolCall{raw: "tool"}, }, }, { input: "\n\t", wantEvents: []qwenEvent{}, }, { input: "after \n this is a song", wantEvents: []qwenEvent{ qwenEventContent{content: "after \n this is a song"}, }, }, }, }, { desc: "multiple tool calls with whitespace between (with content prefill)", prefillMsg: &api.Message{Role: "assistant", Content: "prefill"}, steps: []step{ { input: "first \n second", wantEvents: []qwenEvent{ qwenEventRawToolCall{raw: "first"}, qwenEventRawToolCall{raw: "second"}, }, }, }, }, { desc: "thinking with whitespace then tool call", steps: []step{ { input: "thinking \n tool \n content", wantEvents: []qwenEvent{ qwenEventThinkingContent{content: "thinking"}, qwenEventRawToolCall{raw: "tool"}, qwenEventContent{content: "content"}, }, }, }, }, } anyOnlies := false for _, tc := range cases { if tc.only { anyOnlies = true } } for _, tc := range cases { if anyOnlies && !tc.only { continue } t.Run(tc.desc, func(t *testing.T) { parser := Qwen3VLParser{hasThinkingSupport: true} parser.Init([]api.Tool{}, tc.prefillMsg) for i, step := range tc.steps { parser.buffer.WriteString(step.input) gotEvents := parser.parseEvents() if len(gotEvents) == 0 && len(step.wantEvents) == 0 { continue } if !reflect.DeepEqual(gotEvents, step.wantEvents) { t.Errorf("step %d: input %q: got events %#v, want %#v", i, step.input, gotEvents, step.wantEvents) } } }) } }