package renderers // TestGemma4RendererMatchesReference verifies our renderer matches the checked-in // Gemma 4 reference template. // // Current upstream Gemma 4 chat templates differ by model size. The checked-in // reference cases below use the small (e2b/e4b-style) baseline, with large // (26b/31b-style) checks covered separately in this file. // // To regenerate expected values, save the E2B template to // gemma4_e2b_chat_template.jinja2 and run: // // python3 -c " // from jinja2 import Environment; import json // tmpl = Environment().from_string(open('gemma4_e2b_chat_template.jinja2').read()) // msgs = [{'role':'user','content':'Hello'}] // print(repr(tmpl.render(messages=msgs, bos_token='', add_generation_prompt=True))) // " import ( "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" "testing" "github.com/ollama/ollama/api" "github.com/stretchr/testify/assert" ) const ( gemma4E2BTemplate = "testdata/gemma4_e2b_chat_template.jinja2" gemma431BTemplate = "testdata/gemma4_31b_chat_template.jinja2" ) // The upstream Gemma 4 chat templates are committed by size under testdata/. // Run with VERIFY_JINJA2=1 to verify expected values against the E2B template using uv + Python. func bashRefTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "bash", Description: "Run a command", Parameters: api.ToolFunctionParameters{ Type: "object", Required: []string{"command"}, Properties: testPropsMap(map[string]api.ToolProperty{ "command": {Type: api.PropertyType{"string"}, Description: "The command"}, }), }, }, }} } func bashAndReadRefTools() []api.Tool { return []api.Tool{ bashRefTool()[0], { Type: "function", Function: api.ToolFunction{ Name: "read", Description: "Read a file", Parameters: api.ToolFunctionParameters{ Type: "object", Required: []string{"path"}, Properties: testPropsMap(map[string]api.ToolProperty{ "path": {Type: api.PropertyType{"string"}, Description: "File path"}, }), }, }, }, } } func weatherTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "get_weather", Description: "Get weather", Parameters: api.ToolFunctionParameters{ Type: "object", Properties: testPropsMap(map[string]api.ToolProperty{ "city": {Type: api.PropertyType{"string"}, Description: "City"}, }), }, }, }} } func addTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "add", Description: "Add numbers", Parameters: api.ToolFunctionParameters{ Type: "object", Properties: testPropsMap(map[string]api.ToolProperty{ "a": {Type: api.PropertyType{"number"}}, "b": {Type: api.PropertyType{"number"}}, }), }, }, }} } func flagTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "set_flag", Description: "Set a flag", Parameters: api.ToolFunctionParameters{ Type: "object", Properties: testPropsMap(map[string]api.ToolProperty{ "enabled": {Type: api.PropertyType{"boolean"}, Description: "Flag value"}, }), }, }, }} } func modeTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "set_mode", Description: "Set mode", Parameters: api.ToolFunctionParameters{ Type: "object", Properties: testPropsMap(map[string]api.ToolProperty{ "mode": {Type: api.PropertyType{"string"}, Description: "The mode", Enum: []any{"fast", "slow"}}, }), }, }, }} } func bashSmallTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "bash", Description: "Run", Parameters: api.ToolFunctionParameters{ Type: "object", Required: []string{"command"}, Properties: testPropsMap(map[string]api.ToolProperty{ "command": {Type: api.PropertyType{"string"}, Description: "Cmd"}, }), }, }, }} } func nestedTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "create", Description: "Create item", Parameters: api.ToolFunctionParameters{ Type: "object", Properties: testPropsMap(map[string]api.ToolProperty{ "name": {Type: api.PropertyType{"string"}, Description: "Name"}, "config": {Type: api.PropertyType{"object"}, Description: "Config", Properties: testPropsMap(map[string]api.ToolProperty{ "enabled": {Type: api.PropertyType{"boolean"}, Description: "On/off"}, })}, }), }, }, }} } func arrayTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "batch", Description: "Run batch", Parameters: api.ToolFunctionParameters{ Type: "object", Properties: testPropsMap(map[string]api.ToolProperty{ "commands": {Type: api.PropertyType{"array"}, Description: "Commands", Items: map[string]any{"type": "string"}}, }), }, }, }} } func unionTopLevelTypeTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "maybe_name", Description: "Test nullable union", Parameters: api.ToolFunctionParameters{ Type: "object", Properties: testPropsMap(map[string]api.ToolProperty{ "name": {Type: api.PropertyType{"string", "null"}, Description: "Name"}, }), }, }, }} } func anyOfTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "pick_value", Description: "Pick a value", Parameters: api.ToolFunctionParameters{ Type: "object", Properties: testPropsMap(map[string]api.ToolProperty{ "value": { AnyOf: []api.ToolProperty{ {Type: api.PropertyType{"string"}}, {Type: api.PropertyType{"number"}}, }, Description: "Value", }, }), }, }, }} } func arrayObjectItemsTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "upsert_batch", Description: "Upsert batch", Parameters: api.ToolFunctionParameters{ Type: "object", Properties: testPropsMap(map[string]api.ToolProperty{ "entries": { Type: api.PropertyType{"array"}, Description: "Entries", Items: map[string]any{ "type": "object", "required": []string{"id"}, "properties": map[string]any{ "id": map[string]any{ "type": "string", "description": "ID", }, "count": map[string]any{ "type": "number", "description": "Count", }, }, }, }, }), }, }, }} } func arrayUnionItemsTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "maybe_batch", Description: "Maybe batch", Parameters: api.ToolFunctionParameters{ Type: "object", Properties: testPropsMap(map[string]api.ToolProperty{ "values": { Type: api.PropertyType{"array"}, Description: "Values", Items: map[string]any{ "type": []string{"string", "null"}, }, }, }), }, }, }} } func arrayNestedItemsTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "plan_batch", Description: "Plan batch", Parameters: api.ToolFunctionParameters{ Type: "object", Properties: testPropsMap(map[string]api.ToolProperty{ "steps": { Type: api.PropertyType{"array"}, Description: "Steps", Items: map[string]any{ "type": "object", "required": []string{"name"}, "properties": map[string]any{ "name": map[string]any{ "type": "string", "description": "Name", }, "config": map[string]any{ "type": "object", "description": "Config", "required": []string{"enabled"}, "properties": map[string]any{ "enabled": map[string]any{ "type": "boolean", "description": "Enabled", }, }, }, "tags": map[string]any{ "type": "array", "description": "Tags", "items": map[string]any{ "type": "string", }, }, }, }, }, }), }, }, }} } func arrayNullableNestedItemsTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "annotate_batch", Description: "Annotate batch", Parameters: api.ToolFunctionParameters{ Type: "object", Properties: testPropsMap(map[string]api.ToolProperty{ "entries": { Type: api.PropertyType{"array"}, Description: "Entries", Items: map[string]any{ "type": "object", "required": []string{"note"}, "properties": map[string]any{ "note": map[string]any{ "type": "string", "description": "Note", "nullable": true, }, "metadata": map[string]any{ "type": "object", "description": "Metadata", "nullable": true, "properties": map[string]any{ "tag": map[string]any{ "type": "string", "description": "Tag", }, }, }, }, }, }, }), }, }, }} } func nestedArrayObjectItemsTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "schedule_jobs", Description: "Schedule jobs", Parameters: api.ToolFunctionParameters{ Type: "object", Properties: testPropsMap(map[string]api.ToolProperty{ "jobs": { Type: api.PropertyType{"array"}, Description: "Jobs", Items: map[string]any{ "type": "object", "required": []string{"tasks"}, "properties": map[string]any{ "tasks": map[string]any{ "type": "array", "description": "Tasks", "items": map[string]any{ "type": "object", "required": []string{"command"}, "properties": map[string]any{ "command": map[string]any{ "type": "string", "description": "Command", }, "timeout": map[string]any{ "type": "number", "description": "Timeout", }, }, }, }, }, }, }, }), }, }, }} } func arrayItemsExtraKeysTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "configure_batch", Description: "Configure batch", Parameters: api.ToolFunctionParameters{ Type: "object", Properties: testPropsMap(map[string]api.ToolProperty{ "settings": { Type: api.PropertyType{"array"}, Description: "Settings", Items: map[string]any{ "type": "object", "additionalProperties": false, "default": map[string]any{ "mode": "auto", }, "nullable": true, "required": []string{"mode"}, "properties": map[string]any{ "mode": map[string]any{ "type": "string", "description": "Mode", }, }, }, }, }), }, }, }} } func configureTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "configure", Description: "Configure", Parameters: api.ToolFunctionParameters{ Type: "object", Properties: testPropsMap(map[string]api.ToolProperty{ "config": {Type: api.PropertyType{"object"}, Description: "Config"}, }), }, }, }} } func batchArrayTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "batch", Description: "Run batch", Parameters: api.ToolFunctionParameters{ Type: "object", Properties: testPropsMap(map[string]api.ToolProperty{ "ids": {Type: api.PropertyType{"array"}, Description: "IDs"}, }), }, }, }} } func countTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "count", Description: "Count items", Parameters: api.ToolFunctionParameters{ Type: "object", Properties: testPropsMap(map[string]api.ToolProperty{ "n": {Type: api.PropertyType{"number"}}, }), }, }, }} } func enumNoDescTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "set_level", Description: "Set level", Parameters: api.ToolFunctionParameters{ Type: "object", Properties: testPropsMap(map[string]api.ToolProperty{ "level": {Type: api.PropertyType{"string"}, Enum: []any{"low", "high"}}, }), }, }, }} } func nestedRequiredTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "create_user", Description: "Create user", Parameters: api.ToolFunctionParameters{ Type: "object", Properties: testPropsMap(map[string]api.ToolProperty{ "profile": { Type: api.PropertyType{"object"}, Description: "Profile", Required: []string{"name"}, Properties: testPropsMap(map[string]api.ToolProperty{ "name": {Type: api.PropertyType{"string"}, Description: "Name"}, "age": {Type: api.PropertyType{"number"}, Description: "Age"}, }), }, }), }, }, }} } func calcTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "calc", Description: "Calculate", Parameters: api.ToolFunctionParameters{ Type: "object", Properties: testPropsMap(map[string]api.ToolProperty{ "value": {Type: api.PropertyType{"number"}, Description: "Value"}, }), }, }, }} } func rawTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "raw", Description: "Raw input", Parameters: api.ToolFunctionParameters{ Type: "object", }, }, }} } func moveTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "move", Description: "Move", Parameters: api.ToolFunctionParameters{ Type: "object", Required: []string{"x", "y"}, Properties: testPropsMap(map[string]api.ToolProperty{ "x": {Type: api.PropertyType{"number"}, Description: "X"}, "y": {Type: api.PropertyType{"number"}, Description: "Y"}, }), }, }, }} } func arrayNoItemsTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "tag", Description: "Tag items", Parameters: api.ToolFunctionParameters{ Type: "object", Properties: testPropsMap(map[string]api.ToolProperty{ "tags": {Type: api.PropertyType{"array"}, Description: "Tags"}, }), }, }, }} } func objectNoDescTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "update", Description: "Update settings", Parameters: api.ToolFunctionParameters{ Type: "object", Properties: testPropsMap(map[string]api.ToolProperty{ "settings": {Type: api.PropertyType{"object"}, Properties: testPropsMap(map[string]api.ToolProperty{ "verbose": {Type: api.PropertyType{"boolean"}, Description: "Verbose mode"}, })}, }), }, }, }} } func searchTool() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "search", Description: "Search", Parameters: api.ToolFunctionParameters{ Type: "object", Properties: testPropsMap(map[string]api.ToolProperty{ "query": {Type: api.PropertyType{"string"}, Description: "Search query"}, "limit": {Type: api.PropertyType{"number"}}, "offset": {Type: api.PropertyType{"number"}, Description: "Start offset"}, }), }, }, }} } var ( bashSmallDeclRef = `<|tool>declaration:bash{description:<|"|>Run<|"|>,parameters:{properties:{command:{description:<|"|>Cmd<|"|>,type:<|"|>STRING<|"|>}},required:[<|"|>command<|"|>],type:<|"|>OBJECT<|"|>}}` nestedDeclRef = `<|tool>declaration:create{description:<|"|>Create item<|"|>,parameters:{properties:{config:{description:<|"|>Config<|"|>,properties:{enabled:{description:<|"|>On/off<|"|>,type:<|"|>BOOLEAN<|"|>}},type:<|"|>OBJECT<|"|>},name:{description:<|"|>Name<|"|>,type:<|"|>STRING<|"|>}},type:<|"|>OBJECT<|"|>}}` arrayDeclRef = `<|tool>declaration:batch{description:<|"|>Run batch<|"|>,parameters:{properties:{commands:{description:<|"|>Commands<|"|>,items:{type:<|"|>STRING<|"|>},type:<|"|>ARRAY<|"|>}},type:<|"|>OBJECT<|"|>}}` bashDeclRef = `<|tool>declaration:bash{description:<|"|>Run a command<|"|>,parameters:{properties:{command:{description:<|"|>The command<|"|>,type:<|"|>STRING<|"|>}},required:[<|"|>command<|"|>],type:<|"|>OBJECT<|"|>}}` readDeclRef = `<|tool>declaration:read{description:<|"|>Read a file<|"|>,parameters:{properties:{path:{description:<|"|>File path<|"|>,type:<|"|>STRING<|"|>}},required:[<|"|>path<|"|>],type:<|"|>OBJECT<|"|>}}` weatherDeclRef = `<|tool>declaration:get_weather{description:<|"|>Get weather<|"|>,parameters:{properties:{city:{description:<|"|>City<|"|>,type:<|"|>STRING<|"|>}},type:<|"|>OBJECT<|"|>}}` addDeclRef = `<|tool>declaration:add{description:<|"|>Add numbers<|"|>,parameters:{properties:{a:{type:<|"|>NUMBER<|"|>},b:{type:<|"|>NUMBER<|"|>}},type:<|"|>OBJECT<|"|>}}` flagDeclRef = `<|tool>declaration:set_flag{description:<|"|>Set a flag<|"|>,parameters:{properties:{enabled:{description:<|"|>Flag value<|"|>,type:<|"|>BOOLEAN<|"|>}},type:<|"|>OBJECT<|"|>}}` modeDeclRef = `<|tool>declaration:set_mode{description:<|"|>Set mode<|"|>,parameters:{properties:{mode:{description:<|"|>The mode<|"|>,enum:[<|"|>fast<|"|>,<|"|>slow<|"|>],type:<|"|>STRING<|"|>}},type:<|"|>OBJECT<|"|>}}` configureDeclRef = `<|tool>declaration:configure{description:<|"|>Configure<|"|>,parameters:{properties:{config:{description:<|"|>Config<|"|>,properties:{},type:<|"|>OBJECT<|"|>}},type:<|"|>OBJECT<|"|>}}` batchArrayDeclRef = `<|tool>declaration:batch{description:<|"|>Run batch<|"|>,parameters:{properties:{ids:{description:<|"|>IDs<|"|>,type:<|"|>ARRAY<|"|>}},type:<|"|>OBJECT<|"|>}}` countDeclRef = `<|tool>declaration:count{description:<|"|>Count items<|"|>,parameters:{properties:{n:{type:<|"|>NUMBER<|"|>}},type:<|"|>OBJECT<|"|>}}` enumNoDescDeclRef = `<|tool>declaration:set_level{description:<|"|>Set level<|"|>,parameters:{properties:{level:{enum:[<|"|>low<|"|>,<|"|>high<|"|>],type:<|"|>STRING<|"|>}},type:<|"|>OBJECT<|"|>}}` searchDeclRef = `<|tool>declaration:search{description:<|"|>Search<|"|>,parameters:{properties:{limit:{type:<|"|>NUMBER<|"|>},offset:{description:<|"|>Start offset<|"|>,type:<|"|>NUMBER<|"|>},query:{description:<|"|>Search query<|"|>,type:<|"|>STRING<|"|>}},type:<|"|>OBJECT<|"|>}}` arrayNoItemsDeclRef = `<|tool>declaration:tag{description:<|"|>Tag items<|"|>,parameters:{properties:{tags:{description:<|"|>Tags<|"|>,type:<|"|>ARRAY<|"|>}},type:<|"|>OBJECT<|"|>}}` objectNoDescDeclRef = `<|tool>declaration:update{description:<|"|>Update settings<|"|>,parameters:{properties:{settings:{properties:{verbose:{description:<|"|>Verbose mode<|"|>,type:<|"|>BOOLEAN<|"|>}},type:<|"|>OBJECT<|"|>}},type:<|"|>OBJECT<|"|>}}` nestedRequiredDeclRef = `<|tool>declaration:create_user{description:<|"|>Create user<|"|>,parameters:{properties:{profile:{description:<|"|>Profile<|"|>,properties:{age:{description:<|"|>Age<|"|>,type:<|"|>NUMBER<|"|>},name:{description:<|"|>Name<|"|>,type:<|"|>STRING<|"|>}},required:[<|"|>name<|"|>],type:<|"|>OBJECT<|"|>}},type:<|"|>OBJECT<|"|>}}` calcDeclRef = `<|tool>declaration:calc{description:<|"|>Calculate<|"|>,parameters:{properties:{value:{description:<|"|>Value<|"|>,type:<|"|>NUMBER<|"|>}},type:<|"|>OBJECT<|"|>}}` rawDeclRef = `<|tool>declaration:raw{description:<|"|>Raw input<|"|>,parameters:{type:<|"|>OBJECT<|"|>}}` moveDeclRef = `<|tool>declaration:move{description:<|"|>Move<|"|>,parameters:{properties:{x:{description:<|"|>X<|"|>,type:<|"|>NUMBER<|"|>},y:{description:<|"|>Y<|"|>,type:<|"|>NUMBER<|"|>}},required:[<|"|>x<|"|>,<|"|>y<|"|>],type:<|"|>OBJECT<|"|>}}` ) func TestGemma4RendererMatchesReference(t *testing.T) { q := `<|"|>` tests := []struct { name string messages []api.Message tools []api.Tool think *api.ThinkValue expected string skipJinja2 bool }{ // === Header block paths === { name: "user_only", messages: []api.Message{{Role: "user", Content: "Hello"}}, expected: "<|turn>user\nHello\n<|turn>model\n", }, { name: "system_user", messages: []api.Message{ {Role: "system", Content: "You are helpful."}, {Role: "user", Content: "Hi"}, }, expected: "<|turn>system\nYou are helpful.\n<|turn>user\nHi\n<|turn>model\n", }, { name: "developer_user", messages: []api.Message{ {Role: "developer", Content: "You are helpful."}, {Role: "user", Content: "Hi"}, }, expected: "<|turn>system\nYou are helpful.\n<|turn>user\nHi\n<|turn>model\n", }, { name: "tools_no_system", messages: []api.Message{{Role: "user", Content: "Hi"}}, tools: bashRefTool(), expected: "<|turn>system\n" + bashDeclRef + "\n<|turn>user\nHi\n<|turn>model\n", }, { name: "system_tools", messages: []api.Message{ {Role: "system", Content: "You are helpful."}, {Role: "user", Content: "Hi"}, }, tools: bashRefTool(), expected: "<|turn>system\nYou are helpful." + bashDeclRef + "\n<|turn>user\nHi\n<|turn>model\n", }, { name: "thinking_no_system", messages: []api.Message{{Role: "user", Content: "Hi"}}, think: thinkTrue(), expected: "<|turn>system\n<|think|>\n\n<|turn>user\nHi\n<|turn>model\n", }, { name: "thinking_system", messages: []api.Message{ {Role: "system", Content: "You are helpful."}, {Role: "user", Content: "Hi"}, }, think: thinkTrue(), expected: "<|turn>system\n<|think|>\nYou are helpful.\n<|turn>user\nHi\n<|turn>model\n", }, { name: "thinking_tools", messages: []api.Message{{Role: "user", Content: "Hi"}}, tools: bashRefTool(), think: thinkTrue(), expected: "<|turn>system\n<|think|>\n" + bashDeclRef + "\n<|turn>user\nHi\n<|turn>model\n", }, { name: "thinking_system_tools", messages: []api.Message{ {Role: "system", Content: "You are helpful."}, {Role: "user", Content: "Hi"}, }, tools: bashRefTool(), think: thinkTrue(), expected: "<|turn>system\n<|think|>\nYou are helpful." + bashDeclRef + "\n<|turn>user\nHi\n<|turn>model\n", }, { name: "thinking_explicitly_disabled", messages: []api.Message{{Role: "user", Content: "Hi"}}, think: thinkFalse(), expected: "<|turn>user\nHi\n<|turn>model\n", }, // === Message loop paths === { name: "multi_turn", messages: []api.Message{ {Role: "system", Content: "You are helpful."}, {Role: "user", Content: "Hi"}, {Role: "assistant", Content: "Hello!"}, {Role: "user", Content: "More"}, }, expected: "<|turn>system\nYou are helpful.\n" + "<|turn>user\nHi\n" + "<|turn>model\nHello!\n" + "<|turn>user\nMore\n" + "<|turn>model\n", }, { // Tool call with structured args → tool response as separate <|turn>tool turn name: "tool_call_response", messages: []api.Message{ {Role: "system", Content: "You are helpful."}, {Role: "user", Content: "List files"}, {Role: "assistant", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{ Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"}), }, }}}, {Role: "tool", ToolName: "bash", Content: "file1.txt\nfile2.txt"}, }, tools: bashRefTool(), expected: "<|turn>system\nYou are helpful." + bashDeclRef + "\n" + "<|turn>user\nList files\n" + "<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}" + "<|tool_response>response:bash{value:" + q + "file1.txt\nfile2.txt" + q + "}", }, { name: "tool_call_pending_response", messages: []api.Message{ {Role: "system", Content: "You are helpful."}, {Role: "user", Content: "List files"}, {Role: "assistant", Content: "Let me check.", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{ Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"}), }, }}}, }, tools: bashRefTool(), expected: "<|turn>system\nYou are helpful." + bashDeclRef + "\n" + "<|turn>user\nList files\n" + "<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}Let me check.<|tool_response>", }, { // Full round trip: call → response → assistant reply → user follow-up name: "full_round_trip", messages: []api.Message{ {Role: "system", Content: "You are helpful."}, {Role: "user", Content: "List files"}, {Role: "assistant", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{ Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"}), }, }}}, {Role: "tool", ToolName: "bash", Content: "file1.txt\nfile2.txt"}, {Role: "assistant", Content: "Here are the files."}, {Role: "user", Content: "Read file1.txt"}, }, tools: bashAndReadRefTools(), expected: "<|turn>system\nYou are helpful." + bashDeclRef + readDeclRef + "\n" + "<|turn>user\nList files\n" + "<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}" + "<|tool_response>response:bash{value:" + q + "file1.txt\nfile2.txt" + q + "}" + "Here are the files.\n" + "<|turn>user\nRead file1.txt\n" + "<|turn>model\n", }, { // Multiple tool calls + multiple tool responses name: "multiple_tool_calls", messages: []api.Message{ {Role: "system", Content: "You are helpful."}, {Role: "user", Content: "List and read"}, {Role: "assistant", ToolCalls: []api.ToolCall{ {Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})}}, {Function: api.ToolCallFunction{Name: "read", Arguments: testArgs(map[string]any{"path": "go.mod"})}}, }}, {Role: "tool", ToolName: "bash", Content: "file1.txt\nfile2.txt"}, {Role: "tool", ToolName: "read", Content: "module example.com/foo"}, }, tools: bashAndReadRefTools(), expected: "<|turn>system\nYou are helpful." + bashDeclRef + readDeclRef + "\n" + "<|turn>user\nList and read\n" + "<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}" + "<|tool_call>call:read{path:" + q + "go.mod" + q + "}" + "<|tool_response>response:bash{value:" + q + "file1.txt\nfile2.txt" + q + "}" + "<|tool_response>response:read{value:" + q + "module example.com/foo" + q + "}", skipJinja2: true, }, { // Thinking content in assistant history should be stripped name: "strip_thinking_history", messages: []api.Message{ {Role: "user", Content: "What is 2+2?"}, {Role: "assistant", Content: "<|channel>thought\nThinking...4"}, {Role: "user", Content: "And 3+3?"}, }, expected: "<|turn>user\nWhat is 2+2?\n" + "<|turn>model\n4\n" + "<|turn>user\nAnd 3+3?\n" + "<|turn>model\n", }, // === Additional edge cases ported from original tests === { // Assistant content with tool call — template emits tool_calls before content name: "assistant_content_with_tool_call", messages: []api.Message{ {Role: "user", Content: "Weather?"}, {Role: "assistant", Content: "Let me check.", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{Name: "get_weather", Arguments: testArgs(map[string]any{"city": "Paris"})}, }}}, {Role: "tool", ToolName: "get_weather", Content: "Sunny"}, }, tools: weatherTool(), expected: "<|turn>system\n" + weatherDeclRef + "\n" + "<|turn>user\nWeather?\n" + "<|turn>model\n<|tool_call>call:get_weather{city:" + q + "Paris" + q + "}" + "<|tool_response>response:get_weather{value:" + q + "Sunny" + q + "}" + "Let me check.\n", }, { // Numeric tool call arguments name: "numeric_arguments", messages: []api.Message{ {Role: "user", Content: "Add"}, {Role: "assistant", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{Name: "add", Arguments: testArgs(map[string]any{"a": float64(1), "b": float64(2)})}, }}}, {Role: "tool", ToolName: "add", Content: `{"result": 3}`}, }, tools: addTool(), expected: "<|turn>system\n" + addDeclRef + "\n" + "<|turn>user\nAdd\n" + "<|turn>model\n<|tool_call>call:add{a:1,b:2}" + "<|tool_response>response:add{value:" + q + `{"result": 3}` + q + "}", }, { // Boolean tool call argument name: "boolean_argument", messages: []api.Message{ {Role: "user", Content: "Set flag"}, {Role: "assistant", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{Name: "set_flag", Arguments: testArgs(map[string]any{"enabled": true})}, }}}, {Role: "tool", ToolName: "set_flag", Content: "done"}, }, tools: flagTool(), expected: "<|turn>system\n" + flagDeclRef + "\n" + "<|turn>user\nSet flag\n" + "<|turn>model\n<|tool_call>call:set_flag{enabled:true}" + "<|tool_response>response:set_flag{value:" + q + "done" + q + "}", }, { // Tool with enum parameter name: "tool_with_enum", messages: []api.Message{{Role: "user", Content: "Test"}}, tools: modeTool(), expected: "<|turn>system\n" + modeDeclRef + "\n" + "<|turn>user\nTest\n<|turn>model\n", }, { name: "unicode_content", messages: []api.Message{{Role: "user", Content: "こんにちは"}}, expected: "<|turn>user\nこんにちは\n<|turn>model\n", }, { name: "newlines_in_content", messages: []api.Message{{Role: "user", Content: "Line 1\nLine 2\nLine 3"}}, expected: "<|turn>user\nLine 1\nLine 2\nLine 3\n<|turn>model\n", }, { // Tool response (raw JSON) followed by user message name: "json_tool_response_then_user", messages: []api.Message{ {Role: "user", Content: "Weather?"}, {Role: "assistant", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{Name: "get_weather", Arguments: testArgs(map[string]any{"city": "Tokyo"})}, }}}, {Role: "tool", ToolName: "get_weather", Content: `{"temperature": 15, "weather": "sunny"}`}, {Role: "user", Content: "Thanks!"}, }, tools: weatherTool(), expected: "<|turn>system\n" + weatherDeclRef + "\n" + "<|turn>user\nWeather?\n" + "<|turn>model\n<|tool_call>call:get_weather{city:" + q + "Tokyo" + q + "}" + "<|tool_response>response:get_weather{value:" + q + `{"temperature": 15, "weather": "sunny"}` + q + "}" + "<|turn>user\nThanks!\n" + "<|turn>model\n", }, // === Ordering and whitespace edge cases === { // Tool call arguments are sorted alphabetically name: "sorted_args", messages: []api.Message{ {Role: "user", Content: "Go"}, {Role: "assistant", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"zzz": "last", "aaa": "first", "mmm": "middle"})}, }}}, {Role: "tool", ToolName: "bash", Content: "ok"}, }, tools: bashSmallTool(), expected: "<|turn>system\n" + bashSmallDeclRef + "\n" + "<|turn>user\nGo\n" + "<|turn>model\n<|tool_call>call:bash{aaa:" + q + "first" + q + ",mmm:" + q + "middle" + q + ",zzz:" + q + "last" + q + "}" + "<|tool_response>response:bash{value:" + q + "ok" + q + "}", }, { // User content with whitespace is trimmed name: "user_content_trimmed", messages: []api.Message{{Role: "user", Content: " hello "}}, expected: "<|turn>user\nhello\n<|turn>model\n", }, { // Empty tool call arguments name: "empty_tool_args", messages: []api.Message{ {Role: "user", Content: "Go"}, {Role: "assistant", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{})}, }}}, {Role: "tool", ToolName: "bash", Content: "ok"}, }, tools: bashSmallTool(), expected: "<|turn>system\n" + bashSmallDeclRef + "\n" + "<|turn>user\nGo\n" + "<|turn>model\n<|tool_call>call:bash{}" + "<|tool_response>response:bash{value:" + q + "ok" + q + "}", }, { // Nested object properties in tool declaration name: "nested_object_tool", messages: []api.Message{{Role: "user", Content: "Create"}}, tools: nestedTool(), expected: "<|turn>system\n" + nestedDeclRef + "\n" + "<|turn>user\nCreate\n<|turn>model\n", }, { // Array type in tool declaration name: "array_tool", messages: []api.Message{{Role: "user", Content: "Batch"}}, tools: arrayTool(), expected: "<|turn>system\n" + arrayDeclRef + "\n" + "<|turn>user\nBatch\n<|turn>model\n", }, { // Top-level typed union follows the template's odd stringified-list form. name: "typed_property_union_type", messages: []api.Message{{Role: "user", Content: "Hi"}}, tools: unionTopLevelTypeTool(), expected: `<|turn>system <|tool>declaration:maybe_name{description:<|"|>Test nullable union<|"|>,parameters:{properties:{name:{description:<|"|>Name<|"|>,type:<|"|>['STRING', 'NULL']<|"|>}},type:<|"|>OBJECT<|"|>}} <|turn>user Hi <|turn>model `, }, { // Assistant whitespace is trimmed (strip_thinking includes | trim) name: "assistant_whitespace_trimmed", messages: []api.Message{ {Role: "user", Content: "Hi"}, {Role: "assistant", Content: " spaced "}, {Role: "user", Content: "More"}, }, expected: "<|turn>user\nHi\n" + "<|turn>model\nspaced\n" + "<|turn>user\nMore\n" + "<|turn>model\n", }, { // Three sequential tool responses name: "three_tool_responses", messages: []api.Message{ {Role: "user", Content: "Do three things"}, {Role: "assistant", ToolCalls: []api.ToolCall{ {Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "a"})}}, {Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "b"})}}, {Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "c"})}}, }}, {Role: "tool", ToolName: "bash", Content: "result-a"}, {Role: "tool", ToolName: "bash", Content: "result-b"}, {Role: "tool", ToolName: "bash", Content: "result-c"}, }, tools: bashSmallTool(), expected: "<|turn>system\n" + bashSmallDeclRef + "\n" + "<|turn>user\nDo three things\n" + "<|turn>model\n<|tool_call>call:bash{command:" + q + "a" + q + "}" + "<|tool_call>call:bash{command:" + q + "b" + q + "}" + "<|tool_call>call:bash{command:" + q + "c" + q + "}" + "<|tool_response>response:bash{value:" + q + "result-a" + q + "}" + "<|tool_response>response:bash{value:" + q + "result-b" + q + "}" + "<|tool_response>response:bash{value:" + q + "result-c" + q + "}", }, { // Assistant with only tool calls, no content field name: "tool_calls_no_content", messages: []api.Message{ {Role: "user", Content: "Go"}, {Role: "assistant", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})}, }}}, {Role: "tool", ToolName: "bash", Content: "files"}, }, tools: bashSmallTool(), expected: "<|turn>system\n" + bashSmallDeclRef + "\n" + "<|turn>user\nGo\n" + "<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}" + "<|tool_response>response:bash{value:" + q + "files" + q + "}", }, // === Coverage gap cases === { // Multiple thinking blocks stripped from assistant history name: "multiple_thinking_blocks", messages: []api.Message{ {Role: "user", Content: "Hi"}, {Role: "assistant", Content: "<|channel>Think1Middle<|channel>Think2Done"}, {Role: "user", Content: "More"}, }, expected: "<|turn>user\nHi\n" + "<|turn>model\nMiddleDone\n" + "<|turn>user\nMore\n" + "<|turn>model\n", }, { // Property with no description — just type name: "property_no_description", messages: []api.Message{{Role: "user", Content: "Count"}}, tools: countTool(), expected: "<|turn>system\n" + countDeclRef + "\n" + "<|turn>user\nCount\n<|turn>model\n", }, { // System message with leading/trailing whitespace is trimmed name: "system_message_trimmed", messages: []api.Message{ {Role: "system", Content: " You are helpful. "}, {Role: "user", Content: "Hi"}, }, expected: "<|turn>system\nYou are helpful.\n" + "<|turn>user\nHi\n<|turn>model\n", }, { // Deeply nested map in tool call arguments (3 levels) name: "nested_map_args", messages: []api.Message{ {Role: "user", Content: "Go"}, {Role: "assistant", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{Name: "configure", Arguments: testArgs(map[string]any{ "config": map[string]any{"db": map[string]any{"host": "localhost", "port": float64(5432)}}, })}, }}}, {Role: "tool", ToolName: "configure", Content: "ok"}, }, tools: configureTool(), expected: "<|turn>system\n" + configureDeclRef + "\n" + "<|turn>user\nGo\n" + "<|turn>model\n<|tool_call>call:configure{config:{db:{host:" + q + "localhost" + q + ",port:5432}}}" + "<|tool_response>response:configure{value:" + q + "ok" + q + "}", }, { // Array values in tool call arguments name: "array_in_args", messages: []api.Message{ {Role: "user", Content: "Go"}, {Role: "assistant", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{Name: "batch", Arguments: testArgs(map[string]any{ "ids": []any{float64(1), float64(2), float64(3)}, })}, }}}, {Role: "tool", ToolName: "batch", Content: "done"}, }, tools: batchArrayTool(), expected: "<|turn>system\n" + batchArrayDeclRef + "\n" + "<|turn>user\nGo\n" + "<|turn>model\n<|tool_call>call:batch{ids:[1,2,3]}" + "<|tool_response>response:batch{value:" + q + "done" + q + "}", }, { // Mixed types in array argument (string, number, bool) name: "mixed_array_args", messages: []api.Message{ {Role: "user", Content: "Go"}, {Role: "assistant", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{Name: "batch", Arguments: testArgs(map[string]any{ "ids": []any{"a", float64(1), true}, })}, }}}, {Role: "tool", ToolName: "batch", Content: "done"}, }, tools: batchArrayTool(), expected: "<|turn>system\n" + batchArrayDeclRef + "\n" + "<|turn>user\nGo\n" + "<|turn>model\n<|tool_call>call:batch{ids:[" + q + "a" + q + ",1,true]}" + "<|tool_response>response:batch{value:" + q + "done" + q + "}", }, { // Enum property without description name: "enum_no_description", messages: []api.Message{{Role: "user", Content: "Set"}}, tools: enumNoDescTool(), expected: "<|turn>system\n" + enumNoDescDeclRef + "\n" + "<|turn>user\nSet\n<|turn>model\n", }, { // System message that is only whitespace (trims to empty) name: "system_whitespace_only", messages: []api.Message{ {Role: "system", Content: " "}, {Role: "user", Content: "Hi"}, }, expected: "<|turn>system\n\n" + "<|turn>user\nHi\n<|turn>model\n", }, { // Empty assistant content (empty string, not nil) name: "empty_assistant_content", messages: []api.Message{ {Role: "user", Content: "Hi"}, {Role: "assistant", Content: ""}, {Role: "user", Content: "More"}, }, expected: "<|turn>user\nHi\n" + "<|turn>model\n\n" + "<|turn>user\nMore\n" + "<|turn>model\n", }, { // Map argument with string keys (keys NOT escaped with <|"|>) name: "map_arg_string_keys", messages: []api.Message{ {Role: "user", Content: "Go"}, {Role: "assistant", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{Name: "configure", Arguments: testArgs(map[string]any{ "config": map[string]any{"key": "value"}, })}, }}}, {Role: "tool", ToolName: "configure", Content: "ok"}, }, tools: configureTool(), expected: "<|turn>system\n" + configureDeclRef + "\n" + "<|turn>user\nGo\n" + "<|turn>model\n<|tool_call>call:configure{config:{key:" + q + "value" + q + "}}" + "<|tool_response>response:configure{value:" + q + "ok" + q + "}", }, { // Mixed properties: some with description, some without name: "mixed_desc_no_desc", messages: []api.Message{{Role: "user", Content: "Search"}}, tools: searchTool(), expected: "<|turn>system\n" + searchDeclRef + "\n" + "<|turn>user\nSearch\n<|turn>model\n", }, // === Round 3 coverage gaps === { // Tool content with whitespace is trimmed (template does | trim for all non-model) name: "tool_content_trimmed", messages: []api.Message{ {Role: "user", Content: "Go"}, {Role: "assistant", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})}, }}}, {Role: "tool", ToolName: "bash", Content: " result "}, }, tools: bashSmallTool(), expected: "<|turn>system\n" + bashSmallDeclRef + "\n" + "<|turn>user\nGo\n" + "<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}" + "<|tool_response>response:bash{value:" + q + " result " + q + "}", }, { // Empty system message still emits system turn name: "empty_system_message", messages: []api.Message{ {Role: "system", Content: ""}, {Role: "user", Content: "Hi"}, }, expected: "<|turn>system\n\n" + "<|turn>user\nHi\n<|turn>model\n", }, { // Nested OBJECT property with required field name: "nested_object_with_required", messages: []api.Message{{Role: "user", Content: "Create"}}, tools: nestedRequiredTool(), expected: "<|turn>system\n" + nestedRequiredDeclRef + "\n" + "<|turn>user\nCreate\n<|turn>model\n", }, { // Non-integer float in tool call argument name: "float_argument", messages: []api.Message{ {Role: "user", Content: "Calc"}, {Role: "assistant", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{Name: "calc", Arguments: testArgs(map[string]any{"value": 3.14})}, }}}, {Role: "tool", ToolName: "calc", Content: "ok"}, }, tools: calcTool(), expected: "<|turn>system\n" + calcDeclRef + "\n" + "<|turn>user\nCalc\n" + "<|turn>model\n<|tool_call>call:calc{value:3.14}" + "<|tool_response>response:calc{value:" + q + "ok" + q + "}", }, { // Thinking in the last assistant message (stripped before generation prompt) name: "thinking_in_last_assistant", messages: []api.Message{ {Role: "user", Content: "Hi"}, {Role: "assistant", Content: "<|channel>thinkingResult"}, }, expected: "<|turn>user\nHi\n" + "<|turn>model\nResult\n" + "<|turn>model\n", }, { // Tool content with newlines and leading/trailing whitespace trimmed name: "tool_content_multiline_whitespace", messages: []api.Message{ {Role: "user", Content: "Go"}, {Role: "assistant", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})}, }}}, {Role: "tool", ToolName: "bash", Content: "\n file1\n file2\n"}, }, tools: bashSmallTool(), expected: "<|turn>system\n" + bashSmallDeclRef + "\n" + "<|turn>user\nGo\n" + "<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}" + "<|tool_response>response:bash{value:" + q + "\n file1\n file2\n" + q + "}", }, { // Tool with parameters having only type, no properties name: "tool_params_type_only", messages: []api.Message{{Role: "user", Content: "Raw"}}, tools: rawTool(), expected: "<|turn>system\n" + rawDeclRef + "\n" + "<|turn>user\nRaw\n<|turn>model\n", }, { // Multiple required fields at top level name: "multiple_required", messages: []api.Message{{Role: "user", Content: "Move"}}, tools: moveTool(), expected: "<|turn>system\n" + moveDeclRef + "\n" + "<|turn>user\nMove\n<|turn>model\n", }, { // Assistant content that is ONLY thinking (strips to empty) name: "assistant_only_thinking", messages: []api.Message{ {Role: "user", Content: "Hi"}, {Role: "assistant", Content: "<|channel>just thinking"}, {Role: "user", Content: "More"}, }, expected: "<|turn>user\nHi\n" + "<|turn>model\n\n" + "<|turn>user\nMore\n" + "<|turn>model\n", }, // === Round 4: final coverage gaps === { // Thinking enabled with tool calls in same conversation (full agentic scenario) name: "thinking_with_tool_calls", messages: []api.Message{ {Role: "system", Content: "You are helpful."}, {Role: "user", Content: "List files"}, {Role: "assistant", Content: "<|channel>I should use bash", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})}, }}}, {Role: "tool", ToolName: "bash", Content: "file1.txt"}, {Role: "assistant", Content: "Here are the files."}, {Role: "user", Content: "Thanks"}, }, tools: bashSmallTool(), think: thinkTrue(), expected: "<|turn>system\n<|think|>\nYou are helpful." + bashSmallDeclRef + "\n" + "<|turn>user\nList files\n" + "<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}" + "<|tool_response>response:bash{value:" + q + "file1.txt" + q + "}" + "\nHere are the files.\n" + "<|turn>user\nThanks\n" + "<|turn>model\n", }, { // Array property without items specification name: "array_without_items", messages: []api.Message{{Role: "user", Content: "Tag"}}, tools: arrayNoItemsTool(), expected: "<|turn>system\n" + arrayNoItemsDeclRef + "\n" + "<|turn>user\nTag\n<|turn>model\n", }, { // OBJECT property without description but with nested properties name: "object_no_desc_with_properties", messages: []api.Message{{Role: "user", Content: "Update"}}, tools: objectNoDescTool(), expected: "<|turn>system\n" + objectNoDescDeclRef + "\n" + "<|turn>user\nUpdate\n<|turn>model\n", }, // === Round 5: coding agent patterns === { // Chained tool calls — assistant calls tool, gets result, calls another // tool, gets result, then the model responds. No user messages in between. name: "chained_tool_calls", messages: []api.Message{ {Role: "user", Content: "Set up the project"}, {Role: "assistant", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "mkdir src"})}, }}}, {Role: "tool", ToolName: "bash", Content: ""}, {Role: "assistant", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "touch src/main.go"})}, }}}, {Role: "tool", ToolName: "bash", Content: ""}, {Role: "assistant", Content: "Done."}, {Role: "user", Content: "Thanks"}, }, tools: bashSmallTool(), expected: "<|turn>system\n" + bashSmallDeclRef + "\n" + "<|turn>user\nSet up the project\n" + "<|turn>model\n<|tool_call>call:bash{command:" + q + "mkdir src" + q + "}" + "<|tool_response>response:bash{value:" + q + q + "}" + "<|tool_call>call:bash{command:" + q + "touch src/main.go" + q + "}" + "<|tool_response>response:bash{value:" + q + q + "}" + "Done.\n" + "<|turn>user\nThanks\n" + "<|turn>model\n", }, { // Tool call with thinking that strips to real remaining content name: "tool_call_thinking_with_remaining_content", messages: []api.Message{ {Role: "user", Content: "List files"}, {Role: "assistant", Content: "<|channel>I need to check the directoryLet me list the files.", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})}, }}}, {Role: "tool", ToolName: "bash", Content: "main.go\ngo.mod"}, {Role: "user", Content: "OK"}, }, tools: bashSmallTool(), expected: "<|turn>system\n" + bashSmallDeclRef + "\n" + "<|turn>user\nList files\n" + "<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}" + "<|tool_response>response:bash{value:" + q + "main.go\ngo.mod" + q + "}" + "Let me list the files.\n" + "<|turn>user\nOK\n" + "<|turn>model\n", }, { // Argument value containing newlines (multi-line script) name: "argument_with_newlines", messages: []api.Message{ {Role: "user", Content: "Run it"}, {Role: "assistant", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "echo hello\necho world"})}, }}}, {Role: "tool", ToolName: "bash", Content: "hello\nworld"}, }, tools: bashSmallTool(), expected: "<|turn>system\n" + bashSmallDeclRef + "\n" + "<|turn>user\nRun it\n" + "<|turn>model\n<|tool_call>call:bash{command:" + q + "echo hello\necho world" + q + "}" + "<|tool_response>response:bash{value:" + q + "hello\nworld" + q + "}", }, { // Empty string argument value name: "empty_string_argument", messages: []api.Message{ {Role: "user", Content: "Go"}, {Role: "assistant", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": ""})}, }}}, {Role: "tool", ToolName: "bash", Content: "error"}, }, tools: bashSmallTool(), expected: "<|turn>system\n" + bashSmallDeclRef + "\n" + "<|turn>user\nGo\n" + "<|turn>model\n<|tool_call>call:bash{command:" + q + q + "}" + "<|tool_response>response:bash{value:" + q + "error" + q + "}", }, } verifyJinja2 := os.Getenv("VERIFY_JINJA2") != "" if verifyJinja2 { if err := exec.Command("uv", "run", "--with", "jinja2", "python", "-c", "import jinja2").Run(); err != nil { t.Fatal("VERIFY_JINJA2=1 requires uv and the ability to run uv with jinja2") } t.Log("VERIFY_JINJA2=1: verifying expected values against Jinja2 template") } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Compare our renderer against the hardcoded expected value renderer := &Gemma4Renderer{useImgTags: RenderImgTags} got, err := renderer.Render(tt.messages, tt.tools, tt.think) assert.NoError(t, err) assert.Equal(t, tt.expected, got) // When VERIFY_JINJA2=1, also verify the expected value against // the real Jinja2 template rendered by Python. if verifyJinja2 && !tt.skipJinja2 { jinja2Output := renderWithJinja2(t, tt.messages, tt.tools, tt.think) if jinja2Output != tt.expected || jinja2Output != got { fmt.Fprintf(os.Stderr, "\nJINJA2 OUTPUT for %s (copy-paste as expected):\n%q\n\n", tt.name, jinja2Output) } assert.Equal(t, jinja2Output, tt.expected, "hardcoded expected value doesn't match Jinja2 template output") assert.Equal(t, jinja2Output, got, "renderer output doesn't match Jinja2 template output") } }) } } func TestGemma4RendererVariantsMatchExpectedGenerationPrompt(t *testing.T) { messages := []api.Message{{Role: "user", Content: "Hello"}} tests := []struct { name string rendererName string expected string }{ { name: "legacy_alias", rendererName: "gemma4", expected: "<|turn>user\nHello\n<|turn>model\n", }, { name: "small", rendererName: "gemma4-small", expected: "<|turn>user\nHello\n<|turn>model\n", }, { name: "large", rendererName: "gemma4-large", expected: "<|turn>user\nHello\n<|turn>model\n<|channel>thought\n", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := RenderWithRenderer(tt.rendererName, messages, nil, nil) assert.NoError(t, err) assert.Equal(t, tt.expected, got) }) } } func TestGemma4LargeRendererOmitsEmptyThoughtBlockWhenThinkingEnabled(t *testing.T) { got, err := RenderWithRenderer("gemma4-large", []api.Message{{Role: "user", Content: "Hello"}}, nil, thinkTrue()) assert.NoError(t, err) assert.Equal(t, "<|turn>system\n<|think|>\n\n<|turn>user\nHello\n<|turn>model\n", got) assert.NotContains(t, got, "<|channel>thought\n") } func TestGemma4RendererMatchesJinja2ExpandedParity(t *testing.T) { if os.Getenv("VERIFY_JINJA2") == "" { t.Skip("set VERIFY_JINJA2=1 to run expanded Jinja2 parity checks") } if err := exec.Command("uv", "run", "--with", "jinja2", "python", "-c", "import jinja2").Run(); err != nil { t.Fatal("VERIFY_JINJA2=1 requires uv and the ability to run uv with jinja2") } tests := []struct { name string messages []api.Message tools []api.Tool think *api.ThinkValue }{ { name: "adjacent_assistants_continue_same_model_turn", messages: []api.Message{ {Role: "user", Content: "Start"}, {Role: "assistant", Content: "One."}, {Role: "assistant", Content: "Two."}, {Role: "user", Content: "More"}, }, }, { name: "thinking_field_on_pending_tool_call", messages: []api.Message{ {Role: "user", Content: "List files"}, {Role: "assistant", Content: "Let me check.", Thinking: "I should use bash", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})}, }}}, }, tools: bashRefTool(), }, { name: "thinking_field_ignored_before_later_user", messages: []api.Message{ {Role: "user", Content: "List files"}, {Role: "assistant", Content: "Let me check.", Thinking: "I should use bash", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})}, }}}, {Role: "tool", ToolName: "bash", Content: "file1.txt"}, {Role: "user", Content: "Thanks"}, }, tools: bashRefTool(), }, { name: "tool_response_name_resolved_from_tool_call_id", messages: []api.Message{ {Role: "user", Content: "List and read"}, {Role: "assistant", ToolCalls: []api.ToolCall{ { ID: "call_bash", Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})}, }, { ID: "call_read", Function: api.ToolCallFunction{Name: "read", Arguments: testArgs(map[string]any{"path": "go.mod"})}, }, }}, {Role: "tool", ToolCallID: "call_read", Content: "module example.com/foo"}, {Role: "tool", ToolCallID: "call_bash", Content: "file1.txt\nfile2.txt"}, }, tools: bashAndReadRefTools(), }, { name: "adjacent_assistants_after_tool_response_continue_same_model_turn", messages: []api.Message{ {Role: "user", Content: "Go"}, {Role: "assistant", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})}, }}}, {Role: "tool", ToolName: "bash", Content: "file1.txt"}, {Role: "assistant", Content: "First."}, {Role: "assistant", Content: "Second."}, {Role: "user", Content: "Next"}, }, tools: bashSmallTool(), }, { name: "thinking_enabled_with_pending_tool_call", messages: []api.Message{ {Role: "system", Content: "You are helpful."}, {Role: "user", Content: "List files"}, {Role: "assistant", Thinking: "Use bash", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})}, }}}, }, tools: bashSmallTool(), think: thinkTrue(), }, { name: "array_items_object_with_required", messages: []api.Message{ {Role: "user", Content: "Upsert entries"}, }, tools: arrayObjectItemsTool(), }, { name: "array_items_object_with_nested_object_and_array_properties", messages: []api.Message{ {Role: "user", Content: "Plan steps"}, }, tools: arrayNestedItemsTool(), }, { name: "array_items_nested_array_of_objects", messages: []api.Message{ {Role: "user", Content: "Schedule jobs"}, }, tools: nestedArrayObjectItemsTool(), }, { name: "array_items_union_type", messages: []api.Message{ {Role: "user", Content: "Maybe batch"}, }, tools: arrayUnionItemsTool(), }, { name: "array_items_nested_nullable_properties", messages: []api.Message{ {Role: "user", Content: "Annotate batch"}, }, tools: arrayNullableNestedItemsTool(), }, { name: "array_items_extra_keys", messages: []api.Message{ {Role: "user", Content: "Configure batch"}, }, tools: arrayItemsExtraKeysTool(), }, { name: "typed_property_union_type", messages: []api.Message{ {Role: "user", Content: "Hi"}, }, tools: unionTopLevelTypeTool(), }, } variants := []struct { name string renderer *Gemma4Renderer templateRel string }{ { name: "small", renderer: &Gemma4Renderer{useImgTags: RenderImgTags}, templateRel: gemma4E2BTemplate, }, { name: "large", renderer: &Gemma4Renderer{useImgTags: RenderImgTags, emptyBlockOnNothink: true}, templateRel: gemma431BTemplate, }, } for _, variant := range variants { t.Run(variant.name, func(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := variant.renderer.Render(tt.messages, tt.tools, tt.think) assert.NoError(t, err) jinja2Output := renderWithJinja2Template(t, variant.templateRel, tt.messages, tt.tools, tt.think) assert.Equal(t, jinja2Output, got, "renderer output doesn't match Jinja2 template output") }) } }) } } func TestGemma4RendererKnownJinja2Differences(t *testing.T) { if os.Getenv("VERIFY_JINJA2") == "" { t.Skip("set VERIFY_JINJA2=1 to run Jinja2 difference checks") } if err := exec.Command("uv", "run", "--with", "jinja2", "python", "-c", "import jinja2").Run(); err != nil { t.Fatal("VERIFY_JINJA2=1 requires uv and the ability to run uv with jinja2") } tests := []struct { name string messages []api.Message tools []api.Tool wantJinjaFrag string wantRenderFrag string }{ { name: "typed_property_anyof", messages: []api.Message{ {Role: "user", Content: "Pick"}, }, tools: anyOfTool(), wantJinjaFrag: `value:{description:<|"|>Value<|"|>,type:<|"|><|"|>}`, wantRenderFrag: `value:{description:<|"|>Value<|"|>,type:<|"|>['STRING', 'NUMBER']<|"|>}`, }, { name: "tool_response_name_not_overridden_without_tool_call_id", messages: []api.Message{ {Role: "user", Content: "List and read"}, {Role: "assistant", ToolCalls: []api.ToolCall{ { Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})}, }, { Function: api.ToolCallFunction{Name: "read", Arguments: testArgs(map[string]any{"path": "go.mod"})}, }, }}, {Role: "tool", ToolName: "bash", Content: "payload"}, }, tools: bashAndReadRefTools(), wantJinjaFrag: `response:read{value:<|"|>payload<|"|>}`, wantRenderFrag: `response:bash{value:<|"|>payload<|"|>}`, }, { name: "tool_response_without_name_or_id_uses_unknown", messages: []api.Message{ {Role: "user", Content: "List and read"}, {Role: "assistant", ToolCalls: []api.ToolCall{ { Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})}, }, { Function: api.ToolCallFunction{Name: "read", Arguments: testArgs(map[string]any{"path": "go.mod"})}, }, }}, {Role: "tool", Content: "payload"}, }, tools: bashAndReadRefTools(), wantJinjaFrag: `response:read{value:<|"|>payload<|"|>}`, wantRenderFrag: `response:unknown{value:<|"|>payload<|"|>}`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { renderer := &Gemma4Renderer{useImgTags: RenderImgTags} got, err := renderer.Render(tt.messages, tt.tools, nil) assert.NoError(t, err) jinja2Output := renderWithJinja2(t, tt.messages, tt.tools, nil) assert.NotEqual(t, jinja2Output, got, "case no longer differs from Jinja2 output") assert.Contains(t, jinja2Output, tt.wantJinjaFrag) assert.Contains(t, got, tt.wantRenderFrag) }) } } func TestGemma4RendererNormalizesSimpleAnyOfToTypedUnion(t *testing.T) { renderer := &Gemma4Renderer{useImgTags: RenderImgTags} got, err := renderer.Render([]api.Message{{Role: "user", Content: "Pick"}}, anyOfTool(), nil) assert.NoError(t, err) assert.Contains(t, got, `value:{description:<|"|>Value<|"|>,type:<|"|>['STRING', 'NUMBER']<|"|>}`) } func TestGemma4RendererToolResponseWithoutNameOrIDUsesUnknown(t *testing.T) { renderer := &Gemma4Renderer{useImgTags: RenderImgTags} got, err := renderer.Render([]api.Message{ {Role: "user", Content: "List and read"}, {Role: "assistant", ToolCalls: []api.ToolCall{ { Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})}, }, { Function: api.ToolCallFunction{Name: "read", Arguments: testArgs(map[string]any{"path": "go.mod"})}, }, }}, {Role: "tool", Content: "payload"}, }, bashAndReadRefTools(), nil) assert.NoError(t, err) assert.Contains(t, got, `response:unknown{value:<|"|>payload<|"|>}`) assert.NotContains(t, got, `response:read{value:<|"|>payload<|"|>}`) } func TestGemma4SizeTemplateFixturesDifferAtGenerationPrompt(t *testing.T) { e2b, err := os.ReadFile(gemma4E2BTemplate) if err != nil { t.Fatalf("failed to read %s: %v", gemma4E2BTemplate, err) } thirtyOneB, err := os.ReadFile(gemma431BTemplate) if err != nil { t.Fatalf("failed to read %s: %v", gemma431BTemplate, err) } assert.Contains(t, string(e2b), "{{- '<|turn>model\\n' -}}") assert.NotContains(t, string(e2b), "{{- '<|channel>thought\\n' -}}") assert.Contains(t, string(thirtyOneB), "{{- '<|turn>model\\n' -}}") assert.Contains(t, string(thirtyOneB), "{{- '<|channel>thought\\n' -}}") } // renderWithJinja2 shells out to uv + Python to render messages through the // E2B Jinja2 chat template. Returns the rendered string. func renderWithJinja2(t *testing.T, messages []api.Message, tools []api.Tool, think *api.ThinkValue) string { return renderWithJinja2Template(t, gemma4E2BTemplate, messages, tools, think) } // renderWithJinja2Template shells out to uv + Python to render messages through // the named Jinja2 chat template. Returns the rendered string. func renderWithJinja2Template(t *testing.T, templateRelPath string, messages []api.Message, tools []api.Tool, think *api.ThinkValue) string { t.Helper() templatePath, err := filepath.Abs(templateRelPath) if err != nil { t.Fatalf("failed to get template path: %v", err) } // Convert messages to the format the Jinja2 template expects. // The template uses message['tool_calls'] with function.arguments as a dict. type jinja2ToolCall struct { ID string `json:"id,omitempty"` Function struct { Name string `json:"name"` Arguments any `json:"arguments"` } `json:"function"` } type jinja2Message struct { Role string `json:"role"` Content string `json:"content"` Reasoning string `json:"reasoning,omitempty"` ToolCalls []jinja2ToolCall `json:"tool_calls,omitempty"` Name string `json:"name,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` } var jMsgs []jinja2Message for _, m := range messages { jm := jinja2Message{ Role: m.Role, Content: m.Content, Reasoning: m.Thinking, Name: m.ToolName, ToolCallID: m.ToolCallID, } for _, tc := range m.ToolCalls { jtc := jinja2ToolCall{} jtc.ID = tc.ID jtc.Function.Name = tc.Function.Name // Convert ToolCallFunctionArguments to a map var args map[string]any raw, _ := tc.Function.Arguments.MarshalJSON() json.Unmarshal(raw, &args) jtc.Function.Arguments = args jm.ToolCalls = append(jm.ToolCalls, jtc) } jMsgs = append(jMsgs, jm) } msgsJSON, err := json.Marshal(jMsgs) if err != nil { t.Fatalf("failed to marshal messages: %v", err) } toolsJSON := "None" if len(tools) > 0 { b, _ := json.Marshal(tools) toolsJSON = string(b) } thinking := "False" if think != nil && think.Bool() { thinking = "True" } script := fmt.Sprintf(` import json from jinja2 import Environment tmpl = Environment().from_string(open(%q).read()) msgs = json.loads(%q) tools = json.loads(%q) if %q != "None" else None kwargs = {"messages": msgs, "bos_token": "", "add_generation_prompt": True} if tools: kwargs["tools"] = tools if %s: kwargs["enable_thinking"] = True print(tmpl.render(**kwargs), end="") `, templatePath, string(msgsJSON), toolsJSON, toolsJSON, thinking) cmd := exec.Command("uv", "run", "--with", "jinja2", "python", "-c", script) var stdout, stderr strings.Builder cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { t.Fatalf("uv run failed: %v\nstderr: %s", err, stderr.String()) } return stdout.String() } func thinkTrue() *api.ThinkValue { return &api.ThinkValue{Value: true} } func thinkFalse() *api.ThinkValue { return &api.ThinkValue{Value: false} }