From 0b2f6bc4670e5150634340259a234c62a718779b Mon Sep 17 00:00:00 2001 From: "Dome C." Date: Wed, 18 Mar 2026 16:48:35 +0700 Subject: [PATCH] feat: Add Qwen CLI provider support Add support for Qwen Code CLI (qwen) as a new LLM provider. This enables users to use the qwen CLI tool as a provider, similar to the existing claude-cli and codex-cli providers. Changes: - Add QwenCliProvider implementation with JSON event parsing - Add comprehensive unit tests (32 test cases) - Register qwen-cli protocol in factory_provider.go - Add providerTypeQwenCLI in factory.go - Add qwen-code to default model list in defaults.go --- pkg/config/defaults.go | 6 + pkg/providers/factory.go | 9 + pkg/providers/factory_provider.go | 9 +- pkg/providers/qwen_cli_provider.go | 244 +++++++++ pkg/providers/qwen_cli_provider_test.go | 696 ++++++++++++++++++++++++ 5 files changed, 963 insertions(+), 1 deletion(-) create mode 100644 pkg/providers/qwen_cli_provider.go create mode 100644 pkg/providers/qwen_cli_provider_test.go diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 9e8668779..c2da1758a 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -323,6 +323,12 @@ func DefaultConfig() *Config { AuthMethod: "oauth", }, + // Qwen CLI - Local CLI tool (requires `qwen` installed) + { + ModelName: "qwen-code", + Model: "qwen-cli/qwen-code", + }, + // Ollama (local) - https://ollama.com { ModelName: "llama3", diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go index d2afe2943..aa37604cf 100644 --- a/pkg/providers/factory.go +++ b/pkg/providers/factory.go @@ -21,6 +21,7 @@ const ( providerTypeCodexCLIToken providerTypeClaudeCLI providerTypeCodexCLI + providerTypeQwenCLI providerTypeGitHubCopilot ) @@ -182,6 +183,14 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { sel.providerType = providerTypeCodexCLI sel.workspace = workspace return sel, nil + case "qwen-cli", "qwen-code", "qwencode": + workspace := cfg.WorkspacePath() + if workspace == "" { + workspace = "." + } + sel.providerType = providerTypeQwenCLI + sel.workspace = workspace + return sel, nil case "deepseek": if cfg.Providers.DeepSeek.APIKey != "" { sel.apiKey = cfg.Providers.DeepSeek.APIKey diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index b7567f9fc..6e43c5ffd 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -56,7 +56,7 @@ func ExtractProtocol(model string) (protocol, modelID string) { // CreateProviderFromConfig creates a provider based on the ModelConfig. // It uses the protocol prefix in the Model field to determine which provider to create. // Supported protocols: openai, litellm, anthropic, anthropic-messages, antigravity, -// claude-cli, codex-cli, github-copilot +// claude-cli, codex-cli, qwen-cli, github-copilot // Returns the provider, the model ID (without protocol prefix), and any error. func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) { if cfg == nil { @@ -190,6 +190,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err } return NewCodexCliProvider(workspace), modelID, nil + case "qwen-cli", "qwencli": + workspace := cfg.Workspace + if workspace == "" { + workspace = "." + } + return NewQwenCliProvider(workspace), modelID, nil + case "github-copilot", "copilot": apiBase := cfg.APIBase if apiBase == "" { diff --git a/pkg/providers/qwen_cli_provider.go b/pkg/providers/qwen_cli_provider.go new file mode 100644 index 000000000..11bcc5b6d --- /dev/null +++ b/pkg/providers/qwen_cli_provider.go @@ -0,0 +1,244 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package providers + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" +) + +// QwenCliProvider implements LLMProvider by wrapping the qwen CLI as a subprocess. +type QwenCliProvider struct { + command string + workspace string +} + +// NewQwenCliProvider creates a new Qwen CLI provider. +func NewQwenCliProvider(workspace string) *QwenCliProvider { + return &QwenCliProvider{ + command: "qwen", + workspace: workspace, + } +} + +// Chat implements LLMProvider.Chat by executing the qwen CLI in non-interactive mode. +func (p *QwenCliProvider) Chat( + ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, +) (*LLMResponse, error) { + if p.command == "" { + return nil, fmt.Errorf("qwen command not configured") + } + + prompt := p.buildPrompt(messages, tools) + + args := []string{ + "-p", + "--output-format=json", + "--yolo", + } + if model != "" && model != "qwen-cli" { + args = append(args, "-m", model) + } + args = append(args, "-") // read prompt from stdin + + cmd := exec.CommandContext(ctx, p.command, args...) + cmd.Stdin = bytes.NewReader([]byte(prompt)) + if p.workspace != "" { + cmd.Dir = p.workspace + } + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + + // Parse JSON output even if exit code is non-zero, + // because qwen may write diagnostic noise to stderr + // but still produce valid JSON output. + if stdoutStr := stdout.String(); stdoutStr != "" { + resp, parseErr := p.parseJSONEvents(stdoutStr) + if parseErr == nil && resp != nil && (resp.Content != "" || len(resp.ToolCalls) > 0) { + return resp, nil + } + } + + if err != nil { + if ctx.Err() == context.Canceled { + return nil, ctx.Err() + } + if stderrStr := stderr.String(); stderrStr != "" { + return nil, fmt.Errorf("qwen cli error: %s", stderrStr) + } + return nil, fmt.Errorf("qwen cli error: %w", err) + } + + return p.parseJSONEvents(stdout.String()) +} + +// GetDefaultModel returns the default model identifier. +func (p *QwenCliProvider) GetDefaultModel() string { + return "qwen-cli" +} + +// buildPrompt converts messages to a prompt string for the Qwen CLI. +// System messages are prepended as instructions since Qwen CLI has no --system-prompt flag. +func (p *QwenCliProvider) buildPrompt(messages []Message, tools []ToolDefinition) string { + var systemParts []string + var conversationParts []string + + for _, msg := range messages { + switch msg.Role { + case "system": + systemParts = append(systemParts, msg.Content) + case "user": + conversationParts = append(conversationParts, msg.Content) + case "assistant": + conversationParts = append(conversationParts, "Assistant: "+msg.Content) + case "tool": + conversationParts = append(conversationParts, + fmt.Sprintf("[Tool Result for %s]: %s", msg.ToolCallID, msg.Content)) + } + } + + var sb strings.Builder + + if len(systemParts) > 0 { + sb.WriteString("## System Instructions\n\n") + sb.WriteString(strings.Join(systemParts, "\n\n")) + sb.WriteString("\n\n## Task\n\n") + } + + if len(tools) > 0 { + sb.WriteString(buildCLIToolsPrompt(tools)) + sb.WriteString("\n\n") + } + + // Simplify single user message (no prefix) + if len(conversationParts) == 1 && len(systemParts) == 0 && len(tools) == 0 { + return conversationParts[0] + } + + sb.WriteString(strings.Join(conversationParts, "\n")) + return sb.String() +} + +// qwenEvent represents a single event from qwen CLI JSON output. +type qwenEvent struct { + Type string `json:"type"` + Subtype string `json:"subtype,omitempty"` + UUID string `json:"uuid,omitempty"` + SessionID string `json:"session_id,omitempty"` + Message *qwenMessage `json:"message,omitempty"` + Result string `json:"result,omitempty"` + IsError bool `json:"is_error,omitempty"` + Usage *qwenUsage `json:"usage,omitempty"` + Error *qwenEventError `json:"error,omitempty"` +} + +type qwenMessage struct { + ID string `json:"id"` + Type string `json:"type"` + Role string `json:"role"` + Model string `json:"model,omitempty"` + Content []qwenContentBlock `json:"content,omitempty"` + Usage *qwenUsage `json:"usage,omitempty"` +} + +type qwenContentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` +} + +type qwenUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"` + TotalTokens int `json:"total_tokens,omitempty"` +} + +type qwenEventError struct { + Message string `json:"message"` +} + +// parseJSONEvents processes the JSON output from qwen CLI. +func (p *QwenCliProvider) parseJSONEvents(output string) (*LLMResponse, error) { + output = strings.TrimSpace(output) + if output == "" { + return nil, fmt.Errorf("qwen cli returned empty output") + } + + // Qwen CLI outputs a JSON array of events + var events []qwenEvent + if err := json.Unmarshal([]byte(output), &events); err != nil { + return nil, fmt.Errorf("failed to parse qwen cli response: %w", err) + } + + var contentParts []string + var usage *UsageInfo + var lastError string + + for _, event := range events { + switch event.Type { + case "assistant": + if event.Message != nil && len(event.Message.Content) > 0 { + for _, block := range event.Message.Content { + if block.Type == "text" && block.Text != "" { + contentParts = append(contentParts, block.Text) + } + } + } + case "result": + if event.IsError { + lastError = event.Result + if event.Error != nil && event.Error.Message != "" { + lastError = event.Error.Message + } + } else if event.Result != "" { + // Result may contain the full response text + // Only use it if we haven't collected content from assistant events + if len(contentParts) == 0 { + contentParts = append(contentParts, event.Result) + } + } + if event.Usage != nil { + usage = &UsageInfo{ + PromptTokens: event.Usage.InputTokens, + CompletionTokens: event.Usage.OutputTokens, + TotalTokens: event.Usage.TotalTokens, + } + } + case "error": + lastError = event.Error.Message + } + } + + if lastError != "" && len(contentParts) == 0 { + return nil, fmt.Errorf("qwen cli: %s", lastError) + } + + content := strings.Join(contentParts, "\n") + + // Extract tool calls from response text (same pattern as other CLI providers) + toolCalls := extractToolCallsFromText(content) + + finishReason := "stop" + if len(toolCalls) > 0 { + finishReason = "tool_calls" + content = stripToolCallsFromText(content) + } + + return &LLMResponse{ + Content: strings.TrimSpace(content), + ToolCalls: toolCalls, + FinishReason: finishReason, + Usage: usage, + }, nil +} diff --git a/pkg/providers/qwen_cli_provider_test.go b/pkg/providers/qwen_cli_provider_test.go new file mode 100644 index 000000000..12180f476 --- /dev/null +++ b/pkg/providers/qwen_cli_provider_test.go @@ -0,0 +1,696 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package providers + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/config" +) + +// --- Compile-time interface check --- + +var _ LLMProvider = (*QwenCliProvider)(nil) + +// --- Helper: create mock CLI scripts --- + +// createMockQwenCLI creates a temporary script that simulates the qwen CLI. +func createMockQwenCLI(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), 0o644); err != nil { + t.Fatal(err) + } + } + if stderr != "" { + if err := os.WriteFile(filepath.Join(dir, "stderr.txt"), []byte(stderr), 0o644); 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, "qwen") + if err := os.WriteFile(script, []byte(sb.String()), 0o755); err != nil { + t.Fatal(err) + } + return script +} + +// createSlowMockQwenCLI creates a script that sleeps before responding. +func createSlowMockQwenCLI(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, "qwen") + content := fmt.Sprintf(`#!/bin/sh +sleep %d +cat <<'EOFMOCK' +[{"type":"result","subtype":"success","is_error":false,"result":"late response"}] +EOFMOCK +`, sleepSeconds) + if err := os.WriteFile(script, []byte(content), 0o755); err != nil { + t.Fatal(err) + } + return script +} + +// createArgCaptureQwenCLI creates a script that captures CLI args to a file. +func createArgCaptureQwenCLI(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, "qwen") + content := fmt.Sprintf(`#!/bin/sh +echo "$@" > '%s' +cat <<'EOFMOCK' +[{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"test"}] +EOFMOCK +`, argsFile) + if err := os.WriteFile(script, []byte(content), 0o755); err != nil { + t.Fatal(err) + } + return script +} + +// --- Constructor tests --- + +func TestNewQwenCliProvider(t *testing.T) { + p := NewQwenCliProvider("/test/workspace") + if p == nil { + t.Fatal("NewQwenCliProvider returned nil") + } + if p.workspace != "/test/workspace" { + t.Errorf("workspace = %q, want %q", p.workspace, "/test/workspace") + } + if p.command != "qwen" { + t.Errorf("command = %q, want %q", p.command, "qwen") + } +} + +func TestNewQwenCliProvider_EmptyWorkspace(t *testing.T) { + p := NewQwenCliProvider("") + if p.workspace != "" { + t.Errorf("workspace = %q, want empty", p.workspace) + } +} + +// --- GetDefaultModel tests --- + +func TestQwenCliProvider_GetDefaultModel(t *testing.T) { + p := NewQwenCliProvider("/workspace") + if got := p.GetDefaultModel(); got != "qwen-cli" { + t.Errorf("GetDefaultModel() = %q, want %q", got, "qwen-cli") + } +} + +// --- Chat() tests --- + +func TestQwenChat_Success(t *testing.T) { + mockJSON := `[{"type":"system","subtype":"init"},{"type":"assistant","message":{"content":[{"type":"text","text":"Hello from mock!"}]}},{"type":"result","subtype":"success","is_error":false,"result":"Hello from mock!","usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}]` + script := createMockQwenCLI(t, mockJSON, "", 0) + + p := NewQwenCliProvider(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 != 10 { + t.Errorf("PromptTokens = %d, want 10", resp.Usage.PromptTokens) + } + if resp.Usage.CompletionTokens != 5 { + t.Errorf("CompletionTokens = %d, want 5", resp.Usage.CompletionTokens) + } + if resp.Usage.TotalTokens != 15 { + t.Errorf("TotalTokens = %d, want 15", resp.Usage.TotalTokens) + } +} + +func TestQwenChat_ResultOnly(t *testing.T) { + // Test when only result event is present (no assistant event) + mockJSON := `[{"type":"result","subtype":"success","is_error":false,"result":"Result only response","usage":{"input_tokens":5,"output_tokens":3}}]` + script := createMockQwenCLI(t, mockJSON, "", 0) + + p := NewQwenCliProvider(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 != "Result only response" { + t.Errorf("Content = %q, want %q", resp.Content, "Result only response") + } +} + +func TestQwenChat_IsErrorResponse(t *testing.T) { + mockJSON := `[{"type":"result","subtype":"error","is_error":true,"result":"API key invalid"}]` + script := createMockQwenCLI(t, mockJSON, "", 0) + + p := NewQwenCliProvider(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(), "API key invalid") { + t.Errorf("error = %q, want to contain 'API key invalid'", err.Error()) + } +} + +func TestQwenChat_WithToolCallsInResponse(t *testing.T) { + result := `Let me check the weather. +{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{\"location\":\"NYC\"}"}}]}` + mockJSON := fmt.Sprintf( + `[{"type":"result","subtype":"success","is_error":false,"result":%q,"usage":{"input_tokens":5,"output_tokens":20}}]`, + result, + ) + script := createMockQwenCLI(t, mockJSON, "", 0) + + p := NewQwenCliProvider(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 TestQwenChat_StderrError(t *testing.T) { + script := createMockQwenCLI(t, "", "Error: connection failed", 1) + + p := NewQwenCliProvider(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(), "connection failed") { + t.Errorf("error = %q, want to contain 'connection failed'", err.Error()) + } +} + +func TestQwenChat_NonZeroExitNoStderr(t *testing.T) { + script := createMockQwenCLI(t, "", "", 1) + + p := NewQwenCliProvider(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(), "qwen cli error") { + t.Errorf("error = %q, want to contain 'qwen cli error'", err.Error()) + } +} + +func TestQwenChat_CommandNotFound(t *testing.T) { + p := NewQwenCliProvider(t.TempDir()) + p.command = "/nonexistent/qwen-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 TestQwenChat_InvalidResponseJSON(t *testing.T) { + script := createMockQwenCLI(t, "not valid json", "", 0) + + p := NewQwenCliProvider(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 qwen cli response") { + t.Errorf("error = %q, want to contain 'failed to parse qwen cli response'", err.Error()) + } +} + +func TestQwenChat_EmptyResponse(t *testing.T) { + script := createMockQwenCLI(t, "", "", 0) + + p := NewQwenCliProvider(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 empty response") + } +} + +func TestQwenChat_ContextCancellation(t *testing.T) { + script := createSlowMockQwenCLI(t, 2) // sleep 2s + + p := NewQwenCliProvider(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 TestQwenChat_PassesModelFlag(t *testing.T) { + argsFile := filepath.Join(t.TempDir(), "args.txt") + script := createArgCaptureQwenCLI(t, argsFile) + + p := NewQwenCliProvider(t.TempDir()) + p.command = script + + _, err := p.Chat(context.Background(), []Message{ + {Role: "user", Content: "Hi"}, + }, nil, "qwen-max", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + argsBytes, _ := os.ReadFile(argsFile) + args := string(argsBytes) + if !strings.Contains(args, "-m") { + t.Errorf("CLI args missing -m, got: %s", args) + } + if !strings.Contains(args, "qwen-max") { + t.Errorf("CLI args missing model name, got: %s", args) + } +} + +func TestQwenChat_SkipsModelFlagForQwenCli(t *testing.T) { + argsFile := filepath.Join(t.TempDir(), "args.txt") + script := createArgCaptureQwenCLI(t, argsFile) + + p := NewQwenCliProvider(t.TempDir()) + p.command = script + + _, err := p.Chat(context.Background(), []Message{ + {Role: "user", Content: "Hi"}, + }, nil, "qwen-cli", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + argsBytes, _ := os.ReadFile(argsFile) + args := string(argsBytes) + if strings.Contains(args, "-m") { + t.Errorf("CLI args should NOT contain -m for qwen-cli, got: %s", args) + } +} + +func TestQwenChat_SkipsModelFlagForEmptyModel(t *testing.T) { + argsFile := filepath.Join(t.TempDir(), "args.txt") + script := createArgCaptureQwenCLI(t, argsFile) + + p := NewQwenCliProvider(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, "-m") { + t.Errorf("CLI args should NOT contain -m for empty model, got: %s", args) + } +} + +func TestQwenChat_EmptyWorkspaceDoesNotSetDir(t *testing.T) { + mockJSON := `[{"type":"result","result":"ok","session_id":"s"}]` + script := createMockQwenCLI(t, mockJSON, "", 0) + + p := NewQwenCliProvider("") + 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") + } +} + +// --- buildPrompt tests --- + +func TestQwenBuildPrompt_SingleUser(t *testing.T) { + p := NewQwenCliProvider("/workspace") + messages := []Message{ + {Role: "user", Content: "Hello"}, + } + got := p.buildPrompt(messages, nil) + want := "Hello" + if got != want { + t.Errorf("buildPrompt() = %q, want %q", got, want) + } +} + +func TestQwenBuildPrompt_Conversation(t *testing.T) { + p := NewQwenCliProvider("/workspace") + messages := []Message{ + {Role: "user", Content: "Hi"}, + {Role: "assistant", Content: "Hello!"}, + {Role: "user", Content: "How are you?"}, + } + got := p.buildPrompt(messages, nil) + want := "Hi\nAssistant: Hello!\nHow are you?" + if got != want { + t.Errorf("buildPrompt() = %q, want %q", got, want) + } +} + +func TestQwenBuildPrompt_WithSystemMessage(t *testing.T) { + p := NewQwenCliProvider("/workspace") + messages := []Message{ + {Role: "system", Content: "You are helpful."}, + {Role: "user", Content: "Hello"}, + } + got := p.buildPrompt(messages, nil) + if !strings.Contains(got, "## System Instructions") { + t.Error("missing system instructions header") + } + if !strings.Contains(got, "You are helpful.") { + t.Error("missing system message") + } + if !strings.Contains(got, "## Task") { + t.Error("missing task header") + } +} + +func TestQwenBuildPrompt_WithToolResults(t *testing.T) { + p := NewQwenCliProvider("/workspace") + messages := []Message{ + {Role: "user", Content: "What's the weather?"}, + {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_123"}, + } + got := p.buildPrompt(messages, nil) + if !strings.Contains(got, "[Tool Result for call_123]") { + t.Errorf("buildPrompt() missing tool result marker, got %q", got) + } + if !strings.Contains(got, `{"temp": 72}`) { + t.Errorf("buildPrompt() missing tool result content, got %q", got) + } +} + +func TestQwenBuildPrompt_WithTools(t *testing.T) { + p := NewQwenCliProvider("/workspace") + messages := []Message{ + {Role: "user", Content: "Hello"}, + } + tools := []ToolDefinition{ + { + Type: "function", + Function: ToolFunctionDefinition{ + Name: "get_weather", + Description: "Get weather for a location", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "location": map[string]any{"type": "string"}, + }, + }, + }, + }, + } + got := p.buildPrompt(messages, tools) + if !strings.Contains(got, "get_weather") { + t.Error("buildPrompt() missing tool definition") + } + if !strings.Contains(got, "Available Tools") { + t.Error("buildPrompt() missing tools header") + } +} + +func TestQwenBuildPrompt_EmptyMessages(t *testing.T) { + p := NewQwenCliProvider("/workspace") + got := p.buildPrompt(nil, nil) + if got != "" { + t.Errorf("buildPrompt(nil) = %q, want empty", got) + } +} + +// --- parseJSONEvents tests --- + +func TestQwenParseJSONEvents_Success(t *testing.T) { + p := NewQwenCliProvider("/workspace") + output := `[{"type":"result","subtype":"success","is_error":false,"result":"Hello, world!","usage":{"input_tokens":10,"output_tokens":20}}]` + + resp, err := p.parseJSONEvents(output) + if err != nil { + t.Fatalf("parseJSONEvents() 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 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 TestQwenParseJSONEvents_WithAssistantEvent(t *testing.T) { + p := NewQwenCliProvider("/workspace") + output := `[{"type":"assistant","message":{"content":[{"type":"text","text":"Assistant says hi"}]}},{"type":"result","subtype":"success","is_error":false,"result":"Result text","usage":{"input_tokens":5,"output_tokens":3}}]` + + resp, err := p.parseJSONEvents(output) + if err != nil { + t.Fatalf("parseJSONEvents() error = %v", err) + } + // Should prefer assistant event content over result + if !strings.Contains(resp.Content, "Assistant says hi") { + t.Errorf("Content should contain assistant text, got %q", resp.Content) + } +} + +func TestQwenParseJSONEvents_Error(t *testing.T) { + p := NewQwenCliProvider("/workspace") + output := `[{"type":"result","subtype":"error","is_error":true,"result":"Something went wrong"}]` + + _, err := p.parseJSONEvents(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 TestQwenParseJSONEvents_NoResultEvent(t *testing.T) { + p := NewQwenCliProvider("/workspace") + output := `[{"type":"system","subtype":"init"}]` + + resp, err := p.parseJSONEvents(output) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Content != "" { + t.Errorf("Content = %q, want empty", resp.Content) + } +} + +func TestQwenParseJSONEvents_EmptyOutput(t *testing.T) { + p := NewQwenCliProvider("/workspace") + _, err := p.parseJSONEvents("") + if err == nil { + t.Fatal("expected error for empty output") + } +} + +func TestQwenParseJSONEvents_InvalidJSON(t *testing.T) { + p := NewQwenCliProvider("/workspace") + _, err := p.parseJSONEvents("not json") + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestQwenParseJSONEvents_NoUsage(t *testing.T) { + p := NewQwenCliProvider("/workspace") + output := `[{"type":"result","subtype":"success","is_error":false,"result":"hi"}]` + + resp, err := p.parseJSONEvents(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 TestQwenParseJSONEvents_WithToolCalls(t *testing.T) { + p := NewQwenCliProvider("/workspace") + result := `Let me check. +{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{\"location\":\"Tokyo\"}"}}]}` + output := fmt.Sprintf(`[{"type":"result","subtype":"success","is_error":false,"result":%q}]`, result) + + resp, err := p.parseJSONEvents(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)) + } + if resp.ToolCalls[0].Name != "get_weather" { + t.Errorf("Name = %q, want %q", resp.ToolCalls[0].Name, "get_weather") + } +} + +// --- Factory integration tests --- + +func TestCreateProviderFromConfig_QwenCli(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "qwen-code", + Model: "qwen-cli/qwen-code", + Workspace: "/test/ws", + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig(qwen-cli) error = %v", err) + } + + if modelID != "qwen-code" { + t.Errorf("modelID = %q, want %q", modelID, "qwen-code") + } + + qwenProvider, ok := provider.(*QwenCliProvider) + if !ok { + t.Fatalf("CreateProviderFromConfig(qwen-cli) returned %T, want *QwenCliProvider", provider) + } + if qwenProvider.workspace != "/test/ws" { + t.Errorf("workspace = %q, want %q", qwenProvider.workspace, "/test/ws") + } +} + +func TestCreateProviderFromConfig_QwenCliDefaultWorkspace(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "qwen-code", + Model: "qwen-cli/qwen-code", + Workspace: "", + } + + provider, _, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig error = %v", err) + } + + qwenProvider, ok := provider.(*QwenCliProvider) + if !ok { + t.Fatalf("returned %T, want *QwenCliProvider", provider) + } + if qwenProvider.workspace != "." { + t.Errorf("workspace = %q, want %q (default)", qwenProvider.workspace, ".") + } +}