internal/util/responses_tools_test.go
Rama main · réplica de lectura del repositorio original.
package util
import (
"reflect"
"strings"
"testing"
"github.com/MoeForge/moego/ojson"
)
func TestCollectResponsesToolDescriptors_PriorityAndNamespace(t *testing.T) {
raw := `{
"tools": [
{"type": "function", "name": "top_fn", "description": "top function"}
],
"input": [
{
"type": "additional_tools",
"tools": [
{
"type": "namespace",
"name": "ns1",
"tools": [
{"type": "function", "name": "child_fn", "description": "child function"},
{"type": "custom", "name": "child_custom", "description": "child custom"}
]
},
{"type": "custom", "name": "direct_custom"}
]
}
]
}`
root := ojson.Parse(raw)
descriptors := CollectResponsesToolDescriptors(root)
if len(descriptors) != 4 {
t.Fatalf("expected 4 descriptors, got %d", len(descriptors))
}
decls, forwardMap, reverseMap := BuildGeminiFunctionDeclarations(root)
if len(decls) != 4 {
t.Fatalf("expected 4 declarations, got %d", len(decls))
}
if forwardMap["ns1__child_fn"] != "ns1__child_fn" {
t.Fatalf("forwardMap['ns1__child_fn'] = %q, want ns1__child_fn", forwardMap["ns1__child_fn"])
}
childCustomIdentity := reverseMap["ns1__child_custom"]
if childCustomIdentity.Name != "child_custom" || childCustomIdentity.Namespace != "ns1" || !childCustomIdentity.Custom {
t.Fatalf("unexpected reverseMap for ns1__child_custom: %+v", childCustomIdentity)
}
topFnIdentity := reverseMap["top_fn"]
if topFnIdentity.Name != "top_fn" || topFnIdentity.Namespace != "" || topFnIdentity.Custom {
t.Fatalf("unexpected reverseMap for top_fn: %+v", topFnIdentity)
}
}
func TestResponsesToolWinners_TopLevelBeatsAdditionalTools(t *testing.T) {
raw := `{
"tools": [
{"type": "function", "name": "shared_fn", "description": "top level"}
],
"input": [
{
"type": "additional_tools",
"tools": [
{"type": "function", "name": "shared_fn", "description": "additional"}
]
}
]
}`
root := ojson.Parse(raw)
winners := CollectResponsesToolWinners(root)
winner := winners["shared_fn"]
if winner.SourcePriority != 0 {
t.Fatalf("winner priority = %d, want 0", winner.SourcePriority)
}
if winner.Tool.Get("description").String() != "top level" {
t.Fatalf("winner description = %q, want 'top level'", winner.Tool.Get("description").String())
}
}
func TestResponsesToolWinners_DirectBeatsNamespaceChild(t *testing.T) {
raw := `{
"tools": [
{"type": "namespace", "name": "n", "tools": [{"type": "function", "name": "x", "description": "namespace child"}]},
{"type": "custom", "name": "n__x", "description": "direct"}
]
}`
root := ojson.Parse(raw)
winners := CollectResponsesToolWinners(root)
winner := winners["n__x"]
if !winner.Direct {
t.Fatalf("winner direct = %v, want true", winner.Direct)
}
if winner.ToolType != "custom" {
t.Fatalf("winner toolType = %q, want custom", winner.ToolType)
}
}
func TestConvertResponsesToolChoiceToGemini(t *testing.T) {
tests := []struct {
name string
choiceJSON string
forwardMap map[string]string
wantMode string
wantNames []string
}{
{
name: "auto string",
choiceJSON: `"auto"`,
wantMode: "AUTO",
},
{
name: "none string",
choiceJSON: `"none"`,
wantMode: "NONE",
},
{
name: "required string",
choiceJSON: `"required"`,
wantMode: "ANY",
},
{
name: "function object with namespace",
choiceJSON: `{"type": "function", "name": "my_fn", "namespace": "my_ns"}`,
forwardMap: map[string]string{"my_ns__my_fn": "my_ns__my_fn"},
wantMode: "ANY",
wantNames: []string{"my_ns__my_fn"},
},
{
name: "custom object",
choiceJSON: `{"type": "custom", "name": "exec", "namespace": "functions"}`,
forwardMap: map[string]string{"functions__exec": "functions__exec"},
wantMode: "ANY",
wantNames: []string{"functions__exec"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
choice := ojson.Parse(tt.choiceJSON)
out, ok := ConvertResponsesToolChoiceToGemini(choice, tt.forwardMap)
if !ok {
t.Fatalf("ConvertResponsesToolChoiceToGemini returned false")
}
mode := ojson.GetBytes(out, "mode").String()
if mode != tt.wantMode {
t.Fatalf("mode = %q, want %q", mode, tt.wantMode)
}
if len(tt.wantNames) > 0 {
names := ojson.GetBytes(out, "allowedFunctionNames").Array()
if len(names) != len(tt.wantNames) {
t.Fatalf("allowedFunctionNames count = %d, want %d", len(names), len(tt.wantNames))
}
for i, want := range tt.wantNames {
if names[i].String() != want {
t.Fatalf("allowedFunctionNames[%d] = %q, want %q", i, names[i].String(), want)
}
}
}
})
}
}
func TestUnwrapResponsesCustomToolInput(t *testing.T) {
tests := []struct {
input string
want string
}{
{input: `{"input":"pwd"}`, want: "pwd"},
{input: `{"input":{"cmd":"ls"}}`, want: `{"cmd":"ls"}`},
{input: `"direct text"`, want: "direct text"},
{input: `{}`, want: ""},
{input: ``, want: ""},
}
for _, tt := range tests {
got := UnwrapResponsesCustomToolInput(tt.input)
if got != tt.want {
t.Errorf("UnwrapResponsesCustomToolInput(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestBuildGeminiFunctionDeclarations_DisambiguationAndLongNames(t *testing.T) {
// Two tools that genuinely collide after sanitization (e.g. "read/file" vs "read_file"), and one > 64 chars
raw := `{
"tools": [
{"type": "function", "name": "read/file", "description": "tool with slash"},
{"type": "function", "name": "read_file", "description": "tool with underscore"},
{"type": "custom", "name": "mcp__very_very_very_very_very_very_long_namespace_name__very_very_very_long_custom_tool_name_that_exceeds_sixty_four_chars"}
]
}`
root := ojson.Parse(raw)
decls, forwardMap, reverseMap := BuildGeminiFunctionDeclarations(root)
if len(decls) != 3 {
t.Fatalf("expected 3 decls, got %d", len(decls))
}
name1 := forwardMap["read/file"]
name2 := forwardMap["read_file"]
if name1 == name2 {
t.Fatalf("colliding tools mapped to identical name: %q", name1)
}
identity1 := reverseMap[name1]
if identity1.Name != "read/file" {
t.Fatalf("reverseMap[%q].Name = %q, want read/file", name1, identity1.Name)
}
identity2 := reverseMap[name2]
if identity2.Name != "read_file" {
t.Fatalf("reverseMap[%q].Name = %q, want read_file", name2, identity2.Name)
}
longName := forwardMap["mcp__very_very_very_very_very_very_long_namespace_name__very_very_very_long_custom_tool_name_that_exceeds_sixty_four_chars"]
if len(longName) > 64 {
t.Fatalf("long tool name length = %d > 64: %q", len(longName), longName)
}
identityLong := reverseMap[longName]
if !identityLong.Custom || identityLong.Name != "mcp__very_very_very_very_very_very_long_namespace_name__very_very_very_long_custom_tool_name_that_exceeds_sixty_four_chars" {
t.Fatalf("unexpected reverse identity for long name: %+v", identityLong)
}
}
func TestBuildGeminiApplyPatchDeclaration(t *testing.T) {
root := ojson.Parse(`{"tools":[{"type":"custom","name":"apply_patch","format":{"type":"grammar","syntax":"lark","definition":"start: patch"}}]}`)
declarations, _, reverse := BuildGeminiFunctionDeclarations(root)
if len(declarations) != 1 {
t.Fatalf("declarations: %d", len(declarations))
}
declaration := ojson.ParseBytes(declarations[0])
if !strings.Contains(declaration.Get("description").String(), "*** Begin Patch") || !strings.Contains(declaration.Get("description").String(), "start: patch") {
t.Fatal("patch format is missing")
}
if declaration.Get("parametersJsonSchema.properties.input.type").String() != "string" || !declaration.Get("parametersJsonSchema.additionalProperties").Exists() || declaration.Get("parametersJsonSchema.additionalProperties").Bool() {
t.Fatal("input schema is missing")
}
if !reverse[declaration.Get("name").String()].Custom {
t.Fatal("custom client identity was lost")
}
}
func TestBuildGeminiApplyPatchIdentityUsesWinningOriginalDeclaration(t *testing.T) {
root := ojson.Parse(`{"tools":[{"type":"namespace","name":"a.b","tools":[{"type":"custom","name":"apply_patch"}]},{"type":"function","name":"a_b__apply_patch","parameters":{"type":"object","properties":{"n":{"type":"number"}}}},{"type":"function","name":"apply_patch"}],"input":[{"type":"additional_tools","tools":[{"type":"custom","name":"apply_patch"}]}]}`)
declarations, forward, reverse := BuildGeminiFunctionDeclarations(root)
if len(declarations) != 3 {
t.Fatalf("declarations=%d", len(declarations))
}
if forward["a.b__apply_patch"] == forward["a_b__apply_patch"] {
t.Fatal("sanitized collision was not separated")
}
for _, raw := range declarations {
decl := ojson.ParseBytes(raw)
identity := reverse[decl.Get("name").String()]
patch := identity.Custom && identity.Namespace == "a.b"
if identity.ApplyPatch != patch || strings.Contains(decl.Get("description").String(), "*** Begin Patch") != patch {
t.Fatalf("wrong winning identity=%+v declaration=%s", identity, raw)
}
}
if reverse[forward["apply_patch"]].Custom || reverse[forward["apply_patch"]].ApplyPatch {
t.Fatal("ordinary function became custom patch")
}
}
func TestBuildGeminiFunctionDeclarations_ClientToolSearchShimAndDeferredFiltering(t *testing.T) {
raw := `{
"tools": [
{"type": "function", "name": "exec_command", "parameters": {"type": "object", "properties": {"cmd": {"type": "string"}}}},
{"type": "tool_search", "execution": "client", "description": "Search deferred tools.", "parameters": {"type": "object", "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["query"], "additionalProperties": false}},
{"type": "function", "name": "deferred_fn", "defer_loading": true, "parameters": {"type": "object", "properties": {}}},
{"type": "web_search"}
]
}`
declarations, forwardMap, reverseMap := BuildGeminiFunctionDeclarations(ojson.Parse(raw))
if len(declarations) != 2 {
t.Fatalf("expected exec_command and tool_search declarations (with deferred_fn hidden), got %d: %s", len(declarations), declarations)
}
shim := ojson.ParseBytes(declarations[1])
if got := shim.Get("name").String(); got != "tool_search" {
t.Fatalf("shim name = %q, want tool_search", got)
}
if got := shim.Get("description").String(); got != "Search deferred tools." {
t.Fatalf("shim description = %q, want original description", got)
}
if got := shim.Get("parametersJsonSchema.required.0").String(); got != "query" {
t.Fatalf("shim parameters not preserved: %s", shim.Raw)
}
if got := forwardMap["tool_search"]; got != "tool_search" {
t.Fatalf("forward map for shim = %q, want tool_search", got)
}
if _, ok := reverseMap["tool_search"]; !ok {
t.Fatalf("expected tool_search in reverseMap")
}
if !reverseMap["tool_search"].ToolSearch || reverseMap["tool_search"].Custom || reverseMap["tool_search"].Namespace != "" {
t.Fatalf("unexpected shim identity: %+v", reverseMap["tool_search"])
}
if reverseMap["exec_command"].ToolSearch {
t.Fatalf("regular function must not be marked ToolSearch: %+v", reverseMap["exec_command"])
}
}
func TestBuildGeminiFunctionDeclarations_ToolSearchShimDefaultsAndPrecedence(t *testing.T) {
t.Run("default parameters when omitted and custom schema preserved when provided", func(t *testing.T) {
defaultDecls, _, _ := BuildGeminiFunctionDeclarations(ojson.Parse(`{"tools": [{"type": "tool_search", "execution": "client"}]}`))
if len(defaultDecls) != 1 {
t.Fatalf("expected one shim declaration, got %d", len(defaultDecls))
}
if got := ojson.GetBytes(defaultDecls[0], "parametersJsonSchema.properties.query.type").String(); got != "string" {
t.Fatalf("default parameters missing query: %s", defaultDecls[0])
}
if !ojson.GetBytes(defaultDecls[0], `parametersJsonSchema.required.#(=="query")`).Exists() {
t.Fatalf("default parameters missing required query: %s", defaultDecls[0])
}
customGoalRaw := `{"tools": [{"type": "tool_search", "execution": "client", "description": "Custom goal search", "parameters": {"type": "object", "properties": {"goal": {"type": "string"}}, "required": ["goal"], "additionalProperties": false}}]}`
customDecls, _, _ := BuildGeminiFunctionDeclarations(ojson.Parse(customGoalRaw))
if len(customDecls) != 1 {
t.Fatalf("expected one shim declaration, got %d", len(customDecls))
}
if got := ojson.GetBytes(customDecls[0], "parametersJsonSchema.properties.goal.type").String(); got != "string" {
t.Fatalf("custom goal property not preserved: %s", customDecls[0])
}
if ojson.GetBytes(customDecls[0], "parametersJsonSchema.properties.query").Exists() {
t.Fatalf("custom schema must not be mutated to inject query: %s", customDecls[0])
}
if ojson.GetBytes(customDecls[0], `parametersJsonSchema.required.#(=="query")`).Exists() {
t.Fatalf("custom required array must not inject query: %s", customDecls[0])
}
})
t.Run("server executed search is not emulated", func(t *testing.T) {
raw := `{"tools": [{"type": "tool_search"}, {"type": "tool_search", "execution": "server"}]}`
declarations, _, reverseMap := BuildGeminiFunctionDeclarations(ojson.Parse(raw))
if len(declarations) != 0 || len(reverseMap) != 0 {
t.Fatalf("server-executed tool_search must be dropped, got %s", declarations)
}
})
t.Run("user function and client tool_search coexist in both orders", func(t *testing.T) {
for _, userFnName := range []string{"tool_search", "__cpa_client_tool_search__"} {
for _, tc := range []struct {
name string
raw string
}{
{
name: userFnName + "/user function first",
raw: `{"tools": [{"type": "function", "name": "` + userFnName + `", "description": "user tool"}, {"type": "tool_search", "execution": "client", "description": "client search"}, {"type": "function", "name": "deferred_fn", "defer_loading": true}]}`,
},
{
name: userFnName + "/client tool_search first",
raw: `{"tools": [{"type": "tool_search", "execution": "client", "description": "client search"}, {"type": "function", "name": "` + userFnName + `", "description": "user tool"}, {"type": "function", "name": "deferred_fn", "defer_loading": true}]}`,
},
} {
t.Run(tc.name, func(t *testing.T) {
declarations, forwardMap, reverseMap := BuildGeminiFunctionDeclarations(ojson.Parse(tc.raw))
if len(declarations) != 2 {
t.Fatalf("expected both user %s and client tool_search shim (with deferred_fn hidden), got %d declarations: %s", userFnName, len(declarations), declarations)
}
userGeminiName := forwardMap[userFnName]
shimGeminiName := MapResponsesClientToolSearchName(forwardMap)
if userGeminiName != userFnName {
t.Fatalf("user function Gemini name = %q, want %s", userGeminiName, userFnName)
}
if shimGeminiName == "" || shimGeminiName == userGeminiName {
t.Fatalf("shim Gemini name = %q, must be non-empty and distinct from %q", shimGeminiName, userGeminiName)
}
if reverseMap[userGeminiName].ToolSearch || reverseMap[userGeminiName].Name != userFnName {
t.Fatalf("user function identity = %+v, want non-ToolSearch function named %s", reverseMap[userGeminiName], userFnName)
}
if !reverseMap[shimGeminiName].ToolSearch {
t.Fatalf("shim identity = %+v, want ToolSearch=true", reverseMap[shimGeminiName])
}
})
}
}
})
t.Run("sanitized name collision with tool.search keeps both", func(t *testing.T) {
raw := `{"tools": [{"type": "function", "name": "tool.search", "description": "dotted user tool"}, {"type": "tool_search", "execution": "client", "description": "client search"}]}`
declarations, forwardMap, reverseMap := BuildGeminiFunctionDeclarations(ojson.Parse(raw))
if len(declarations) != 2 {
t.Fatalf("expected 2 declarations, got %d: %s", len(declarations), declarations)
}
dottedGeminiName := forwardMap["tool.search"]
shimGeminiName := MapResponsesClientToolSearchName(forwardMap)
if shimGeminiName != "tool_search" {
t.Fatalf("shim Gemini name = %q, want tool_search", shimGeminiName)
}
if dottedGeminiName == "" || dottedGeminiName == shimGeminiName {
t.Fatalf("dotted user tool Gemini name = %q, must be distinct from %q", dottedGeminiName, shimGeminiName)
}
if reverseMap[dottedGeminiName].ToolSearch || reverseMap[dottedGeminiName].Name != "tool.search" {
t.Fatalf("dotted tool identity = %+v", reverseMap[dottedGeminiName])
}
if !reverseMap[shimGeminiName].ToolSearch {
t.Fatalf("shim identity = %+v", reverseMap[shimGeminiName])
}
})
t.Run("shared CollectResponsesToolDescriptors keeps deferred tools for non-search callers", func(t *testing.T) {
raw := `{"tools": [{"type": "tool_search", "execution": "client"}, {"type": "function", "name": "deferred_fn", "defer_loading": true}, {"type": "function", "name": "eager_fn"}]}`
descriptors := CollectResponsesToolDescriptors(ojson.Parse(raw))
if len(descriptors) != 2 || descriptors[0].Name != "deferred_fn" || descriptors[1].Name != "eager_fn" {
t.Fatalf("CollectResponsesToolDescriptors = %+v, want [deferred_fn eager_fn]", descriptors)
}
})
t.Run("additional_tools declaration", func(t *testing.T) {
raw := `{"input": [{"type": "additional_tools", "tools": [{"type": "tool_search", "execution": "client"}]}]}`
_, _, reverseMap := BuildGeminiFunctionDeclarations(ojson.Parse(raw))
if !reverseMap[ResponsesToolSearchFunctionName].ToolSearch {
t.Fatalf("tool_search inside additional_tools must be exposed: %+v", reverseMap)
}
})
}
func TestBuildGeminiFunctionDeclarations_CollectsToolSearchOutputTools(t *testing.T) {
raw := `{
"tools": [
{"type": "namespace", "name": "mcp__cua_repl", "tools": [{"type": "function", "name": "js", "description": "current"}]},
{"type": "tool_search", "execution": "client"},
{"type": "function", "name": "deferred_fn", "defer_loading": true, "description": "top-level deferred"}
],
"input": [
{"type": "tool_search_call", "execution": "client", "call_id": "search_1", "status": "completed", "arguments": {"query": "fixture"}},
{"type": "tool_search_output", "execution": "client", "call_id": "search_1", "status": "completed", "tools": [
{"type": "namespace", "name": "fixture_tools", "description": "fixture", "tools": [
{"type": "function", "name": "ping", "defer_loading": true, "parameters": {"type": "object", "properties": {}, "additionalProperties": false}}
]},
{"type": "namespace", "name": "mcp__cua_repl", "tools": [{"type": "function", "name": "js", "description": "discovered"}]}
]},
{"type": "tool_search_output", "execution": "client", "call_id": "search_2", "status": "completed", "tools": [
{"type": "namespace", "name": "fixture_tools", "tools": [{"type": "function", "name": "ping", "description": "again"}]}
]}
]
}`
declarations, forwardMap, reverseMap := BuildGeminiFunctionDeclarations(ojson.Parse(raw))
names := make([]string, 0, len(declarations))
for _, declaration := range declarations {
names = append(names, ojson.GetBytes(declaration, "name").String())
}
if len(names) != 3 || names[0] != "mcp__cua_repl__js" || names[1] != "tool_search" || names[2] != "fixture_tools__ping" {
t.Fatalf("declarations = %v, want [mcp__cua_repl__js tool_search fixture_tools__ping]", names)
}
if got := ojson.GetBytes(declarations[0], "description").String(); got != "current" {
t.Fatalf("top-level declaration must win over a discovered duplicate, got %q", got)
}
if got := forwardMap["fixture_tools__ping"]; got != "fixture_tools__ping" {
t.Fatalf("forward map for discovered tool = %q", got)
}
identity := reverseMap["fixture_tools__ping"]
if identity.Name != "ping" || identity.Namespace != "fixture_tools" {
t.Fatalf("discovered identity = %+v, want ping in fixture_tools", identity)
}
}
func TestResponsesToolReverseIdentityMap_MatchesFullDeclarations(t *testing.T) {
minimalRaw := []byte(`{
"tools": [
{"type": "function", "name": "read.file", "parameters": {"type": "object"}},
{"type": "custom", "name": "apply_patch", "description": "Apply a patch", "format": {"type": "grammar", "syntax": "lark", "definition": "start: /.+/"}},
{"type": "custom", "name": "notes"},
{"type": "tool_search", "execution": "client"},
{"type": "namespace", "name": "mcp", "tools": [{"type": "function", "name": "search", "parameters": {"type": "object"}}]}
]
}`)
heavyRaw := []byte(`{
"tools": [
{"type": "function", "name": "read.file", "parameters": {"$defs": {"item": {"type": "object", "properties": {"id": {"type": "string", "pattern": "^[^\\x00]*$", "examples": ["/tmp/a"]}}}}, "type": "object", "properties": {"path": {"type": "string", "pattern": "^[^\\x00]*$", "examples": ["/tmp/a"]}, "items": {"type": "array", "items": {"$ref": "#/$defs/item"}}, "mode": {"anyOf": [{"type": "string", "const": "fast"}, {"type": "null"}]}}, "additionalProperties": false}},
{"type": "custom", "name": "apply_patch", "description": "Apply a patch", "format": {"type": "grammar", "syntax": "lark", "definition": "start: /.+/"}},
{"type": "custom", "name": "notes"},
{"type": "tool_search", "execution": "client", "parameters": {"type": "object", "properties": {"query": {"type": ["string", "null"], "examples": ["mcp"]}, "limit": {"type": "number", "minimum": 1, "maximum": 50}}, "required": ["query"], "additionalProperties": false}},
{"type": "namespace", "name": "mcp", "tools": [{"type": "function", "name": "search", "parameters": {"$defs": {"filter": {"type": "object", "properties": {"tag": {"type": ["string", "null"]}}}}, "type": "object", "properties": {"q": {"type": ["string", "null"]}, "filter": {"$ref": "#/$defs/filter"}}, "additionalProperties": false}}]}
]
}`)
_, _, full := BuildGeminiFunctionDeclarations(ojson.ParseBytes(heavyRaw))
fast := ResponsesToolReverseIdentityMap(heavyRaw)
if len(full) == 0 || !reflect.DeepEqual(full, fast) {
t.Fatalf("reverse identity map differs from full declarations:\nfull=%#v\nfast=%#v", full, fast)
}
minimalAllocs := testing.AllocsPerRun(10, func() {
_ = ResponsesToolReverseIdentityMap(minimalRaw)
})
heavyAllocs := testing.AllocsPerRun(10, func() {
_ = ResponsesToolReverseIdentityMap(heavyRaw)
})
if heavyAllocs > minimalAllocs+10 {
t.Fatalf("ResponsesToolReverseIdentityMap performed schema cleanup allocations: minimalAllocs=%.1f heavyAllocs=%.1f", minimalAllocs, heavyAllocs)
}
}