mirror of
https://github.com/ollama/ollama.git
synced 2026-04-17 15:53:27 +02:00
* gemma4: update renderer to match new jinja template Google has updated their jinja template for gemma4, and so this change gives us parity with the new template. The parsing also slightly changed upstream, so we make a small change to our parser as well. I've also corrected a few probably existing edge cases, especially around type unions. The upstream output format is weird (a stringified array), but in practice the models seem to understand it well. * gemma4: special case simple `AnyOf`s The upstream template doesn't handle `AnyOf`s, but since in the previous commit we saw type unions work reasonably well, I'm now treating very simple `AnyOf`s as type unions to help in cases where they might be used * fix lint * gemma4: prefer empty instead of `None` We can't currently distinguish between a result being not-present vs. empty. The empty case seems more important (e.g., a legitimately empty tool call) * gemma4: be more careful for tool results with missing IDs
1330 lines
36 KiB
Go
1330 lines
36 KiB
Go
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...<channel|>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|><|channel>second thought<channel|>Final answer.",
|
|
expectedContent: "Final answer.",
|
|
expectedThinking: "first thoughtsecond thought",
|
|
thinkingEnabled: true,
|
|
},
|
|
{
|
|
name: "thinking_only_no_content",
|
|
input: "<|channel>just thinking<channel|>",
|
|
expectedContent: "",
|
|
expectedThinking: "just thinking",
|
|
thinkingEnabled: true,
|
|
},
|
|
{
|
|
name: "tool_call_simple",
|
|
input: `<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}<tool_call|>`,
|
|
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<|"|>}<tool_call|>`,
|
|
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}<tool_call|>`,
|
|
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}<tool_call|>`,
|
|
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<|"|>}}<tool_call|>`,
|
|
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<|"|>]}<tool_call|>`,
|
|
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<|"|>]}<tool_call|>`,
|
|
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
|
|
<|"|>}<tool_call|>`,
|
|
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\"<|"|>}<tool_call|>`,
|
|
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"<|"|>}<tool_call|>`,
|
|
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"<|"|>}<tool_call|>`,
|
|
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"<|"|>}<tool_call|>`,
|
|
expectedToolCalls: []api.ToolCall{
|
|
{
|
|
Function: api.ToolCallFunction{
|
|
Name: "search",
|
|
Arguments: testArgs(map[string]any{
|
|
"query": `first \"quoted\" then "raw"`,
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
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"<|"|>}<tool_call|>`,
|
|
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}]}<tool_call|>`,
|
|
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_with_windows_path_single_backslashes",
|
|
input: `<|tool_call>call:open_file{path:<|"|>C:\users\bob\file.txt<|"|>}<tool_call|>`,
|
|
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|><|tool_call>call:get_weather{location:<|"|>London<|"|>}<tool_call|>`,
|
|
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<channel|><|tool_call>call:get_weather{location:<|\"|>Paris<|\"|>}<tool_call|>",
|
|
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<|"|>}<tool_call|>`,
|
|
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 thinking<channel|>actual 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",
|
|
"...<channel|>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<|"|>}<tool_call|>`,
|
|
}
|
|
|
|
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<|"|>}<tool_call|><tool_call|>`,
|
|
},
|
|
expectedContent: "",
|
|
},
|
|
{
|
|
name: "same_chunk_before_real_content",
|
|
chunks: []string{
|
|
`<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}<tool_call|><tool_call|>Done.`,
|
|
},
|
|
expectedContent: "Done.",
|
|
},
|
|
{
|
|
name: "split_across_chunks_before_real_content",
|
|
chunks: []string{
|
|
`<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}<tool_call|><tool_`,
|
|
`call|>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_call|><|tool_response>`,
|
|
},
|
|
expectedContent: "",
|
|
},
|
|
{
|
|
name: "same_chunk_before_real_content",
|
|
chunks: []string{
|
|
`<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}<tool_call|><|tool_response>Done.`,
|
|
},
|
|
expectedContent: "Done.",
|
|
},
|
|
{
|
|
name: "split_across_chunks_before_real_content",
|
|
chunks: []string{
|
|
`<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}<tool_call|><|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 here<channel|>content",
|
|
},
|
|
expectedContent: "content",
|
|
expectedThinking: "thinking here",
|
|
},
|
|
{
|
|
name: "split_channel_close_tag",
|
|
chunks: []string{
|
|
"<|channel>thinking here<chan",
|
|
"nel|>content",
|
|
},
|
|
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: "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: "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_InvalidRawQuotedStructuralString(t *testing.T) {
|
|
_, err := parseGemma4ToolCall(`call:foo{q:"a,b:c"}`, nil)
|
|
if err == nil {
|
|
t.Fatal("expected parseGemma4ToolCall to reject raw-quoted strings with structural text that the reference implementation does not support")
|
|
}
|
|
}
|