Files
odidere/internal/llm/tool_test.go

390 lines
11 KiB
Go
Raw Permalink Normal View History

package llm
2026-02-13 15:02:07 +00:00
import (
2026-07-08 14:29:42 +00:00
"bytes"
2026-07-08 11:52:45 +00:00
"context"
"os/exec"
"strings"
2026-02-13 15:02:07 +00:00
"testing"
"time"
2026-06-19 14:31:41 +00:00
"code.chimeric.al/chimerical/odidere/internal/config"
2026-02-13 15:02:07 +00:00
)
2026-06-19 14:31:41 +00:00
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) {
2026-02-13 15:02:07 +00:00
tests := []struct {
name string
2026-06-19 14:31:41 +00:00
cfg config.ToolConfig
2026-02-13 15:02:07 +00:00
wantErr bool
}{
{
2026-06-19 14:31:41 +00:00
name: "valid",
cfg: config.ToolConfig{
2026-02-13 15:02:07 +00:00
Name: "test",
2026-06-19 14:31:41 +00:00
Description: "desc",
Command: "ls",
Timeout: "10s",
2026-02-13 15:02:07 +00:00
},
2026-06-19 14:31:41 +00:00
wantErr: false,
2026-02-13 15:02:07 +00:00
},
{
name: "invalid timeout",
2026-06-19 14:31:41 +00:00
cfg: config.ToolConfig{
2026-02-13 15:02:07 +00:00
Name: "test",
2026-06-19 14:31:41 +00:00
Description: "desc",
Command: "ls",
2026-02-13 15:02:07 +00:00
Timeout: "invalid",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
2026-06-19 14:31:41 +00:00
tool, err := NewTool(tt.cfg)
2026-02-13 15:02:07 +00:00
if (err != nil) != tt.wantErr {
2026-06-19 14:31:41 +00:00
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)
}
2026-02-13 15:02:07 +00:00
}
})
}
}
2026-06-19 14:31:41 +00:00
func TestRegistry_NewRegistry(t *testing.T) {
2026-02-13 15:02:07 +00:00
tests := []struct {
name string
2026-06-19 14:31:41 +00:00
tools []config.ToolConfig
2026-02-13 15:02:07 +00:00
wantErr bool
}{
{
2026-06-19 14:31:41 +00:00
name: "valid",
tools: []config.ToolConfig{
{Name: "t1", Description: "d1", Command: "c1"},
{Name: "t2", Description: "d2", Command: "c2"},
2026-02-13 15:02:07 +00:00
},
wantErr: false,
},
{
2026-06-19 14:31:41 +00:00
name: "duplicate",
tools: []config.ToolConfig{
{Name: "t1", Description: "d1", Command: "c1"},
{Name: "t1", Description: "d2", Command: "c2"},
2026-02-13 15:02:07 +00:00
},
wantErr: true,
},
{
name: "invalid tool",
2026-06-19 14:31:41 +00:00
tools: []config.ToolConfig{
{Name: "t1", Description: "d1", Command: "c1", Timeout: "invalid"},
2026-02-13 15:02:07 +00:00
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NewRegistry(tt.tools)
if (err != nil) != tt.wantErr {
2026-06-19 14:31:41 +00:00
t.Errorf("NewRegistry() error = %v, wantErr %v", err, tt.wantErr)
2026-02-13 15:02:07 +00:00
}
})
}
}
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")
}
}
2026-07-08 11:52:45 +00:00
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",
2026-07-08 14:29:42 +00:00
result: ToolResult{Stdout: []byte("hello\n"), Stderr: nil, ProcessState: cmdOK.ProcessState},
2026-07-08 11:52:45 +00:00
wantContains: []string{"▸ stdout:", "hello", "▸ stderr:", "┗ exit: 0"},
},
{
name: "failure with stderr",
2026-07-08 14:29:42 +00:00
result: ToolResult{Stdout: nil, Stderr: []byte("error occurred\n"), ProcessState: cmdFail.ProcessState},
2026-07-08 11:52:45 +00:00
wantContains: []string{"▸ stdout:", "▸ stderr:", "error occurred", "┗ exit: 3"},
},
{
name: "both stdout and stderr",
2026-07-08 14:29:42 +00:00
result: ToolResult{Stdout: []byte("output"), Stderr: []byte("warning"), ProcessState: cmdOK.ProcessState},
2026-07-08 11:52:45 +00:00
wantContains: []string{"▸ stdout:", "output", "▸ stderr:", "warning", "┗ exit: 0"},
},
{
name: "empty output",
2026-07-08 14:29:42 +00:00
result: ToolResult{Stdout: nil, Stderr: nil, ProcessState: cmdOK.ProcessState},
2026-07-08 11:52:45 +00:00
wantContains: []string{"▸ stdout:", "▸ stderr:", "┗ exit: 0"},
},
2026-07-08 14:29:42 +00:00
{
name: "binary stdout (PNG)",
result: ToolResult{Stdout: []byte("\x89PNG\r\n\x1a\n"), Stderr: nil, ProcessState: cmdOK.ProcessState},
wantContains: []string{"▸ stdout:", binaryOutputPrefix, "image/png", "▸ stderr:", "┗ exit: 0"},
},
{
name: "binary stderr",
result: ToolResult{Stdout: nil, Stderr: []byte("\x89PNG\r\n\x1a\n"), ProcessState: cmdOK.ProcessState},
wantContains: []string{"▸ stdout:", "▸ stderr:", binaryOutputPrefix, "image/png", "┗ exit: 0"},
},
{
name: "HTML",
result: ToolResult{Stdout: []byte("<HTML><BODY>Hello</BODY></HTML>\n"), Stderr: nil, ProcessState: cmdOK.ProcessState},
wantContains: []string{"▸ stdout:", "<HTML>", "▸ stderr:", "┗ exit: 0"},
},
{
name: "XML",
result: ToolResult{Stdout: []byte("<?xml version=\"1.0\"?><root/>\n"), Stderr: nil, ProcessState: cmdOK.ProcessState},
wantContains: []string{"▸ stdout:", "<?xml", "▸ stderr:", "┗ exit: 0"},
},
{
name: "Go source",
result: ToolResult{Stdout: []byte("package main\n\nfunc main() {}\n"), Stderr: nil, ProcessState: cmdOK.ProcessState},
wantContains: []string{"▸ stdout:", "package main", "▸ stderr:", "┗ exit: 0"},
},
{
name: "JSON",
result: ToolResult{Stdout: []byte(`{"key":"value"}`), Stderr: nil, ProcessState: cmdOK.ProcessState},
wantContains: []string{"▸ stdout:", `{"key":"value"}`, "▸ stderr:", "┗ exit: 0"},
},
2026-07-08 11:52:45 +00:00
}
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())
}
2026-07-08 14:29:42 +00:00
if !bytes.Contains(result.Stdout, []byte("hello")) {
2026-07-08 11:52:45 +00:00
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())
}
}
}