fix(codex): expand integer field normalization mappings for tools

- Add integer field paths for memories, history, notes, web, and collaboration tool schemas
- Support nested property paths and union types in parameter schema normalization

Closes: #6255
This commit is contained in:
Luis Pater
2026-10-03 17:01:12 +08:00
parent 2c1dcc7c8b
commit d7914afded
3 changed files with 738 additions and 21 deletions

View File

@@ -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"
]
}
}
]
}
]
}

View File

@@ -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
}

View File

@@ -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
}