package parsers import ( "reflect" "testing" "github.com/ollama/ollama/api" ) func TestQwen3VLNonThinkingParserStreaming(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{qwenEventContent{content: "abc"}}}, }, }, { desc: "simple trip thinking", steps: []step{ {input: "abc", wantEvents: []qwenEvent{qwenEventContent{content: "abc"}}}, }, }, { desc: "thinking with split tags", steps: []step{ {input: "abc", wantEvents: []qwenEvent{qwenEventContent{content: "abc"}}}, {input: "", wantEvents: []qwenEvent{qwenEventContent{content: ""}}}, }, }, { desc: "multiple think tags", steps: []step{ {input: "abcactually, is not thinking", wantEvents: []qwenEvent{qwenEventContent{content: "abcactually, is not thinking"}}}, }, }, { desc: "thinking and tool call", steps: []step{ { input: "I'm thinkingI'm tool calling", wantEvents: []qwenEvent{ qwenEventContent{content: "I'm thinking"}, qwenEventRawToolCall{raw: "I'm tool calling"}, }, }, }, }, { desc: "nested thinking (outside thinking, inside thinking)", steps: []step{ { input: "I'm thinkingI'm nested thinking", wantEvents: []qwenEvent{ qwenEventContent{content: "I'm thinkingI'm nested thinking"}, }, }, }, }, { desc: "interleaved thinking", steps: []step{ { input: "I'm thinkingI'm actually content", wantEvents: []qwenEvent{ qwenEventContent{content: "I'm thinkingI'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{ qwenEventContent{content: "I'm thinking"}, qwenEventRawToolCall{raw: "I'm nested tool call"}, qwenEventContent{content: ""}, }, }, }, }, { desc: "nested thinking and tool call (outside tool call, inside thinking)", steps: []step{ { input: "I'm nested tool callI'm thinking", wantEvents: []qwenEvent{ qwenEventRawToolCall{raw: "I'm nested tool callI'm thinking"}, }, }, }, }, { 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{ qwenEventContent{content: "I'm thinking"}, qwenEventRawToolCall{raw: "I'm NOT a nested tool call"}, qwenEventRawToolCall{raw: "I'm nested tool call 2"}, qwenEventContent{content: ""}, }, }, }, }, { desc: "emit unambiguous before partial tool open (trailing ws)", steps: []step{ { input: "abc\u00a0\nabc", wantEvents: []qwenEvent{ qwenEventRawToolCall{raw: "abc"}, }, }, }, }, { desc: "partial thinking tag fakeout", steps: []step{ { input: "abcunfinished<", // when something is ambiguious, we dont emit anything wantEvents: []qwenEvent{qwenEventContent{content: "abcunfinished"}}, }, }, }, { desc: "test with split tool and content", steps: []step{ { input: "abcunfinished def", wantEvents: []qwenEvent{ qwenEventRawToolCall{raw: "unfinished"}, qwenEventContent{content: "def"}, }, }, }, }, } 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: false} 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 { // 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 TestQwenOldParserStreaming(t *testing.T) { type step struct { input string wantEvents []qwenEvent } cases := []struct { desc string steps []step only bool }{ { desc: "simple message streamed word by word", steps: []step{ { input: "hi", wantEvents: []qwenEvent{qwenEventContent{content: "hi"}}, }, { input: " there", wantEvents: []qwenEvent{qwenEventContent{content: " there"}}, }, }, }, { desc: "content before tool call", steps: []step{ { input: "hi there", wantEvents: []qwenEvent{qwenEventContent{content: "hi there"}}, }, }, }, { desc: "multiple tool calls in one message", steps: []step{ { input: "before1in tool callafter1in tool call 2after2", wantEvents: []qwenEvent{ qwenEventContent{content: "before1"}, qwenEventRawToolCall{raw: "in tool call"}, qwenEventContent{content: "after1"}, qwenEventRawToolCall{raw: "in tool call 2"}, qwenEventContent{content: "after2"}, }, }, }, }, { desc: "tool calls with split tags", steps: []step{ { input: "beforein tool callaf", wantEvents: []qwenEvent{ qwenEventRawToolCall{raw: "in tool call"}, qwenEventContent{content: "af"}, }, }, { input: "ter", wantEvents: []qwenEvent{ qwenEventContent{content: "ter"}, }, }, }, }, { desc: "trailing whitespace between content and tool call", steps: []step{ { input: "abc\ndef", wantEvents: []qwenEvent{ qwenEventContent{content: "abc"}, qwenEventRawToolCall{raw: "def"}, }, }, }, }, { desc: "trailing whitespace between tool call and content", steps: []step{ { input: "abc\ndef", wantEvents: []qwenEvent{ qwenEventRawToolCall{raw: "abc"}, qwenEventContent{content: "def"}, }, }, }, }, { desc: "empty content before tool call", steps: []step{ { input: "\nabc", wantEvents: []qwenEvent{ qwenEventRawToolCall{raw: "abc"}, }, }, }, }, { desc: "partial tool open tag fakeout", steps: []step{ { input: "abc\ntestمرحبا", wantEvents: []qwenEvent{ qwenEventContent{content: "你好 🌍"}, qwenEventRawToolCall{raw: "test"}, qwenEventContent{content: "مرحبا"}, }, }, }, }, { desc: "arabic text handling", steps: []step{ { input: "مرحبا بالعالم", wantEvents: []qwenEvent{qwenEventContent{content: "مرحبا بالعالم"}}, }, }, }, { desc: "emoji passthrough", steps: []step{ { input: "✅", wantEvents: []qwenEvent{qwenEventContent{content: "✅"}}, }, }, }, { desc: "emoji after tool call", steps: []step{ { input: "test完成 ✅", wantEvents: []qwenEvent{ qwenEventRawToolCall{raw: "test"}, qwenEventContent{content: "完成 ✅"}, }, }, }, }, { desc: "unicode streaming with whitespace handling", steps: []step{ { input: "مرحبا", wantEvents: []qwenEvent{ qwenEventContent{content: "مرحبا"}, }, }, { input: " \n", wantEvents: []qwenEvent{}, }, { input: "世界", wantEvents: []qwenEvent{ qwenEventContent{content: " \n世界"}, }, }, }, }, { desc: "non-breaking space withheld across chunks", steps: []step{ { input: "Hello\u00a0", wantEvents: []qwenEvent{ qwenEventContent{content: "Hello"}, }, }, { input: "world", wantEvents: []qwenEvent{ qwenEventContent{content: "\u00a0world"}, }, }, }, }, { desc: "ideographic space before partial tool", steps: []step{ { input: "Hello\u3000abc", wantEvents: []qwenEvent{}, }, { input: "def", wantEvents: []qwenEvent{ qwenEventRawToolCall{raw: "abc"}, qwenEventContent{content: "def"}, }, }, }, }, { desc: "ideographic space before partial tool fakeout", steps: []step{ { input: "Hello\u3000abc", wantEvents: []qwenEvent{ qwenEventContent{content: "\u3000abc"}, }, }, }, }, { desc: "unicode with partial tool tag", steps: []step{ { input: "测试🎯 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 TestQwen3VLNonThinkingToolCallWhitespaceHandling(t *testing.T) { type step struct { input string wantEvents []qwenEvent } cases := []struct { desc string steps []step only bool }{ { desc: "whitespace inside tool call preserves trailing space", steps: []step{ { input: "before tool content after", wantEvents: []qwenEvent{ qwenEventContent{content: "before"}, qwenEventRawToolCall{raw: " tool content "}, qwenEventContent{content: "after"}, }, }, }, }, { desc: "whitespace inside tool call preserves trailing space", steps: []step{ { input: "\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{ qwenEventContent{content: "\n \n \n \n \n \n blahhhhhhhhhh blahhhh blahhhh"}, qwenEventRawToolCall{raw: " tool content "}, qwenEventContent{content: "after"}, }, }, }, }, { desc: "whitespace inside tool call preserves trailing space", steps: []step{ { 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"}, }, }, }, }, { desc: "whitespace between content and tool call", steps: []step{ { input: "content \n tool \n more content", wantEvents: []qwenEvent{ qwenEventContent{content: "content"}, qwenEventRawToolCall{raw: "tool"}, qwenEventContent{content: "more content"}, }, }, }, }, { desc: "consecutive tool calls with whitespace", steps: []step{ { input: "first \n second \n third", wantEvents: []qwenEvent{ qwenEventRawToolCall{raw: "first"}, qwenEventRawToolCall{raw: "second"}, qwenEventRawToolCall{raw: "third"}, }, }, }, }, { desc: "whitespace before and after tool open tag", steps: []step{ { input: "text \n content", wantEvents: []qwenEvent{ qwenEventContent{content: "text"}, qwenEventRawToolCall{raw: "content"}, }, }, }, }, { desc: "unicode whitespace around tool calls", steps: []step{ { input: "text\u00a0\u3000content\u00a0\u3000text", wantEvents: []qwenEvent{ qwenEventContent{content: "text"}, qwenEventRawToolCall{raw: "content"}, qwenEventContent{content: "text"}, }, }, }, }, { desc: "empty tool call with surrounding whitespace", steps: []step{ { input: "before after", wantEvents: []qwenEvent{ qwenEventContent{content: "before"}, qwenEventRawToolCall{raw: ""}, qwenEventContent{content: "after"}, }, }, }, }, { desc: "whitespace in tool call split across chunks", steps: []step{ { input: "before ", wantEvents: []qwenEvent{qwenEventContent{content: "before"}}, }, { input: "tool", wantEvents: []qwenEvent{}, }, { input: " after", wantEvents: []qwenEvent{ qwenEventRawToolCall{raw: " tool "}, qwenEventContent{content: "after"}, }, }, }, }, { desc: "mixed whitespace types between tool calls", steps: []step{ { input: "first \t\n\r second", wantEvents: []qwenEvent{ qwenEventRawToolCall{raw: "first"}, qwenEventRawToolCall{raw: "second"}, }, }, }, }, } 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: false} 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) } } }) } }