Files
cc-connect/agent/codex/provider_switch_test.go
2026-06-02 23:14:41 +08:00

359 lines
9.3 KiB
Go

package codex
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/chenhg5/cc-connect/config"
"github.com/chenhg5/cc-connect/core"
)
func skipIfNoConfig(t *testing.T) *config.Config {
t.Helper()
if os.Getenv("CC_SKIP_INTEGRATION") == "1" {
t.Skip("CC_SKIP_INTEGRATION=1")
}
if os.Getenv("CC_RUN_PROVIDER_INTEGRATION") != "1" {
t.Skip("set CC_RUN_PROVIDER_INTEGRATION=1 to run provider integration tests")
}
cfgPath := os.ExpandEnv("$HOME/.cc-connect/config.toml")
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
t.Skipf("config not found at %s", cfgPath)
}
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
cfg.ResolveProviderRefs()
return cfg
}
func configToCoreProv(p config.ProviderConfig) core.ProviderConfig {
cp := core.ProviderConfig{
Name: p.Name,
APIKey: p.APIKey,
BaseURL: p.BaseURL,
Model: p.Model,
Env: p.Env,
}
if p.Codex != nil {
cp.CodexWireAPI = p.Codex.WireAPI
cp.CodexHTTPHeaders = p.Codex.HTTPHeaders
}
for _, m := range p.Models {
cp.Models = append(cp.Models, core.ModelOption{Name: m.Model})
}
return cp
}
func envSliceToMap(env []string) map[string]string {
out := make(map[string]string, len(env))
for _, entry := range env {
key, value, ok := strings.Cut(entry, "=")
if !ok {
continue
}
out[key] = value
}
return out
}
func findCodexProject(cfg *config.Config) (name string, providers []core.ProviderConfig, workDir, codexHome string) {
for i := range cfg.Projects {
proj := &cfg.Projects[i]
if proj.Agent.Type != "codex" || len(proj.Agent.Providers) == 0 {
continue
}
for _, p := range proj.Agent.Providers {
providers = append(providers, configToCoreProv(p))
}
name = proj.Name
if wd, ok := proj.Agent.Options["work_dir"].(string); ok {
workDir = wd
}
if ch, ok := proj.Agent.Options["codex_home"].(string); ok {
codexHome = ch
}
return
}
return
}
func TestIntegration_Codex_ProviderSwitch_EnvVars(t *testing.T) {
cfg := skipIfNoConfig(t)
name, providers, _, _ := findCodexProject(cfg)
if name == "" {
t.Skip("no codex project with providers found")
}
if len(providers) < 2 {
t.Skipf("project %q has only %d provider(s), need at least 2", name, len(providers))
}
a := &Agent{
providers: providers,
activeIdx: -1,
}
p0, p1 := providers[0], providers[1]
t.Logf("provider[0]: name=%s model=%s base_url=%s", p0.Name, p0.Model, p0.BaseURL)
t.Logf("provider[1]: name=%s model=%s base_url=%s", p1.Name, p1.Model, p1.BaseURL)
a.SetActiveProvider(p0.Name)
a.mu.RLock()
env0 := envSliceToMap(a.providerEnvLocked())
a.mu.RUnlock()
if p0.BaseURL != "" {
if got := env0["OPENAI_BASE_URL"]; got != p0.BaseURL {
t.Errorf("provider[0] OPENAI_BASE_URL = %q, want %q", got, p0.BaseURL)
}
}
if p0.APIKey != "" {
if got := env0["OPENAI_API_KEY"]; got == "" {
t.Error("provider[0] OPENAI_API_KEY not set")
}
}
a.SetActiveProvider(p1.Name)
a.mu.RLock()
env1 := envSliceToMap(a.providerEnvLocked())
a.mu.RUnlock()
if p1.BaseURL != "" {
if got := env1["OPENAI_BASE_URL"]; got != p1.BaseURL {
t.Errorf("provider[1] OPENAI_BASE_URL = %q, want %q", got, p1.BaseURL)
}
}
if p1.APIKey != "" {
if got := env1["OPENAI_API_KEY"]; got == "" {
t.Error("provider[1] OPENAI_API_KEY not set")
}
}
if p0.BaseURL != p1.BaseURL {
if env0["OPENAI_BASE_URL"] == env1["OPENAI_BASE_URL"] {
t.Error("providers have different base_urls but OPENAI_BASE_URL didn't change after switch")
}
}
}
func TestIntegration_Codex_ProviderSwitch_SessionArgs(t *testing.T) {
cfg := skipIfNoConfig(t)
name, providers, workDir, codexHome := findCodexProject(cfg)
if name == "" {
t.Skip("no codex project with providers found")
}
if workDir == "" {
workDir = t.TempDir()
}
for _, prov := range providers {
t.Run(prov.Name, func(t *testing.T) {
a := &Agent{
model: "default-model",
providers: providers,
activeIdx: -1,
workDir: workDir,
codexHome: codexHome,
mode: "suggest",
backend: "exec",
}
a.SetActiveProvider(prov.Name)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
sess, err := a.StartSession(ctx, "")
if err != nil {
t.Fatalf("StartSession failed: %v", err)
}
defer sess.Close()
cs := sess.(*codexSession)
if prov.Model != "" && cs.model != prov.Model {
t.Errorf("session model = %q, want %q", cs.model, prov.Model)
}
if prov.BaseURL != "" && cs.baseURL != prov.BaseURL {
t.Errorf("session baseURL = %q, want %q", cs.baseURL, prov.BaseURL)
}
envMap := envSliceToMap(cs.extraEnv)
if prov.APIKey != "" {
if got := envMap["OPENAI_API_KEY"]; got == "" {
t.Error("OPENAI_API_KEY not set in session extraEnv")
}
}
if prov.BaseURL != "" {
if got := envMap["OPENAI_BASE_URL"]; got != prov.BaseURL {
t.Errorf("OPENAI_BASE_URL = %q, want %q", got, prov.BaseURL)
}
}
t.Logf("session OK: model=%s baseURL=%s modelProvider=%s",
cs.model, cs.baseURL, cs.modelProvider)
})
}
}
func TestIntegration_Codex_ProviderConfig_WrittenCorrectly(t *testing.T) {
cfg := skipIfNoConfig(t)
name, providers, _, _ := findCodexProject(cfg)
if name == "" {
t.Skip("no codex project with providers found")
}
for _, prov := range providers {
t.Run(prov.Name, func(t *testing.T) {
if prov.BaseURL == "" || prov.APIKey == "" {
t.Skip("provider has no base_url or api_key")
}
home := filepath.Join(t.TempDir(), ".codex")
err := ensureCodexProviderConfig(home, prov.Name, prov.BaseURL, prov.CodexWireAPI, prov.CodexHTTPHeaders)
if err != nil {
t.Fatalf("ensureCodexProviderConfig failed: %v", err)
}
cfgData, err := os.ReadFile(filepath.Join(home, "config.toml"))
if err != nil {
t.Fatalf("read config.toml: %v", err)
}
cfgContent := string(cfgData)
if !strings.Contains(cfgContent, `[model_providers.`+prov.Name+`]`) {
t.Errorf("config.toml missing provider section for %s", prov.Name)
}
if !strings.Contains(cfgContent, prov.BaseURL) {
t.Errorf("config.toml missing base_url %s", prov.BaseURL)
}
err = ensureCodexAuth(home, prov.APIKey)
if err != nil {
t.Fatalf("ensureCodexAuth failed: %v", err)
}
authData, err := os.ReadFile(filepath.Join(home, "auth.json"))
if err != nil {
t.Fatalf("read auth.json: %v", err)
}
var authMap map[string]any
if err := json.Unmarshal(authData, &authMap); err != nil {
t.Fatalf("parse auth.json: %v", err)
}
if authMap["OPENAI_API_KEY"] != prov.APIKey {
t.Error("OPENAI_API_KEY not written correctly to auth.json")
}
t.Logf("provider config written OK: name=%s wireAPI=%s", prov.Name, prov.CodexWireAPI)
})
}
}
func TestIntegration_Codex_ProviderSwitch_SendMessage(t *testing.T) {
cfg := skipIfNoConfig(t)
if _, err := exec.LookPath("codex"); err != nil {
t.Skip("codex CLI not in PATH")
}
name, providers, workDir, codexHome := findCodexProject(cfg)
if name == "" {
t.Skip("no codex project with providers found")
}
if codexHome == "" {
home, _ := os.UserHomeDir()
codexHome = filepath.Join(home, ".codex")
}
if workDir == "" {
workDir = t.TempDir()
gitInit := exec.Command("git", "init", "-q")
gitInit.Dir = workDir
if err := gitInit.Run(); err != nil {
t.Fatalf("git init: %v", err)
}
}
for _, prov := range providers {
t.Run(prov.Name, func(t *testing.T) {
if prov.APIKey == "" || prov.BaseURL == "" {
t.Skip("provider missing api_key or base_url")
}
a := &Agent{
model: "default-model",
providers: providers,
activeIdx: -1,
workDir: workDir,
codexHome: codexHome,
mode: "full-auto",
backend: "exec",
}
a.SetActiveProvider(prov.Name)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
sess, err := a.StartSession(ctx, "")
if err != nil {
t.Fatalf("StartSession failed: %v", err)
}
defer sess.Close()
err = sess.Send("reply with exactly 'codex-provider-ok' and nothing else", nil, nil)
if err != nil {
t.Fatalf("Send failed: %v", err)
}
timer := time.NewTimer(25 * time.Second)
defer timer.Stop()
var allText strings.Builder
for {
select {
case ev, ok := <-sess.Events():
if !ok {
if allText.Len() > 0 && strings.Contains(allText.String(), "codex-provider-ok") {
t.Logf("provider %s: send+receive OK (from text events)", prov.Name)
return
}
t.Fatalf("event channel closed, collected text: %s", allText.String())
return
}
t.Logf("event: type=%s content_len=%d", ev.Type, len(ev.Content))
if ev.Type == core.EventText || ev.Type == core.EventResult {
allText.WriteString(ev.Content)
}
if ev.Type == core.EventResult {
if strings.Contains(allText.String(), "codex-provider-ok") {
t.Logf("provider %s: send+receive OK", prov.Name)
} else {
t.Errorf("expected 'codex-provider-ok' in output, got: %s", allText.String())
}
return
}
if ev.Type == core.EventError {
t.Fatalf("agent error: %s", ev.Content)
}
case <-timer.C:
if strings.Contains(allText.String(), "codex-provider-ok") {
t.Logf("provider %s: send+receive OK (timeout but got result)", prov.Name)
return
}
t.Fatalf("timeout waiting for codex response, collected: %s", allText.String())
}
}
})
}
}