package parsers import ( "reflect" "testing" "github.com/google/go-cmp/cmp" "github.com/ollama/ollama/api" ) func TestMinistralParserStreaming(t *testing.T) { type step struct { input string wantEvents []ministralEvent } cases := []struct { desc string tools []api.Tool steps []step think bool // whether to enable thinking support }{ // Content streaming { desc: "simple content", steps: []step{ {input: "Hello, how can I help you?", wantEvents: []ministralEvent{ ministralEventContent{content: "Hello, how can I help you?"}, }}, }, }, { desc: "streaming content word by word", steps: []step{ {input: "Hello,", wantEvents: []ministralEvent{ministralEventContent{content: "Hello,"}}}, {input: " how", wantEvents: []ministralEvent{ministralEventContent{content: " how"}}}, {input: " can I help?", wantEvents: []ministralEvent{ministralEventContent{content: " can I help?"}}}, }, }, // Simple tool calls { desc: "simple tool call", tools: []api.Tool{{Function: api.ToolFunction{Name: "get_weather"}}}, steps: []step{ {input: `[TOOL_CALLS]get_weather[ARGS]{"location": "San Francisco"}`, wantEvents: []ministralEvent{ ministralEventToolCall{name: "get_weather", args: `{"location": "San Francisco"}`}, }}, }, }, { desc: "tool call with nested object", tools: []api.Tool{{Function: api.ToolFunction{Name: "create_entities"}}}, steps: []step{ {input: `[TOOL_CALLS]create_entities[ARGS]{"entities": [{"entityType": "Person", "name": "Jack", "observations": ["Works as a baker"]}]}`, wantEvents: []ministralEvent{ ministralEventToolCall{name: "create_entities", args: `{"entities": [{"entityType": "Person", "name": "Jack", "observations": ["Works as a baker"]}]}`}, }}, }, }, { desc: "tool call with deeply nested objects", tools: []api.Tool{{Function: api.ToolFunction{Name: "update_config"}}}, steps: []step{ {input: `[TOOL_CALLS]update_config[ARGS]{"settings": {"user": {"profile": {"name": "John", "age": 30}}, "theme": "dark"}}`, wantEvents: []ministralEvent{ ministralEventToolCall{name: "update_config", args: `{"settings": {"user": {"profile": {"name": "John", "age": 30}}, "theme": "dark"}}`}, }}, }, }, { desc: "tool call with array of objects", tools: []api.Tool{{Function: api.ToolFunction{Name: "process_items"}}}, steps: []step{ {input: `[TOOL_CALLS]process_items[ARGS]{"items": [{"id": 1}, {"id": 2}, {"id": 3}]}`, wantEvents: []ministralEvent{ ministralEventToolCall{name: "process_items", args: `{"items": [{"id": 1}, {"id": 2}, {"id": 3}]}`}, }}, }, }, { desc: "tool call with escaped quotes in string", tools: []api.Tool{{Function: api.ToolFunction{Name: "search"}}}, steps: []step{ {input: `[TOOL_CALLS]search[ARGS]{"query": "say \"hello\""}`, wantEvents: []ministralEvent{ ministralEventToolCall{name: "search", args: `{"query": "say \"hello\""}`}, }}, }, }, { desc: "tool call with braces inside string", tools: []api.Tool{{Function: api.ToolFunction{Name: "format"}}}, steps: []step{ {input: `[TOOL_CALLS]format[ARGS]{"template": "Hello {name}!"}`, wantEvents: []ministralEvent{ ministralEventToolCall{name: "format", args: `{"template": "Hello {name}!"}`}, }}, }, }, { desc: "empty JSON object", tools: []api.Tool{{Function: api.ToolFunction{Name: "no_args"}}}, steps: []step{ {input: `[TOOL_CALLS]no_args[ARGS]{}`, wantEvents: []ministralEvent{ ministralEventToolCall{name: "no_args", args: `{}`}, }}, }, }, { desc: "JSON with newlines in string", tools: []api.Tool{{Function: api.ToolFunction{Name: "write"}}}, steps: []step{ {input: `[TOOL_CALLS]write[ARGS]{"content": "line1\nline2\nline3"}`, wantEvents: []ministralEvent{ ministralEventToolCall{name: "write", args: `{"content": "line1\nline2\nline3"}`}, }}, }, }, { desc: "backslash in string value", tools: []api.Tool{{Function: api.ToolFunction{Name: "path"}}}, steps: []step{ {input: `[TOOL_CALLS]path[ARGS]{"dir": "C:\\Users\\test"}`, wantEvents: []ministralEvent{ ministralEventToolCall{name: "path", args: `{"dir": "C:\\Users\\test"}`}, }}, }, }, // Content after tool call { desc: "content after tool call", tools: []api.Tool{{Function: api.ToolFunction{Name: "test"}}}, steps: []step{ // NOTE: It's unclear if this is valid Ministral output, but the parser // currently treats text after a tool call as regular content. This test // documents that behavior so we notice if it changes. {input: `[TOOL_CALLS]test[ARGS]{"a": 1}some content after`, wantEvents: []ministralEvent{ ministralEventToolCall{name: "test", args: `{"a": 1}`}, ministralEventContent{content: "some content after"}, }}, }, }, // Multiple tool calls { desc: "multiple tool calls in sequence", tools: []api.Tool{ {Function: api.ToolFunction{Name: "get_weather"}}, {Function: api.ToolFunction{Name: "get_time"}}, }, steps: []step{ {input: `[TOOL_CALLS]get_weather[ARGS]{"location": "NYC"}[TOOL_CALLS]get_time[ARGS]{"timezone": "EST"}`, wantEvents: []ministralEvent{ ministralEventToolCall{name: "get_weather", args: `{"location": "NYC"}`}, ministralEventToolCall{name: "get_time", args: `{"timezone": "EST"}`}, }}, }, }, { desc: "multiple tool calls streamed separately", tools: []api.Tool{ {Function: api.ToolFunction{Name: "tool_a"}}, {Function: api.ToolFunction{Name: "tool_b"}}, }, steps: []step{ {input: `[TOOL_CALLS]tool_a[ARGS]{"x": 1}`, wantEvents: []ministralEvent{ ministralEventToolCall{name: "tool_a", args: `{"x": 1}`}, }}, {input: `[TOOL_CALLS]tool_b[ARGS]{"y": 2}`, wantEvents: []ministralEvent{ ministralEventToolCall{name: "tool_b", args: `{"y": 2}`}, }}, }, }, // Streaming tool calls { desc: "streaming tool call with nested objects", tools: []api.Tool{{Function: api.ToolFunction{Name: "create_entities"}}}, steps: []step{ {input: "[TOOL_CALLS]create_entities[ARGS]", wantEvents: []ministralEvent{}}, {input: `{"entities": [{"entityType": "Person",`, wantEvents: []ministralEvent{}}, {input: ` "name": "Jack",`, wantEvents: []ministralEvent{}}, {input: ` "observations": ["Works`, wantEvents: []ministralEvent{}}, {input: ` as a baker"]}`, wantEvents: []ministralEvent{}}, {input: `]}`, wantEvents: []ministralEvent{ ministralEventToolCall{name: "create_entities", args: `{"entities": [{"entityType": "Person", "name": "Jack", "observations": ["Works as a baker"]}]}`}, }}, }, }, { desc: "streaming with incomplete JSON waits for completion", tools: []api.Tool{{Function: api.ToolFunction{Name: "test"}}}, steps: []step{ {input: "[TOOL_CALLS]test[ARGS]{", wantEvents: []ministralEvent{}}, {input: `"a": {`, wantEvents: []ministralEvent{}}, {input: `"b": 1`, wantEvents: []ministralEvent{}}, {input: `}`, wantEvents: []ministralEvent{}}, {input: `}`, wantEvents: []ministralEvent{ ministralEventToolCall{name: "test", args: `{"a": {"b": 1}}`}, }}, }, }, // Partial tag handling { desc: "partial tool tag fakeout", steps: []step{ {input: "abc[TOOL", wantEvents: []ministralEvent{ministralEventContent{content: "abc"}}}, {input: " not a tag", wantEvents: []ministralEvent{ministralEventContent{content: "[TOOL not a tag"}}}, }, }, { desc: "tool call tag split across chunks", tools: []api.Tool{{Function: api.ToolFunction{Name: "test"}}}, steps: []step{ {input: "[TOOL_", wantEvents: []ministralEvent{}}, {input: "CALLS]test[ARGS]{}", wantEvents: []ministralEvent{ ministralEventToolCall{name: "test", args: `{}`}, }}, }, }, { desc: "content before tool call", tools: []api.Tool{{Function: api.ToolFunction{Name: "get_weather"}}}, steps: []step{ {input: "hello [TOOL_CALLS]get_weather[ARGS]{}", wantEvents: []ministralEvent{ ministralEventContent{content: "hello"}, ministralEventToolCall{name: "get_weather", args: `{}`}, }}, }, }, { desc: "whitespace between content and tool call is trimmed", tools: []api.Tool{{Function: api.ToolFunction{Name: "test"}}}, steps: []step{ {input: "content \n [TOOL_CALLS]test[ARGS]{}", wantEvents: []ministralEvent{ ministralEventContent{content: "content"}, ministralEventToolCall{name: "test", args: `{}`}, }}, }, }, { desc: "tabs and newlines before tool call are trimmed", tools: []api.Tool{{Function: api.ToolFunction{Name: "test"}}}, steps: []step{ {input: "content\t\n\t[TOOL_CALLS]test[ARGS]{}", wantEvents: []ministralEvent{ ministralEventContent{content: "content"}, ministralEventToolCall{name: "test", args: `{}`}, }}, }, }, { desc: "non-breaking space before tool call is trimmed", tools: []api.Tool{{Function: api.ToolFunction{Name: "test"}}}, steps: []step{ // \u00a0 is non-breaking space, which unicode.IsSpace considers whitespace {input: "content\u00a0[TOOL_CALLS]test[ARGS]{}", wantEvents: []ministralEvent{ ministralEventContent{content: "content"}, ministralEventToolCall{name: "test", args: `{}`}, }}, }, }, { desc: "whitespace before THINK tag is trimmed", steps: []step{ {input: "content \n [THINK]thinking[/THINK]after", wantEvents: []ministralEvent{ ministralEventContent{content: "content"}, ministralEventThinking{thinking: "thinking"}, ministralEventContent{content: "after"}, }}, }, }, { desc: "trailing whitespace withheld then emitted", steps: []step{ {input: "Hello ", wantEvents: []ministralEvent{ministralEventContent{content: "Hello"}}}, {input: "world", wantEvents: []ministralEvent{ministralEventContent{content: " world"}}}, }, }, { desc: "trailing newline withheld then emitted", steps: []step{ {input: "Hello\n", wantEvents: []ministralEvent{ministralEventContent{content: "Hello"}}}, {input: "world", wantEvents: []ministralEvent{ministralEventContent{content: "\nworld"}}}, }, }, // Thinking support { desc: "thinking content", think: true, steps: []step{ {input: "thinking here[/THINK]", wantEvents: []ministralEvent{ ministralEventThinking{thinking: "thinking here"}, }}, {input: "content after", wantEvents: []ministralEvent{ ministralEventContent{content: "content after"}, }}, }, }, { desc: "thinking with whitespace after end tag", think: true, steps: []step{ {input: "my thoughts[/THINK] \n response", wantEvents: []ministralEvent{ ministralEventThinking{thinking: "my thoughts"}, ministralEventContent{content: "response"}, }}, }, }, { desc: "non-breaking space after think end tag is trimmed", think: true, steps: []step{ // \u00a0 is non-breaking space {input: "thinking[/THINK]\u00a0response", wantEvents: []ministralEvent{ ministralEventThinking{thinking: "thinking"}, ministralEventContent{content: "response"}, }}, }, }, { desc: "partial think end tag", think: true, steps: []step{ {input: "thinking[/THI", wantEvents: []ministralEvent{ministralEventThinking{thinking: "thinking"}}}, {input: "NK]after", wantEvents: []ministralEvent{ministralEventContent{content: "after"}}}, }, }, { desc: "think tag fakeout", think: true, steps: []step{ {input: "thinking[/THI", wantEvents: []ministralEvent{ministralEventThinking{thinking: "thinking"}}}, {input: "not end tag", wantEvents: []ministralEvent{ministralEventThinking{thinking: "[/THInot end tag"}}}, }, }, { desc: "thinking then tool call", think: true, tools: []api.Tool{{Function: api.ToolFunction{Name: "test"}}}, steps: []step{ {input: "let me think[/THINK][TOOL_CALLS]test[ARGS]{}", wantEvents: []ministralEvent{ ministralEventThinking{thinking: "let me think"}, ministralEventToolCall{name: "test", args: `{}`}, }}, }, }, // Content then THINK tag transition { desc: "content then think tag", steps: []step{ {input: "content[THINK]thinking[/THINK]more", wantEvents: []ministralEvent{ ministralEventContent{content: "content"}, ministralEventThinking{thinking: "thinking"}, ministralEventContent{content: "more"}, }}, }, }, // Unicode handling { desc: "unicode content", steps: []step{ {input: "你好 🌍 مرحبا", wantEvents: []ministralEvent{ ministralEventContent{content: "你好 🌍 مرحبا"}, }}, }, }, { desc: "unicode in tool args", tools: []api.Tool{{Function: api.ToolFunction{Name: "greet"}}}, steps: []step{ {input: `[TOOL_CALLS]greet[ARGS]{"message": "你好 🌍"}`, wantEvents: []ministralEvent{ ministralEventToolCall{name: "greet", args: `{"message": "你好 🌍"}`}, }}, }, }, } for _, tc := range cases { t.Run(tc.desc, func(t *testing.T) { parser := MinistralParser{} parser.hasThinkingSupport = tc.think parser.Init(tc.tools, nil, 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 TestMinistralParserAssignsSequentialToolCallIndices(t *testing.T) { parser := &MinistralParser{} parser.Init([]api.Tool{ {Function: api.ToolFunction{Name: "get_weather"}}, {Function: api.ToolFunction{Name: "get_time"}}, }, nil, nil) content, thinking, calls, err := parser.Add( `[TOOL_CALLS]get_weather[ARGS]{"location":"NYC"}[TOOL_CALLS]get_time[ARGS]{"timezone":"EST"}`, true, ) if err != nil { t.Fatalf("Add() error = %v", err) } if content != "" { t.Fatalf("expected no content, got %q", content) } if thinking != "" { t.Fatalf("expected no thinking, got %q", thinking) } expected := []api.ToolCall{ { Function: api.ToolCallFunction{ Index: 0, Name: "get_weather", Arguments: testArgs(map[string]any{ "location": "NYC", }), }, }, { Function: api.ToolCallFunction{ Index: 1, Name: "get_time", Arguments: testArgs(map[string]any{ "timezone": "EST", }), }, }, } if diff := cmp.Diff(expected, calls, argsComparer); diff != "" { t.Fatalf("tool calls mismatch (-want +got):\n%s", diff) } } func TestMinistralParser_Errors(t *testing.T) { t.Run("unknown tool returns error", func(t *testing.T) { p := &MinistralParser{} p.Init([]api.Tool{{Function: api.ToolFunction{Name: "known_tool"}}}, nil, nil) _, _, _, err := p.Add(`[TOOL_CALLS]unknown_tool[ARGS]{"a": 1}`, true) if err == nil { t.Fatal("expected error for unknown tool") } }) t.Run("invalid JSON returns error", func(t *testing.T) { p := &MinistralParser{} p.Init([]api.Tool{{Function: api.ToolFunction{Name: "test"}}}, nil, nil) _, _, _, err := p.Add(`[TOOL_CALLS]test[ARGS]{invalid json}`, true) if err == nil { t.Fatal("expected error for invalid JSON") } }) } func TestFindJSONEnd(t *testing.T) { tests := []struct { name string input string expected int }{ { name: "simple object", input: `{"a": 1}`, expected: 7, }, { name: "nested object", input: `{"a": {"b": 2}}`, expected: 14, }, { name: "array inside object", input: `{"items": [1, 2, 3]}`, expected: 19, }, { name: "braces in string", input: `{"template": "Hello {name}!"}`, expected: 28, }, { name: "escaped quotes", input: `{"msg": "say \"hi\""}`, expected: 20, }, { name: "incomplete object", input: `{"a": {"b": 1}`, expected: -1, }, { name: "deeply nested", input: `{"a": {"b": {"c": {"d": 1}}}}`, expected: 28, }, { name: "object with trailing content", input: `{"a": 1} extra`, expected: 7, }, { name: "array", input: `[{"a": 1}, {"b": 2}]`, expected: 19, }, { name: "escaped backslash before quote", input: `{"path": "C:\\"}`, expected: 15, }, { name: "empty string", input: "", expected: -1, }, { name: "no opening brace", input: "hello world", expected: -1, }, { name: "only opening brace", input: "{", expected: -1, }, { name: "unclosed string", input: `{"key": "unclosed`, expected: -1, }, { name: "double escaped backslash then quote", input: `{"path": "C:\\\\"}`, expected: 17, }, { name: "unicode in key and value", input: `{"키": "값"}`, expected: 13, }, { name: "nested arrays", input: `{"matrix": [[1, 2], [3, 4]]}`, expected: 27, }, { name: "mixed nesting", input: `{"a": [{"b": {"c": [1, 2, 3]}}]}`, expected: 31, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := findJSONEnd(tt.input) if result != tt.expected { t.Errorf("findJSONEnd(%q) = %d, want %d", tt.input, result, tt.expected) } }) } } func TestMinistralParser_HasToolSupport(t *testing.T) { p := &MinistralParser{} if !p.HasToolSupport() { t.Error("expected HasToolSupport to return true") } } func TestMinistralParser_HasThinkingSupport(t *testing.T) { p := &MinistralParser{hasThinkingSupport: false} if p.HasThinkingSupport() { t.Error("expected HasThinkingSupport to return false") } p = &MinistralParser{hasThinkingSupport: true} if !p.HasThinkingSupport() { t.Error("expected HasThinkingSupport to return true") } }