Files
CLIProxyAPI/internal/client/codex/tool-schema/tool_schema_integer_fields_test.go
Luis Pater d7914afded 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
2026-10-03 17:01:12 +08:00

220 lines
11 KiB
Go

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
}