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", // when something is ambiguious, we dont emit anything
wantEvents: []qwenEvent{
qwenEventContent{content: "abc"},
},
},
{
input: "tool_call> 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)
}
}
})
}
}