Refactor command handlers into separate files to improve code organization and maintainability. Each command (agent, auth, cron, gateway, migrate, onboard, skills, status) now has its own dedicated file. Restructure provider creation to support new model_list configuration system that enables zero-code addition of OpenAI-compatible providers. Move legacy provider logic to separate file for backward compatibility. Move configuration functions from config.go to separate files (defaults.go, migration.go) for better organization.
981 lines
29 KiB
Go
981 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)
|
|
}
|
|
}
|
|
}
|