package parsers import ( "strings" "testing" "github.com/google/go-cmp/cmp" "github.com/ollama/ollama/api" ) func TestGemma4Parser(t *testing.T) { tests := []struct { name string input string expectedContent string expectedThinking string expectedToolCalls []api.ToolCall thinkingEnabled bool lastMessage *api.Message }{ { name: "simple_content", input: "This is a simple response.", expectedContent: "This is a simple response.", }, { name: "thinking_then_content", input: "<|channel>thought\nLet me think about this...The answer is 42.", expectedContent: "The answer is 42.", expectedThinking: "Let me think about this...", thinkingEnabled: true, }, { name: "multiple_thinking_blocks", input: "<|channel>first thought<|channel>second thoughtFinal answer.", expectedContent: "Final answer.", expectedThinking: "first thoughtsecond thought", thinkingEnabled: true, }, { name: "thinking_only_no_content", input: "<|channel>just thinking", expectedContent: "", expectedThinking: "just thinking", thinkingEnabled: true, }, { name: "tool_call_simple", input: `<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}`, expectedToolCalls: []api.ToolCall{ { Function: api.ToolCallFunction{ Name: "get_weather", Arguments: testArgs(map[string]any{ "location": "Paris", }), }, }, }, }, { name: "tool_call_with_multiple_args", input: `<|tool_call>call:get_weather{location:<|"|>Paris<|"|>,units:<|"|>metric<|"|>}`, expectedToolCalls: []api.ToolCall{ { Function: api.ToolCallFunction{ Name: "get_weather", Arguments: testArgs(map[string]any{ "location": "Paris", "units": "metric", }), }, }, }, }, { name: "tool_call_with_number_arg", input: `<|tool_call>call:set_temp{value:42}`, expectedToolCalls: []api.ToolCall{ { Function: api.ToolCallFunction{ Name: "set_temp", Arguments: testArgs(map[string]any{ "value": 42.0, }), }, }, }, }, { name: "tool_call_with_boolean_arg", input: `<|tool_call>call:toggle{enabled:true}`, expectedToolCalls: []api.ToolCall{ { Function: api.ToolCallFunction{ Name: "toggle", Arguments: testArgs(map[string]any{ "enabled": true, }), }, }, }, }, { name: "tool_call_with_nested_object", input: `<|tool_call>call:process{config:{enabled:true,name:<|"|>test<|"|>}}`, expectedToolCalls: []api.ToolCall{ { Function: api.ToolCallFunction{ Name: "process", Arguments: testArgs(map[string]any{ "config": map[string]any{ "enabled": true, "name": "test", }, }), }, }, }, }, { name: "tool_call_with_array", input: `<|tool_call>call:process{items:[<|"|>a<|"|>,<|"|>b<|"|>]}`, expectedToolCalls: []api.ToolCall{ { Function: api.ToolCallFunction{ Name: "process", Arguments: testArgs(map[string]any{ "items": []any{"a", "b"}, }), }, }, }, }, { name: "tool_call_with_array_of_multiple_gemma_quoted_strings", input: `<|tool_call>call:process{items:[<|"|>a<|"|>,<|"|>b "quoted"<|"|>,<|"|>c<|"|>]}`, expectedToolCalls: []api.ToolCall{ { Function: api.ToolCallFunction{ Name: "process", Arguments: testArgs(map[string]any{ "items": []any{"a", `b "quoted"`, "c"}, }), }, }, }, }, { name: "tool_call_with_multiline_string_arg", input: `<|tool_call>call:bash{command:<|"|>date <|"|>}`, expectedToolCalls: []api.ToolCall{ { Function: api.ToolCallFunction{ Name: "bash", Arguments: testArgs(map[string]any{ "command": "date\n", }), }, }, }, }, { name: "tool_call_with_escaped_double_quotes_in_string_arg", input: `<|tool_call>call:search{query:<|"|>say \"hello\"<|"|>}`, expectedToolCalls: []api.ToolCall{ { Function: api.ToolCallFunction{ Name: "search", Arguments: testArgs(map[string]any{ "query": `say \"hello\"`, }), }, }, }, }, { name: "tool_call_with_unescaped_double_quotes_in_string_arg", input: `<|tool_call>call:search{query:<|"|>say "hello"<|"|>}`, expectedToolCalls: []api.ToolCall{ { Function: api.ToolCallFunction{ Name: "search", Arguments: testArgs(map[string]any{ "query": `say "hello"`, }), }, }, }, }, { name: "tool_call_with_multiple_unescaped_double_quote_segments", input: `<|tool_call>call:search{query:<|"|>say "hello", then "goodbye"<|"|>}`, expectedToolCalls: []api.ToolCall{ { Function: api.ToolCallFunction{ Name: "search", Arguments: testArgs(map[string]any{ "query": `say "hello", then "goodbye"`, }), }, }, }, }, { name: "tool_call_with_mixed_escaped_and_unescaped_double_quotes", input: `<|tool_call>call:search{query:<|"|>first \"quoted\" then "raw"<|"|>}`, expectedToolCalls: []api.ToolCall{ { Function: api.ToolCallFunction{ Name: "search", Arguments: testArgs(map[string]any{ "query": `first \"quoted\" then "raw"`, }), }, }, }, }, { name: "tool_call_exec_with_embedded_quoted_path", input: `<|tool_call>call:exec{command:<|"|>ls -F "vault/"<|"|>}`, expectedToolCalls: []api.ToolCall{ { Function: api.ToolCallFunction{ Name: "exec", Arguments: testArgs(map[string]any{ "command": `ls -F "vault/"`, }), }, }, }, }, { name: "tool_call_exec_with_embedded_quoted_url", input: `<|tool_call>call:exec{command:<|"|>fetch "https://ollama.com/library/gemma4" --extract<|"|>}`, expectedToolCalls: []api.ToolCall{ { Function: api.ToolCallFunction{ Name: "exec", Arguments: testArgs(map[string]any{ "command": `fetch "https://ollama.com/library/gemma4" --extract`, }), }, }, }, }, { name: "tool_call_done_flush_without_close_tag_with_unescaped_double_quotes", input: `<|tool_call>call:search{query:<|"|>say "hello" and "bye"<|"|>}`, expectedToolCalls: []api.ToolCall{ { Function: api.ToolCallFunction{ Name: "search", Arguments: testArgs(map[string]any{ "query": `say "hello" and "bye"`, }), }, }, }, }, { name: "tool_call_with_mixed_raw_and_gemma_quoted_values", input: `<|tool_call>call:search{query:"raw \"quoted\"",note:<|"|>gemma "quoted"<|"|>}`, expectedToolCalls: []api.ToolCall{ { Function: api.ToolCallFunction{ Name: "search", Arguments: testArgs(map[string]any{ "query": `raw "quoted"`, "note": `gemma "quoted"`, }), }, }, }, }, { name: "tool_call_with_array_of_objects_and_mixed_quotes", input: `<|tool_call>call:plan{steps:[{title:<|"|>step "one"<|"|>,done:false},{title:<|"|>step \"two\"<|"|>,done:true}]}`, expectedToolCalls: []api.ToolCall{ { Function: api.ToolCallFunction{ Name: "plan", Arguments: testArgs(map[string]any{ "steps": []any{ map[string]any{ "title": `step "one"`, "done": false, }, map[string]any{ "title": `step \"two\"`, "done": true, }, }, }), }, }, }, }, { name: "tool_call_edit_with_array_of_objects_multiline_markdown_and_quotes", input: `<|tool_call>call:edit{edits:[ {newText:<|"|># Gemma4 + openclaude speed optimization guide Use "nvfp4" only after validating tool calls. <|"|>,oldText:<|"|># Gemma4 + openclaude speed optimization guide Use the default quantization. <|"|>}, {newText:<|"|>## 14. Methods tried but not adopted ### 1. Model quantization Do not enable "mxfp8" by default. <|"|>,oldText:<|"|>## 14. Methods tried but not adopted <|"|>} ]}`, expectedToolCalls: []api.ToolCall{ { Function: api.ToolCallFunction{ Name: "edit", Arguments: testArgs(map[string]any{ "edits": []any{ map[string]any{ "newText": "# Gemma4 + openclaude speed optimization guide\n\nUse \"nvfp4\" only after validating tool calls.\n", "oldText": "# Gemma4 + openclaude speed optimization guide\n\nUse the default quantization.\n", }, map[string]any{ "newText": "## 14. Methods tried but not adopted\n\n### 1. Model quantization\nDo not enable \"mxfp8\" by default.\n", "oldText": "## 14. Methods tried but not adopted\n", }, }, }), }, }, }, }, { name: "tool_call_edit_with_array_of_objects_issue_comment_spacing", input: `<|tool_call>call:edit{edits:[ {newText:<|"|># Gemma4 + openclaude speed optimization guide...<|"|>, oldText:<|"|># Gemma4 + openclaude speed optimization guide...<|"|>}, {newText:<|"|>## 14. Methods tried but not adopted\n\n### 1. Model quantization...<|"|>, oldText:<|"|>## 14. Methods tried but not adopted\n<|"|>} ]}`, expectedToolCalls: []api.ToolCall{ { Function: api.ToolCallFunction{ Name: "edit", Arguments: testArgs(map[string]any{ "edits": []any{ map[string]any{ "newText": "# Gemma4 + openclaude speed optimization guide...", "oldText": "# Gemma4 + openclaude speed optimization guide...", }, map[string]any{ "newText": "## 14. Methods tried but not adopted\\n\\n### 1. Model quantization...", "oldText": "## 14. Methods tried but not adopted\\n", }, }, }), }, }, }, }, { name: "tool_call_with_windows_path_single_backslashes", input: `<|tool_call>call:open_file{path:<|"|>C:\users\bob\file.txt<|"|>}`, expectedToolCalls: []api.ToolCall{ { Function: api.ToolCallFunction{ Name: "open_file", Arguments: testArgs(map[string]any{ "path": `C:\users\bob\file.txt`, }), }, }, }, }, { name: "multiple_tool_calls", input: `<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}<|tool_call>call:get_weather{location:<|"|>London<|"|>}`, expectedToolCalls: []api.ToolCall{ { Function: api.ToolCallFunction{ Index: 0, Name: "get_weather", Arguments: testArgs(map[string]any{ "location": "Paris", }), }, }, { Function: api.ToolCallFunction{ Index: 1, Name: "get_weather", Arguments: testArgs(map[string]any{ "location": "London", }), }, }, }, }, { name: "thinking_then_tool_call", input: "<|channel>thought\nI need to check the weather<|tool_call>call:get_weather{location:<|\"|>Paris<|\"|>}", expectedThinking: "I need to check the weather", expectedToolCalls: []api.ToolCall{ { Function: api.ToolCallFunction{ Name: "get_weather", Arguments: testArgs(map[string]any{ "location": "Paris", }), }, }, }, thinkingEnabled: true, }, { name: "content_then_tool_call", input: `Let me check that for you.<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}`, expectedContent: "Let me check that for you.", expectedToolCalls: []api.ToolCall{ { Function: api.ToolCallFunction{ Name: "get_weather", Arguments: testArgs(map[string]any{ "location": "Paris", }), }, }, }, }, { name: "thinking_disabled_channel_tags_as_content", input: "<|channel>this is not thinkingactual content", expectedContent: "actual content", thinkingEnabled: false, }, { name: "prefill_content_only", input: "Continuing content.", expectedContent: "Continuing content.", lastMessage: &api.Message{ Role: "assistant", Content: "Previous content", }, thinkingEnabled: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { parser := &Gemma4Parser{hasThinkingSupport: true} parser.Init(nil, tt.lastMessage, &api.ThinkValue{Value: tt.thinkingEnabled}) content, thinking, toolCalls, err := parser.Add(tt.input, true) if err != nil { t.Fatalf("Add() error = %v", err) } if diff := cmp.Diff(tt.expectedContent, content); diff != "" { t.Errorf("content mismatch (-want +got):\n%s", diff) } if diff := cmp.Diff(tt.expectedThinking, thinking); diff != "" { t.Errorf("thinking mismatch (-want +got):\n%s", diff) } if diff := cmp.Diff(tt.expectedToolCalls, toolCalls, argsComparer); diff != "" { t.Errorf("tool calls mismatch (-want +got):\n%s", diff) } }) } } func TestGemma4Parser_Streaming(t *testing.T) { parser := &Gemma4Parser{hasThinkingSupport: true} parser.Init(nil, nil, &api.ThinkValue{Value: true}) chunks := []string{ "<|channel>thought", "\nLet me think", "...The answer", " is 42.", } var finalContent, finalThinking strings.Builder for i, chunk := range chunks { done := i == len(chunks)-1 content, thinking, _, err := parser.Add(chunk, done) if err != nil { t.Fatalf("Add() error on chunk %d: %v", i, err) } finalContent.WriteString(content) finalThinking.WriteString(thinking) } if finalContent.String() != "The answer is 42." { t.Errorf("expected content %q, got %q", "The answer is 42.", finalContent.String()) } if finalThinking.String() != "Let me think..." { t.Errorf("expected thinking %q, got %q", "Let me think...", finalThinking.String()) } } func TestGemma4Parser_StreamingToolCall(t *testing.T) { parser := &Gemma4Parser{hasThinkingSupport: false} parser.Init(nil, nil, nil) chunks := []string{ `<|tool_call>call:get_`, `weather{location:<|"|>Par`, `is<|"|>}`, } var finalContent strings.Builder var finalToolCalls []api.ToolCall for i, chunk := range chunks { done := i == len(chunks)-1 content, _, toolCalls, err := parser.Add(chunk, done) if err != nil { t.Fatalf("Add() error on chunk %d: %v", i, err) } finalContent.WriteString(content) finalToolCalls = append(finalToolCalls, toolCalls...) } if finalContent.String() != "" { t.Errorf("expected no content, got %q", finalContent.String()) } expectedToolCalls := []api.ToolCall{ { Function: api.ToolCallFunction{ Name: "get_weather", Arguments: testArgs(map[string]any{ "location": "Paris", }), }, }, } if diff := cmp.Diff(expectedToolCalls, finalToolCalls, argsComparer); diff != "" { t.Errorf("tool calls mismatch (-want +got):\n%s", diff) } } func TestGemma4Parser_IgnoresExtraToolCallCloseTags(t *testing.T) { tests := []struct { name string chunks []string expectedContent string }{ { name: "same_chunk_without_trailing_content", chunks: []string{ `<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}`, }, expectedContent: "", }, { name: "same_chunk_before_real_content", chunks: []string{ `<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}Done.`, }, expectedContent: "Done.", }, { name: "split_across_chunks_before_real_content", chunks: []string{ `<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}Done.`, }, expectedContent: "Done.", }, } expectedToolCalls := []api.ToolCall{ { Function: api.ToolCallFunction{ Name: "get_weather", Arguments: testArgs(map[string]any{ "location": "Paris", }), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { parser := &Gemma4Parser{hasThinkingSupport: false} parser.Init(nil, nil, nil) var finalContent strings.Builder var finalToolCalls []api.ToolCall for i, chunk := range tt.chunks { done := i == len(tt.chunks)-1 content, _, toolCalls, err := parser.Add(chunk, done) if err != nil { t.Fatalf("Add() error on chunk %d: %v", i, err) } finalContent.WriteString(content) finalToolCalls = append(finalToolCalls, toolCalls...) } if diff := cmp.Diff(tt.expectedContent, finalContent.String()); diff != "" { t.Errorf("content mismatch (-want +got):\n%s", diff) } if diff := cmp.Diff(expectedToolCalls, finalToolCalls, argsComparer); diff != "" { t.Errorf("tool calls mismatch (-want +got):\n%s", diff) } }) } } func TestGemma4Parser_IgnoresToolResponseBoundaryAfterToolCall(t *testing.T) { tests := []struct { name string chunks []string expectedContent string }{ { name: "same_chunk_without_trailing_content", chunks: []string{ `<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}<|tool_response>`, }, expectedContent: "", }, { name: "same_chunk_before_real_content", chunks: []string{ `<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}<|tool_response>Done.`, }, expectedContent: "Done.", }, { name: "split_across_chunks_before_real_content", chunks: []string{ `<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}<|tool_res`, `ponse>Done.`, }, expectedContent: "Done.", }, } expectedToolCalls := []api.ToolCall{ { Function: api.ToolCallFunction{ Name: "get_weather", Arguments: testArgs(map[string]any{ "location": "Paris", }), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { parser := &Gemma4Parser{hasThinkingSupport: false} parser.Init(nil, nil, nil) var finalContent strings.Builder var finalToolCalls []api.ToolCall for i, chunk := range tt.chunks { done := i == len(tt.chunks)-1 content, _, toolCalls, err := parser.Add(chunk, done) if err != nil { t.Fatalf("Add() error on chunk %d: %v", i, err) } finalContent.WriteString(content) finalToolCalls = append(finalToolCalls, toolCalls...) } if diff := cmp.Diff(tt.expectedContent, finalContent.String()); diff != "" { t.Errorf("content mismatch (-want +got):\n%s", diff) } if diff := cmp.Diff(expectedToolCalls, finalToolCalls, argsComparer); diff != "" { t.Errorf("tool calls mismatch (-want +got):\n%s", diff) } }) } } func TestGemma4Parser_StreamingSplitThinkingTag(t *testing.T) { tests := []struct { name string chunks []string expectedContent string expectedThinking string }{ { name: "split_channel_open_tag", chunks: []string{ "<|chan", "nel>thinking herecontent", }, expectedContent: "content", expectedThinking: "thinking here", }, { name: "split_channel_close_tag", chunks: []string{ "<|channel>thinking herecontent", }, expectedContent: "content", expectedThinking: "thinking here", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { parser := &Gemma4Parser{hasThinkingSupport: true} parser.Init(nil, nil, &api.ThinkValue{Value: true}) var finalContent, finalThinking strings.Builder for i, chunk := range tt.chunks { done := i == len(tt.chunks)-1 content, thinking, _, err := parser.Add(chunk, done) if err != nil { t.Fatalf("Add() error on chunk %d: %v", i, err) } finalContent.WriteString(content) finalThinking.WriteString(thinking) } if finalContent.String() != tt.expectedContent { t.Errorf("expected content %q, got %q", tt.expectedContent, finalContent.String()) } if finalThinking.String() != tt.expectedThinking { t.Errorf("expected thinking %q, got %q", tt.expectedThinking, finalThinking.String()) } }) } } func TestGemma4ArgsToJSON(t *testing.T) { tests := []struct { name string input string expected string }{ { name: "simple_string", input: `{location:<|"|>Paris<|"|>}`, expected: `{"location":"Paris"}`, }, { name: "multiple_args", input: `{location:<|"|>Paris<|"|>,units:<|"|>metric<|"|>}`, expected: `{"location":"Paris","units":"metric"}`, }, { name: "number_value", input: `{value:42}`, expected: `{"value":42}`, }, { name: "boolean_value", input: `{enabled:true}`, expected: `{"enabled":true}`, }, { name: "nested_object", input: `{config:{enabled:true,name:<|"|>test<|"|>}}`, expected: `{"config":{"enabled":true,"name":"test"}}`, }, { name: "nested_object_with_space_after_object_open_and_before_bare_key", input: `{edits:[{ newText:<|"|>a<|"|>, oldText:<|"|>b<|"|>} ]}`, expected: `{"edits":[{ "newText":"a", "oldText":"b"} ]}`, }, { name: "nested_object_with_space_before_bare_key", input: `{edits:[{newText:<|"|>a<|"|>, oldText:<|"|>b<|"|>} ]}`, expected: `{"edits":[{"newText":"a", "oldText":"b"} ]}`, }, { name: "nested_object_with_newline_before_bare_key", input: `{edits:[ {newText:<|"|>a<|"|>, oldText:<|"|>b<|"|>} ]}`, expected: `{"edits":[ {"newText":"a", "oldText":"b"} ]}`, }, { name: "array_value", input: `{items:[<|"|>a<|"|>,<|"|>b<|"|>]}`, expected: `{"items":["a","b"]}`, }, { name: "array_value_with_multiple_gemma_quoted_strings", input: `{items:[<|"|>a<|"|>,<|"|>b "quoted"<|"|>,<|"|>c<|"|>]}`, expected: `{"items":["a","b \"quoted\"","c"]}`, }, { name: "empty_object", input: `{}`, expected: `{}`, }, { name: "mixed_types", input: `{name:<|"|>test<|"|>,count:5,active:true,tags:[<|"|>a<|"|>]}`, expected: `{"name":"test","count":5,"active":true,"tags":["a"]}`, }, { name: "null_value", input: `{value:null}`, expected: `{"value":null}`, }, { name: "multiline_string_value", input: `{command:<|"|>date <|"|>}`, expected: `{"command":"date\n"}`, }, { name: "string_value_with_escaped_double_quotes", input: `{query:<|"|>say \"hello\"<|"|>}`, expected: `{"query":"say \\\"hello\\\""}`, }, { name: "string_value_with_unescaped_double_quotes", input: `{query:<|"|>say "hello"<|"|>}`, expected: `{"query":"say \"hello\""}`, }, { name: "string_value_with_multiple_unescaped_double_quote_segments", input: `{query:<|"|>say "hello", then "goodbye"<|"|>}`, expected: `{"query":"say \"hello\", then \"goodbye\""}`, }, { name: "string_value_with_mixed_escaped_and_unescaped_double_quotes", input: `{query:<|"|>first \"quoted\" then "raw"<|"|>}`, expected: `{"query":"first \\\"quoted\\\" then \"raw\""}`, }, { name: "string_value_with_punctuation_and_structural_chars", input: `{query:<|"|>a,b:{c}[d]<|"|>}`, expected: `{"query":"a,b:{c}[d]"}`, }, { name: "string_value_with_windows_path_backslashes", input: `{path:<|"|>C:\\Temp\\file.txt<|"|>}`, expected: `{"path":"C:\\\\Temp\\\\file.txt"}`, }, { name: "string_value_with_windows_path_single_backslashes", input: `{path:<|"|>C:\users\bob<|"|>}`, expected: `{"path":"C:\\users\\bob"}`, }, { name: "string_value_with_escaped_forward_slashes", input: `{url:<|"|>https:\/\/example.com\/a<|"|>}`, expected: `{"url":"https:\\/\\/example.com\\/a"}`, }, { name: "string_value_with_unicode_escape_sequence", input: `{s:<|"|>snowman:\u2603<|"|>}`, expected: `{"s":"snowman:\\u2603"}`, }, { name: "string_value_with_unknown_escape_sequence", input: `{s:<|"|>bad \x escape<|"|>}`, expected: `{"s":"bad \\x escape"}`, }, { name: "string_value_with_invalid_unicode_escape_sequence", input: `{s:<|"|>bad \uZZZZ escape<|"|>}`, expected: `{"s":"bad \\uZZZZ escape"}`, }, { name: "raw_quoted_string_with_escaped_quotes", input: `{q:"say \"hi\" and \"bye\""}`, expected: `{"q":"say \"hi\" and \"bye\""}`, }, { name: "raw_quoted_string_containing_key_like_text", input: `{q:"keep , oldText: literal",note:<|"|>ok<|"|>}`, expected: `{"q":"keep , oldText: literal","note":"ok"}`, }, { name: "raw_quoted_string_with_escaped_quotes_and_key_like_text", input: `{q:"keep , oldText: and \"quoted\" text",note:<|"|>ok<|"|>}`, expected: `{"q":"keep , oldText: and \"quoted\" text","note":"ok"}`, }, { name: "nested_mixed_raw_and_gemma_quoted_values", input: `{meta:{title:<|"|>t "1"<|"|>,note:"n \"2\""},items:[<|"|>x "3"<|"|>,"y \"4\""]}`, expected: `{"meta":{"title":"t \"1\"","note":"n \"2\""},"items":["x \"3\"","y \"4\""]}`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := gemma4ArgsToJSON(tt.input) if result != tt.expected { t.Errorf("expected %q, got %q", tt.expected, result) } }) } } func TestRepairGemma4MissingStringDelimiter(t *testing.T) { tests := []struct { name string input string expected string }{ { name: "closes_before_object_close", input: `{command:<|"|>ls}`, expected: `{command:<|"|>ls<|"|>}`, }, { name: "closes_terminal_value_after_previous_property", input: `{path:<|"|>/tmp<|"|>,command:<|"|>ls}`, expected: `{path:<|"|>/tmp<|"|>,command:<|"|>ls<|"|>}`, }, { name: "closes_at_end", input: `{command:<|"|>ls`, expected: `{command:<|"|>ls<|"|>`, }, { name: "preserves_valid_gemma_quoted_string", input: `{command:<|"|>ls<|"|>}`, expected: `{command:<|"|>ls<|"|>}`, }, { name: "preserves_input_without_gemma_delimiter", input: `{command:ls}`, expected: `{command:ls}`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := repairGemma4MissingStringDelimiter(tt.input) if got != tt.expected { t.Fatalf("repairGemma4MissingStringDelimiter(%q) = %q, want %q", tt.input, got, tt.expected) } }) } } func TestRepairGemma4MissingObjectClose(t *testing.T) { tests := []struct { name string input string expected string }{ { name: "adds_object_close", input: `{content:<|"|>hello<|"|>`, expected: `{content:<|"|>hello<|"|>}`, }, { name: "adds_object_close_before_trailing_space", input: "{content:<|\"|>hello<|\"|> ", expected: "{content:<|\"|>hello<|\"|>} ", }, { name: "preserves_existing_object_close", input: `{content:<|"|>hello<|"|>}`, expected: `{content:<|"|>hello<|"|>}`, }, { name: "preserves_non_object_input", input: `content:<|"|>hello<|"|>`, expected: `content:<|"|>hello<|"|>`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := repairGemma4MissingObjectClose(tt.input) if got != tt.expected { t.Fatalf("repairGemma4MissingObjectClose(%q) = %q, want %q", tt.input, got, tt.expected) } }) } } func TestRepairGemma4SingleQuotedValues(t *testing.T) { tests := []struct { name string input string expected string }{ { name: "converts_single_quoted_value", input: `{pattern:':\s*\w+'}`, expected: `{pattern:<|"|>:\s*\w+<|"|>}`, }, { name: "converts_middle_single_quoted_value", input: `{include:<|"|>*.py<|"|>,pattern:'abc',path:<|"|>/tmp<|"|>}`, expected: `{include:<|"|>*.py<|"|>,pattern:<|"|>abc<|"|>,path:<|"|>/tmp<|"|>}`, }, { name: "drops_dangling_gemma_delimiter_after_single_quoted_value", input: `{pattern:'abc'<|"|>}`, expected: `{pattern:<|"|>abc<|"|>}`, }, { name: "preserves_gemma_quoted_value", input: `{pattern:<|"|>abc<|"|>}`, expected: `{pattern:<|"|>abc<|"|>}`, }, { name: "preserves_json_quoted_value", input: `{pattern:"abc"}`, expected: `{pattern:"abc"}`, }, { name: "preserves_unterminated_single_quote", input: `{pattern:'abc}`, expected: `{pattern:'abc}`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := repairGemma4SingleQuotedValues(tt.input) if got != tt.expected { t.Fatalf("repairGemma4SingleQuotedValues(%q) = %q, want %q", tt.input, got, tt.expected) } }) } } func TestRepairGemma4RawTerminalStringValue(t *testing.T) { numberProps := api.NewToolPropertiesMap() numberProps.Set("content", api.ToolProperty{Type: api.PropertyType{"number"}}) numberTool := api.Tool{ Type: "function", Function: api.ToolFunction{ Name: "write", Parameters: api.ToolFunctionParameters{ Type: "object", Properties: numberProps, }, }, } tests := []struct { name string input string toolName string tools []api.Tool expected string expectedOK bool }{ { name: "wraps_known_string_property_terminal_value", input: "{content:\n\n# Title", toolName: "write", tools: []api.Tool{gemma4TestStringTool("write", "content")}, expected: "{content:<|\"|>\n\n# Title<|\"|>", expectedOK: true, }, { name: "stops_before_next_known_property", input: `{content:hello,mode:<|"|>fast<|"|>}`, toolName: "write", tools: []api.Tool{gemma4TestStringTool("write", "content", "mode")}, expected: `{content:<|"|>hello<|"|>,mode:<|"|>fast<|"|>}`, expectedOK: true, }, { name: "does_not_repair_without_schema", input: `{content:hello`, toolName: "write", tools: nil, expectedOK: false, }, { name: "does_not_repair_non_string_property", input: `{content:hello`, toolName: "write", tools: []api.Tool{numberTool}, expectedOK: false, }, { name: "does_not_repair_already_structured_value", input: `{content:<|"|>hello<|"|>}`, toolName: "write", tools: []api.Tool{gemma4TestStringTool("write", "content")}, expectedOK: false, }, { name: "does_not_repair_json_literal_start", input: `{content:123}`, toolName: "write", tools: []api.Tool{gemma4TestStringTool("write", "content")}, expectedOK: false, }, { name: "does_not_repair_unknown_tool", input: `{content:hello`, toolName: "missing", tools: []api.Tool{gemma4TestStringTool("write", "content")}, expectedOK: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, ok := repairGemma4RawTerminalStringValue(tt.input, tt.toolName, tt.tools) if ok != tt.expectedOK { t.Fatalf("repairGemma4RawTerminalStringValue ok = %t, want %t", ok, tt.expectedOK) } if got != tt.expected { t.Fatalf("repairGemma4RawTerminalStringValue got %q, want %q", got, tt.expected) } }) } } func TestGemma4RepairCandidates(t *testing.T) { tests := []struct { name string input string toolName string tools []api.Tool expected []string }{ { name: "missing_string_delimiter_candidate", input: `{command:<|"|>ls}`, toolName: "bash", tools: []api.Tool{gemma4TestStringTool("bash", "command")}, expected: []string{`{command:<|"|>ls<|"|>}`}, }, { name: "single_quoted_value_candidate", input: `{pattern:'abc'<|"|>}`, toolName: "grep", tools: []api.Tool{gemma4TestStringTool("grep", "pattern")}, expected: []string{`{pattern:<|"|>abc<|"|>}`}, }, { name: "raw_string_candidate_also_gets_missing_object_close", input: `{content:hello`, toolName: "write", tools: []api.Tool{gemma4TestStringTool("write", "content")}, expected: []string{ `{content:hello`, `{content:<|"|>hello<|"|>}`, }, }, { name: "does_not_add_missing_object_close_without_another_repair", input: `{n:1`, toolName: "count", tools: []api.Tool{gemma4TestStringTool("count", "name")}, expected: []string{`{n:1`}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := gemma4RepairCandidates(tt.input, tt.toolName, tt.tools) if diff := cmp.Diff(tt.expected, got); diff != "" { t.Fatalf("gemma4RepairCandidates mismatch (-want +got):\n%s", diff) } }) } } func gemma4TestStringTool(name string, argNames ...string) api.Tool { props := api.NewToolPropertiesMap() for _, argName := range argNames { props.Set(argName, api.ToolProperty{Type: api.PropertyType{"string"}}) } return api.Tool{ Type: "function", Function: api.ToolFunction{ Name: name, Parameters: api.ToolFunctionParameters{ Type: "object", Properties: props, }, }, } } func TestGemma4Parser_HasToolSupport(t *testing.T) { parser := &Gemma4Parser{} if !parser.HasToolSupport() { t.Error("Gemma4Parser should support tools") } } func TestGemma4Parser_HasThinkingSupport(t *testing.T) { parser := &Gemma4Parser{hasThinkingSupport: true} if !parser.HasThinkingSupport() { t.Error("Gemma4Parser with thinking support should report it") } parser2 := &Gemma4Parser{hasThinkingSupport: false} if parser2.HasThinkingSupport() { t.Error("Gemma4Parser without thinking support should not report it") } } func TestParseGemma4ToolCall_InvalidRawQuotedEscape(t *testing.T) { _, err := parseGemma4ToolCall(`call:open_file{path:"C:\users\bob\file.txt"}`, nil) if err == nil { t.Fatal("expected parseGemma4ToolCall to reject malformed raw-quoted JSON escapes") } } func TestParseGemma4ToolCall_QuotedScalarsStayStrings(t *testing.T) { toolCall, err := parseGemma4ToolCall(`call:foo{n:<|"|>1<|"|>,b:<|"|>true<|"|>,z:<|"|>null<|"|>}`, nil) if err != nil { t.Fatalf("parseGemma4ToolCall returned error: %v", err) } want := api.ToolCall{ Function: api.ToolCallFunction{ Name: "foo", Arguments: testArgs(map[string]any{ "n": "1", "b": "true", "z": "null", }), }, } if diff := cmp.Diff(want, toolCall, argsComparer); diff != "" { t.Fatalf("quoted scalar handling differed from the reference implementation (-want +got):\n%s", diff) } } func TestParseGemma4ToolCall_UnquotedScalarsKeepStructuredTypes(t *testing.T) { toolCall, err := parseGemma4ToolCall(`call:foo{n:1,b:true,z:null}`, nil) if err != nil { t.Fatalf("parseGemma4ToolCall returned error: %v", err) } want := api.ToolCall{ Function: api.ToolCallFunction{ Name: "foo", Arguments: testArgs(map[string]any{ "n": 1.0, "b": true, "z": nil, }), }, } if diff := cmp.Diff(want, toolCall, argsComparer); diff != "" { t.Fatalf("unquoted scalar handling differed from the reference implementation (-want +got):\n%s", diff) } } func TestParseGemma4ToolCall_ReferenceImplementationExample(t *testing.T) { toolCall, err := parseGemma4ToolCall(`call:get_current_temperature{detail_level:0,location:<|"|>Paris, France<|"|>,unit:<|"|>celsius<|"|>}`, nil) if err != nil { t.Fatalf("parseGemma4ToolCall returned error: %v", err) } want := api.ToolCall{ Function: api.ToolCallFunction{ Name: "get_current_temperature", Arguments: testArgs(map[string]any{ "detail_level": 0.0, "location": "Paris, France", "unit": "celsius", }), }, } if diff := cmp.Diff(want, toolCall, argsComparer); diff != "" { t.Fatalf("tool call handling differed from the reference implementation (-want +got):\n%s", diff) } } func TestParseGemma4ToolCall_RepairsIssue15315Examples(t *testing.T) { writeContent := "\n\n# Project Style Guide for Autonomous Agents' Code Generation (AGENTS.md)\n\n" + "This document captures the *de facto* coding standards observed across the `src/` and `components/` source code, designed to ensure consistency for all generated code and modules consumed by the agent system." tests := []struct { name string content string tools []api.Tool want api.ToolCall }{ { name: "raw multiline string", // Source: https://github.com/ollama/ollama/issues/15315#issue-4203625511 content: "call:write{content:" + writeContent, tools: []api.Tool{gemma4TestStringTool("write", "content")}, want: api.ToolCall{ Function: api.ToolCallFunction{ Name: "write", Arguments: testArgs(map[string]any{ "content": writeContent, }), }, }, }, { name: "single quoted value with dangling gemma string delimiter", content: `call:grep{include:<|"|>*.py<|"|>,output_mode:<|"|>content<|"|>,path:<|"|>/data/robotics/experiment1<|"|>,pattern:':\s*\w+'<|"|>}`, tools: []api.Tool{gemma4TestStringTool("grep", "include", "output_mode", "path", "pattern")}, // Source: https://github.com/ollama/ollama/issues/15315#issue-4203625511 want: api.ToolCall{ Function: api.ToolCallFunction{ Name: "grep", Arguments: testArgs(map[string]any{ "include": "*.py", "output_mode": "content", "path": "/data/robotics/experiment1", "pattern": `:\s*\w+`, }), }, }, }, { name: "unclosed gemma string before object close", content: `call:bash{command:<|"|>ls}`, tools: []api.Tool{gemma4TestStringTool("bash", "command")}, // Source: https://github.com/ollama/ollama/issues/15315#issuecomment-4194547092 want: api.ToolCall{ Function: api.ToolCallFunction{ Name: "bash", Arguments: testArgs(map[string]any{ "command": "ls", }), }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := parseGemma4ToolCall(tt.content, tt.tools) if err != nil { t.Fatalf("parseGemma4ToolCall returned error: %v", err) } if diff := cmp.Diff(tt.want, got, argsComparer); diff != "" { t.Fatalf("tool call mismatch (-want +got):\n%s", diff) } }) } } func TestParseGemma4ToolCall_RepairsMultipleProperties(t *testing.T) { tests := []struct { name string content string tools []api.Tool want api.ToolCall }{ { name: "single_quoted_middle_property", content: `call:grep{include:<|"|>*.py<|"|>,pattern:'abc',path:<|"|>/tmp<|"|>}`, tools: []api.Tool{gemma4TestStringTool("grep", "include", "pattern", "path")}, want: api.ToolCall{ Function: api.ToolCallFunction{ Name: "grep", Arguments: testArgs(map[string]any{ "include": "*.py", "pattern": "abc", "path": "/tmp", }), }, }, }, { name: "unclosed_gemma_string_terminal_property", content: `call:bash{path:<|"|>/tmp<|"|>,command:<|"|>ls}`, tools: []api.Tool{gemma4TestStringTool("bash", "path", "command")}, want: api.ToolCall{ Function: api.ToolCallFunction{ Name: "bash", Arguments: testArgs(map[string]any{ "path": "/tmp", "command": "ls", }), }, }, }, { name: "raw_string_before_next_property", content: `call:write{content:hello,mode:<|"|>fast<|"|>}`, tools: []api.Tool{gemma4TestStringTool("write", "content", "mode")}, want: api.ToolCall{ Function: api.ToolCallFunction{ Name: "write", Arguments: testArgs(map[string]any{ "content": "hello", "mode": "fast", }), }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := parseGemma4ToolCall(tt.content, tt.tools) if err != nil { t.Fatalf("parseGemma4ToolCall returned error: %v", err) } if diff := cmp.Diff(tt.want, got, argsComparer); diff != "" { t.Fatalf("tool call mismatch (-want +got):\n%s", diff) } }) } } func TestParseGemma4ToolCall_DoesNotRepairNonTerminalUnclosedGemmaString(t *testing.T) { // TODO(drifkin): our current examples show unclosed gemma strings as the last // values, but if we find examples where there's an unclosed non-last value, // we should consider repairing it. This test shows that we don't yet repair // this type (the heuristics of where to close are much more complicated) _, err := parseGemma4ToolCall(`call:example{first:<|"|>one,second:<|"|>two<|"|>}`, []api.Tool{ gemma4TestStringTool("example", "first", "second"), }) if err == nil { t.Fatal("expected non-terminal unclosed Gemma string to remain unsupported") } } func TestParseGemma4ToolCall_RawQuotedStructuralString(t *testing.T) { got, err := parseGemma4ToolCall(`call:foo{q:"a,b:c"}`, nil) if err != nil { t.Fatalf("parseGemma4ToolCall returned error: %v", err) } want := api.ToolCall{ Function: api.ToolCallFunction{ Name: "foo", Arguments: testArgs(map[string]any{ "q": "a,b:c", }), }, } if diff := cmp.Diff(want, got, argsComparer); diff != "" { t.Fatalf("tool call mismatch (-want +got):\n%s", diff) } }