package llm import ( "context" "os/exec" "strings" "testing" "time" "code.chimeric.al/chimerical/odidere/internal/config" ) func TestTool_ParseArguments(t *testing.T) { tool := &Tool{ Arguments: []string{"--name {{.name}}", "{{if .flag}}--enabled{{end}}"}, } tests := []struct { name string args string expected []string }{ { name: "basic", args: `{"name": "test"}`, expected: []string{"--name test"}, }, { name: "with flag", args: `{"name": "test", "flag": true}`, expected: []string{"--name test", "--enabled"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := tool.ParseArguments(tt.args) if err != nil { t.Fatalf("ParseArguments failed: %v", err) } if len(got) != len(tt.expected) { t.Errorf("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 TestNewTool(t *testing.T) { tests := []struct { name string cfg config.ToolConfig wantErr bool }{ { name: "valid", cfg: config.ToolConfig{ Name: "test", Description: "desc", Command: "ls", Timeout: "10s", }, wantErr: false, }, { name: "invalid timeout", cfg: config.ToolConfig{ Name: "test", Description: "desc", Command: "ls", Timeout: "invalid", }, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tool, err := NewTool(tt.cfg) if (err != nil) != tt.wantErr { t.Errorf("NewTool() error = %v, wantErr %v", err, tt.wantErr) } if err == nil { if tool.Name != tt.cfg.Name { t.Errorf("expected name %s, got %s", tt.cfg.Name, tool.Name) } if tt.cfg.Timeout == "10s" && tool.timeout != 10*time.Second { t.Errorf("expected timeout 10s, got %v", tool.timeout) } } }) } } 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, }, { name: "invalid tool", tools: []config.ToolConfig{ {Name: "t1", Description: "d1", Command: "c1", Timeout: "invalid"}, }, 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 tests := []struct { name string result ToolResult wantContains []string }{ { name: "success with stdout", result: ToolResult{Stdout: "hello\n", Stderr: "", ProcessState: cmdOK.ProcessState}, wantContains: []string{"▸ stdout:", "hello", "▸ stderr:", "┗ exit: 0"}, }, { name: "failure with stderr", result: ToolResult{Stdout: "", Stderr: "error occurred\n", ProcessState: cmdFail.ProcessState}, wantContains: []string{"▸ stdout:", "▸ stderr:", "error occurred", "┗ exit: 3"}, }, { name: "both stdout and stderr", result: ToolResult{Stdout: "output", Stderr: "warning", ProcessState: cmdOK.ProcessState}, wantContains: []string{"▸ stdout:", "output", "▸ stderr:", "warning", "┗ exit: 0"}, }, { name: "empty output", result: ToolResult{Stdout: "", Stderr: "", ProcessState: cmdOK.ProcessState}, wantContains: []string{"▸ stdout:", "▸ stderr:", "┗ exit: 0"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tt.result.FormatResult() for _, want := range tt.wantContains { if !strings.Contains(got, want) { t.Errorf("FormatResult() = %q, want to contain %q", got, want) } } }) } } func TestRegistry_Execute_Success(t *testing.T) { reg, err := NewRegistry([]config.ToolConfig{ {Name: "echo", Description: "echo", Command: "echo", Arguments: []string{"hello"}}, }) if err != nil { t.Fatalf("NewRegistry: %v", err) } result, err := reg.Execute(context.Background(), "echo", "{}") if err != nil { t.Fatalf("Execute: %v", err) } if result.ProcessState.ExitCode() != 0 { t.Errorf("ExitCode = %d, want 0", result.ProcessState.ExitCode()) } if !strings.Contains(result.Stdout, "hello") { t.Errorf("Stdout = %q, want to contain hello", result.Stdout) } } func TestRegistry_Execute_NonZeroExit(t *testing.T) { reg, err := NewRegistry([]config.ToolConfig{ {Name: "fail", Description: "fail", Command: "sh", Arguments: []string{"-c", "exit 42"}}, }) if err != nil { t.Fatalf("NewRegistry: %v", err) } result, err := reg.Execute(context.Background(), "fail", "{}") if err != nil { t.Fatalf("Execute: %v", err) } if result.ProcessState.ExitCode() != 42 { t.Errorf("ExitCode = %d, want 42", result.ProcessState.ExitCode()) } } func TestRegistry_Execute_UnknownCommand(t *testing.T) { reg, err := NewRegistry([]config.ToolConfig{ {Name: "nope", Description: "nope", Command: "nonexistent_binary_xyz", Arguments: []string{}}, }) if err != nil { t.Fatalf("NewRegistry: %v", err) } _, err = reg.Execute(context.Background(), "nope", "{}") if err == nil { t.Fatal("Execute: want error, got nil") } } func TestRegistry_Execute_UnknownTool(t *testing.T) { reg, err := NewRegistry([]config.ToolConfig{}) if err != nil { t.Fatalf("NewRegistry: %v", err) } _, err = reg.Execute(context.Background(), "missing", "{}") if err == nil { t.Fatal("Execute: want error, got nil") } if !strings.Contains(err.Error(), "unknown tool") { t.Errorf("err = %v, want unknown tool error", err) } } func TestRegistry_Execute_Timeout(t *testing.T) { reg, err := NewRegistry([]config.ToolConfig{ {Name: "sleep", Description: "sleep", Command: "sleep", Arguments: []string{"10"}, Timeout: "50ms"}, }) if err != nil { t.Fatalf("NewRegistry: %v", err) } _, err = reg.Execute(context.Background(), "sleep", "{}") if err == nil { t.Fatal("Execute: want timeout error, got nil") } if !strings.Contains(err.Error(), "timed out") { t.Errorf("err = %v, want timed out error", err) } } func TestRegistry_Execute_SignalKilled(t *testing.T) { reg, err := NewRegistry([]config.ToolConfig{ {Name: "kill", Description: "kill", Command: "sh", Arguments: []string{"-c", "sleep 100 & kill -9 $!; wait"}}, }) if err != nil { t.Fatalf("NewRegistry: %v", err) } result, err := reg.Execute(context.Background(), "kill", "{}") if err != nil { t.Fatalf("Execute: %v", err) } // The process we kill exits with signal, but the shell itself may exit 0. // On some systems the exit code is 137 (128+9). Check ProcessState for signal info. if result.ProcessState.ExitCode() == -1 { if !strings.Contains(result.ProcessState.String(), "signal:") { t.Errorf("ProcessState.String() = %q, want signal info", result.ProcessState.String()) } } }