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
This commit is contained in:
parent
a1e8ee56f0
commit
0b2f6bc467
5 changed files with 963 additions and 1 deletions
|
|
@ -323,6 +323,12 @@ func DefaultConfig() *Config {
|
||||||
AuthMethod: "oauth",
|
AuthMethod: "oauth",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Qwen CLI - Local CLI tool (requires `qwen` installed)
|
||||||
|
{
|
||||||
|
ModelName: "qwen-code",
|
||||||
|
Model: "qwen-cli/qwen-code",
|
||||||
|
},
|
||||||
|
|
||||||
// Ollama (local) - https://ollama.com
|
// Ollama (local) - https://ollama.com
|
||||||
{
|
{
|
||||||
ModelName: "llama3",
|
ModelName: "llama3",
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ const (
|
||||||
providerTypeCodexCLIToken
|
providerTypeCodexCLIToken
|
||||||
providerTypeClaudeCLI
|
providerTypeClaudeCLI
|
||||||
providerTypeCodexCLI
|
providerTypeCodexCLI
|
||||||
|
providerTypeQwenCLI
|
||||||
providerTypeGitHubCopilot
|
providerTypeGitHubCopilot
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -182,6 +183,14 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
||||||
sel.providerType = providerTypeCodexCLI
|
sel.providerType = providerTypeCodexCLI
|
||||||
sel.workspace = workspace
|
sel.workspace = workspace
|
||||||
return sel, nil
|
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":
|
case "deepseek":
|
||||||
if cfg.Providers.DeepSeek.APIKey != "" {
|
if cfg.Providers.DeepSeek.APIKey != "" {
|
||||||
sel.apiKey = cfg.Providers.DeepSeek.APIKey
|
sel.apiKey = cfg.Providers.DeepSeek.APIKey
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ func ExtractProtocol(model string) (protocol, modelID string) {
|
||||||
// CreateProviderFromConfig creates a provider based on the ModelConfig.
|
// CreateProviderFromConfig creates a provider based on the ModelConfig.
|
||||||
// It uses the protocol prefix in the Model field to determine which provider to create.
|
// It uses the protocol prefix in the Model field to determine which provider to create.
|
||||||
// Supported protocols: openai, litellm, anthropic, anthropic-messages, antigravity,
|
// 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.
|
// Returns the provider, the model ID (without protocol prefix), and any error.
|
||||||
func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) {
|
func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
|
|
@ -190,6 +190,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
}
|
}
|
||||||
return NewCodexCliProvider(workspace), modelID, nil
|
return NewCodexCliProvider(workspace), modelID, nil
|
||||||
|
|
||||||
|
case "qwen-cli", "qwencli":
|
||||||
|
workspace := cfg.Workspace
|
||||||
|
if workspace == "" {
|
||||||
|
workspace = "."
|
||||||
|
}
|
||||||
|
return NewQwenCliProvider(workspace), modelID, nil
|
||||||
|
|
||||||
case "github-copilot", "copilot":
|
case "github-copilot", "copilot":
|
||||||
apiBase := cfg.APIBase
|
apiBase := cfg.APIBase
|
||||||
if apiBase == "" {
|
if apiBase == "" {
|
||||||
|
|
|
||||||
244
pkg/providers/qwen_cli_provider.go
Normal file
244
pkg/providers/qwen_cli_provider.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
696
pkg/providers/qwen_cli_provider_test.go
Normal file
696
pkg/providers/qwen_cli_provider_test.go
Normal file
|
|
@ -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, ".")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue