diff --git a/internal/client/codex/tool-schema/testdata/history_notes_tools.json b/internal/client/codex/tool-schema/testdata/history_notes_tools.json new file mode 100644 index 000000000..5f47de678 --- /dev/null +++ b/internal/client/codex/tool-schema/testdata/history_notes_tools.json @@ -0,0 +1,412 @@ +{ + "tools": [ + { + "type": "namespace", + "name": "history", + "tools": [ + { + "type": "function", + "name": "list_windows", + "parameters": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of windows to return." + }, + "agent_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Agent whose history to inspect. Omit to use the current agent; otherwise pass an absolute agent name or a name relative to the current agent." + }, + "recent_first": { + "type": "boolean", + "description": "Whether to return the most recently created windows first." + } + } + } + } + ] + }, + { + "type": "namespace", + "name": "history", + "tools": [ + { + "type": "function", + "name": "list_items", + "parameters": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of items to return." + }, + "recent_first": { + "type": "boolean", + "description": "Whether to return the most recently created items first." + }, + "tool_namespace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Callable namespace to include. When set, non-tool messages are excluded." + }, + "role": { + "anyOf": [ + { + "type": "string", + "enum": [ + "user", + "assistant", + "tool", + "system", + "developer" + ] + }, + { + "type": "null" + } + ], + "description": "Message role to include. Null or omission includes all roles." + }, + "agent_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Agent whose history to inspect. Omit to use the current agent; otherwise pass an absolute agent name or a name relative to the current agent." + }, + "tool_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Callable tool name to include. When set, non-tool messages are excluded." + }, + "window_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Full window ID. Null or omission includes all windows." + }, + "max_chars_per_item": { + "type": "integer", + "minimum": 1, + "description": "Maximum characters returned in each item's truncated_content." + } + } + } + } + ] + }, + { + "type": "namespace", + "name": "history", + "tools": [ + { + "type": "function", + "name": "read_item", + "parameters": { + "type": "object", + "properties": { + "agent_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Agent whose history to inspect. Omit to use the current agent; otherwise pass an absolute agent name or a name relative to the current agent." + }, + "item_id": { + "type": "string", + "description": "The short item ID is the suffix shown in the target item's trailing `[id: ...]` marker, printed after that item's content." + }, + "offset_chars": { + "type": "integer", + "minimum": 0, + "description": "Zero-based character offset at which reading starts." + }, + "limit_chars": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of characters to return." + }, + "window_id": { + "type": "string", + "description": "Full window ID containing the item." + } + }, + "required": [ + "item_id", + "window_id" + ] + } + } + ] + }, + { + "type": "namespace", + "name": "history", + "tools": [ + { + "type": "function", + "name": "search_contents", + "parameters": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of matching items to return." + }, + "query": { + "type": "string", + "encrypted": true, + "description": "Case-sensitive literal substring to find in item content." + }, + "recent_first": { + "type": "boolean", + "description": "Whether to return the most recently created matches first." + }, + "tool_namespace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Callable namespace to include. When set, non-tool messages are excluded." + }, + "role": { + "anyOf": [ + { + "type": "string", + "enum": [ + "user", + "assistant", + "tool", + "system", + "developer" + ] + }, + { + "type": "null" + } + ], + "description": "Message role to include. Null or omission includes all roles." + }, + "agent_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Agent whose history to inspect. Omit to use the current agent; otherwise pass an absolute agent name or a name relative to the current agent." + }, + "tool_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Callable tool name to include. When set, non-tool messages are excluded." + }, + "window_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Full window ID. Null or omission includes all windows." + } + }, + "required": [ + "query" + ] + } + } + ] + }, + { + "type": "namespace", + "name": "notes", + "tools": [ + { + "type": "function", + "name": "list_files_by_prefix", + "parameters": { + "type": "object", + "properties": { + "prefix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Note path prefix to list." + }, + "max_results": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of files to return." + }, + "file_order_by": { + "type": "string", + "enum": [ + "name", + "created_at", + "updated_at" + ], + "description": "Field used to order files." + }, + "file_order": { + "type": "string", + "enum": [ + "ascending", + "descending" + ], + "description": "Direction used to order files." + } + } + } + } + ] + }, + { + "type": "namespace", + "name": "notes", + "tools": [ + { + "type": "function", + "name": "read_file", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Note file path to read." + }, + "start_line": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "First line to return, inclusive and 1-based. Negative values count backward from the final line." + }, + "stop_line": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Last line to return, inclusive and 1-based. Negative values count backward from the final line." + } + }, + "required": [ + "path" + ] + } + } + ] + }, + { + "type": "namespace", + "name": "notes", + "tools": [ + { + "type": "function", + "name": "search_contents", + "parameters": { + "type": "object", + "properties": { + "max_matches_per_file": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of matching lines returned per file." + }, + "query": { + "type": "string", + "encrypted": true, + "description": "Case-sensitive literal substring to find in note lines." + }, + "recent_file_first": { + "type": "boolean", + "description": "Whether to order matching files by creation time, newest first." + }, + "max_files": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of matching files returned." + }, + "path_prefix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Note path prefix to search." + } + }, + "required": [ + "query" + ] + } + } + ] + } + ] +} diff --git a/internal/client/codex/tool-schema/tool_schema.go b/internal/client/codex/tool-schema/tool_schema.go index 3c240a10d..21e0a477b 100644 --- a/internal/client/codex/tool-schema/tool_schema.go +++ b/internal/client/codex/tool-schema/tool_schema.go @@ -35,6 +35,7 @@ func headerValueCaseInsensitive(headers http.Header, name string) string { return "" } +// Keys are explicit schema paths relative to parameters.properties, not recursive field names. var codexClientToolIntegerFields = map[string]map[string]struct{}{ "exec_command": { "yield_time_ms": struct{}{}, @@ -60,10 +61,85 @@ var codexClientToolIntegerFields = map[string]map[string]struct{}{ "limit": struct{}{}, }, "test_sync_tool": { - "sleep_before_ms": struct{}{}, - "sleep_after_ms": struct{}{}, - "participants": struct{}{}, - "timeout_ms": struct{}{}, + "sleep_before_ms": struct{}{}, + "sleep_after_ms": struct{}{}, + "participants": struct{}{}, + "timeout_ms": struct{}{}, + "barrier.properties.participants": struct{}{}, + "barrier.properties.timeout_ms": struct{}{}, + }, + "create_goal": { + "token_budget": struct{}{}, + }, + "get_channels": { + "limit": struct{}{}, + }, + "list_threads": { + "limit": struct{}{}, + "max_chars_per_post": struct{}{}, + }, + "search_posts": { + "limit": struct{}{}, + "max_chars_per_post": struct{}{}, + }, + "read_thread": { + "limit": struct{}{}, + "max_chars_per_post": struct{}{}, + }, + "read_post": { + "offset_chars": struct{}{}, + "limit_chars": struct{}{}, + }, + "memories__list": { + "max_results": struct{}{}, + }, + "memories__read": { + "line_offset": struct{}{}, + "max_lines": struct{}{}, + }, + "memories__search": { + "context_lines": struct{}{}, + "max_results": struct{}{}, + }, + "history__list_windows": { + "limit": struct{}{}, + }, + "history__list_items": { + "limit": struct{}{}, + "max_chars_per_item": struct{}{}, + }, + "history__read_item": { + "offset_chars": struct{}{}, + "limit_chars": struct{}{}, + }, + "history__search_contents": { + "limit": struct{}{}, + }, + "notes__list_files_by_prefix": { + "max_results": struct{}{}, + }, + "notes__read_file": { + "start_line": struct{}{}, + "stop_line": struct{}{}, + // Codex declares signed line numbers in the first nullable union branch. + "start_line.anyOf.0": struct{}{}, + "stop_line.anyOf.0": struct{}{}, + }, + "notes__search_contents": { + "max_matches_per_file": struct{}{}, + "max_files": struct{}{}, + }, + "image_gen__imagegen": { + "num_last_images_to_include": struct{}{}, + }, + "web__run": { + "search_query.items.properties.recency": struct{}{}, + "image_query.items.properties.recency": struct{}{}, + "open.items.properties.lineno": struct{}{}, + "click.items.properties.id": struct{}{}, + "screenshot.items.properties.pageno": struct{}{}, + "weather.items.properties.duration": struct{}{}, + "sports.items.properties.num_games": struct{}{}, }, } @@ -74,6 +150,13 @@ func matchCodexTargetTool(toolName string) map[string]struct{} { } else if strings.HasPrefix(baseName, "collab__") { baseName = strings.TrimPrefix(baseName, "collab__") } + switch baseName { + case "multi_agent_v1__wait_agent", "collaboration__wait_agent": + baseName = "wait_agent" + case "collaboration__get_channels", "collaboration__list_threads", "collaboration__search_posts", + "collaboration__read_thread", "collaboration__read_post": + baseName = strings.TrimPrefix(baseName, "collaboration__") + } return codexClientToolIntegerFields[baseName] } @@ -96,9 +179,9 @@ func normalizeCodexToolFieldTypes(rawParams []byte, targetFields map[string]stru if !typeVal.Exists() { continue } - escapedKey := escapeCodexSjsonKey(fieldName) + typePath := "properties." + fieldName + ".type" if typeVal.Type == gjson.String && typeVal.String() == "number" { - if updated, errSet := sjson.SetBytes(rawParams, "properties."+escapedKey+".type", "integer"); errSet == nil { + if updated, errSet := sjson.SetBytes(rawParams, typePath, "integer"); errSet == nil { rawParams = updated changed = true } @@ -119,7 +202,7 @@ func normalizeCodexToolFieldTypes(rawParams []byte, targetFields map[string]stru } } if hasNumber { - if updated, errSet := sjson.SetBytes(rawParams, "properties."+escapedKey+".type", newTypes); errSet == nil { + if updated, errSet := sjson.SetBytes(rawParams, typePath, newTypes); errSet == nil { rawParams = updated changed = true } @@ -142,7 +225,7 @@ func NormalizeCodexToolIntegerTypes(body []byte, headers http.Header) []byte { // 1. Process top-level tools toolsResult := gjson.GetBytes(body, "tools") if toolsResult.IsArray() { - if updated, ok := normalizeToolIntegerTypesInArray(toolsResult); ok { + if updated, ok := normalizeToolIntegerTypesInArray(toolsResult, ""); ok { if out, errSet := sjson.SetRawBytes(body, "tools", updated); errSet == nil { body = out changed = true @@ -157,7 +240,7 @@ func NormalizeCodexToolIntegerTypes(body []byte, headers http.Header) []byte { if item.Get("type").String() == "additional_tools" { addTools := item.Get("tools") if addTools.IsArray() { - if updated, ok := normalizeToolIntegerTypesInArray(addTools); ok { + if updated, ok := normalizeToolIntegerTypesInArray(addTools, ""); ok { path := fmt.Sprintf("input.%d.tools", idx) if out, errSet := sjson.SetRawBytes(body, path, updated); errSet == nil { body = out @@ -175,14 +258,14 @@ func NormalizeCodexToolIntegerTypes(body []byte, headers http.Header) []byte { return body } -func normalizeToolIntegerTypesInArray(tools gjson.Result) ([]byte, bool) { +func normalizeToolIntegerTypesInArray(tools gjson.Result, namespace string) ([]byte, bool) { if !tools.IsArray() { return nil, false } var out []byte offset := 0 tools.ForEach(func(_, tool gjson.Result) bool { - updated, changed := normalizeToolIntegerTypesInElement(tool) + updated, changed := normalizeToolIntegerTypesInElement(tool, namespace) if !changed { return true } @@ -201,15 +284,22 @@ func normalizeToolIntegerTypesInArray(tools gjson.Result) ([]byte, bool) { return append(out, tools.Raw[offset:]...), true } -func normalizeToolIntegerTypesInElement(tool gjson.Result) ([]byte, bool) { +func normalizeToolIntegerTypesInElement(tool gjson.Result, namespace string) ([]byte, bool) { toolRaw := []byte(tool.Raw) changed := false // Handle namespace tools if tool.Get("type").String() == "namespace" { + if namespace != "" { + return nil, false + } + namespace = tool.Get("name").String() + if namespace == "" { + return nil, false + } nested := tool.Get("tools") if nested.IsArray() { - if updated, ok := normalizeToolIntegerTypesInArray(nested); ok { + if updated, ok := normalizeToolIntegerTypesInArray(nested, namespace); ok { if out, errSet := sjson.SetRawBytes(toolRaw, "tools", updated); errSet == nil { toolRaw = out changed = true @@ -223,7 +313,7 @@ func normalizeToolIntegerTypesInElement(tool gjson.Result) ([]byte, bool) { for _, declKey := range []string{"function_declarations", "functionDeclarations"} { decls := tool.Get(declKey) if decls.IsArray() { - if updated, ok := normalizeToolIntegerTypesInArray(decls); ok { + if updated, ok := normalizeToolIntegerTypesInArray(decls, namespace); ok { if out, errSet := sjson.SetRawBytes(toolRaw, declKey, updated); errSet == nil { toolRaw = out changed = true @@ -256,6 +346,9 @@ func normalizeToolIntegerTypesInElement(tool gjson.Result) ([]byte, bool) { } } + if namespace != "" { + toolName = namespace + "__" + toolName + } targetFields := matchCodexTargetTool(toolName) if len(targetFields) == 0 { return nil, false @@ -272,10 +365,3 @@ func normalizeToolIntegerTypesInElement(tool gjson.Result) ([]byte, bool) { } return updatedTool, true } - -func escapeCodexSjsonKey(key string) string { - key = strings.ReplaceAll(key, `\`, `\\`) - key = strings.ReplaceAll(key, `.`, `\.`) - key = strings.ReplaceAll(key, `:`, `\:`) - return key -} diff --git a/internal/client/codex/tool-schema/tool_schema_integer_fields_test.go b/internal/client/codex/tool-schema/tool_schema_integer_fields_test.go new file mode 100644 index 000000000..580e4813b --- /dev/null +++ b/internal/client/codex/tool-schema/tool_schema_integer_fields_test.go @@ -0,0 +1,219 @@ +package toolschema + +import ( + "bytes" + "fmt" + "net/http" + "os" + "strings" + "testing" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +func TestNormalizeCodexToolIntegerTypesSourceFields(t *testing.T) { + // Paths follow the input schemas and Deserialize types in codex-rs core and ext. + tests := []struct { + name string + fields []string + }{ + {"test_sync_tool", []string{"barrier.properties.participants", "barrier.properties.timeout_ms"}}, + {"create_goal", []string{"token_budget"}}, + {"get_channels", []string{"limit"}}, + {"list_threads", []string{"limit", "max_chars_per_post"}}, + {"search_posts", []string{"limit", "max_chars_per_post"}}, + {"read_thread", []string{"limit", "max_chars_per_post"}}, + {"read_post", []string{"offset_chars", "limit_chars"}}, + {"memories__list", []string{"max_results"}}, + {"memories__read", []string{"line_offset", "max_lines"}}, + {"memories__search", []string{"context_lines", "max_results"}}, + {"history__list_windows", []string{"limit"}}, + {"history__list_items", []string{"limit", "max_chars_per_item"}}, + {"history__read_item", []string{"offset_chars", "limit_chars"}}, + {"history__search_contents", []string{"limit"}}, + {"notes__list_files_by_prefix", []string{"max_results"}}, + {"notes__read_file", []string{"start_line", "stop_line", "start_line.anyOf.0", "stop_line.anyOf.0"}}, + {"notes__search_contents", []string{"max_matches_per_file", "max_files"}}, + {"image_gen__imagegen", []string{"num_last_images_to_include"}}, + {"web__run", []string{ + "search_query.items.properties.recency", "image_query.items.properties.recency", + "open.items.properties.lineno", "click.items.properties.id", + "screenshot.items.properties.pageno", "weather.items.properties.duration", + "sports.items.properties.num_games", + }}, + {"collaboration__wait_agent", []string{"timeout_ms"}}, + {"multi_agent_v1__wait_agent", []string{"timeout_ms"}}, + {"functions__create_goal", []string{"token_budget"}}, + {"collab__read_post", []string{"offset_chars", "limit_chars"}}, + {"collaboration__get_channels", []string{"limit"}}, + {"collaboration__list_threads", []string{"limit", "max_chars_per_post"}}, + {"collaboration__search_posts", []string{"limit", "max_chars_per_post"}}, + {"collaboration__read_thread", []string{"limit", "max_chars_per_post"}}, + {"collaboration__read_post", []string{"offset_chars", "limit_chars"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, typeJSON := range []string{`"number"`, `["number","integer","null"]`, `"integer"`, `"string"`} { + input := []byte(fmt.Sprintf(`{"tools":[{"type":"function","name":%q,"parameters":{"type":"object","properties":{"unrelated":{"type":"number","default":1.5}}},"output_schema":{"properties":{"wall_time_seconds":{"type":"number"}}}}],"input":[{"type":"function_call","arguments":"{\"limit\":1.5}"}]}`, tt.name)) + for _, field := range tt.fields { + input = setIntegerTestRaw(t, input, "tools.0.parameters.properties."+field, `{"type":`+typeJSON+`,"description":"Keep metadata","minimum":0,"default":1}`) + } + for _, ua := range []string{"codex-tui/0.154.0", "curl/8.7.1", ""} { + var headers http.Header + if ua != "" { + headers = http.Header{"user-agent": []string{ua}} + } + want := bytes.Clone(input) + if ua == "codex-tui/0.154.0" { + wantType := typeJSON + if typeJSON == `"number"` { + wantType = `"integer"` + } else if typeJSON == `["number","integer","null"]` { + wantType = `["integer","null"]` + } + for _, field := range tt.fields { + want = setIntegerTestRaw(t, want, "tools.0.parameters.properties."+field+".type", wantType) + } + } + out := NormalizeCodexToolIntegerTypes(input, headers) + if !bytes.Equal(out, want) { + t.Fatalf("UA %q type %s: got %s, want %s", ua, typeJSON, out, want) + } + if again := NormalizeCodexToolIntegerTypes(out, headers); !bytes.Equal(again, out) { + t.Fatal("normalization is not idempotent") + } + } + } + }) + } +} + +func TestNormalizeCodexToolIntegerTypesNamespaceFormats(t *testing.T) { + schema := `{"type":"object","properties":{"line_offset":{"type":"number"},"max_lines":{"type":["number","null"]},"max_tokens":{"type":"number"}}}` + tests := []struct { + name string + body string + path string + }{ + {"responses", `{"tools":[{"type":"function","name":"memories__read","parameters":%s}]}`, "tools.0.parameters"}, + {"chat", `{"tools":[{"type":"function","function":{"name":"memories__read","parameters":%s}}]}`, "tools.0.function.parameters"}, + {"claude", `{"tools":[{"name":"memories__read","input_schema":%s}]}`, "tools.0.input_schema"}, + {"gemini", `{"tools":[{"function_declarations":[{"name":"memories__read","parameters":%s}]}]}`, "tools.0.function_declarations.0.parameters"}, + {"gemini JSON schema", `{"tools":[{"functionDeclarations":[{"name":"memories__read","parametersJsonSchema":%s}]}]}`, "tools.0.functionDeclarations.0.parametersJsonSchema"}, + {"namespace", `{"tools":[{"type":"namespace","name":"memories","tools":[{"type":"function","name":"read","parameters":%s}]}]}`, "tools.0.tools.0.parameters"}, + {"additional namespace", `{"input":[{"type":"additional_tools","tools":[{"type":"namespace","name":"memories","tools":[{"type":"function","name":"read","parameters":%s}]}]}]}`, "input.0.tools.0.tools.0.parameters"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input := []byte(fmt.Sprintf(tt.body, schema)) + want := setIntegerTestRaw(t, input, tt.path+".properties.line_offset.type", `"integer"`) + want = setIntegerTestRaw(t, want, tt.path+".properties.max_lines.type", `["integer","null"]`) + out := NormalizeCodexToolIntegerTypes(input, http.Header{"User-Agent": []string{"Codex/1.0"}}) + if !bytes.Equal(out, want) { + t.Fatalf("got %s, want %s", out, want) + } + }) + } +} + +func TestNormalizeCodexToolIntegerTypesPreservesUnprovenFields(t *testing.T) { + tests := []struct { + name string + namespace string + }{ + {"unknown_tool", ""}, {"mcp__server__read_post", ""}, + {"read", ""}, {"run", ""}, {"imagegen", ""}, + {"read_post", "user_tools"}, {"wait_agent", "mcp__server"}, + {"read", "skills"}, {"read", "user_tools"}, + {"read_post", "multi_agent_v1"}, {"read_post", "arbitrary_collaboration"}, + } + for _, tt := range tests { + t.Run(tt.namespace+"/"+tt.name, func(t *testing.T) { + tool := fmt.Sprintf(`{"type":"function","name":%q,"parameters":{"properties":{"limit":{"type":"number"},"offset_chars":{"type":"number"},"line_offset":{"type":"number"},"timeout_ms":{"type":"number"}}}}`, tt.name) + if tt.namespace != "" { + tool = fmt.Sprintf(`{"type":"namespace","name":%q,"tools":[%s]}`, tt.namespace, tool) + } + input := []byte(`{"tools":[` + tool + `]}`) + out := NormalizeCodexToolIntegerTypes(input, http.Header{"User-Agent": []string{"codex"}}) + if !bytes.Equal(out, input) { + t.Fatalf("unknown tool changed: %s", out) + } + }) + } + input := []byte(`{"tools":[{"name":"test_sync_tool","parameters":{"properties":{"barrier.participants":{"type":"number"},"other":{"properties":{"participants":{"type":"number"}}},"barrier":{"properties":{"participants":{"type":"number"},"ratio":{"type":"number"}}}}}}]}`) + out := NormalizeCodexToolIntegerTypes(input, http.Header{"User-Agent": []string{"codex"}}) + want := setIntegerTestRaw(t, input, "tools.0.parameters.properties.barrier.properties.participants.type", `"integer"`) + if !bytes.Equal(out, want) || !gjson.ValidBytes(out) { + t.Fatalf("nested path changed unrelated fields: %s", out) + } +} + +func TestNormalizeCodexToolIntegerTypesHistoryNotesSchemas(t *testing.T) { + // Parameter schemas copied from codex-rs/ext/history-notes/src/tools.rs:143-218. + fixture, errRead := os.ReadFile("testdata/history_notes_tools.json") + if errRead != nil { + t.Fatal(errRead) + } + if !gjson.ValidBytes(fixture) { + t.Fatal("invalid source schema fixture") + } + for _, namespace := range gjson.GetBytes(fixture, "tools").Array() { + tool := namespace.Get("tools.0") + name := namespace.Get("name").String() + "__" + tool.Get("name").String() + t.Run(name, func(t *testing.T) { + formats := []struct { + name string + body string + }{ + {"namespace", `{"tools":[` + namespace.Raw + `]}`}, + {"flat", fmt.Sprintf(`{"tools":[{"name":%q,"parameters":%s}]}`, name, tool.Get("parameters").Raw)}, + {"additional", `{"input":[{"type":"additional_tools","tools":[` + namespace.Raw + `]}]}`}, + } + for _, format := range formats { + t.Run(format.name, func(t *testing.T) { + // Simulate integer declarations degraded to number, retaining the real schema shape. + input := []byte(strings.ReplaceAll(format.body, `"type": "integer"`, `"type": "number"`)) + for _, ua := range []string{"codex", "curl", ""} { + want := input + if ua == "codex" { + want = []byte(format.body) + } + out := NormalizeCodexToolIntegerTypes(input, http.Header{"User-Agent": []string{ua}}) + if !bytes.Equal(out, want) { + t.Fatalf("UA %q: got %s, want %s", ua, out, want) + } + if again := NormalizeCodexToolIntegerTypes(out, http.Header{"User-Agent": []string{ua}}); !bytes.Equal(again, out) { + t.Fatal("source schema is not stable") + } + } + }) + } + for _, unknownName := range []string{tool.Get("name").String(), "mcp__server__" + name, "user__" + name} { + input := []byte(fmt.Sprintf(`{"tools":[{"name":%q,"parameters":%s}]}`, unknownName, strings.ReplaceAll(tool.Get("parameters").Raw, `"type": "integer"`, `"type": "number"`))) + if out := NormalizeCodexToolIntegerTypes(input, http.Header{"User-Agent": []string{"codex"}}); !bytes.Equal(out, input) { + t.Fatalf("unknown tool %q changed: %s", unknownName, out) + } + } + }) + } +} + +func TestNormalizeCodexToolIntegerTypesNotesExplicitUnionPaths(t *testing.T) { + input := []byte(`{"tools":[{"name":"notes__read_file","parameters":{"type":"object","properties":{"start_line":{"anyOf":[{"type":"number"},{"type":"null"}],"default":-3},"stop_line":{"anyOf":[{"type":"number"},{"type":"number"}],"default":-1},"ratio":{"anyOf":[{"type":"number"},{"type":"null"}]},"other":{"properties":{"start_line":{"anyOf":[{"type":"number"}]}}}},"required":["path"]}}],"input":[{"type":"function_call","arguments":"{\"start_line\":-3,\"stop_line\":-1}"}]}`) + want := setIntegerTestRaw(t, input, "tools.0.parameters.properties.start_line.anyOf.0.type", `"integer"`) + want = setIntegerTestRaw(t, want, "tools.0.parameters.properties.stop_line.anyOf.0.type", `"integer"`) + out := NormalizeCodexToolIntegerTypes(input, http.Header{"User-Agent": []string{"codex"}}) + if !bytes.Equal(out, want) { + t.Fatalf("explicit union paths changed unrelated branches or signed values: %s", out) + } +} + +func setIntegerTestRaw(t *testing.T, body []byte, path, value string) []byte { + t.Helper() + out, errSet := sjson.SetRawBytes(body, path, []byte(value)) + if errSet != nil { + t.Fatal(errSet) + } + return out +}