package tool import ( "bytes" "context" "os/exec" "strings" "testing" "time" "code.chimeric.al/chimerical/odidere/internal/config" ) func TestTool_ParseArguments(t *testing.T) { tool, err := NewTool(config.ToolConfig{ Arguments: []string{"--name {{.name}}", "{{if .flag}}--enabled{{end}}"}, }) if err != nil { t.Fatalf("NewTool() error = %v", err) } tests := []struct { name string args string expected []string wantErr bool errContains string }{ { name: "basic", args: `{"name": "test"}`, expected: []string{"--name test"}, }, { // Documented limitation (see the ParseArguments doc): numbers // decode as float64, so integers past 2^53 render in // floating-point form. name: "large integer (float64 limitation)", args: `{"name": 9007199254740993}`, expected: []string{"--name 9.007199254740992e+15"}, }, { name: "with flag", args: `{"name": "test", "flag": true}`, expected: []string{"--name test", "--enabled"}, }, { name: "null", args: "null", expected: []string{"--name "}, }, { name: "invalid JSON", args: `{invalid`, wantErr: true, errContains: "invalid arguments JSON", }, { name: "non-object JSON", args: `[1, 2, 3]`, wantErr: true, errContains: "invalid arguments JSON", }, { name: "trailing data", args: `{"name": "x"} trailing`, wantErr: true, errContains: "invalid arguments JSON", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := tool.ParseArguments(tt.args) if (err != nil) != tt.wantErr { t.Fatalf("ParseArguments() error = %v, wantErr %v", err, tt.wantErr) } if tt.wantErr { if !strings.Contains(err.Error(), tt.errContains) { t.Errorf("err = %q, want to contain %q", err, tt.errContains) } return } if len(got) != len(tt.expected) { t.Fatalf("expected len %d, got %d", len(tt.expected), len(got)) } for i := range got { if got[i] != tt.expected[i] { t.Errorf("expected %q, got %q", tt.expected[i], got[i]) } } }) } } func TestTool_ParseArguments_ExecutionError(t *testing.T) { // "{{printf}}" parses cleanly but fails at execution: printf // requires at least one argument. tool, err := NewTool(config.ToolConfig{ Arguments: []string{"{{printf}}"}, }) if err != nil { t.Fatalf("NewTool() error = %v", err) } _, err = tool.ParseArguments(`{}`) if err == nil { t.Fatal("ParseArguments() error = nil, want execution error") } if !strings.Contains(err.Error(), "execute template") { t.Errorf("err = %v, want execute template error", err) } } func TestNewTool(t *testing.T) { tool, err := NewTool(config.ToolConfig{ Name: "test", Description: "desc", Command: "ls", Timeout: 10 * time.Second, RequiresApproval: true, }) if err != nil { t.Fatalf("NewTool() error = %v", err) } if tool.Name != "test" { t.Errorf("Name = %q, want %q", tool.Name, "test") } if !tool.RequiresApproval { t.Error("RequiresApproval = false, want true") } if tool.timeout != 10*time.Second { t.Errorf("timeout = %v, want 10s", tool.timeout) } } func TestNewTool_InvalidTemplate(t *testing.T) { tests := []struct { name string arg string }{ {name: "unclosed action", arg: "{{.name"}, {name: "undefined function", arg: "{{nosuch .name}}"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := NewTool(config.ToolConfig{ Name: "t", Arguments: []string{tt.arg}, }) if err == nil { t.Fatal("NewTool() error = nil, want template error") } if !strings.Contains(err.Error(), "invalid template") { t.Errorf("err = %v, want invalid template error", err) } }) } } func TestRegistry_NewRegistry(t *testing.T) { tests := []struct { name string tools []config.ToolConfig wantErr bool }{ { name: "valid", tools: []config.ToolConfig{ {Name: "t1", Description: "d1", Command: "c1"}, {Name: "t2", Description: "d2", Command: "c2"}, }, wantErr: false, }, { name: "duplicate", tools: []config.ToolConfig{ {Name: "t1", Description: "d1", Command: "c1"}, {Name: "t1", Description: "d2", Command: "c2"}, }, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := NewRegistry(tt.tools) if (err != nil) != tt.wantErr { t.Errorf("NewRegistry() error = %v, wantErr %v", err, tt.wantErr) } }) } } func TestRegistry_ToAPI_Nil(t *testing.T) { var reg *Registry got := reg.ToAPI() if got != nil { t.Errorf("Registry.ToAPI(nil) = %v, want nil", got) } } func TestRegistry_ToAPI_Empty(t *testing.T) { reg, err := NewRegistry(nil) if err != nil { t.Fatalf("NewRegistry(nil) error = %v", err) } got := reg.ToAPI() if len(got) != 0 { t.Errorf("Registry.ToAPI(empty) len = %d, want 0", len(got)) } } func TestRegistry_ToAPI_Single(t *testing.T) { reg, err := NewRegistry([]config.ToolConfig{ { Name: "weather", Description: "Get the weather", Parameters: map[string]any{"type": "object"}, }, }) if err != nil { t.Fatalf("NewRegistry error = %v", err) } got := reg.ToAPI() if len(got) != 1 { t.Fatalf("len = %d, want 1", len(got)) } if got[0].Type != "function" { t.Errorf("type = %q, want %q", got[0].Type, "function") } if got[0].Function.Name != "weather" { t.Errorf("name = %q, want %q", got[0].Function.Name, "weather") } if got[0].Function.Description != "Get the weather" { t.Errorf("description = %q, want %q", got[0].Function.Description, "Get the weather") } } func TestTool_ToAPI(t *testing.T) { tool := &Tool{ Name: "test", Description: "A test tool", Parameters: map[string]any{"type": "object"}, Command: "echo", Arguments: []string{"hello"}, } api := tool.ToAPI() if api.Type != "function" { t.Errorf("type = %q, want %q", api.Type, "function") } if api.Function.Name != "test" { t.Errorf("name = %q, want %q", api.Function.Name, "test") } if api.Function.Description != "A test tool" { t.Errorf("description = %q, want %q", api.Function.Description, "A test tool") } if api.Function.Parameters == nil { t.Error("parameters is nil") } } func TestToolResult_FormatResult(t *testing.T) { // Get real ProcessState values from actual commands. cmdOK := exec.Command("sh", "-c", "exit 0") if err := cmdOK.Run(); err != nil { t.Fatalf("cmdOK: %v", err) } cmdFail := exec.Command("sh", "-c", "exit 3") cmdFail.Run() // expect non-nil error cmdKill := exec.Command("sh", "-c", "kill -9 $$") cmdKill.Run() // expect non-nil error // Fixed times, so the rendered timing lines are deterministic. started := time.Date(2026, 9, 23, 20, 31, 0, 123_000_000, time.UTC) finished := started.Add(1500 * time.Millisecond) // Every case renders the same timing lines, because every case uses // the same times. wantStart := "┗ start: " + started.Format(time.RFC3339Nano) wantEnd := "┗ end: " + finished.Format(time.RFC3339Nano) wantDuration := "┗ duration: 1.5s" tests := []struct { name string result ToolResult wantContains []string }{ { name: "success with stdout", result: ToolResult{ FinishedAt: finished, ProcessState: cmdOK.ProcessState, StartedAt: started, Stdout: []byte("hello\n"), }, wantContains: []string{ "▸ stdout:", "hello", "▸ stderr:", "┗ exit: 0", wantStart, wantEnd, wantDuration, }, }, { name: "failure with stderr", result: ToolResult{ FinishedAt: finished, ProcessState: cmdFail.ProcessState, StartedAt: started, Stderr: []byte("error occurred\n"), }, wantContains: []string{ "▸ stdout:", "▸ stderr:", "error occurred", "┗ exit: 3", wantStart, wantEnd, wantDuration, }, }, { name: "both stdout and stderr", result: ToolResult{ FinishedAt: finished, ProcessState: cmdOK.ProcessState, StartedAt: started, Stderr: []byte("warning"), Stdout: []byte("output"), }, wantContains: []string{ "▸ stdout:", "output", "▸ stderr:", "warning", "┗ exit: 0", wantStart, wantEnd, wantDuration, }, }, { name: "empty output", result: ToolResult{ FinishedAt: finished, ProcessState: cmdOK.ProcessState, StartedAt: started, }, wantContains: []string{ "▸ stdout:", "▸ stderr:", "┗ exit: 0", wantStart, wantEnd, wantDuration, }, }, { name: "binary stdout (PNG)", result: ToolResult{ FinishedAt: finished, ProcessState: cmdOK.ProcessState, StartedAt: started, Stdout: []byte("\x89PNG\r\n\x1a\n"), }, wantContains: []string{ "▸ stdout:", binaryOutputPrefix, "image/png", "▸ stderr:", "┗ exit: 0", wantStart, wantEnd, wantDuration, }, }, { name: "binary stderr", result: ToolResult{ FinishedAt: finished, ProcessState: cmdOK.ProcessState, StartedAt: started, Stderr: []byte("\x89PNG\r\n\x1a\n"), }, wantContains: []string{ "▸ stdout:", "▸ stderr:", binaryOutputPrefix, "image/png", "┗ exit: 0", wantStart, wantEnd, wantDuration, }, }, { name: "HTML", result: ToolResult{ FinishedAt: finished, ProcessState: cmdOK.ProcessState, StartedAt: started, Stdout: []byte("Hello\n"), }, wantContains: []string{ "▸ stdout:", "", "▸ stderr:", "┗ exit: 0", wantStart, wantEnd, wantDuration, }, }, { name: "XML", result: ToolResult{ FinishedAt: finished, ProcessState: cmdOK.ProcessState, StartedAt: started, Stdout: []byte("\n"), }, wantContains: []string{ "▸ stdout:", "