From e6b355bc16fea6ffbe4345dbcdea12607e93d65d Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Sun, 9 Aug 2026 21:41:51 +0200 Subject: [PATCH] Use typescript types for codemode Signed-off-by: Djordje Lukic --- docs/features/code-mode/index.md | 2 +- pkg/tools/codemode/codemode.go | 2 +- pkg/tools/codemode/codemode_test.go | 54 ++++- pkg/tools/codemode/exec.go | 6 +- pkg/tools/codemode/functions.go | 292 +++++++++++++++++++++-- pkg/tools/codemode/functions_test.go | 332 ++++++++++++++++++++++++--- 6 files changed, 633 insertions(+), 55 deletions(-) diff --git a/docs/features/code-mode/index.md b/docs/features/code-mode/index.md index 0eadb4fa7f..80a55978a9 100644 --- a/docs/features/code-mode/index.md +++ b/docs/features/code-mode/index.md @@ -33,7 +33,7 @@ agents: ref: docker:github-official ``` -Every toolset configured on the agent (the GitHub MCP server here) is wrapped: the model no longer sees the individual GitHub tools, only `run_tools_with_javascript`, with each wrapped tool documented as a JSDoc-commented function signature inside its description. +Every toolset configured on the agent (the GitHub MCP server here) is wrapped: the model no longer sees the individual GitHub tools, only `run_tools_with_javascript`, with each wrapped tool documented as TypeScript interfaces, type aliases, and function declarations inside its description. To force Code Mode for every agent in a run regardless of their individual config, use the `--code-mode-tools` CLI flag (or the equivalent `--code-mode-tools` [runtime configuration flag](../cli/index.md#runtime-configuration-flags), accepted by `run`, `run --exec`, `serve api`, `serve mcp`, and the other commands that load an agent): diff --git a/pkg/tools/codemode/codemode.go b/pkg/tools/codemode/codemode.go index 4715793a45..f3f87dc319 100644 --- a/pkg/tools/codemode/codemode.go +++ b/pkg/tools/codemode/codemode.go @@ -79,7 +79,7 @@ func (c *codeModeTool) Tools(ctx context.Context) ([]tools.Tool, error) { if isExcludedTool(tool) { excludedTools = append(excludedTools, tool) } else { - functionsDoc = append(functionsDoc, toolToJsDoc(tool)) + functionsDoc = append(functionsDoc, toolToTypeScript(tool)) } } } diff --git a/pkg/tools/codemode/codemode_test.go b/pkg/tools/codemode/codemode_test.go index 0dd30619a3..d354134770 100644 --- a/pkg/tools/codemode/codemode_test.go +++ b/pkg/tools/codemode/codemode_test.go @@ -94,6 +94,26 @@ func TestCodeModeTool_Tools(t *testing.T) { }`, string(outputSchema)) } +func TestCodeModeTool_TypeScriptDeclarationsInDescription(t *testing.T) { + t.Parallel() + + tool := Wrap(&testToolSet{tools: []tools.Tool{{ + Name: "find_item", + Description: "Find an item", + Parameters: map[string]any{"type": "object", "properties": map[string]any{"id": map[string]any{"type": "string"}}, "required": []any{"id"}}, + OutputSchema: map[string]any{"type": "boolean"}, + }}}) + + allTools, err := tool.Tools(t.Context()) + require.NoError(t, err) + require.Len(t, allTools, 1) + assert.Contains(t, allTools[0].Description, "interface FindItemInput") + assert.Contains(t, allTools[0].Description, "id: string;") + assert.Contains(t, allTools[0].Description, "type FindItemOutput = boolean;") + assert.Contains(t, allTools[0].Description, "declare function FindItem(args: FindItemInput): FindItemOutput;") + assert.NotContains(t, allTools[0].Description, "Where Input follows the following JSON schema") +} + func TestCodeModeTool_Instructions(t *testing.T) { t.Parallel() tool := &codeModeTool{} @@ -143,7 +163,7 @@ func TestCodeModeTool_CallHello(t *testing.T) { result, err := allTools[0].Handler(t.Context(), tools.ToolCall{ Function: tools.FunctionCall{ - Arguments: `{"script":"return hello_world();"}`, + Arguments: `{"script":"return HelloWorld();"}`, }, }, tools.NopRuntime{}) require.NoError(t, err) @@ -157,6 +177,38 @@ func TestCodeModeTool_CallHello(t *testing.T) { require.Empty(t, scriptResult.StdOut) } +func TestCodeModeTool_CallToolWithNonIdentifierName(t *testing.T) { + t.Parallel() + tool := Wrap(&testToolSet{ + tools: []tools.Tool{ + { + Name: "hello-world", + Handler: tools.NewHandler(func(ctx context.Context, args map[string]any) (*tools.ToolCallResult, error) { + return tools.ResultSuccess("Hello, World!"), nil + }), + }, + }, + }) + + allTools, err := tool.Tools(t.Context()) + require.NoError(t, err) + require.Len(t, allTools, 1) + assert.Contains(t, allTools[0].Description, "declare function HelloWorld(args: HelloWorldInput): HelloWorldOutput;") + + result, err := allTools[0].Handler(t.Context(), tools.ToolCall{ + Function: tools.FunctionCall{ + Arguments: `{"script":"return HelloWorld();"}`, + }, + }, tools.NopRuntime{}) + require.NoError(t, err) + + var scriptResult ScriptResult + err = json.Unmarshal([]byte(result.Output), &scriptResult) + require.NoError(t, err) + + require.Equal(t, "Hello, World!", scriptResult.Value) +} + func TestCodeModeTool_CallEcho(t *testing.T) { t.Parallel() type EchoArgs struct { diff --git a/pkg/tools/codemode/exec.go b/pkg/tools/codemode/exec.go index 50cadef72c..9808b68c23 100644 --- a/pkg/tools/codemode/exec.go +++ b/pkg/tools/codemode/exec.go @@ -83,7 +83,11 @@ func (c *codeModeTool) runJavascript(ctx context.Context, rt tools.Runtime, scri } for _, tool := range allTools { - _ = vm.Set(tool.Name, callTool(ctx, rt, tool, tracker)) + call := callTool(ctx, rt, tool, tracker) + _ = vm.Set(tool.Name, call) + if name := typeName(tool.Name); name != tool.Name { + _ = vm.Set(name, call) + } } } diff --git a/pkg/tools/codemode/functions.go b/pkg/tools/codemode/functions.go index 95d92fefa1..25966b2e7b 100644 --- a/pkg/tools/codemode/functions.go +++ b/pkg/tools/codemode/functions.go @@ -3,40 +3,290 @@ package codemode import ( "encoding/json" "fmt" + "maps" + "regexp" + "sort" "strings" + "unicode" "github.com/docker/docker-agent/pkg/tools" ) -func toolToJsDoc(tool tools.Tool) string { +var typeScriptIdentifier = regexp.MustCompile(`^[A-Za-z_$][A-Za-z0-9_$]*$`) + +func toolToTypeScript(tool tools.Tool) string { + baseName := typeName(tool.Name) + inputName := baseName + "Input" + outputName := baseName + "Output" + + input := schemaMap(tool.Parameters) + output := schemaMap(tool.OutputSchema) + var doc strings.Builder + writeDocComment(&doc, tool.Description) - doc.WriteString(toComment(&tool)) - fmt.Fprintf(&doc, "function %s(args: Input): Output { ... }\n", tool.Name) + if isObjectSchema(input) { + fmt.Fprintf(&doc, "interface %s %s\n\n", inputName, objectType(input, input, 0)) + } else { + fmt.Fprintf(&doc, "type %s = %s;\n\n", inputName, schemaType(input, input, 0)) + } + fmt.Fprintf(&doc, "type %s = %s;\n\n", outputName, schemaType(output, output, 0)) + fmt.Fprintf(&doc, "declare function %s(args: %s): %s;\n", baseName, inputName, outputName) return doc.String() } -func toComment(tool *tools.Tool) string { - var comment strings.Builder +func schemaMap(schema any) map[string]any { + data, err := json.Marshal(schema) + if err != nil { + return nil + } + + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + return nil + } + return result +} + +func schemaType(schema, root map[string]any, level int) string { + if schema == nil { + return "unknown" + } + if ref, ok := schema["$ref"].(string); ok { + if resolved := resolveRef(root, ref); resolved != nil { + return schemaType(resolved, root, level) + } + } + if value, ok := schema["const"]; ok { + return literal(value) + } + if values, ok := schema["enum"].([]any); ok && len(values) > 0 { + parts := make([]string, 0, len(values)) + for _, value := range values { + parts = append(parts, literal(value)) + } + return strings.Join(parts, " | ") + } + for _, keyword := range []struct { + name string + separator string + }{{"oneOf", " | "}, {"anyOf", " | "}, {"allOf", " & "}} { + if variants, ok := schema[keyword.name].([]any); ok && len(variants) > 0 { + parts := make([]string, 0, len(variants)) + for _, variant := range variants { + if variantSchema, ok := variant.(map[string]any); ok { + parts = append(parts, schemaType(variantSchema, root, level)) + } + } + if len(parts) > 0 { + return strings.Join(parts, keyword.separator) + } + } + } + + types := schemaTypes(schema) + if len(types) > 1 { + parts := make([]string, 0, len(types)) + for _, typ := range types { + schemaCopy := cloneMap(schema) + schemaCopy["type"] = typ + parts = append(parts, schemaType(schemaCopy, root, level)) + } + return strings.Join(parts, " | ") + } + + typ := "" + if len(types) == 1 { + typ = types[0] + } + if typ == "" { + switch { + case schema["properties"] != nil || schema["additionalProperties"] != nil: + typ = "object" + case schema["items"] != nil: + typ = "array" + } + } + + switch typ { + case "object": + return objectType(schema, root, level) + case "array": + itemSchema, _ := schema["items"].(map[string]any) + itemType := schemaType(itemSchema, root, level) + if strings.Contains(itemType, " | ") || strings.Contains(itemType, " & ") { + itemType = "(" + itemType + ")" + } + return itemType + "[]" + case "string": + return "string" + case "integer", "number": + return "number" + case "boolean": + return "boolean" + case "null": + return "null" + default: + return "unknown" + } +} + +func objectType(schema, root map[string]any, level int) string { + properties, _ := schema["properties"].(map[string]any) + required := stringSet(schema["required"]) + keys := make([]string, 0, len(properties)) + for key := range properties { + keys = append(keys, key) + } + sort.Strings(keys) + + indent := strings.Repeat(" ", level) + childIndent := strings.Repeat(" ", level+1) + var result strings.Builder + result.WriteString("{\n") + for _, key := range keys { + property, _ := properties[key].(map[string]any) + if description, _ := property["description"].(string); description != "" { + writeLineComments(&result, description, childIndent) + } + result.WriteString(childIndent) + result.WriteString(propertyName(key)) + if !required[key] { + result.WriteByte('?') + } + result.WriteString(": ") + result.WriteString(schemaType(property, root, level+1)) + result.WriteString(";\n") + } + + if additional, exists := schema["additionalProperties"]; exists { + if allowed, ok := additional.(bool); ok && !allowed { + result.WriteString(indent) + result.WriteByte('}') + return result.String() + } + additionalType := "unknown" + if additionalSchema, ok := additional.(map[string]any); ok { + additionalType = schemaType(additionalSchema, root, level+1) + } + result.WriteString(childIndent) + fmt.Fprintf(&result, "[key: string]: %s;\n", additionalType) + } + result.WriteString(indent) + result.WriteByte('}') + return result.String() +} + +func schemaTypes(schema map[string]any) []string { + switch value := schema["type"].(type) { + case string: + return []string{value} + case []any: + result := make([]string, 0, len(value)) + for _, item := range value { + if typ, ok := item.(string); ok { + result = append(result, typ) + } + } + return result + default: + return nil + } +} + +func isObjectSchema(schema map[string]any) bool { + types := schemaTypes(schema) + return len(types) == 1 && types[0] == "object" +} + +func resolveRef(root map[string]any, ref string) map[string]any { + if !strings.HasPrefix(ref, "#/") { + return nil + } + var current any = root + for part := range strings.SplitSeq(strings.TrimPrefix(ref, "#/"), "/") { + object, ok := current.(map[string]any) + if !ok { + return nil + } + current = object[strings.ReplaceAll(strings.ReplaceAll(part, "~1", "/"), "~0", "~")] + } + resolved, _ := current.(map[string]any) + return resolved +} + +func cloneMap(value map[string]any) map[string]any { + result := make(map[string]any, len(value)) + maps.Copy(result, value) + return result +} + +func stringSet(value any) map[string]bool { + result := make(map[string]bool) + if values, ok := value.([]any); ok { + for _, item := range values { + if text, ok := item.(string); ok { + result[text] = true + } + } + } + return result +} + +func typeName(name string) string { + var result strings.Builder + upperNext := true + for _, r := range name { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) { + upperNext = true + continue + } + if result.Len() == 0 && unicode.IsDigit(r) { + result.WriteString("Tool") + } + if upperNext { + r = unicode.ToUpper(r) + upperNext = false + } + result.WriteRune(r) + } + if result.Len() == 0 { + return "Tool" + } + return result.String() +} + +func propertyName(name string) string { + if typeScriptIdentifier.MatchString(name) { + return name + } + data, _ := json.Marshal(name) + return string(data) +} - inputSchema, _ := json.MarshalIndent(tool.Parameters, " * ", " ") - outputSchema, _ := json.MarshalIndent(tool.OutputSchema, " * ", " ") +func literal(value any) string { + data, err := json.Marshal(value) + if err != nil { + return "unknown" + } + return string(data) +} - comment.WriteString("\n/**\n") - for line := range strings.SplitSeq(tool.Description, "\n") { - comment.WriteString(" * " + strings.TrimSpace(line) + "\n") +func writeLineComments(doc *strings.Builder, description, indent string) { + for line := range strings.SplitSeq(description, "\n") { + doc.WriteString(indent + "// " + strings.TrimSpace(line) + "\n") } - comment.WriteString(" * \n") - comment.WriteString(" * @param args - Input object containing the parameters.\n") - comment.WriteString(" * @returns Output - The result of the function execution.\n") - comment.WriteString(" *\n") - comment.WriteString(" * Where Input follows the following JSON schema:\n") - comment.WriteString(" * " + string(inputSchema) + "\n") - comment.WriteString(" *\n") - comment.WriteString(" * And Output follows the following JSON schema:\n") - comment.WriteString(" * " + string(outputSchema) + "\n") - comment.WriteString(" */\n") +} - return comment.String() +func writeDocComment(doc *strings.Builder, description string) { + writeIndentedDocComment(doc, description, "") +} + +func writeIndentedDocComment(doc *strings.Builder, description, indent string) { + doc.WriteString(indent + "/**\n") + for line := range strings.SplitSeq(description, "\n") { + doc.WriteString(indent + " * " + strings.ReplaceAll(strings.TrimSpace(line), "*/", "*\\/") + "\n") + } + doc.WriteString(indent + " */\n") } diff --git a/pkg/tools/codemode/functions_test.go b/pkg/tools/codemode/functions_test.go index acf1e212d9..7c930fc7ae 100644 --- a/pkg/tools/codemode/functions_test.go +++ b/pkg/tools/codemode/functions_test.go @@ -1,6 +1,7 @@ package codemode import ( + "math" "testing" "github.com/stretchr/testify/assert" @@ -8,10 +9,11 @@ import ( "github.com/docker/docker-agent/pkg/tools" ) -func TestToolToJsDoc(t *testing.T) { +func TestToolToTypeScript(t *testing.T) { t.Parallel() type CreateTodoArgs struct { - Description string `json:"description" jsonschema:"Description of the todo item"` + Description string `json:"description" jsonschema:"Description of the todo item"` + Labels []string `json:"labels,omitempty" jsonschema:"Labels to apply"` } tool := tools.Tool{ @@ -21,36 +23,306 @@ func TestToolToJsDoc(t *testing.T) { OutputSchema: tools.MustSchemaFor[string](), } - jsDoc := toolToJsDoc(tool) + declaration := toolToTypeScript(tool) - assert.Equal(t, ` -/** + assert.Equal(t, `/** * Create new todo * each of them with a description - * - * @param args - Input object containing the parameters. - * @returns Output - The result of the function execution. - * - * Where Input follows the following JSON schema: - * { - * "type": "object", - * "properties": { - * "description": { - * "type": "string", - * "description": "Description of the todo item" - * } - * }, - * "required": [ - * "description" - * ], - * "additionalProperties": false - * } - * - * And Output follows the following JSON schema: - * { - * "type": "string" - * } */ -function create_todo(args: Input): Output { ... } -`, jsDoc) +interface CreateTodoInput { + // Description of the todo item + description: string; + // Labels to apply + labels?: null | string[]; +} + +type CreateTodoOutput = string; + +declare function CreateTodo(args: CreateTodoInput): CreateTodoOutput; +`, declaration) +} + +func TestToolToTypeScriptExamples(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + parameters map[string]any + output map[string]any + want string + }{ + { + name: "primitive aliases", + parameters: map[string]any{"type": "string"}, + output: map[string]any{"type": "boolean"}, + want: `/** + * Example tool + */ +type ExampleToolInput = string; + +type ExampleToolOutput = boolean; + +declare function ExampleTool(args: ExampleToolInput): ExampleToolOutput; +`, + }, + { + name: "nested object, enum, nullable array, and property comments", + parameters: map[string]any{ + "type": "object", + "required": []string{"filter"}, + "properties": map[string]any{ + "filter": map[string]any{ + "type": "object", + "description": "Filter to apply\n before searching", + "properties": map[string]any{ + "sort-order": map[string]any{"enum": []any{"asc", "desc"}}, + }, + }, + }, + "additionalProperties": false, + }, + output: map[string]any{ + "type": []any{"array", "null"}, + "items": map[string]any{"type": "number"}, + }, + want: `/** + * Example tool + */ +interface ExampleToolInput { + // Filter to apply + // before searching + filter: { + "sort-order"?: "asc" | "desc"; + }; +} + +type ExampleToolOutput = number[] | null; + +declare function ExampleTool(args: ExampleToolInput): ExampleToolOutput; +`, + }, + { + name: "unions and intersections", + parameters: map[string]any{ + "oneOf": []any{ + map[string]any{"type": "string"}, + map[string]any{"type": "number"}, + }, + }, + output: map[string]any{ + "allOf": []any{ + map[string]any{"type": "object", "properties": map[string]any{"id": map[string]any{"type": "string"}}}, + map[string]any{"type": "object", "properties": map[string]any{"active": map[string]any{"type": "boolean"}}}, + }, + }, + want: `/** + * Example tool + */ +type ExampleToolInput = string | number; + +type ExampleToolOutput = { + id?: string; +} & { + active?: boolean; +}; + +declare function ExampleTool(args: ExampleToolInput): ExampleToolOutput; +`, + }, + { + name: "references and typed additional properties", + parameters: map[string]any{ + "type": "object", + "$defs": map[string]any{ + "identifier": map[string]any{"type": "integer"}, + }, + "properties": map[string]any{ + "id": map[string]any{"$ref": "#/$defs/identifier"}, + }, + "additionalProperties": map[string]any{"type": "string"}, + }, + output: map[string]any{"const": "ok"}, + want: `/** + * Example tool + */ +interface ExampleToolInput { + id?: number; + [key: string]: string; +} + +type ExampleToolOutput = "ok"; + +declare function ExampleTool(args: ExampleToolInput): ExampleToolOutput; +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tool := tools.Tool{ + Name: "example_tool", + Description: "Example tool", + Parameters: tt.parameters, + OutputSchema: tt.output, + } + assert.Equal(t, tt.want, toolToTypeScript(tool)) + }) + } +} + +func TestSchemaType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + schema map[string]any + want string + }{ + {name: "string", schema: map[string]any{"type": "string"}, want: "string"}, + {name: "integer", schema: map[string]any{"type": "integer"}, want: "number"}, + {name: "number", schema: map[string]any{"type": "number"}, want: "number"}, + {name: "boolean", schema: map[string]any{"type": "boolean"}, want: "boolean"}, + {name: "null", schema: map[string]any{"type": "null"}, want: "null"}, + {name: "const", schema: map[string]any{"const": "fixed"}, want: `"fixed"`}, + {name: "mixed enum", schema: map[string]any{"enum": []any{"ready", 2.0, true, nil}}, want: `"ready" | 2 | true | null`}, + {name: "oneOf", schema: map[string]any{"oneOf": []any{map[string]any{"type": "string"}, map[string]any{"type": "number"}}}, want: "string | number"}, + {name: "anyOf", schema: map[string]any{"anyOf": []any{map[string]any{"type": "boolean"}, map[string]any{"type": "null"}}}, want: "boolean | null"}, + {name: "allOf", schema: map[string]any{"allOf": []any{map[string]any{"type": "string"}, map[string]any{"const": "x"}}}, want: `string & "x"`}, + {name: "nullable", schema: map[string]any{"type": []any{"string", "null"}}, want: "string | null"}, + {name: "array", schema: map[string]any{"type": "array", "items": map[string]any{"type": "integer"}}, want: "number[]"}, + {name: "array of union", schema: map[string]any{"type": "array", "items": map[string]any{"type": []any{"string", "null"}}}, want: "(string | null)[]"}, + {name: "inferred array", schema: map[string]any{"items": map[string]any{"type": "boolean"}}, want: "boolean[]"}, + {name: "unknown", schema: map[string]any{}, want: "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, schemaType(tt.schema, tt.schema, 0)) + }) + } +} + +func TestSchemaTypeReferences(t *testing.T) { + t.Parallel() + + root := map[string]any{ + "$defs": map[string]any{ + "plain": map[string]any{"type": "string"}, + "a/b~c": map[string]any{"type": "boolean"}, + }, + } + + assert.Equal(t, "string", schemaType(map[string]any{"$ref": "#/$defs/plain"}, root, 0)) + assert.Equal(t, "boolean", schemaType(map[string]any{"$ref": "#/$defs/a~1b~0c"}, root, 0)) + assert.Equal(t, "unknown", schemaType(map[string]any{"$ref": "https://example.com/schema"}, root, 0)) + assert.Equal(t, "unknown", schemaType(map[string]any{"$ref": "#/$defs/missing"}, root, 0)) +} + +func TestObjectType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + additional any + want string + }{ + {name: "forbidden", additional: false, want: "{\n id: string;\n}"}, + {name: "allowed", additional: true, want: "{\n id: string;\n [key: string]: unknown;\n}"}, + {name: "typed", additional: map[string]any{"type": "number"}, want: "{\n id: string;\n [key: string]: number;\n}"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + schema := map[string]any{ + "type": "object", + "required": []any{"id"}, + "properties": map[string]any{"id": map[string]any{"type": "string"}}, + "additionalProperties": tt.additional, + } + assert.Equal(t, tt.want, objectType(schema, schema, 0)) + }) + } +} + +func TestObjectTypePropertyNamesAndComments(t *testing.T) { + t.Parallel() + + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "validName": map[string]any{ + "type": "string", + "description": "First line\n Second line", + }, + "with-dash": map[string]any{"type": "boolean"}, + }, + } + + assert.Equal(t, `{ + // First line + // Second line + validName?: string; + "with-dash"?: boolean; +}`, objectType(schema, schema, 0)) +} + +func TestSchemaMapAndLiteralFallbacks(t *testing.T) { + t.Parallel() + + assert.Nil(t, schemaMap(make(chan int))) + assert.Equal(t, "unknown", literal(math.Inf(1))) +} + +func TestTypeName(t *testing.T) { + t.Parallel() + + tests := map[string]string{ + "create_todo": "CreateTodo", + "search-items": "SearchItems", + "2fa": "Tool2fa", + "---": "Tool", + "über_tool": "ÜberTool", + } + for input, want := range tests { + assert.Equal(t, want, typeName(input)) + } +} + +func TestToolToTypeScriptNestedAndNullableTypes(t *testing.T) { + t.Parallel() + + tool := tools.Tool{ + Name: "search-items", + Description: "Search items", + Parameters: map[string]any{ + "type": "object", + "required": []string{"filter"}, + "properties": map[string]any{ + "filter": map[string]any{ + "type": "object", + "properties": map[string]any{ + "sort-order": map[string]any{"enum": []any{"asc", "desc"}}, + }, + }, + }, + "additionalProperties": false, + }, + OutputSchema: map[string]any{ + "type": []any{"array", "null"}, + "items": map[string]any{"type": "number"}, + }, + } + + declaration := toolToTypeScript(tool) + + assert.Contains(t, declaration, `interface SearchItemsInput { + filter: { + "sort-order"?: "asc" | "desc"; + }; +}`) + assert.Contains(t, declaration, "type SearchItemsOutput = number[] | null;") + assert.Contains(t, declaration, "declare function SearchItems(args: SearchItemsInput): SearchItemsOutput;") }