982 lines
29 KiB
Go
982 lines
29 KiB
Go
package providers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
)
|
|
|
|
// --- Compile-time interface check ---
|
|
|
|
var _ LLMProvider = (*ClaudeCliProvider)(nil)
|
|
|
|
// --- Helper: create mock CLI scripts ---
|
|
|
|
// createMockCLI creates a temporary script that simulates the claude CLI.
|
|
// Uses files for stdout/stderr to avoid shell quoting issues with JSON.
|
|
func createMockCLI(t *testing.T, stdout, stderr string, exitCode int) string {
|
|
t.Helper()
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("mock CLI scripts not supported on Windows")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
|
|
if stdout != "" {
|
|
if err := os.WriteFile(filepath.Join(dir, "stdout.txt"), []byte(stdout), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if stderr != "" {
|
|
if err := os.WriteFile(filepath.Join(dir, "stderr.txt"), []byte(stderr), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString("#!/bin/sh\n")
|
|
if stderr != "" {
|
|
sb.WriteString(fmt.Sprintf("cat '%s/stderr.txt' >&2\n", dir))
|
|
}
|
|
if stdout != "" {
|
|
sb.WriteString(fmt.Sprintf("cat '%s/stdout.txt'\n", dir))
|
|
}
|
|
sb.WriteString(fmt.Sprintf("exit %d\n", exitCode))
|
|
|
|
script := filepath.Join(dir, "claude")
|
|
if err := os.WriteFile(script, []byte(sb.String()), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return script
|
|
}
|
|
|
|
// createSlowMockCLI creates a script that sleeps before responding (for context cancellation tests).
|
|
func createSlowMockCLI(t *testing.T, sleepSeconds int) string {
|
|
t.Helper()
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("mock CLI scripts not supported on Windows")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
script := filepath.Join(dir, "claude")
|
|
content := fmt.Sprintf("#!/bin/sh\nsleep %d\necho '{\"type\":\"result\",\"result\":\"late\"}'\n", sleepSeconds)
|
|
if err := os.WriteFile(script, []byte(content), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return script
|
|
}
|
|
|
|
// createArgCaptureCLI creates a script that captures CLI args to a file, then outputs JSON.
|
|
func createArgCaptureCLI(t *testing.T, argsFile string) string {
|
|
t.Helper()
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("mock CLI scripts not supported on Windows")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
script := filepath.Join(dir, "claude")
|
|
content := fmt.Sprintf(`#!/bin/sh
|
|
echo "$@" > '%s'
|
|
cat <<'EOFMOCK'
|
|
{"type":"result","result":"ok","session_id":"test"}
|
|
EOFMOCK
|
|
`, argsFile)
|
|
if err := os.WriteFile(script, []byte(content), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return script
|
|
}
|
|
|
|
// --- Constructor tests ---
|
|
|
|
func TestNewClaudeCliProvider(t *testing.T) {
|
|
p := NewClaudeCliProvider("/test/workspace")
|
|
if p == nil {
|
|
t.Fatal("NewClaudeCliProvider returned nil")
|
|
}
|
|
if p.workspace != "/test/workspace" {
|
|
t.Errorf("workspace = %q, want %q", p.workspace, "/test/workspace")
|
|
}
|
|
if p.command != "claude" {
|
|
t.Errorf("command = %q, want %q", p.command, "claude")
|
|
}
|
|
}
|
|
|
|
func TestNewClaudeCliProvider_EmptyWorkspace(t *testing.T) {
|
|
p := NewClaudeCliProvider("")
|
|
if p.workspace != "" {
|
|
t.Errorf("workspace = %q, want empty", p.workspace)
|
|
}
|
|
}
|
|
|
|
// --- GetDefaultModel tests ---
|
|
|
|
func TestClaudeCliProvider_GetDefaultModel(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
if got := p.GetDefaultModel(); got != "claude-code" {
|
|
t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-code")
|
|
}
|
|
}
|
|
|
|
// --- Chat() tests ---
|
|
|
|
func TestChat_Success(t *testing.T) {
|
|
mockJSON := `{"type":"result","subtype":"success","is_error":false,"result":"Hello from mock!","session_id":"sess_123","total_cost_usd":0.005,"duration_ms":200,"duration_api_ms":150,"num_turns":1,"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":100,"cache_read_input_tokens":0}}`
|
|
script := createMockCLI(t, mockJSON, "", 0)
|
|
|
|
p := NewClaudeCliProvider(t.TempDir())
|
|
p.command = script
|
|
|
|
resp, err := p.Chat(context.Background(), []Message{
|
|
{Role: "user", Content: "Hello"},
|
|
}, nil, "", nil)
|
|
|
|
if err != nil {
|
|
t.Fatalf("Chat() error = %v", err)
|
|
}
|
|
if resp.Content != "Hello from mock!" {
|
|
t.Errorf("Content = %q, want %q", resp.Content, "Hello from mock!")
|
|
}
|
|
if resp.FinishReason != "stop" {
|
|
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
|
}
|
|
if len(resp.ToolCalls) != 0 {
|
|
t.Errorf("ToolCalls len = %d, want 0", len(resp.ToolCalls))
|
|
}
|
|
if resp.Usage == nil {
|
|
t.Fatal("Usage should not be nil")
|
|
}
|
|
if resp.Usage.PromptTokens != 110 { // 10 + 100 + 0
|
|
t.Errorf("PromptTokens = %d, want 110", resp.Usage.PromptTokens)
|
|
}
|
|
if resp.Usage.CompletionTokens != 5 {
|
|
t.Errorf("CompletionTokens = %d, want 5", resp.Usage.CompletionTokens)
|
|
}
|
|
if resp.Usage.TotalTokens != 115 { // 110 + 5
|
|
t.Errorf("TotalTokens = %d, want 115", resp.Usage.TotalTokens)
|
|
}
|
|
}
|
|
|
|
func TestChat_IsErrorResponse(t *testing.T) {
|
|
mockJSON := `{"type":"result","subtype":"error","is_error":true,"result":"Rate limit exceeded","session_id":"s1","total_cost_usd":0}`
|
|
script := createMockCLI(t, mockJSON, "", 0)
|
|
|
|
p := NewClaudeCliProvider(t.TempDir())
|
|
p.command = script
|
|
|
|
_, err := p.Chat(context.Background(), []Message{
|
|
{Role: "user", Content: "Hello"},
|
|
}, nil, "", nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("Chat() expected error when is_error=true")
|
|
}
|
|
if !strings.Contains(err.Error(), "Rate limit exceeded") {
|
|
t.Errorf("error = %q, want to contain 'Rate limit exceeded'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestChat_WithToolCallsInResponse(t *testing.T) {
|
|
mockJSON := `{"type":"result","subtype":"success","is_error":false,"result":"Checking weather.\n{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"location\\\":\\\"NYC\\\"}\"}}]}","session_id":"s1","total_cost_usd":0.01,"usage":{"input_tokens":5,"output_tokens":20,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}`
|
|
script := createMockCLI(t, mockJSON, "", 0)
|
|
|
|
p := NewClaudeCliProvider(t.TempDir())
|
|
p.command = script
|
|
|
|
resp, err := p.Chat(context.Background(), []Message{
|
|
{Role: "user", Content: "What's the weather?"},
|
|
}, nil, "", nil)
|
|
|
|
if err != nil {
|
|
t.Fatalf("Chat() error = %v", err)
|
|
}
|
|
if resp.FinishReason != "tool_calls" {
|
|
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
|
|
}
|
|
if len(resp.ToolCalls) != 1 {
|
|
t.Fatalf("ToolCalls len = %d, want 1", len(resp.ToolCalls))
|
|
}
|
|
if resp.ToolCalls[0].Name != "get_weather" {
|
|
t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "get_weather")
|
|
}
|
|
if resp.ToolCalls[0].Arguments["location"] != "NYC" {
|
|
t.Errorf("ToolCalls[0].Arguments[location] = %v, want NYC", resp.ToolCalls[0].Arguments["location"])
|
|
}
|
|
}
|
|
|
|
func TestChat_StderrError(t *testing.T) {
|
|
script := createMockCLI(t, "", "Error: rate limited", 1)
|
|
|
|
p := NewClaudeCliProvider(t.TempDir())
|
|
p.command = script
|
|
|
|
_, err := p.Chat(context.Background(), []Message{
|
|
{Role: "user", Content: "Hello"},
|
|
}, nil, "", nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("Chat() expected error")
|
|
}
|
|
if !strings.Contains(err.Error(), "rate limited") {
|
|
t.Errorf("error = %q, want to contain 'rate limited'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestChat_NonZeroExitNoStderr(t *testing.T) {
|
|
script := createMockCLI(t, "", "", 1)
|
|
|
|
p := NewClaudeCliProvider(t.TempDir())
|
|
p.command = script
|
|
|
|
_, err := p.Chat(context.Background(), []Message{
|
|
{Role: "user", Content: "Hello"},
|
|
}, nil, "", nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("Chat() expected error for non-zero exit")
|
|
}
|
|
if !strings.Contains(err.Error(), "claude cli error") {
|
|
t.Errorf("error = %q, want to contain 'claude cli error'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestChat_CommandNotFound(t *testing.T) {
|
|
p := NewClaudeCliProvider(t.TempDir())
|
|
p.command = "/nonexistent/claude-binary-that-does-not-exist"
|
|
|
|
_, err := p.Chat(context.Background(), []Message{
|
|
{Role: "user", Content: "Hello"},
|
|
}, nil, "", nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("Chat() expected error for missing command")
|
|
}
|
|
}
|
|
|
|
func TestChat_InvalidResponseJSON(t *testing.T) {
|
|
script := createMockCLI(t, "not valid json at all", "", 0)
|
|
|
|
p := NewClaudeCliProvider(t.TempDir())
|
|
p.command = script
|
|
|
|
_, err := p.Chat(context.Background(), []Message{
|
|
{Role: "user", Content: "Hello"},
|
|
}, nil, "", nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("Chat() expected error for invalid JSON")
|
|
}
|
|
if !strings.Contains(err.Error(), "failed to parse claude cli response") {
|
|
t.Errorf("error = %q, want to contain 'failed to parse claude cli response'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestChat_ContextCancellation(t *testing.T) {
|
|
script := createSlowMockCLI(t, 2) // sleep 2s
|
|
|
|
p := NewClaudeCliProvider(t.TempDir())
|
|
p.command = script
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
|
|
start := time.Now()
|
|
_, err := p.Chat(ctx, []Message{
|
|
{Role: "user", Content: "Hello"},
|
|
}, nil, "", nil)
|
|
elapsed := time.Since(start)
|
|
|
|
if err == nil {
|
|
t.Fatal("Chat() expected error on context cancellation")
|
|
}
|
|
// Should fail well before the full 2s sleep completes
|
|
if elapsed > 3*time.Second {
|
|
t.Errorf("Chat() took %v, expected to fail faster via context cancellation", elapsed)
|
|
}
|
|
}
|
|
|
|
func TestChat_PassesSystemPromptFlag(t *testing.T) {
|
|
argsFile := filepath.Join(t.TempDir(), "args.txt")
|
|
script := createArgCaptureCLI(t, argsFile)
|
|
|
|
p := NewClaudeCliProvider(t.TempDir())
|
|
p.command = script
|
|
|
|
_, err := p.Chat(context.Background(), []Message{
|
|
{Role: "system", Content: "Be helpful."},
|
|
{Role: "user", Content: "Hi"},
|
|
}, nil, "", nil)
|
|
if err != nil {
|
|
t.Fatalf("Chat() error = %v", err)
|
|
}
|
|
|
|
argsBytes, err := os.ReadFile(argsFile)
|
|
if err != nil {
|
|
t.Fatalf("failed to read args file: %v", err)
|
|
}
|
|
args := string(argsBytes)
|
|
if !strings.Contains(args, "--system-prompt") {
|
|
t.Errorf("CLI args missing --system-prompt, got: %s", args)
|
|
}
|
|
}
|
|
|
|
func TestChat_PassesModelFlag(t *testing.T) {
|
|
argsFile := filepath.Join(t.TempDir(), "args.txt")
|
|
script := createArgCaptureCLI(t, argsFile)
|
|
|
|
p := NewClaudeCliProvider(t.TempDir())
|
|
p.command = script
|
|
|
|
_, err := p.Chat(context.Background(), []Message{
|
|
{Role: "user", Content: "Hi"},
|
|
}, nil, "claude-sonnet-4-5-20250929", nil)
|
|
if err != nil {
|
|
t.Fatalf("Chat() error = %v", err)
|
|
}
|
|
|
|
argsBytes, _ := os.ReadFile(argsFile)
|
|
args := string(argsBytes)
|
|
if !strings.Contains(args, "--model") {
|
|
t.Errorf("CLI args missing --model, got: %s", args)
|
|
}
|
|
if !strings.Contains(args, "claude-sonnet-4-5-20250929") {
|
|
t.Errorf("CLI args missing model name, got: %s", args)
|
|
}
|
|
}
|
|
|
|
func TestChat_SkipsModelFlagForClaudeCode(t *testing.T) {
|
|
argsFile := filepath.Join(t.TempDir(), "args.txt")
|
|
script := createArgCaptureCLI(t, argsFile)
|
|
|
|
p := NewClaudeCliProvider(t.TempDir())
|
|
p.command = script
|
|
|
|
_, err := p.Chat(context.Background(), []Message{
|
|
{Role: "user", Content: "Hi"},
|
|
}, nil, "claude-code", nil)
|
|
if err != nil {
|
|
t.Fatalf("Chat() error = %v", err)
|
|
}
|
|
|
|
argsBytes, _ := os.ReadFile(argsFile)
|
|
args := string(argsBytes)
|
|
if strings.Contains(args, "--model") {
|
|
t.Errorf("CLI args should NOT contain --model for claude-code, got: %s", args)
|
|
}
|
|
}
|
|
|
|
func TestChat_SkipsModelFlagForEmptyModel(t *testing.T) {
|
|
argsFile := filepath.Join(t.TempDir(), "args.txt")
|
|
script := createArgCaptureCLI(t, argsFile)
|
|
|
|
p := NewClaudeCliProvider(t.TempDir())
|
|
p.command = script
|
|
|
|
_, err := p.Chat(context.Background(), []Message{
|
|
{Role: "user", Content: "Hi"},
|
|
}, nil, "", nil)
|
|
if err != nil {
|
|
t.Fatalf("Chat() error = %v", err)
|
|
}
|
|
|
|
argsBytes, _ := os.ReadFile(argsFile)
|
|
args := string(argsBytes)
|
|
if strings.Contains(args, "--model") {
|
|
t.Errorf("CLI args should NOT contain --model for empty model, got: %s", args)
|
|
}
|
|
}
|
|
|
|
func TestChat_EmptyWorkspaceDoesNotSetDir(t *testing.T) {
|
|
mockJSON := `{"type":"result","result":"ok","session_id":"s"}`
|
|
script := createMockCLI(t, mockJSON, "", 0)
|
|
|
|
p := NewClaudeCliProvider("")
|
|
p.command = script
|
|
|
|
resp, err := p.Chat(context.Background(), []Message{
|
|
{Role: "user", Content: "Hello"},
|
|
}, nil, "", nil)
|
|
|
|
if err != nil {
|
|
t.Fatalf("Chat() with empty workspace error = %v", err)
|
|
}
|
|
if resp.Content != "ok" {
|
|
t.Errorf("Content = %q, want %q", resp.Content, "ok")
|
|
}
|
|
}
|
|
|
|
// --- CreateProvider factory tests ---
|
|
|
|
func TestCreateProvider_ClaudeCli(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Agents.Defaults.Provider = "claude-cli"
|
|
cfg.Agents.Defaults.Workspace = "/test/ws"
|
|
|
|
provider, err := CreateProvider(cfg)
|
|
if err != nil {
|
|
t.Fatalf("CreateProvider(claude-cli) error = %v", err)
|
|
}
|
|
|
|
cliProvider, ok := provider.(*ClaudeCliProvider)
|
|
if !ok {
|
|
t.Fatalf("CreateProvider(claude-cli) returned %T, want *ClaudeCliProvider", provider)
|
|
}
|
|
if cliProvider.workspace != "/test/ws" {
|
|
t.Errorf("workspace = %q, want %q", cliProvider.workspace, "/test/ws")
|
|
}
|
|
}
|
|
|
|
func TestCreateProvider_ClaudeCode(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Agents.Defaults.Provider = "claude-code"
|
|
|
|
provider, err := CreateProvider(cfg)
|
|
if err != nil {
|
|
t.Fatalf("CreateProvider(claude-code) error = %v", err)
|
|
}
|
|
if _, ok := provider.(*ClaudeCliProvider); !ok {
|
|
t.Fatalf("CreateProvider(claude-code) returned %T, want *ClaudeCliProvider", provider)
|
|
}
|
|
}
|
|
|
|
func TestCreateProvider_ClaudeCodec(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Agents.Defaults.Provider = "claudecode"
|
|
|
|
provider, err := CreateProvider(cfg)
|
|
if err != nil {
|
|
t.Fatalf("CreateProvider(claudecode) error = %v", err)
|
|
}
|
|
if _, ok := provider.(*ClaudeCliProvider); !ok {
|
|
t.Fatalf("CreateProvider(claudecode) returned %T, want *ClaudeCliProvider", provider)
|
|
}
|
|
}
|
|
|
|
func TestCreateProvider_ClaudeCliDefaultWorkspace(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Agents.Defaults.Provider = "claude-cli"
|
|
cfg.Agents.Defaults.Workspace = ""
|
|
|
|
provider, err := CreateProvider(cfg)
|
|
if err != nil {
|
|
t.Fatalf("CreateProvider error = %v", err)
|
|
}
|
|
|
|
cliProvider, ok := provider.(*ClaudeCliProvider)
|
|
if !ok {
|
|
t.Fatalf("returned %T, want *ClaudeCliProvider", provider)
|
|
}
|
|
if cliProvider.workspace != "." {
|
|
t.Errorf("workspace = %q, want %q (default)", cliProvider.workspace, ".")
|
|
}
|
|
}
|
|
|
|
// --- messagesToPrompt tests ---
|
|
|
|
func TestMessagesToPrompt_SingleUser(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
messages := []Message{
|
|
{Role: "user", Content: "Hello"},
|
|
}
|
|
got := p.messagesToPrompt(messages)
|
|
want := "Hello"
|
|
if got != want {
|
|
t.Errorf("messagesToPrompt() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestMessagesToPrompt_Conversation(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
messages := []Message{
|
|
{Role: "user", Content: "Hi"},
|
|
{Role: "assistant", Content: "Hello!"},
|
|
{Role: "user", Content: "How are you?"},
|
|
}
|
|
got := p.messagesToPrompt(messages)
|
|
want := "User: Hi\nAssistant: Hello!\nUser: How are you?"
|
|
if got != want {
|
|
t.Errorf("messagesToPrompt() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestMessagesToPrompt_WithSystemMessage(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
messages := []Message{
|
|
{Role: "system", Content: "You are helpful."},
|
|
{Role: "user", Content: "Hello"},
|
|
}
|
|
got := p.messagesToPrompt(messages)
|
|
want := "Hello"
|
|
if got != want {
|
|
t.Errorf("messagesToPrompt() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestMessagesToPrompt_WithToolResults(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
messages := []Message{
|
|
{Role: "user", Content: "What's the weather?"},
|
|
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_123"},
|
|
}
|
|
got := p.messagesToPrompt(messages)
|
|
if !strings.Contains(got, "[Tool Result for call_123]") {
|
|
t.Errorf("messagesToPrompt() missing tool result marker, got %q", got)
|
|
}
|
|
if !strings.Contains(got, `{"temp": 72}`) {
|
|
t.Errorf("messagesToPrompt() missing tool result content, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestMessagesToPrompt_EmptyMessages(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
got := p.messagesToPrompt(nil)
|
|
if got != "" {
|
|
t.Errorf("messagesToPrompt(nil) = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestMessagesToPrompt_OnlySystemMessages(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
messages := []Message{
|
|
{Role: "system", Content: "System 1"},
|
|
{Role: "system", Content: "System 2"},
|
|
}
|
|
got := p.messagesToPrompt(messages)
|
|
if got != "" {
|
|
t.Errorf("messagesToPrompt() with only system msgs = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
// --- buildSystemPrompt tests ---
|
|
|
|
func TestBuildSystemPrompt_NoSystemNoTools(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
messages := []Message{
|
|
{Role: "user", Content: "Hi"},
|
|
}
|
|
got := p.buildSystemPrompt(messages, nil)
|
|
if got != "" {
|
|
t.Errorf("buildSystemPrompt() = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildSystemPrompt_SystemOnly(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
messages := []Message{
|
|
{Role: "system", Content: "You are helpful."},
|
|
{Role: "user", Content: "Hi"},
|
|
}
|
|
got := p.buildSystemPrompt(messages, nil)
|
|
if got != "You are helpful." {
|
|
t.Errorf("buildSystemPrompt() = %q, want %q", got, "You are helpful.")
|
|
}
|
|
}
|
|
|
|
func TestBuildSystemPrompt_MultipleSystemMessages(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
messages := []Message{
|
|
{Role: "system", Content: "You are helpful."},
|
|
{Role: "system", Content: "Be concise."},
|
|
{Role: "user", Content: "Hi"},
|
|
}
|
|
got := p.buildSystemPrompt(messages, nil)
|
|
if !strings.Contains(got, "You are helpful.") {
|
|
t.Error("missing first system message")
|
|
}
|
|
if !strings.Contains(got, "Be concise.") {
|
|
t.Error("missing second system message")
|
|
}
|
|
// Should be joined with double newline
|
|
want := "You are helpful.\n\nBe concise."
|
|
if got != want {
|
|
t.Errorf("buildSystemPrompt() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestBuildSystemPrompt_WithTools(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
messages := []Message{
|
|
{Role: "system", Content: "You are helpful."},
|
|
}
|
|
tools := []ToolDefinition{
|
|
{
|
|
Type: "function",
|
|
Function: ToolFunctionDefinition{
|
|
Name: "get_weather",
|
|
Description: "Get weather for a location",
|
|
Parameters: map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"location": map[string]interface{}{"type": "string"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
got := p.buildSystemPrompt(messages, tools)
|
|
if !strings.Contains(got, "You are helpful.") {
|
|
t.Error("buildSystemPrompt() missing system message")
|
|
}
|
|
if !strings.Contains(got, "get_weather") {
|
|
t.Error("buildSystemPrompt() missing tool definition")
|
|
}
|
|
if !strings.Contains(got, "Available Tools") {
|
|
t.Error("buildSystemPrompt() missing tools header")
|
|
}
|
|
}
|
|
|
|
func TestBuildSystemPrompt_ToolsOnlyNoSystem(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
tools := []ToolDefinition{
|
|
{
|
|
Type: "function",
|
|
Function: ToolFunctionDefinition{
|
|
Name: "test_tool",
|
|
Description: "A test tool",
|
|
},
|
|
},
|
|
}
|
|
got := p.buildSystemPrompt(nil, tools)
|
|
if !strings.Contains(got, "test_tool") {
|
|
t.Error("should include tool definitions even without system messages")
|
|
}
|
|
}
|
|
|
|
// --- buildToolsPrompt tests ---
|
|
|
|
func TestBuildToolsPrompt_SkipsNonFunction(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
tools := []ToolDefinition{
|
|
{Type: "other", Function: ToolFunctionDefinition{Name: "skip_me"}},
|
|
{Type: "function", Function: ToolFunctionDefinition{Name: "include_me", Description: "Included"}},
|
|
}
|
|
got := p.buildToolsPrompt(tools)
|
|
if strings.Contains(got, "skip_me") {
|
|
t.Error("buildToolsPrompt() should skip non-function tools")
|
|
}
|
|
if !strings.Contains(got, "include_me") {
|
|
t.Error("buildToolsPrompt() should include function tools")
|
|
}
|
|
}
|
|
|
|
func TestBuildToolsPrompt_NoDescription(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
tools := []ToolDefinition{
|
|
{Type: "function", Function: ToolFunctionDefinition{Name: "bare_tool"}},
|
|
}
|
|
got := p.buildToolsPrompt(tools)
|
|
if !strings.Contains(got, "bare_tool") {
|
|
t.Error("should include tool name")
|
|
}
|
|
if strings.Contains(got, "Description:") {
|
|
t.Error("should not include Description: line when empty")
|
|
}
|
|
}
|
|
|
|
func TestBuildToolsPrompt_NoParameters(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
tools := []ToolDefinition{
|
|
{Type: "function", Function: ToolFunctionDefinition{
|
|
Name: "no_params_tool",
|
|
Description: "A tool with no parameters",
|
|
}},
|
|
}
|
|
got := p.buildToolsPrompt(tools)
|
|
if strings.Contains(got, "Parameters:") {
|
|
t.Error("should not include Parameters: section when nil")
|
|
}
|
|
}
|
|
|
|
// --- parseClaudeCliResponse tests ---
|
|
|
|
func TestParseClaudeCliResponse_TextOnly(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
output := `{"type":"result","subtype":"success","is_error":false,"result":"Hello, world!","session_id":"abc123","total_cost_usd":0.01,"duration_ms":500,"usage":{"input_tokens":10,"output_tokens":20,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}`
|
|
|
|
resp, err := p.parseClaudeCliResponse(output)
|
|
if err != nil {
|
|
t.Fatalf("parseClaudeCliResponse() error = %v", err)
|
|
}
|
|
if resp.Content != "Hello, world!" {
|
|
t.Errorf("Content = %q, want %q", resp.Content, "Hello, world!")
|
|
}
|
|
if resp.FinishReason != "stop" {
|
|
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
|
}
|
|
if len(resp.ToolCalls) != 0 {
|
|
t.Errorf("ToolCalls = %d, want 0", len(resp.ToolCalls))
|
|
}
|
|
if resp.Usage == nil {
|
|
t.Fatal("Usage should not be nil")
|
|
}
|
|
if resp.Usage.PromptTokens != 10 {
|
|
t.Errorf("PromptTokens = %d, want 10", resp.Usage.PromptTokens)
|
|
}
|
|
if resp.Usage.CompletionTokens != 20 {
|
|
t.Errorf("CompletionTokens = %d, want 20", resp.Usage.CompletionTokens)
|
|
}
|
|
}
|
|
|
|
func TestParseClaudeCliResponse_EmptyResult(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
output := `{"type":"result","subtype":"success","is_error":false,"result":"","session_id":"abc"}`
|
|
|
|
resp, err := p.parseClaudeCliResponse(output)
|
|
if err != nil {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
if resp.Content != "" {
|
|
t.Errorf("Content = %q, want empty", resp.Content)
|
|
}
|
|
if resp.FinishReason != "stop" {
|
|
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
|
}
|
|
}
|
|
|
|
func TestParseClaudeCliResponse_IsError(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
output := `{"type":"result","subtype":"error","is_error":true,"result":"Something went wrong","session_id":"abc"}`
|
|
|
|
_, err := p.parseClaudeCliResponse(output)
|
|
if err == nil {
|
|
t.Fatal("expected error when is_error=true")
|
|
}
|
|
if !strings.Contains(err.Error(), "Something went wrong") {
|
|
t.Errorf("error = %q, want to contain 'Something went wrong'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestParseClaudeCliResponse_NoUsage(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
output := `{"type":"result","subtype":"success","is_error":false,"result":"hi","session_id":"s"}`
|
|
|
|
resp, err := p.parseClaudeCliResponse(output)
|
|
if err != nil {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
if resp.Usage != nil {
|
|
t.Errorf("Usage should be nil when no tokens, got %+v", resp.Usage)
|
|
}
|
|
}
|
|
|
|
func TestParseClaudeCliResponse_InvalidJSON(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
_, err := p.parseClaudeCliResponse("not json")
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid JSON")
|
|
}
|
|
if !strings.Contains(err.Error(), "failed to parse claude cli response") {
|
|
t.Errorf("error = %q, want to contain 'failed to parse claude cli response'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestParseClaudeCliResponse_WithToolCalls(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
output := `{"type":"result","subtype":"success","is_error":false,"result":"Let me check.\n{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"location\\\":\\\"Tokyo\\\"}\"}}]}","session_id":"abc123","total_cost_usd":0.01}`
|
|
|
|
resp, err := p.parseClaudeCliResponse(output)
|
|
if err != nil {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
if resp.FinishReason != "tool_calls" {
|
|
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
|
|
}
|
|
if len(resp.ToolCalls) != 1 {
|
|
t.Fatalf("ToolCalls = %d, want 1", len(resp.ToolCalls))
|
|
}
|
|
tc := resp.ToolCalls[0]
|
|
if tc.Name != "get_weather" {
|
|
t.Errorf("Name = %q, want %q", tc.Name, "get_weather")
|
|
}
|
|
if tc.Function == nil {
|
|
t.Fatal("Function is nil")
|
|
}
|
|
if tc.Function.Name != "get_weather" {
|
|
t.Errorf("Function.Name = %q, want %q", tc.Function.Name, "get_weather")
|
|
}
|
|
if tc.Arguments["location"] != "Tokyo" {
|
|
t.Errorf("Arguments[location] = %v, want Tokyo", tc.Arguments["location"])
|
|
}
|
|
if strings.Contains(resp.Content, "tool_calls") {
|
|
t.Errorf("Content should not contain tool_calls JSON, got %q", resp.Content)
|
|
}
|
|
if resp.Content != "Let me check." {
|
|
t.Errorf("Content = %q, want %q", resp.Content, "Let me check.")
|
|
}
|
|
}
|
|
|
|
func TestParseClaudeCliResponse_WhitespaceResult(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
output := `{"type":"result","subtype":"success","is_error":false,"result":" hello \n ","session_id":"s"}`
|
|
|
|
resp, err := p.parseClaudeCliResponse(output)
|
|
if err != nil {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
if resp.Content != "hello" {
|
|
t.Errorf("Content = %q, want %q (should be trimmed)", resp.Content, "hello")
|
|
}
|
|
}
|
|
|
|
// --- extractToolCalls tests ---
|
|
|
|
func TestExtractToolCalls_NoToolCalls(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
got := p.extractToolCalls("Just a regular response.")
|
|
if len(got) != 0 {
|
|
t.Errorf("extractToolCalls() = %d, want 0", len(got))
|
|
}
|
|
}
|
|
|
|
func TestExtractToolCalls_WithToolCalls(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
text := `Here's the result:
|
|
{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"test","arguments":"{}"}}]}`
|
|
|
|
got := p.extractToolCalls(text)
|
|
if len(got) != 1 {
|
|
t.Fatalf("extractToolCalls() = %d, want 1", len(got))
|
|
}
|
|
if got[0].ID != "call_1" {
|
|
t.Errorf("ID = %q, want %q", got[0].ID, "call_1")
|
|
}
|
|
if got[0].Name != "test" {
|
|
t.Errorf("Name = %q, want %q", got[0].Name, "test")
|
|
}
|
|
if got[0].Type != "function" {
|
|
t.Errorf("Type = %q, want %q", got[0].Type, "function")
|
|
}
|
|
}
|
|
|
|
func TestExtractToolCalls_InvalidJSON(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
got := p.extractToolCalls(`{"tool_calls":invalid}`)
|
|
if len(got) != 0 {
|
|
t.Errorf("extractToolCalls() with invalid JSON = %d, want 0", len(got))
|
|
}
|
|
}
|
|
|
|
func TestExtractToolCalls_MultipleToolCalls(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
text := `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"/tmp/test\"}"}},{"id":"call_2","type":"function","function":{"name":"write_file","arguments":"{\"path\":\"/tmp/out\",\"content\":\"hello\"}"}}]}`
|
|
|
|
got := p.extractToolCalls(text)
|
|
if len(got) != 2 {
|
|
t.Fatalf("extractToolCalls() = %d, want 2", len(got))
|
|
}
|
|
if got[0].Name != "read_file" {
|
|
t.Errorf("[0].Name = %q, want %q", got[0].Name, "read_file")
|
|
}
|
|
if got[1].Name != "write_file" {
|
|
t.Errorf("[1].Name = %q, want %q", got[1].Name, "write_file")
|
|
}
|
|
// Verify arguments were parsed
|
|
if got[0].Arguments["path"] != "/tmp/test" {
|
|
t.Errorf("[0].Arguments[path] = %v, want /tmp/test", got[0].Arguments["path"])
|
|
}
|
|
if got[1].Arguments["content"] != "hello" {
|
|
t.Errorf("[1].Arguments[content] = %v, want hello", got[1].Arguments["content"])
|
|
}
|
|
}
|
|
|
|
func TestExtractToolCalls_UnmatchedBrace(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
got := p.extractToolCalls(`{"tool_calls":[{"id":"call_1"`)
|
|
if len(got) != 0 {
|
|
t.Errorf("extractToolCalls() with unmatched brace = %d, want 0", len(got))
|
|
}
|
|
}
|
|
|
|
func TestExtractToolCalls_ToolCallArgumentsParsing(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
text := `{"tool_calls":[{"id":"c1","type":"function","function":{"name":"fn","arguments":"{\"num\":42,\"flag\":true,\"name\":\"test\"}"}}]}`
|
|
|
|
got := p.extractToolCalls(text)
|
|
if len(got) != 1 {
|
|
t.Fatalf("len = %d, want 1", len(got))
|
|
}
|
|
// Verify different argument types
|
|
if got[0].Arguments["num"] != float64(42) {
|
|
t.Errorf("Arguments[num] = %v (%T), want 42", got[0].Arguments["num"], got[0].Arguments["num"])
|
|
}
|
|
if got[0].Arguments["flag"] != true {
|
|
t.Errorf("Arguments[flag] = %v, want true", got[0].Arguments["flag"])
|
|
}
|
|
if got[0].Arguments["name"] != "test" {
|
|
t.Errorf("Arguments[name] = %v, want test", got[0].Arguments["name"])
|
|
}
|
|
// Verify raw arguments string is preserved in FunctionCall
|
|
if got[0].Function.Arguments == "" {
|
|
t.Error("Function.Arguments should contain raw JSON string")
|
|
}
|
|
}
|
|
|
|
// --- stripToolCallsJSON tests ---
|
|
|
|
func TestStripToolCallsJSON(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
text := `Let me check the weather.
|
|
{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"test","arguments":"{}"}}]}
|
|
Done.`
|
|
|
|
got := p.stripToolCallsJSON(text)
|
|
if strings.Contains(got, "tool_calls") {
|
|
t.Errorf("should remove tool_calls JSON, got %q", got)
|
|
}
|
|
if !strings.Contains(got, "Let me check the weather.") {
|
|
t.Errorf("should keep text before, got %q", got)
|
|
}
|
|
if !strings.Contains(got, "Done.") {
|
|
t.Errorf("should keep text after, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestStripToolCallsJSON_NoToolCalls(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
text := "Just regular text."
|
|
got := p.stripToolCallsJSON(text)
|
|
if got != text {
|
|
t.Errorf("stripToolCallsJSON() = %q, want %q", got, text)
|
|
}
|
|
}
|
|
|
|
func TestStripToolCallsJSON_OnlyToolCalls(t *testing.T) {
|
|
p := NewClaudeCliProvider("/workspace")
|
|
text := `{"tool_calls":[{"id":"c1","type":"function","function":{"name":"fn","arguments":"{}"}}]}`
|
|
got := p.stripToolCallsJSON(text)
|
|
if got != "" {
|
|
t.Errorf("stripToolCallsJSON() = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
// --- findMatchingBrace tests ---
|
|
|
|
func TestFindMatchingBrace(t *testing.T) {
|
|
tests := []struct {
|
|
text string
|
|
pos int
|
|
want int
|
|
}{
|
|
{`{"a":1}`, 0, 7},
|
|
{`{"a":{"b":2}}`, 0, 13},
|
|
{`text {"a":1} more`, 5, 12},
|
|
{`{unclosed`, 0, 0}, // no match returns pos
|
|
{`{}`, 0, 2}, // empty object
|
|
{`{{{}}}`, 0, 6}, // deeply nested
|
|
{`{"a":"b{c}d"}`, 0, 13}, // braces in strings (simplified matcher)
|
|
}
|
|
for _, tt := range tests {
|
|
got := findMatchingBrace(tt.text, tt.pos)
|
|
if got != tt.want {
|
|
t.Errorf("findMatchingBrace(%q, %d) = %d, want %d", tt.text, tt.pos, got, tt.want)
|
|
}
|
|
}
|
|
}
|