Spaces:
Sleeping
Sleeping
| package providers | |
| import ( | |
| "context" | |
| "encoding/json" | |
| "fmt" | |
| "os" | |
| "os/exec" | |
| "path/filepath" | |
| "strings" | |
| "testing" | |
| ) | |
| // --- JSONL Event Parsing Tests --- | |
| func TestParseJSONLEvents_AgentMessage(t *testing.T) { | |
| p := &CodexCliProvider{} | |
| events := `{"type":"thread.started","thread_id":"abc-123"} | |
| {"type":"turn.started"} | |
| {"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Hello from Codex!"}} | |
| {"type":"turn.completed","usage":{"input_tokens":100,"cached_input_tokens":50,"output_tokens":20}}` | |
| resp, err := p.parseJSONLEvents(events) | |
| if err != nil { | |
| t.Fatalf("parseJSONLEvents() error: %v", err) | |
| } | |
| if resp.Content != "Hello from Codex!" { | |
| t.Errorf("Content = %q, want %q", resp.Content, "Hello from Codex!") | |
| } | |
| 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 != 150 { | |
| t.Errorf("PromptTokens = %d, want 150", resp.Usage.PromptTokens) | |
| } | |
| if resp.Usage.CompletionTokens != 20 { | |
| t.Errorf("CompletionTokens = %d, want 20", resp.Usage.CompletionTokens) | |
| } | |
| if resp.Usage.TotalTokens != 170 { | |
| t.Errorf("TotalTokens = %d, want 170", resp.Usage.TotalTokens) | |
| } | |
| if len(resp.ToolCalls) != 0 { | |
| t.Errorf("ToolCalls should be empty, got %d", len(resp.ToolCalls)) | |
| } | |
| } | |
| func TestParseJSONLEvents_ToolCallExtraction(t *testing.T) { | |
| p := &CodexCliProvider{} | |
| toolCallText := `Let me read that file. | |
| {"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"/tmp/test.txt\"}"}}]}` | |
| // Build valid JSONL by marshaling the event | |
| item := codexEvent{ | |
| Type: "item.completed", | |
| Item: &codexEventItem{ID: "item_1", Type: "agent_message", Text: toolCallText}, | |
| } | |
| itemJSON, _ := json.Marshal(item) | |
| usageEvt := `{"type":"turn.completed","usage":{"input_tokens":50,"cached_input_tokens":0,"output_tokens":20}}` | |
| events := `{"type":"turn.started"}` + "\n" + string(itemJSON) + "\n" + usageEvt | |
| resp, err := p.parseJSONLEvents(events) | |
| if err != nil { | |
| t.Fatalf("parseJSONLEvents() 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 count = %d, want 1", len(resp.ToolCalls)) | |
| } | |
| if resp.ToolCalls[0].Name != "read_file" { | |
| t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "read_file") | |
| } | |
| if resp.ToolCalls[0].ID != "call_1" { | |
| t.Errorf("ToolCalls[0].ID = %q, want %q", resp.ToolCalls[0].ID, "call_1") | |
| } | |
| if resp.ToolCalls[0].Function.Arguments != `{"path":"/tmp/test.txt"}` { | |
| t.Errorf("ToolCalls[0].Function.Arguments = %q", resp.ToolCalls[0].Function.Arguments) | |
| } | |
| // Content should have the tool call JSON stripped | |
| if strings.Contains(resp.Content, "tool_calls") { | |
| t.Errorf("Content should not contain tool_calls JSON, got: %q", resp.Content) | |
| } | |
| } | |
| func TestParseJSONLEvents_MultipleToolCalls(t *testing.T) { | |
| p := &CodexCliProvider{} | |
| toolCallText := `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"a.txt\"}"}},{"id":"call_2","type":"function","function":{"name":"write_file","arguments":"{\"path\":\"b.txt\",\"content\":\"hello\"}"}}]}` | |
| item := codexEvent{ | |
| Type: "item.completed", | |
| Item: &codexEventItem{ID: "item_1", Type: "agent_message", Text: toolCallText}, | |
| } | |
| itemJSON, _ := json.Marshal(item) | |
| events := `{"type":"turn.started"}` + "\n" + string(itemJSON) + "\n" + `{"type":"turn.completed"}` | |
| resp, err := p.parseJSONLEvents(events) | |
| if err != nil { | |
| t.Fatalf("parseJSONLEvents() error: %v", err) | |
| } | |
| if len(resp.ToolCalls) != 2 { | |
| t.Fatalf("ToolCalls count = %d, want 2", len(resp.ToolCalls)) | |
| } | |
| if resp.ToolCalls[0].Name != "read_file" { | |
| t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "read_file") | |
| } | |
| if resp.ToolCalls[1].Name != "write_file" { | |
| t.Errorf("ToolCalls[1].Name = %q, want %q", resp.ToolCalls[1].Name, "write_file") | |
| } | |
| if resp.FinishReason != "tool_calls" { | |
| t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls") | |
| } | |
| } | |
| func TestParseJSONLEvents_MultipleMessages(t *testing.T) { | |
| p := &CodexCliProvider{} | |
| events := `{"type":"turn.started"} | |
| {"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"First part."}} | |
| {"type":"item.completed","item":{"id":"item_2","type":"command_execution","command":"ls","status":"completed"}} | |
| {"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"Second part."}} | |
| {"type":"turn.completed"}` | |
| resp, err := p.parseJSONLEvents(events) | |
| if err != nil { | |
| t.Fatalf("parseJSONLEvents() error: %v", err) | |
| } | |
| if resp.Content != "First part.\nSecond part." { | |
| t.Errorf("Content = %q, want %q", resp.Content, "First part.\nSecond part.") | |
| } | |
| } | |
| func TestParseJSONLEvents_ErrorEvent(t *testing.T) { | |
| p := &CodexCliProvider{} | |
| events := `{"type":"thread.started","thread_id":"abc"} | |
| {"type":"turn.started"} | |
| {"type":"error","message":"token expired"} | |
| {"type":"turn.failed","error":{"message":"token expired"}}` | |
| _, err := p.parseJSONLEvents(events) | |
| if err == nil { | |
| t.Fatal("expected error") | |
| } | |
| if !strings.Contains(err.Error(), "token expired") { | |
| t.Errorf("error = %q, want to contain 'token expired'", err.Error()) | |
| } | |
| } | |
| func TestParseJSONLEvents_TurnFailed(t *testing.T) { | |
| p := &CodexCliProvider{} | |
| events := `{"type":"turn.started"} | |
| {"type":"turn.failed","error":{"message":"rate limit exceeded"}}` | |
| _, err := p.parseJSONLEvents(events) | |
| if err == nil { | |
| t.Fatal("expected error") | |
| } | |
| if !strings.Contains(err.Error(), "rate limit exceeded") { | |
| t.Errorf("error = %q, want to contain 'rate limit exceeded'", err.Error()) | |
| } | |
| } | |
| func TestParseJSONLEvents_ErrorWithContent(t *testing.T) { | |
| p := &CodexCliProvider{} | |
| // If there's an error but also content, return the content (partial success) | |
| events := `{"type":"turn.started"} | |
| {"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Partial result."}} | |
| {"type":"error","message":"connection reset"} | |
| {"type":"turn.failed","error":{"message":"connection reset"}}` | |
| resp, err := p.parseJSONLEvents(events) | |
| if err != nil { | |
| t.Fatalf("should not error when content exists: %v", err) | |
| } | |
| if resp.Content != "Partial result." { | |
| t.Errorf("Content = %q, want %q", resp.Content, "Partial result.") | |
| } | |
| } | |
| func TestParseJSONLEvents_EmptyOutput(t *testing.T) { | |
| p := &CodexCliProvider{} | |
| resp, err := p.parseJSONLEvents("") | |
| if err != nil { | |
| t.Fatalf("empty output should not error: %v", err) | |
| } | |
| if resp.Content != "" { | |
| t.Errorf("Content = %q, want empty", resp.Content) | |
| } | |
| } | |
| func TestParseJSONLEvents_MalformedLines(t *testing.T) { | |
| p := &CodexCliProvider{} | |
| events := `not json at all | |
| {"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Good line."}} | |
| another bad line | |
| {"type":"turn.completed","usage":{"input_tokens":10,"output_tokens":5}}` | |
| resp, err := p.parseJSONLEvents(events) | |
| if err != nil { | |
| t.Fatalf("should skip malformed lines: %v", err) | |
| } | |
| if resp.Content != "Good line." { | |
| t.Errorf("Content = %q, want %q", resp.Content, "Good line.") | |
| } | |
| if resp.Usage == nil || resp.Usage.TotalTokens != 15 { | |
| t.Errorf("Usage.TotalTokens = %v, want 15", resp.Usage) | |
| } | |
| } | |
| func TestParseJSONLEvents_CommandExecution(t *testing.T) { | |
| p := &CodexCliProvider{} | |
| events := `{"type":"turn.started"} | |
| {"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","status":"in_progress"}} | |
| {"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","status":"completed","exit_code":0,"output":"file1.go\nfile2.go"}} | |
| {"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"Found 2 files."}} | |
| {"type":"turn.completed"}` | |
| resp, err := p.parseJSONLEvents(events) | |
| if err != nil { | |
| t.Fatalf("parseJSONLEvents() error: %v", err) | |
| } | |
| // command_execution items should be skipped; only agent_message text is returned | |
| if resp.Content != "Found 2 files." { | |
| t.Errorf("Content = %q, want %q", resp.Content, "Found 2 files.") | |
| } | |
| } | |
| func TestParseJSONLEvents_NoUsage(t *testing.T) { | |
| p := &CodexCliProvider{} | |
| events := `{"type":"turn.started"} | |
| {"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"No usage info."}} | |
| {"type":"turn.completed"}` | |
| resp, err := p.parseJSONLEvents(events) | |
| if err != nil { | |
| t.Fatalf("parseJSONLEvents() error: %v", err) | |
| } | |
| if resp.Usage != nil { | |
| t.Errorf("Usage should be nil when turn.completed has no usage, got %+v", resp.Usage) | |
| } | |
| } | |
| // --- Prompt Building Tests --- | |
| func TestBuildPrompt_SystemAsInstructions(t *testing.T) { | |
| p := &CodexCliProvider{} | |
| messages := []Message{ | |
| {Role: "system", Content: "You are helpful."}, | |
| {Role: "user", Content: "Hi there"}, | |
| } | |
| prompt := p.buildPrompt(messages, nil) | |
| if !strings.Contains(prompt, "## System Instructions") { | |
| t.Error("prompt should contain '## System Instructions'") | |
| } | |
| if !strings.Contains(prompt, "You are helpful.") { | |
| t.Error("prompt should contain system content") | |
| } | |
| if !strings.Contains(prompt, "## Task") { | |
| t.Error("prompt should contain '## Task'") | |
| } | |
| if !strings.Contains(prompt, "Hi there") { | |
| t.Error("prompt should contain user message") | |
| } | |
| } | |
| func TestBuildPrompt_NoSystem(t *testing.T) { | |
| p := &CodexCliProvider{} | |
| messages := []Message{ | |
| {Role: "user", Content: "Just a question"}, | |
| } | |
| prompt := p.buildPrompt(messages, nil) | |
| if strings.Contains(prompt, "## System Instructions") { | |
| t.Error("prompt should not contain system instructions header") | |
| } | |
| if prompt != "Just a question" { | |
| t.Errorf("prompt = %q, want %q", prompt, "Just a question") | |
| } | |
| } | |
| func TestBuildPrompt_WithTools(t *testing.T) { | |
| p := &CodexCliProvider{} | |
| messages := []Message{ | |
| {Role: "user", Content: "Get weather"}, | |
| } | |
| tools := []ToolDefinition{ | |
| { | |
| Type: "function", | |
| Function: ToolFunctionDefinition{ | |
| Name: "get_weather", | |
| Description: "Get current weather", | |
| Parameters: map[string]interface{}{ | |
| "type": "object", | |
| "properties": map[string]interface{}{ | |
| "city": map[string]interface{}{"type": "string"}, | |
| }, | |
| }, | |
| }, | |
| }, | |
| } | |
| prompt := p.buildPrompt(messages, tools) | |
| if !strings.Contains(prompt, "## Available Tools") { | |
| t.Error("prompt should contain tools section") | |
| } | |
| if !strings.Contains(prompt, "get_weather") { | |
| t.Error("prompt should contain tool name") | |
| } | |
| if !strings.Contains(prompt, "Get current weather") { | |
| t.Error("prompt should contain tool description") | |
| } | |
| } | |
| func TestBuildPrompt_MultipleMessages(t *testing.T) { | |
| p := &CodexCliProvider{} | |
| messages := []Message{ | |
| {Role: "user", Content: "Hello"}, | |
| {Role: "assistant", Content: "Hi! How can I help?"}, | |
| {Role: "user", Content: "Tell me about Go"}, | |
| } | |
| prompt := p.buildPrompt(messages, nil) | |
| if !strings.Contains(prompt, "Hello") { | |
| t.Error("prompt should contain first user message") | |
| } | |
| if !strings.Contains(prompt, "Assistant: Hi! How can I help?") { | |
| t.Error("prompt should contain assistant message with prefix") | |
| } | |
| if !strings.Contains(prompt, "Tell me about Go") { | |
| t.Error("prompt should contain second user message") | |
| } | |
| } | |
| func TestBuildPrompt_ToolResults(t *testing.T) { | |
| p := &CodexCliProvider{} | |
| messages := []Message{ | |
| {Role: "user", Content: "Weather?"}, | |
| {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"}, | |
| } | |
| prompt := p.buildPrompt(messages, nil) | |
| if !strings.Contains(prompt, "[Tool Result for call_1]") { | |
| t.Error("prompt should contain tool result") | |
| } | |
| if !strings.Contains(prompt, `{"temp": 72}`) { | |
| t.Error("prompt should contain tool result content") | |
| } | |
| } | |
| func TestBuildPrompt_SystemAndTools(t *testing.T) { | |
| p := &CodexCliProvider{} | |
| messages := []Message{ | |
| {Role: "system", Content: "Be concise."}, | |
| {Role: "user", Content: "Do something"}, | |
| } | |
| tools := []ToolDefinition{ | |
| { | |
| Type: "function", | |
| Function: ToolFunctionDefinition{ | |
| Name: "my_tool", | |
| Description: "A tool", | |
| }, | |
| }, | |
| } | |
| prompt := p.buildPrompt(messages, tools) | |
| // System instructions should come first | |
| sysIdx := strings.Index(prompt, "## System Instructions") | |
| toolIdx := strings.Index(prompt, "## Available Tools") | |
| taskIdx := strings.Index(prompt, "## Task") | |
| if sysIdx == -1 || toolIdx == -1 || taskIdx == -1 { | |
| t.Fatal("prompt should contain all sections") | |
| } | |
| if sysIdx >= taskIdx { | |
| t.Error("system instructions should come before task") | |
| } | |
| if taskIdx >= toolIdx { | |
| t.Error("task section should come before tools in the output") | |
| } | |
| } | |
| // --- CLI Argument Tests --- | |
| func TestCodexCliProvider_GetDefaultModel(t *testing.T) { | |
| p := NewCodexCliProvider("") | |
| if got := p.GetDefaultModel(); got != "codex-cli" { | |
| t.Errorf("GetDefaultModel() = %q, want %q", got, "codex-cli") | |
| } | |
| } | |
| // --- Mock CLI Integration Test --- | |
| func createMockCodexCLI(t *testing.T, events []string) string { | |
| t.Helper() | |
| tmpDir := t.TempDir() | |
| scriptPath := filepath.Join(tmpDir, "codex") | |
| var sb strings.Builder | |
| sb.WriteString("#!/bin/bash\n") | |
| for _, event := range events { | |
| sb.WriteString(fmt.Sprintf("echo '%s'\n", event)) | |
| } | |
| if err := os.WriteFile(scriptPath, []byte(sb.String()), 0755); err != nil { | |
| t.Fatal(err) | |
| } | |
| return scriptPath | |
| } | |
| func TestCodexCliProvider_MockCLI_Success(t *testing.T) { | |
| scriptPath := createMockCodexCLI(t, []string{ | |
| `{"type":"thread.started","thread_id":"test-123"}`, | |
| `{"type":"turn.started"}`, | |
| `{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Mock response from Codex CLI"}}`, | |
| `{"type":"turn.completed","usage":{"input_tokens":50,"cached_input_tokens":10,"output_tokens":15}}`, | |
| }) | |
| p := &CodexCliProvider{ | |
| command: scriptPath, | |
| workspace: "", | |
| } | |
| messages := []Message{{Role: "user", Content: "Hello"}} | |
| resp, err := p.Chat(context.Background(), messages, nil, "", nil) | |
| if err != nil { | |
| t.Fatalf("Chat() error: %v", err) | |
| } | |
| if resp.Content != "Mock response from Codex CLI" { | |
| t.Errorf("Content = %q, want %q", resp.Content, "Mock response from Codex CLI") | |
| } | |
| if resp.Usage == nil { | |
| t.Fatal("Usage should not be nil") | |
| } | |
| if resp.Usage.PromptTokens != 60 { | |
| t.Errorf("PromptTokens = %d, want 60", resp.Usage.PromptTokens) | |
| } | |
| if resp.Usage.CompletionTokens != 15 { | |
| t.Errorf("CompletionTokens = %d, want 15", resp.Usage.CompletionTokens) | |
| } | |
| } | |
| func TestCodexCliProvider_MockCLI_Error(t *testing.T) { | |
| scriptPath := createMockCodexCLI(t, []string{ | |
| `{"type":"thread.started","thread_id":"test-err"}`, | |
| `{"type":"turn.started"}`, | |
| `{"type":"error","message":"auth token expired"}`, | |
| `{"type":"turn.failed","error":{"message":"auth token expired"}}`, | |
| }) | |
| p := &CodexCliProvider{ | |
| command: scriptPath, | |
| workspace: "", | |
| } | |
| messages := []Message{{Role: "user", Content: "Hello"}} | |
| _, err := p.Chat(context.Background(), messages, nil, "", nil) | |
| if err == nil { | |
| t.Fatal("expected error") | |
| } | |
| if !strings.Contains(err.Error(), "auth token expired") { | |
| t.Errorf("error = %q, want to contain 'auth token expired'", err.Error()) | |
| } | |
| } | |
| func TestCodexCliProvider_MockCLI_WithModel(t *testing.T) { | |
| // Mock script that captures args to verify model flag is passed | |
| tmpDir := t.TempDir() | |
| scriptPath := filepath.Join(tmpDir, "codex") | |
| script := `#!/bin/bash | |
| # Write args to a file for verification | |
| echo "$@" > "` + filepath.Join(tmpDir, "args.txt") + `" | |
| echo '{"type":"item.completed","item":{"id":"1","type":"agent_message","text":"ok"}}' | |
| echo '{"type":"turn.completed"}'` | |
| if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { | |
| t.Fatal(err) | |
| } | |
| p := &CodexCliProvider{ | |
| command: scriptPath, | |
| workspace: "/tmp/test-workspace", | |
| } | |
| messages := []Message{{Role: "user", Content: "test"}} | |
| _, err := p.Chat(context.Background(), messages, nil, "gpt-5.2-codex", nil) | |
| if err != nil { | |
| t.Fatalf("Chat() error: %v", err) | |
| } | |
| // Verify the args | |
| argsData, err := os.ReadFile(filepath.Join(tmpDir, "args.txt")) | |
| if err != nil { | |
| t.Fatalf("reading args: %v", err) | |
| } | |
| args := string(argsData) | |
| if !strings.Contains(args, "-m gpt-5.2-codex") { | |
| t.Errorf("args should contain model flag, got: %s", args) | |
| } | |
| if !strings.Contains(args, "-C /tmp/test-workspace") { | |
| t.Errorf("args should contain workspace flag, got: %s", args) | |
| } | |
| if !strings.Contains(args, "--json") { | |
| t.Errorf("args should contain --json, got: %s", args) | |
| } | |
| if !strings.Contains(args, "--dangerously-bypass-approvals-and-sandbox") { | |
| t.Errorf("args should contain bypass flag, got: %s", args) | |
| } | |
| } | |
| func TestCodexCliProvider_MockCLI_ContextCancel(t *testing.T) { | |
| // Script that sleeps forever | |
| tmpDir := t.TempDir() | |
| scriptPath := filepath.Join(tmpDir, "codex") | |
| script := "#!/bin/bash\nsleep 60" | |
| if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { | |
| t.Fatal(err) | |
| } | |
| p := &CodexCliProvider{ | |
| command: scriptPath, | |
| workspace: "", | |
| } | |
| ctx, cancel := context.WithCancel(context.Background()) | |
| cancel() // cancel immediately | |
| messages := []Message{{Role: "user", Content: "test"}} | |
| _, err := p.Chat(ctx, messages, nil, "", nil) | |
| if err == nil { | |
| t.Fatal("expected error on canceled context") | |
| } | |
| } | |
| func TestCodexCliProvider_EmptyCommand(t *testing.T) { | |
| p := &CodexCliProvider{command: ""} | |
| messages := []Message{{Role: "user", Content: "test"}} | |
| _, err := p.Chat(context.Background(), messages, nil, "", nil) | |
| if err == nil { | |
| t.Fatal("expected error for empty command") | |
| } | |
| } | |
| // --- Integration Test (requires real codex CLI with valid auth) --- | |
| func TestCodexCliProvider_Integration(t *testing.T) { | |
| if os.Getenv("PICOCLAW_INTEGRATION_TESTS") == "" { | |
| t.Skip("skipping integration test (set PICOCLAW_INTEGRATION_TESTS=1 to enable)") | |
| } | |
| // Verify codex is available | |
| codexPath, err := exec.LookPath("codex") | |
| if err != nil { | |
| t.Skip("codex CLI not found in PATH") | |
| } | |
| p := &CodexCliProvider{ | |
| command: codexPath, | |
| workspace: "", | |
| } | |
| messages := []Message{ | |
| {Role: "user", Content: "Respond with just the word 'hello' and nothing else."}, | |
| } | |
| resp, err := p.Chat(context.Background(), messages, nil, "", nil) | |
| if err != nil { | |
| t.Fatalf("Chat() error: %v", err) | |
| } | |
| lower := strings.ToLower(strings.TrimSpace(resp.Content)) | |
| if !strings.Contains(lower, "hello") { | |
| t.Errorf("Content = %q, expected to contain 'hello'", resp.Content) | |
| } | |
| } | |