mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-19 05:56:43 +08:00
- Handle apply_patch custom tool call streaming deltas and snapshots for Responses API translation - Track and resolve step indexes, function call identities, and error states across stream events - Propagate apply_patch tool input errors and upstream failure states in stream and non-stream responses
339 lines
15 KiB
Go
339 lines
15 KiB
Go
package applypatch
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
func TestApplyPatchToolContract(t *testing.T) {
|
|
tool := gjson.Parse(`{"type":"custom","name":"apply_patch","description":"This is a FREEFORM tool, so do not wrap the patch in JSON.","format":{"type":"grammar","syntax":"lark","definition":"start: begin_patch hunk+ end_patch"}}`)
|
|
if !IsCustomTool(tool) {
|
|
t.Fatal("custom apply_patch was not recognized")
|
|
}
|
|
params := gjson.ParseBytes(Parameters())
|
|
if params.Get("properties.input.type").String() != "string" || params.Get("additionalProperties").Bool() {
|
|
t.Fatalf("invalid schema: %s", Parameters())
|
|
}
|
|
description := Description(tool)
|
|
for _, token := range []string{"*** Begin Patch", "*** End Patch", "*** Add File:", "*** Delete File:", "*** Update File:", "@@", "input", "start: begin_patch hunk+ end_patch"} {
|
|
if !strings.Contains(description, token) {
|
|
t.Fatalf("missing format instruction %q", token)
|
|
}
|
|
}
|
|
if strings.Contains(description, "do not wrap the patch in JSON") {
|
|
t.Fatal("freeform wrapper instruction leaked")
|
|
}
|
|
patch := "*** Begin Patch\n*** Add File: a.txt\n+hello\n*** End Patch\n"
|
|
got, errUnwrap := UnwrapInput(WrapInput(patch))
|
|
if errUnwrap != nil || got != patch {
|
|
t.Fatalf("round trip: %q, %v", got, errUnwrap)
|
|
}
|
|
}
|
|
|
|
func TestApplyPatchToolRecognition(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
tool string
|
|
want bool
|
|
}{
|
|
{name: "custom", tool: `{"type":"custom","name":"apply_patch"}`, want: true},
|
|
{name: "trimmed_name", tool: `{"type":"custom","name":" \tapply_patch\n"}`, want: true},
|
|
{name: "ordinary_function", tool: `{"type":"function","name":"apply_patch"}`},
|
|
{name: "nested_function", tool: `{"type":"function","function":{"name":"apply_patch"}}`},
|
|
{name: "other_custom_tool", tool: `{"type":"custom","name":"shell"}`},
|
|
{name: "case_sensitive_name", tool: `{"type":"custom","name":"APPLY_PATCH"}`},
|
|
{name: "qualified_name", tool: `{"type":"custom","name":"functions.apply_patch"}`},
|
|
{name: "exact_type", tool: `{"type":" custom ","name":"apply_patch"}`},
|
|
{name: "missing_type", tool: `{"name":"apply_patch"}`},
|
|
{name: "missing_name", tool: `{"type":"custom"}`},
|
|
{name: "non_string_type", tool: `{"type":true,"name":"apply_patch"}`},
|
|
{name: "non_string_name", tool: `{"type":"custom","name":42}`},
|
|
{name: "null_name", tool: `{"type":"custom","name":null}`},
|
|
{name: "empty_object", tool: `{}`},
|
|
{name: "missing_tool", tool: ``},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
if got := IsCustomTool(gjson.Parse(test.tool)); got != test.want {
|
|
t.Fatalf("IsCustomTool(%s) = %v, want %v", test.tool, got, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestApplyPatchToolParameters(t *testing.T) {
|
|
parameters := Parameters()
|
|
if !gjson.ValidBytes(parameters) {
|
|
t.Fatalf("invalid JSON schema: %s", parameters)
|
|
}
|
|
schema := gjson.ParseBytes(parameters)
|
|
if schema.Get("type").String() != "object" {
|
|
t.Fatalf("schema must describe an object: %s", parameters)
|
|
}
|
|
properties := schema.Get("properties").Map()
|
|
if len(properties) != 1 || properties["input"].Get("type").String() != "string" {
|
|
t.Fatalf("schema must expose only a string input: %s", parameters)
|
|
}
|
|
if properties["input"].Get("description").String() != "The complete apply_patch patch text." {
|
|
t.Fatalf("schema does not describe the complete patch input: %s", parameters)
|
|
}
|
|
required := schema.Get("required").Array()
|
|
if len(required) != 1 || required[0].String() != "input" {
|
|
t.Fatalf("input must be required: %s", parameters)
|
|
}
|
|
additional := schema.Get("additionalProperties")
|
|
if additional.Type != gjson.False {
|
|
t.Fatalf("additional properties must be explicitly forbidden: %s", parameters)
|
|
}
|
|
}
|
|
|
|
func TestApplyPatchToolParametersAreIndependent(t *testing.T) {
|
|
first := Parameters()
|
|
second := Parameters()
|
|
first[0] = '['
|
|
if second[0] != '{' || !gjson.ValidBytes(second) {
|
|
t.Fatalf("mutating one schema changed another: %s", second)
|
|
}
|
|
third := Parameters()
|
|
if third[0] != '{' || !gjson.ValidBytes(third) {
|
|
t.Fatalf("mutating one schema changed later calls: %s", third)
|
|
}
|
|
}
|
|
|
|
func TestApplyPatchToolDescriptionRules(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
tool string
|
|
}{
|
|
{name: "no_description_or_grammar", tool: `{"type":"custom","name":"apply_patch"}`},
|
|
{name: "empty_description_and_grammar", tool: `{"description":"","format":{"definition":""}}`},
|
|
{name: "format_without_definition", tool: `{"description":"Edit files.","format":{"type":"grammar","syntax":"lark"}}`},
|
|
{name: "grammar_without_environment", tool: `{"format":{"definition":"start: begin_patch hunk+ end_patch"}}`},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
description := Description(gjson.Parse(test.tool))
|
|
for _, instruction := range []string{
|
|
"Call this function with a JSON object whose input field contains the complete patch text.",
|
|
"Use the Codex apply_patch format, not a conventional git unified diff.",
|
|
"Start with *** Begin Patch and end with *** End Patch.",
|
|
"Use *** Add File: path, *** Delete File: path, or *** Update File: path.",
|
|
"Every added-file content line starts with +.",
|
|
"For updates, use @@; context lines start with one space, removed lines with -, and added lines with +.",
|
|
"Use *** Move to: path for a rename and *** End of File when required by the patch grammar.",
|
|
"Example input:\n*** Begin Patch\n*** Update File: src/main.go\n@@\n-old\n+new\n*** End Patch",
|
|
} {
|
|
if !strings.Contains(description, instruction) {
|
|
t.Errorf("missing instruction %q in %q", instruction, description)
|
|
}
|
|
}
|
|
if strings.Contains(description, "*** Environment ID:") {
|
|
t.Fatalf("environment syntax invented without an environment grammar: %q", description)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestApplyPatchToolDescriptionPreservesNonWrapperInstructions(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
tool string
|
|
retained []string
|
|
}{
|
|
{
|
|
name: "non_wrapper_description",
|
|
tool: `{"description":"Use apply_patch to edit files.\n Keep context lines intact.\nDo not run the patch on the proxy."}`,
|
|
retained: []string{"Use apply_patch to edit files.\n Keep context lines intact.\nDo not run the patch on the proxy."},
|
|
},
|
|
{
|
|
name: "wrapper_between_instructions",
|
|
tool: `{"description":"Edit files only. This is a FREEFORM tool, so do not wrap the patch in JSON. Preserve line endings.\n Keep indentation."}`,
|
|
retained: []string{"Edit files only.", "Preserve line endings.\n Keep indentation."},
|
|
},
|
|
{
|
|
name: "wrapper_only",
|
|
tool: `{"description":"This is a FREEFORM tool, so do not wrap the patch in JSON."}`,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
tool := gjson.Parse(test.tool)
|
|
description := Description(tool)
|
|
if strings.Contains(description, "This is a FREEFORM tool") || strings.Contains(description, "do not wrap the patch in JSON") {
|
|
t.Fatalf("freeform wrapper instruction leaked: %q", description)
|
|
}
|
|
for _, retained := range test.retained {
|
|
if !strings.Contains(description, retained) {
|
|
t.Errorf("lost non-wrapper instruction %q in %q", retained, description)
|
|
}
|
|
}
|
|
if tool.Raw != test.tool {
|
|
t.Fatalf("description generation mutated the original tool: %s", tool.Raw)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestApplyPatchToolDescriptionPreservesGrammar(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
tool string
|
|
wantGrammar string
|
|
}{
|
|
{
|
|
name: "whitespace_and_line_endings",
|
|
tool: `{"format":{"definition":" \nstart: begin_patch hunk+ end_patch\r\n begin_patch: \"*** Begin Patch\"\n\n"}}`,
|
|
wantGrammar: " \nstart: begin_patch hunk+ end_patch\r\n begin_patch: \"*** Begin Patch\"\n\n",
|
|
},
|
|
{
|
|
name: "wrapper_text_inside_grammar_is_not_rewritten",
|
|
tool: `{"description":"This is a FREEFORM tool, so do not wrap the patch in JSON.","format":{"definition":"// This is a FREEFORM tool, so do not wrap the patch in JSON.\nstart: begin_patch hunk+ end_patch"}}`,
|
|
wantGrammar: "// This is a FREEFORM tool, so do not wrap the patch in JSON.\nstart: begin_patch hunk+ end_patch",
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
description := Description(gjson.Parse(test.tool))
|
|
if !strings.HasSuffix(description, test.wantGrammar) {
|
|
t.Fatalf("original grammar was not appended verbatim: %q", description)
|
|
}
|
|
beforeGrammar := strings.TrimSuffix(description, test.wantGrammar)
|
|
if strings.Contains(beforeGrammar, "do not wrap the patch in JSON") {
|
|
t.Fatalf("wrapper text leaked outside the original grammar: %q", beforeGrammar)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestApplyPatchToolDescriptionEnvironmentGrammar(t *testing.T) {
|
|
tool := gjson.Parse(`{"format":{"definition":"start: begin_patch environment_id? hunk+ end_patch\nenvironment_id: \"*** Environment ID:\" /[^\\n]+/\n"}}`)
|
|
description := Description(tool)
|
|
grammar := "start: begin_patch environment_id? hunk+ end_patch\nenvironment_id: \"*** Environment ID:\" /[^\\n]+/\n"
|
|
if !strings.HasSuffix(description, grammar) {
|
|
t.Fatalf("environment grammar changed: %q", description)
|
|
}
|
|
instructions := strings.TrimSuffix(description, grammar)
|
|
if !strings.Contains(instructions, "*** Environment ID:") {
|
|
t.Fatalf("environment syntax was not explained before its grammar: %q", instructions)
|
|
}
|
|
}
|
|
|
|
func TestApplyPatchToolInputEncoding(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
wantJSON string
|
|
wantEscaped string
|
|
}{
|
|
{name: "empty", input: "", wantJSON: `{"input":""}`, wantEscaped: ``},
|
|
{name: "plain", input: "patch", wantJSON: `{"input":"patch"}`, wantEscaped: `patch`},
|
|
{name: "newlines_and_leading_spaces", input: " context\n+added\r\n\tline\n ", wantJSON: `{"input":" context\n+added\r\n\tline\n "}`, wantEscaped: ` context\n+added\r\n\tline\n `},
|
|
{name: "quotes_and_backslashes", input: "\"C:\\file\"", wantJSON: `{"input":"\"C:\\file\""}`, wantEscaped: `\"C:\\file\"`},
|
|
{name: "control_characters", input: "\x00\b\f\r\t", wantJSON: `{"input":"\u0000\b\f\r\t"}`, wantEscaped: `\u0000\b\f\r\t`},
|
|
{name: "unicode", input: "补丁🙂", wantJSON: `{"input":"补丁🙂"}`, wantEscaped: `补丁🙂`},
|
|
{name: "json_sensitive_characters", input: "<>&\u2028\u2029", wantJSON: `{"input":"\u003c\u003e\u0026\u2028\u2029"}`, wantEscaped: `\u003c\u003e\u0026\u2028\u2029`},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
if got := WrapInput(test.input); got != test.wantJSON {
|
|
t.Errorf("WrapInput(%q) = %q, want %q", test.input, got, test.wantJSON)
|
|
}
|
|
if got := EscapeInputFragment(test.input); got != test.wantEscaped {
|
|
t.Errorf("EscapeInputFragment(%q) = %q, want %q", test.input, got, test.wantEscaped)
|
|
}
|
|
got, errUnwrap := UnwrapInput(WrapInput(test.input))
|
|
if errUnwrap != nil || got != test.input {
|
|
t.Fatalf("round trip = %q, %v, want %q", got, errUnwrap, test.input)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestApplyPatchToolEscapeInputFragmentsCompose(t *testing.T) {
|
|
fragments := []string{"*** Begin Patch\n", " context\n", "-\"old\"\r\n", "+C:\\new\n", "+补丁🙂\n", "*** End Patch\n"}
|
|
var encoded strings.Builder
|
|
for _, fragment := range fragments {
|
|
encoded.WriteString(EscapeInputFragment(fragment))
|
|
}
|
|
var decoded struct {
|
|
Input string `json:"input"`
|
|
}
|
|
if errUnmarshal := json.Unmarshal([]byte(`{"input":"`+encoded.String()+`"}`), &decoded); errUnmarshal != nil {
|
|
t.Fatalf("escaped fragments did not form valid JSON: %v", errUnmarshal)
|
|
}
|
|
want := "*** Begin Patch\n context\n-\"old\"\r\n+C:\\new\n+补丁🙂\n*** End Patch\n"
|
|
if decoded.Input != want {
|
|
t.Fatalf("escaped fragments changed patch text: got %q, want %q", decoded.Input, want)
|
|
}
|
|
}
|
|
|
|
func TestApplyPatchToolUnwrapInput(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
arguments string
|
|
want string
|
|
wantError bool
|
|
}{
|
|
{name: "empty_input", arguments: `{"input":""}`, want: ""},
|
|
{name: "plain_input", arguments: `{"input":"patch"}`, want: "patch"},
|
|
{name: "whitespace_in_input", arguments: `{"input":" context\n\tline\r\n\n "}`, want: " context\n\tline\r\n\n "},
|
|
{name: "json_whitespace", arguments: " \n\t{ \"input\" : \"patch\" }\r\n ", want: "patch"},
|
|
{name: "escaped_field_name", arguments: `{"\u0069nput":"patch"}`, want: "patch"},
|
|
{name: "unicode_input", arguments: `{"input":"补丁🙂"}`, want: "补丁🙂"},
|
|
{name: "empty_arguments", arguments: "", wantError: true},
|
|
{name: "whitespace_arguments", arguments: " \r\n\t ", wantError: true},
|
|
{name: "missing_input", arguments: `{}`, wantError: true},
|
|
{name: "wrong_field", arguments: `{"patch":"text"}`, wantError: true},
|
|
{name: "case_sensitive_field", arguments: `{"Input":"text"}`, wantError: true},
|
|
{name: "null_input", arguments: `{"input":null}`, wantError: true},
|
|
{name: "boolean_input", arguments: `{"input":true}`, wantError: true},
|
|
{name: "number_input", arguments: `{"input":1}`, wantError: true},
|
|
{name: "array_input", arguments: `{"input":[]}`, wantError: true},
|
|
{name: "object_input", arguments: `{"input":{}}`, wantError: true},
|
|
{name: "extra_field_after_input", arguments: `{"input":"patch","other":"value"}`, wantError: true},
|
|
{name: "extra_field_before_input", arguments: `{"other":"value","input":"patch"}`, wantError: true},
|
|
{name: "duplicate_input", arguments: `{"input":"first","input":"second"}`, wantError: true},
|
|
{name: "duplicate_identical_input", arguments: `{"input":"patch","input":"patch"}`, wantError: true},
|
|
{name: "duplicate_escaped_input", arguments: `{"input":"first","\u0069nput":"second"}`, wantError: true},
|
|
{name: "trailing_object", arguments: `{"input":"patch"}{"input":"second"}`, wantError: true},
|
|
{name: "trailing_array", arguments: `{"input":"patch"} []`, wantError: true},
|
|
{name: "trailing_null", arguments: `{"input":"patch"} null`, wantError: true},
|
|
{name: "trailing_boolean", arguments: `{"input":"patch"} true`, wantError: true},
|
|
{name: "trailing_garbage", arguments: `{"input":"patch"} invalid`, wantError: true},
|
|
{name: "top_level_array", arguments: `["patch"]`, wantError: true},
|
|
{name: "top_level_string", arguments: `"patch"`, wantError: true},
|
|
{name: "top_level_null", arguments: `null`, wantError: true},
|
|
{name: "top_level_number", arguments: `1`, wantError: true},
|
|
{name: "top_level_boolean", arguments: `false`, wantError: true},
|
|
{name: "raw_patch", arguments: "*** Begin Patch\n*** End Patch\n", wantError: true},
|
|
{name: "missing_closing_brace", arguments: `{"input":"patch"`, wantError: true},
|
|
{name: "missing_colon", arguments: `{"input" "patch"}`, wantError: true},
|
|
{name: "missing_value", arguments: `{"input":}`, wantError: true},
|
|
{name: "unterminated_string", arguments: `{"input":"patch}`, wantError: true},
|
|
{name: "trailing_comma", arguments: `{"input":"patch",}`, wantError: true},
|
|
{name: "unquoted_field", arguments: `{input:"patch"}`, wantError: true},
|
|
{name: "unescaped_newline", arguments: "{\"input\":\"line\nline\"}", wantError: true},
|
|
{name: "invalid_escape", arguments: `{"input":"\x"}`, wantError: true},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
got, errUnwrap := UnwrapInput(test.arguments)
|
|
if test.wantError {
|
|
if errUnwrap == nil {
|
|
t.Fatalf("UnwrapInput(%q) accepted an invalid wrapper: %q", test.arguments, got)
|
|
}
|
|
if got != "" {
|
|
t.Fatalf("invalid wrapper returned input or fallback text: %q", got)
|
|
}
|
|
return
|
|
}
|
|
if errUnwrap != nil || got != test.want {
|
|
t.Fatalf("UnwrapInput(%q) = %q, %v, want %q", test.arguments, got, errUnwrap, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|