package launch import ( "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "runtime" "slices" "strings" "testing" "time" "github.com/google/go-cmp/cmp" "github.com/ollama/ollama/api" "github.com/ollama/ollama/cmd/internal/fileutil" "github.com/ollama/ollama/internal/proxy" "github.com/ollama/ollama/model/renderers" "github.com/ollama/ollama/types/model" ) func withCodexAppPlatform(t *testing.T, goos string) { t.Helper() old := codexAppGOOS codexAppGOOS = goos t.Cleanup(func() { codexAppGOOS = old }) } func withCodexAppRouterHealth(t *testing.T, health func() error) { t.Helper() old := codexAppRouterHealth codexAppRouterHealth = health t.Cleanup(func() { codexAppRouterHealth = old }) } func withCodexAppProcessHooks(t *testing.T, isRunning func() bool, quit func() error, open func() error) { t.Helper() oldIsRunning := codexAppIsRunning oldQuit := codexAppQuitApp oldOpen := codexAppOpenApp oldOpenPath := codexAppOpenPath oldOpenStart := codexAppOpenStart oldRunPath := codexAppRunPath oldStartID := codexAppStartID oldCanOpenID := codexAppCanOpenID oldExitTimeout := codexAppExitTimeout codexAppIsRunning = isRunning codexAppQuitApp = quit codexAppOpenApp = func([]string) error { return open() } t.Cleanup(func() { codexAppIsRunning = oldIsRunning codexAppQuitApp = oldQuit codexAppOpenApp = oldOpen codexAppOpenPath = oldOpenPath codexAppOpenStart = oldOpenStart codexAppRunPath = oldRunPath codexAppStartID = oldStartID codexAppCanOpenID = oldCanOpenID codexAppExitTimeout = oldExitTimeout }) } func stubCodexAppProcessCommands(t *testing.T, commands map[string]string) { t.Helper() if runtime.GOOS == "windows" { t.Skip("test commands require a POSIX shell") } binDir := t.TempDir() for name, script := range commands { if err := os.WriteFile(filepath.Join(binDir, name), []byte("#!/bin/sh\n"+script), 0o755); err != nil { t.Fatal(err) } } // Never fall back to the real osascript or another system command. t.Setenv("PATH", binDir) } func TestCodexAppIntegration(t *testing.T) { c := &CodexApp{} t.Run("implements runner", func(t *testing.T) { var _ Runner = c }) t.Run("implements supported integration", func(t *testing.T) { var _ SupportedIntegration = c }) t.Run("implements managed single model", func(t *testing.T) { var _ ManagedSingleModel = c }) t.Run("receives model list", func(t *testing.T) { var _ ManagedModelListConfigurer = c }) t.Run("onboarding is noninteractive", func(t *testing.T) { var _ ManagedInteractiveOnboarding = c if c.RequiresInteractiveOnboarding() { t.Fatal("Codex App onboarding should only mark launch config") } }) t.Run("implements restore", func(t *testing.T) { var _ RestorableIntegration = c var _ RestoreHintIntegration = c var _ ConfigurationSuccessIntegration = c var _ RestoreSuccessIntegration = c }) } func TestCodexAppNativeCatalogUsesDebugModelsInScratchHome(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(filepath.Dir(configPath), "auth.json"), []byte("test-auth"), 0o600); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(filepath.Dir(configPath), "models_cache.json"), []byte(`{"models":[{"slug":"cached"}]}`), 0o600); err != nil { t.Fatal(err) } oldExecutable := codexAppCodexExecutable oldRun := codexAppRunDebugModels codexAppCodexExecutable = func() (string, error) { return "/test/codex", nil } codexAppRunDebugModels = func(executable, codexHome string, bundled bool) ([]byte, error) { if executable != "/test/codex" || codexHome == "" || bundled { t.Fatalf("debug models call = executable %q, CODEX_HOME %q, bundled %v", executable, codexHome, bundled) } for name, want := range map[string]string{ "auth.json": "test-auth", "models_cache.json": `{"models":[{"slug":"cached"}]}`, } { data, err := os.ReadFile(filepath.Join(codexHome, name)) if err != nil || string(data) != want { t.Fatalf("scratch %s = %q, %v; want %q", name, data, err, want) } } return []byte(`{"models":[{"slug":"gpt-live","priority":1}]}`), nil } t.Cleanup(func() { codexAppCodexExecutable = oldExecutable codexAppRunDebugModels = oldRun }) data, err := defaultCodexAppNativeModelCatalog(configPath) if err != nil { t.Fatal(err) } catalog, err := parseCodexAppModelCatalog(data) if err != nil { t.Fatal(err) } slug, err := codexAppRawCatalogSlug(catalog.Models[0]) if err != nil || slug != "gpt-live" { t.Fatalf("native catalog slug = %q, %v; want gpt-live", slug, err) } } func TestCodexAppNativeCatalogFallsBackToCacheThenBundled(t *testing.T) { t.Run("cache", func(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(filepath.Dir(configPath), "models_cache.json"), []byte(`{"client_version":"test","models":[{"slug":"gpt-cached"}]}`), 0o600); err != nil { t.Fatal(err) } oldExecutable := codexAppCodexExecutable oldRun := codexAppRunDebugModels codexAppCodexExecutable = func() (string, error) { return "/test/codex", nil } codexAppRunDebugModels = func(_ string, _ string, bundled bool) ([]byte, error) { if bundled { t.Fatal("bundled fallback should not run when cache is valid") } return nil, errors.New("offline") } t.Cleanup(func() { codexAppCodexExecutable = oldExecutable codexAppRunDebugModels = oldRun }) data, err := defaultCodexAppNativeModelCatalog(configPath) if err != nil { t.Fatal(err) } if !strings.Contains(string(data), `"gpt-cached"`) || strings.Contains(string(data), "client_version") { t.Fatalf("normalized cached catalog = %s", data) } }) t.Run("bundled", func(t *testing.T) { configPath := filepath.Join(t.TempDir(), ".codex", "config.toml") oldExecutable := codexAppCodexExecutable oldRun := codexAppRunDebugModels codexAppCodexExecutable = func() (string, error) { return "/test/codex", nil } codexAppRunDebugModels = func(_ string, _ string, bundled bool) ([]byte, error) { if !bundled { return nil, errors.New("offline") } return []byte(`{"models":[{"slug":"gpt-bundled"}]}`), nil } t.Cleanup(func() { codexAppCodexExecutable = oldExecutable codexAppRunDebugModels = oldRun }) data, err := defaultCodexAppNativeModelCatalog(configPath) if err != nil { t.Fatal(err) } if !strings.Contains(string(data), `"gpt-bundled"`) { t.Fatalf("bundled catalog = %s", data) } }) } func TestCodexAppConfigureMarksOnlyOllamaModelsForNonChatGPTSessions(t *testing.T) { setTestHome(t, t.TempDir()) if err := (&CodexApp{}).ConfigureWithModels("llama3.2", testLaunchModels("llama3.2", "qwen3:8b")); err != nil { t.Fatal(err) } data, err := os.ReadFile(mustCodexAppModelCatalogPath(t)) if err != nil { t.Fatal(err) } var catalog struct { Models []map[string]any `json:"models"` } if err := json.Unmarshal(data, &catalog); err != nil { t.Fatal(err) } if got := catalogSlugs(catalog.Models); strings.Join(got, ",") != "llama3.2,qwen3:8b,gpt-5.6-sol" { t.Fatalf("catalog slugs = %v, want Ollama and ChatGPT models", got) } var nonChatGPTSlugs []string for _, entry := range catalog.Models { slug, _ := entry["slug"].(string) if supported, _ := entry["supported_in_api"].(bool); supported { nonChatGPTSlugs = append(nonChatGPTSlugs, slug) } } if got := strings.Join(nonChatGPTSlugs, ","); got != "llama3.2,qwen3:8b" { t.Fatalf("non-ChatGPT model slugs = %q, want only selected Ollama models", got) } } func TestCodexAppConfigureKeepsPickerOrderWhenPrimaryDiffers(t *testing.T) { want := []string{ "kimi-k3:cloud", "glm-5.3:cloud", "glm-5.3-flash:cloud", "deepseek-v4-flash:cloud", "gemma4:31b-cloud", } for _, primary := range []string{"glm-5.3-flash:cloud", "gemma4:31b-cloud"} { t.Run(primary, func(t *testing.T) { setTestHome(t, t.TempDir()) app := &CodexApp{} if err := app.ConfigureWithModels(primary, testLaunchModels(want...)); err != nil { t.Fatal(err) } if got := app.CurrentModel(); got != primary { t.Errorf("configured default = %q, want %q", got, primary) } data, err := os.ReadFile(mustCodexAppModelCatalogPath(t)) if err != nil { t.Fatal(err) } var catalog struct { Models []struct { Slug string `json:"slug"` Priority int `json:"priority"` } `json:"models"` } if err := json.Unmarshal(data, &catalog); err != nil { t.Fatal(err) } if len(catalog.Models) != len(want)+1 { t.Fatalf("catalog = %#v, want five Ollama models and the native model", catalog) } for i, model := range catalog.Models { if i < len(want) && model.Slug != want[i] { t.Errorf("model %d = %q, want %q", i, model.Slug, want[i]) } if i > 0 && model.Priority <= catalog.Models[i-1].Priority { t.Errorf("model %q priority = %d, want after %q", model.Slug, model.Priority, catalog.Models[i-1].Slug) } } }) } } func TestCodexAppRouterHealth(t *testing.T) { t.Run("ready", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != proxy.CodexDesktopPathPrefix+"/_health" { t.Fatalf("health path = %q", r.URL.Path) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"ok":true}`)) })) defer server.Close() t.Setenv("OLLAMA_HOST", server.URL) if err := defaultCodexAppRouterHealth(); err != nil { t.Fatal(err) } }) t.Run("old server", func(t *testing.T) { server := httptest.NewServer(http.NotFoundHandler()) defer server.Close() t.Setenv("OLLAMA_HOST", server.URL) err := defaultCodexAppRouterHealth() if err == nil || !strings.Contains(err.Error(), "does not include ChatGPT routing") { t.Fatalf("health error = %v", err) } }) } func TestCodexAppDesktopChecksRouterBeforeChangingProfile(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) withCodexAppPlatform(t, "darwin") healthErr := errors.New("router unavailable") withCodexAppRouterHealth(t, func() error { return healthErr }) configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } original := "model = \"gpt-5.5\"\nmodel_provider = \"openai\"\n" if err := os.WriteFile(configPath, []byte(original), 0o600); err != nil { t.Fatal(err) } openCalls := 0 quitCalls := 0 oldCanOpenID := codexAppCanOpenID codexAppCanOpenID = func() bool { return true } t.Cleanup(func() { codexAppCanOpenID = oldCanOpenID }) withCodexAppProcessHooks(t, func() bool { return true }, func() error { quitCalls++; return nil }, func() error { openCalls++; return nil }, ) err := (&CodexApp{}).UseOllamaFromDesktop("qwen3:8b", testLaunchModels("qwen3:8b"), false) if !errors.Is(err, healthErr) { t.Fatalf("UseOllamaFromDesktop error = %v", err) } if quitCalls != 0 || openCalls != 0 { t.Fatalf("process calls = quit %d, open %d; want none", quitCalls, openCalls) } data, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } if string(data) != original { t.Fatalf("config changed before router health check: %q", data) } } func TestCodexAppDesktopRequiresRestartConfirmationBeforeChangingProfile(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) withCodexAppPlatform(t, "darwin") withCodexAppRouterHealth(t, func() error { return nil }) configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } original := "model = \"gpt-5.5\"\nmodel_provider = \"openai\"\n" if err := os.WriteFile(configPath, []byte(original), 0o600); err != nil { t.Fatal(err) } openCalls := 0 quitCalls := 0 oldCanOpenID := codexAppCanOpenID codexAppCanOpenID = func() bool { return true } t.Cleanup(func() { codexAppCanOpenID = oldCanOpenID }) withCodexAppProcessHooks(t, func() bool { return true }, func() error { quitCalls++; return nil }, func() error { openCalls++; return nil }, ) err := (&CodexApp{}).UseOllamaFromDesktop("qwen3:8b", testLaunchModels("qwen3:8b"), false) if !errors.Is(err, ErrCodexAppRestartConfirmationRequired) { t.Fatalf("UseOllamaFromDesktop error = %v, want restart confirmation", err) } if quitCalls != 0 || openCalls != 0 { t.Fatalf("process calls = quit %d, open %d; want none", quitCalls, openCalls) } data, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } if string(data) != original { t.Fatalf("config changed before restart confirmation: %q", data) } } func TestCodexAppDesktopRequiresConsentBeforeStoppingLegacyProfile(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) withCodexAppPlatform(t, "darwin") withCodexAppRouterHealth(t, func() error { return nil }) configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } original := "model = \"gpt-5.5\"\nmodel_provider = \"openai\"\n" if err := os.WriteFile(configPath, []byte(original), 0o600); err != nil { t.Fatal(err) } oldCanOpenID := codexAppCanOpenID oldProfileRunning := codexAppProfileIsRunning oldStopProfile := codexAppStopProfile codexAppCanOpenID = func() bool { return true } codexAppProfileIsRunning = func() bool { return true } stopCalls := 0 codexAppStopProfile = func() error { stopCalls++; return nil } t.Cleanup(func() { codexAppCanOpenID = oldCanOpenID codexAppProfileIsRunning = oldProfileRunning codexAppStopProfile = oldStopProfile }) withCodexAppProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { return nil }, ) err := (&CodexApp{}).UseOllamaFromDesktop("qwen3:8b", testLaunchModels("qwen3:8b"), false) if !errors.Is(err, ErrCodexAppRestartConfirmationRequired) { t.Fatalf("UseOllamaFromDesktop error = %v, want restart confirmation", err) } if stopCalls != 0 { t.Fatalf("legacy profile stops before confirmation = %d, want none", stopCalls) } data, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } if string(data) != original { t.Fatalf("config changed before legacy profile consent: %q", data) } } func TestCodexAppDesktopRechecksRestartConfirmationBeforeProfileWrite(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) withCodexAppPlatform(t, "darwin") withCodexAppRouterHealth(t, func() error { return nil }) configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } original := "model = \"gpt-5.5\"\nmodel_provider = \"openai\"\n" if err := os.WriteFile(configPath, []byte(original), 0o600); err != nil { t.Fatal(err) } runningChecks := 0 openCalls := 0 quitCalls := 0 oldCanOpenID := codexAppCanOpenID codexAppCanOpenID = func() bool { return true } t.Cleanup(func() { codexAppCanOpenID = oldCanOpenID }) withCodexAppProcessHooks(t, func() bool { runningChecks++ return runningChecks > 1 }, func() error { quitCalls++; return nil }, func() error { openCalls++; return nil }, ) err := (&CodexApp{}).UseOllamaFromDesktop("qwen3:8b", testLaunchModels("qwen3:8b"), false) if !errors.Is(err, ErrCodexAppRestartConfirmationRequired) { t.Fatalf("UseOllamaFromDesktop error = %v, want late restart confirmation", err) } if quitCalls != 0 || openCalls != 0 { t.Fatalf("process calls = quit %d, open %d; want none", quitCalls, openCalls) } data, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } if string(data) != original { t.Fatalf("config changed after ChatGPT opened during preparation: %q", data) } } func TestCodexAppDesktopUsesAndRestoresRegularProfile(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) withCodexAppPlatform(t, "darwin") withCodexAppRouterHealth(t, func() error { return nil }) t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } original := "model = \"gpt-5.5\"\nmodel_provider = \"openai\"\n" if err := os.WriteFile(configPath, []byte(original), 0o600); err != nil { t.Fatal(err) } authPath := filepath.Join(filepath.Dir(configPath), "auth.json") auth := []byte(`{"tokens":{"access_token":"same-profile"}}`) if err := os.WriteFile(authPath, auth, 0o600); err != nil { t.Fatal(err) } openCalls := 0 oldCanOpenID := codexAppCanOpenID codexAppCanOpenID = func() bool { return true } t.Cleanup(func() { codexAppCanOpenID = oldCanOpenID }) withCodexAppProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { openCalls++; return nil }, ) app := &CodexApp{} if err := app.UseOllamaFromDesktop("qwen3:8b", testLaunchModels("qwen3:8b", "glm-5.3-flash:cloud"), false); err != nil { t.Fatal(err) } if !app.OllamaConfigured() || app.CurrentModel() != "qwen3:8b" { t.Fatalf("regular profile was not configured for Ollama") } if got, err := os.ReadFile(authPath); err != nil || string(got) != string(auth) { t.Fatalf("shared auth changed: %q, %v", got, err) } if openCalls != 1 { t.Fatalf("open calls = %d, want 1", openCalls) } if err := app.RestoreFromDesktop(false); err != nil { t.Fatal(err) } data, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } if strings.TrimSpace(string(data)) != strings.TrimSpace(original) { t.Fatalf("restored config = %q, want %q", data, original) } if openCalls != 1 { t.Fatalf("stopped ChatGPT should remain stopped on restore; open calls = %d", openCalls) } } func codexAppRestoreFixture(t *testing.T) (*CodexApp, string) { t.Helper() home := t.TempDir() setTestHome(t, home) withCodexAppPlatform(t, "darwin") t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") old := codexAppProfileIsRunning codexAppProfileIsRunning = func() bool { return false } t.Cleanup(func() { codexAppProfileIsRunning = old }) withCodexAppProcessHooks(t, func() bool { return false }, func() error { t.Fatal("unexpected process quit"); return nil }, func() error { t.Fatal("unexpected app launch"); return nil }, ) return &CodexApp{}, filepath.Join(home, ".codex", "config.toml") } func TestCodexAppDesktopCleanupFailureCanRetry(t *testing.T) { app, configPath := codexAppRestoreFixture(t) must := func(err error) { t.Helper() if err != nil { t.Fatal(err) } } read := func(path string) string { t.Helper() data, err := os.ReadFile(path) must(err) return string(data) } original := "model = \"native-fixture\"\nmodel_provider = \"openai\"\n\n[mcp_servers.fixture]\ncommand = \"fixture-before\"\n" must(os.MkdirAll(filepath.Dir(configPath), 0o700)) must(os.WriteFile(configPath, []byte(original), 0o600)) must(app.ConfigureWithModels("qwen3:8b", testLaunchModels("qwen3:8b"))) restoreState := read(codexAppRestoreStatePath()) must(os.WriteFile(configPath, []byte(strings.ReplaceAll(read(configPath), "fixture-before", "fixture-after")), 0o600)) catalog := codexAppModelCatalogPathForConfig(configPath) must(os.Remove(catalog)) must(os.Mkdir(catalog, 0o700)) blocker := filepath.Join(catalog, "fixture-obstruction") must(os.WriteFile(blocker, []byte("fixture"), 0o600)) if err := app.RestoreFromDesktop(false); err == nil { t.Fatal("expected cleanup failure") } if app.OllamaConfigured() || !app.RestorePending() || read(codexAppRestoreStatePath()) != restoreState { t.Fatal("partial disconnect lost or replaced its recovery state") } must(os.WriteFile(configPath, []byte(strings.ReplaceAll(read(configPath), "native-fixture", "native-after-failure")), 0o600)) must(os.Remove(blocker)) must(app.RestoreFromDesktop(false)) if app.RestorePending() { t.Fatal("cleanup remained pending after retry") } want, err := codexParseConfigText(strings.NewReplacer("fixture-before", "fixture-after", "native-fixture", "native-after-failure").Replace(original)) must(err) got, err := codexParseConfigText(read(configPath)) must(err) if diff := cmp.Diff(want, got); diff != "" { t.Fatalf("retry changed user settings (-want +got):\n%s", diff) } } func TestCodexAppDesktopCleanupWithoutRestoreStateCanRetry(t *testing.T) { for _, missingConfig := range []bool{false, true} { t.Run(fmt.Sprintf("missing_config=%v", missingConfig), func(t *testing.T) { app, configPath := codexAppRestoreFixture(t) must := func(err error) { t.Helper() if err != nil { t.Fatal(err) } } read := func(path string) string { t.Helper() data, err := os.ReadFile(path) must(err) return string(data) } must(app.ConfigureWithModels("qwen3:8b", testLaunchModels("qwen3:8b"))) must(os.WriteFile(configPath, []byte(codexAppSetReasoningEfforts(read(configPath), []string{"medium"})), 0o600)) for _, path := range []string{codexAppRestoreStatePath(), codexAppModelCatalogPathForConfig(configPath), codexAppRoutingCatalogPathForConfig(configPath)} { must(os.Remove(path)) } if missingConfig { must(os.Remove(configPath)) } authPath := filepath.Join(filepath.Dir(configPath), "auth.json") must(os.Remove(authPath)) must(os.Mkdir(authPath, 0o700)) blocker := filepath.Join(authPath, "fixture-obstruction") must(os.WriteFile(blocker, []byte("fixture"), 0o600)) history := filepath.Join(filepath.Dir(configPath), "sessions", "fixture.jsonl") must(os.MkdirAll(filepath.Dir(history), 0o700)) must(os.WriteFile(history, []byte("fixture history"), 0o600)) if err := app.RestoreFromDesktop(false); err == nil { t.Fatal("expected auth cleanup failure") } if app.OllamaConfigured() || !app.RestorePending() { t.Fatal("retry disappeared without catalogs or an original restore record") } edited := "model = \"native-after-failure\"\n" if !missingConfig { parsed, err := codexParseConfig(read(configPath)) must(err) if efforts, ok := codexAppConfigReasoningEfforts(parsed); !ok || !slices.Equal(efforts, []string{"medium"}) { t.Fatal("legacy restore changed desktop settings") } must(os.WriteFile(configPath, []byte(edited), 0o600)) } must(os.Remove(blocker)) must(os.Remove(authPath)) must(os.WriteFile(authPath, []byte(`{"fixture":"replacement user auth"}`), 0o600)) must(app.RestoreFromDesktop(false)) if app.RestorePending() { t.Fatal("cleanup remained pending after retry") } if missingConfig { if _, err := os.Stat(configPath); !os.IsNotExist(err) { t.Fatal("retry recreated missing config") } } else if read(configPath) != edited { t.Fatal("retry overwrote user edits") } if read(authPath) != `{"fixture":"replacement user auth"}` || read(history) != "fixture history" { t.Fatal("retry changed user auth or history") } }) } } func TestCodexAppRestorePendingKeepsReferencedCatalog(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) configPath := filepath.Join(tmpDir, ".codex", "config.toml") catalogPath := codexAppModelCatalogPathForConfig(configPath) if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(catalogPath, []byte(`{"models":[]}`), 0o600); err != nil { t.Fatal(err) } text := fmt.Sprintf("model = \"native-fixture\"\nmodel_catalog_json = %q\n", catalogPath) if err := os.WriteFile(configPath, []byte(text), 0o600); err != nil { t.Fatal(err) } if (&CodexApp{}).RestorePending() { t.Fatal("a catalog still referenced by the user's config must not be treated as abandoned cleanup") } } func TestCodexAppDesktopUpdatesStoppedProfileWithoutOpening(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) withCodexAppPlatform(t, "darwin") withCodexAppRouterHealth(t, func() error { return nil }) t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(configPath, []byte("model = \"gpt-5.5\"\n"), 0o600); err != nil { t.Fatal(err) } openCalls := 0 oldCanOpenID := codexAppCanOpenID codexAppCanOpenID = func() bool { return true } t.Cleanup(func() { codexAppCanOpenID = oldCanOpenID }) withCodexAppProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { openCalls++; return nil }, ) app := &CodexApp{} if err := app.UpdateOllamaModelsFromDesktop("qwen3:8b", testLaunchModels("qwen3:8b"), false); err != nil { t.Fatal(err) } if !app.OllamaConfigured() || app.CurrentModel() != "qwen3:8b" { t.Fatal("stopped regular profile was not updated for Ollama") } if openCalls != 0 { t.Fatalf("open calls = %d, want 0", openCalls) } } func TestCodexAppManagedAuthLifecycle(t *testing.T) { t.Run("signed out user gets local auth that restore removes", func(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") app := &CodexApp{} if err := app.ConfigureWithModels("qwen3:8b", testLaunchModels("qwen3:8b")); err != nil { t.Fatal(err) } authPath := filepath.Join(tmpDir, ".codex", "auth.json") data, err := os.ReadFile(authPath) if err != nil { t.Fatal(err) } var auth codexAppManagedAuth if err := json.Unmarshal(data, &auth); err != nil { t.Fatal(err) } if auth.AuthMode != "apikey" || auth.OpenAIAPIKey != proxy.CodexDesktopManagedAPIKey { t.Fatalf("managed auth = %+v", auth) } info, err := os.Stat(authPath) if err != nil { t.Fatal(err) } if got := info.Mode().Perm(); got != 0o600 { t.Fatalf("auth mode = %o, want 600", got) } if err := restoreCodexAppProfile(); err != nil { t.Fatal(err) } if _, err := os.Stat(authPath); !os.IsNotExist(err) { t.Fatalf("managed auth should be removed on restore, err=%v", err) } }) t.Run("existing auth is preserved", func(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") authPath := filepath.Join(tmpDir, ".codex", "auth.json") if err := os.MkdirAll(filepath.Dir(authPath), 0o700); err != nil { t.Fatal(err) } original := []byte(`{"auth_mode":"chatgpt","tokens":{"access_token":"user-owned"}}`) if err := os.WriteFile(authPath, original, 0o600); err != nil { t.Fatal(err) } app := &CodexApp{} if err := app.ConfigureWithModels("qwen3:8b", testLaunchModels("qwen3:8b")); err != nil { t.Fatal(err) } if got, err := os.ReadFile(authPath); err != nil || string(got) != string(original) { t.Fatalf("auth after configure = %q, %v; want %q", got, err, original) } if err := restoreCodexAppProfile(); err != nil { t.Fatal(err) } if got, err := os.ReadFile(authPath); err != nil || string(got) != string(original) { t.Fatalf("auth after restore = %q, %v; want %q", got, err, original) } }) t.Run("real login that replaces local auth is preserved", func(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") app := &CodexApp{} if err := app.ConfigureWithModels("qwen3:8b", testLaunchModels("qwen3:8b")); err != nil { t.Fatal(err) } authPath := filepath.Join(tmpDir, ".codex", "auth.json") replacement := []byte(`{"auth_mode":"apikey","OPENAI_API_KEY":"sk-user-owned"}`) if err := os.WriteFile(authPath, replacement, 0o600); err != nil { t.Fatal(err) } if err := restoreCodexAppProfile(); err != nil { t.Fatal(err) } if got, err := os.ReadFile(authPath); err != nil || string(got) != string(replacement) { t.Fatalf("replacement auth after restore = %q, %v; want %q", got, err, replacement) } }) } func TestCodexAppDesktopAppliesProfileAfterRunningAppExits(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) withCodexAppPlatform(t, "darwin") withCodexAppRouterHealth(t, func() error { return nil }) t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } original := "model = \"gpt-5.5\"\nmodel_provider = \"openai\"\n" if err := os.WriteFile(configPath, []byte(original), 0o600); err != nil { t.Fatal(err) } running := true events := []string{} oldCanOpenID := codexAppCanOpenID codexAppCanOpenID = func() bool { return true } t.Cleanup(func() { codexAppCanOpenID = oldCanOpenID }) withCodexAppProcessHooks(t, func() bool { return running }, func() error { data, err := os.ReadFile(configPath) if err != nil { return err } if string(data) != original { return fmt.Errorf("config changed before ChatGPT exited: %s", data) } events = append(events, "quit") running = false return nil }, func() error { events = append(events, "open") running = true return nil }, ) if err := (&CodexApp{}).UseOllamaFromDesktop("qwen3:8b", testLaunchModels("qwen3:8b"), true); err != nil { t.Fatal(err) } if !slices.Equal(events, []string{"quit", "open"}) { t.Fatalf("events = %v, want quit then open", events) } data, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } if got, ok := codexRootStringValueOK(string(data), codexRootModelProviderKey); ok { t.Fatalf("model provider = %q, want omitted built-in default after exit:\n%s", got, data) } if got := codexRootStringValue(string(data), codexRootOpenAIBaseURLKey); got != "http://127.0.0.1:11434/api/codex/v1" { t.Fatalf("openai_base_url = %q, want Codex router after exit:\n%s", got, data) } } func TestCodexAppDesktopRestartRepairsCatalogWithoutLiveInventory(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) withCodexAppPlatform(t, "darwin") t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") app := &CodexApp{} if err := app.ConfigureWithModels("qwen3:8b", testLaunchModels("qwen3:8b")); err != nil { t.Fatal(err) } configPath := filepath.Join(tmpDir, ".codex", "config.toml") original, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } catalogPath := mustCodexAppModelCatalogPath(t) catalogData, err := os.ReadFile(catalogPath) if err != nil { t.Fatal(err) } var staleCatalog struct { Models []map[string]any `json:"models"` } if err := json.Unmarshal(catalogData, &staleCatalog); err != nil { t.Fatal(err) } foundNative := false for _, model := range staleCatalog.Models { if model["slug"] == "gpt-5.6-sol" { foundNative = true model["supported_in_api"] = true } } if !foundNative { t.Fatal("test catalog has no native model") } catalogData, err = json.Marshal(staleCatalog) if err != nil { t.Fatal(err) } if err := os.WriteFile(catalogPath, catalogData, 0o600); err != nil { t.Fatal(err) } running := true quitCalls := 0 openCalls := 0 oldCanOpenID := codexAppCanOpenID codexAppCanOpenID = func() bool { return true } t.Cleanup(func() { codexAppCanOpenID = oldCanOpenID }) withCodexAppProcessHooks(t, func() bool { return running }, func() error { quitCalls++ running = false return nil }, func() error { openCalls++ running = true return nil }, ) if err := app.RestartFromDesktop(false); !errors.Is(err, ErrCodexAppRestartConfirmationRequired) { t.Fatalf("RestartFromDesktop error = %v, want restart confirmation", err) } if quitCalls != 0 || openCalls != 0 { t.Fatalf("process calls before confirmation = quit %d, open %d", quitCalls, openCalls) } if err := app.RestartFromDesktop(true); err != nil { t.Fatal(err) } if quitCalls != 1 || openCalls != 1 { t.Fatalf("process calls = quit %d, open %d; want one restart", quitCalls, openCalls) } data, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } if !slices.Equal(data, original) { t.Fatal("restart changed the existing ChatGPT profile") } catalogData, err = os.ReadFile(catalogPath) if err != nil { t.Fatal(err) } var repairedCatalog struct { Models []map[string]any `json:"models"` } if err := json.Unmarshal(catalogData, &repairedCatalog); err != nil { t.Fatal(err) } foundNative = false foundOllama := false for _, model := range repairedCatalog.Models { if model["slug"] == "qwen3:8b" { foundOllama = true if model["supported_in_api"] != true { t.Fatalf("Ollama model supported_in_api = %v, want true", model["supported_in_api"]) } } if model["slug"] != "gpt-5.6-sol" { continue } foundNative = true if model["supported_in_api"] != false { t.Fatalf("native model supported_in_api = %v, want false", model["supported_in_api"]) } } if !foundNative { t.Fatal("repaired catalog has no native model") } if !foundOllama { t.Fatal("repaired catalog has no Ollama model") } } func TestCodexAppCountsOnlyOllamaRequestsInRegularProfile(t *testing.T) { setTestHome(t, t.TempDir()) requestLine := func(at time.Time, kinds ...string) []byte { line, err := json.Marshal(map[string]any{ "timestamp": at, "type": "response_item", "payload": map[string]any{ "type": "message", "role": "user", "internal_chat_message_metadata_passthrough": map[string]any{ "content_item_kinds": kinds, }, }, }) if err != nil { t.Fatal(err) } return append(line, '\n') } turnContextLine := func(at time.Time, model string) []byte { line, err := json.Marshal(map[string]any{ "timestamp": at, "type": "turn_context", "payload": map[string]any{ "model": model, }, }) if err != nil { t.Fatal(err) } return append(line, '\n') } if err := resetCodexAppRegularProfileRequestCount(); err != nil { t.Fatal(err) } startPath, err := codexAppRegularProfileSessionStartPath() if err != nil { t.Fatal(err) } startData, err := os.ReadFile(startPath) if err != nil { t.Fatal(err) } start, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(string(startData))) if err != nil { t.Fatal(err) } // Keep session files newer than the cutoff even with coarse file timestamps. start = start.Add(-time.Minute) if err := os.WriteFile(startPath, []byte(start.Format(time.RFC3339Nano)), 0o600); err != nil { t.Fatal(err) } configPath, err := codexConfigPath() if err != nil { t.Fatal(err) } regularSessionPath := filepath.Join(filepath.Dir(configPath), "sessions", "2026", "08", "28", "rollout.jsonl") if err := os.MkdirAll(filepath.Dir(regularSessionPath), 0o700); err != nil { t.Fatal(err) } routingCatalog := []byte(`{"models":[{"slug":"qwen3:8b"}]}`) if err := os.WriteFile(codexAppRoutingCatalogPathForConfig(configPath), routingCatalog, 0o600); err != nil { t.Fatal(err) } regularLines := append(turnContextLine(start.Add(time.Second), "qwen3:8b"), requestLine(start.Add(2*time.Second), "user.text")...) regularLines = append(regularLines, turnContextLine(start.Add(3*time.Second), "gpt-5.6-sol")...) regularLines = append(regularLines, requestLine(start.Add(4*time.Second), "user.text")...) regularLines = append(regularLines, turnContextLine(start.Add(5*time.Second), "qwen3:8b:latest")...) regularLines = append(regularLines, requestLine(start.Add(6*time.Second), "user.text")...) if err := os.WriteFile(regularSessionPath, regularLines, 0o600); err != nil { t.Fatal(err) } if got := codexAppRegularProfileRequestCount(); got != 2 { t.Fatalf("regular profile Ollama request count = %d, want 2", got) } file, err := os.OpenFile(regularSessionPath, os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { t.Fatal(err) } nativeTurn := append(turnContextLine(start.Add(7*time.Second), "gpt-5.6-sol"), requestLine(start.Add(8*time.Second), "user.text")...) if _, err := file.Write(nativeTurn); err != nil { _ = file.Close() t.Fatal(err) } if err := file.Close(); err != nil { t.Fatal(err) } if got := codexAppRegularProfileRequestCount(); got != 2 { t.Fatalf("regular profile count after native request = %d, want 2", got) } file, err = os.OpenFile(regularSessionPath, os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { t.Fatal(err) } ollamaTurn := append(turnContextLine(start.Add(9*time.Second), "qwen3:8b"), requestLine(start.Add(10*time.Second), "user.text")...) if _, err := file.Write(ollamaTurn); err != nil { _ = file.Close() t.Fatal(err) } if err := file.Close(); err != nil { t.Fatal(err) } if got := codexAppRegularProfileRequestCount(); got != 3 { t.Fatalf("incremental regular profile Ollama request count = %d, want 3", got) } } func TestCodexAppLegacyProfileCleanupRejectsStalePID(t *testing.T) { setTestHome(t, t.TempDir()) withCodexAppPlatform(t, "darwin") if err := writeCodexAppOllamaProfilePID(424242); err != nil { t.Fatal(err) } oldExecutable := codexAppProfileExecutable oldCommand := codexAppProcessCommand codexAppProfileExecutable = func() (string, error) { return "/Applications/ChatGPT.app/Contents/MacOS/ChatGPT", nil } codexAppProcessCommand = func(int) (string, error) { return "/usr/bin/unrelated --serve", nil } t.Cleanup(func() { codexAppProfileExecutable = oldExecutable codexAppProcessCommand = oldCommand }) err := defaultCodexAppStopOllamaProfile() if err == nil || !strings.Contains(err.Error(), "identity could not be verified") { t.Fatalf("stale PID error = %v, want identity verification", err) } if _, ok := codexAppOllamaProfilePID(); ok { t.Fatal("stale PID was not cleared") } } func TestCodexAppSupportedPlatforms(t *testing.T) { for _, goos := range []string{"darwin", "windows"} { t.Run(goos, func(t *testing.T) { withCodexAppPlatform(t, goos) if err := codexAppSupported(); err != nil { t.Fatalf("codexAppSupported returned error: %v", err) } }) } t.Run("linux unsupported", func(t *testing.T) { withCodexAppPlatform(t, "linux") err := codexAppSupported() if err == nil || !strings.Contains(err.Error(), "macOS and Windows") { t.Fatalf("codexAppSupported error = %v, want platform message", err) } }) } func TestCodexAppWindowsAppPathCandidates(t *testing.T) { withCodexAppPlatform(t, "windows") local := filepath.Join(t.TempDir(), "LocalAppData") t.Setenv("LOCALAPPDATA", local) exe := filepath.Join(local, "Codex", "app-26.429.30905", "Codex.exe") if err := os.MkdirAll(filepath.Dir(exe), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(exe, []byte{}, 0o644); err != nil { t.Fatal(err) } if got := codexAppAppPath(); got != exe { t.Fatalf("codexAppAppPath = %q, want %q", got, exe) } } func TestCodexAppInstalledUsesWindowsStartMenuFallback(t *testing.T) { withCodexAppPlatform(t, "windows") t.Setenv("LOCALAPPDATA", filepath.Join(t.TempDir(), "LocalAppData")) oldStartID := codexAppStartID oldIsRunning := codexAppIsRunning codexAppStartID = func() string { return "OpenAI.Codex_12345!App" } codexAppIsRunning = func() bool { return false } t.Cleanup(func() { codexAppStartID = oldStartID codexAppIsRunning = oldIsRunning }) if !codexAppInstalled() { t.Fatal("expected Windows Start menu app id to count as installed") } } func TestCodexAppInstalledUsesMacBundleIDFallback(t *testing.T) { withCodexAppPlatform(t, "darwin") oldCanOpenID := codexAppCanOpenID oldStat := codexAppStat codexAppCanOpenID = func() bool { return true } codexAppStat = func(string) (os.FileInfo, error) { return nil, os.ErrNotExist } t.Cleanup(func() { codexAppCanOpenID = oldCanOpenID codexAppStat = oldStat }) if !codexAppInstalled() { t.Fatal("expected macOS LaunchServices bundle id fallback to count as installed") } } func TestChatGPTMissingAppGivesDownloadRecovery(t *testing.T) { withCodexAppPlatform(t, "darwin") oldCanOpenID := codexAppCanOpenID oldStat := codexAppStat codexAppCanOpenID = func() bool { return false } codexAppStat = func(string) (os.FileInfo, error) { return nil, os.ErrNotExist } t.Cleanup(func() { codexAppCanOpenID = oldCanOpenID codexAppStat = oldStat }) err := EnsureIntegrationInstalled(chatGPTIntegrationName, &CodexApp{}) if err == nil { t.Fatal("expected missing ChatGPT install error") } if !strings.Contains(err.Error(), "chatgpt is not installed") || !strings.Contains(err.Error(), "https://chatgpt.com/download") { t.Fatalf("missing-app error = %q, want ChatGPT download recovery", err) } } func TestCodexAppConfigureAddsOllamaModelsToBuiltInProvider(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999") configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } existing := "" + "profile = \"default\"\n" + "model = \"gpt-5.5\"\n\n" + "[profiles.default]\n" + "model = \"gpt-5.5\"\n" if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { t.Fatal(err) } c := &CodexApp{} if err := c.ConfigureWithModels("llama3.2", testLaunchModels("llama3.2", "qwen3:8b")); err != nil { t.Fatalf("ConfigureWithModels returned error: %v", err) } catalogPath, err := codexAppModelCatalogPath() if err != nil { t.Fatal(err) } data, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } content := string(data) for _, want := range []string{ `model = "llama3.2"`, fmt.Sprintf(`model_catalog_json = %q`, catalogPath), `openai_base_url = "http://127.0.0.1:9999/api/codex/v1"`, `[profiles.default]`, } { if !strings.Contains(content, want) { t.Fatalf("expected config to contain %q, got:\n%s", want, content) } } if got, ok := codexRootStringValueOK(content, "profile"); ok { t.Fatalf("legacy root profile should be removed, got %q in:\n%s", got, content) } if got, ok := codexRootStringValueOK(content, codexRootModelProviderKey); ok { t.Fatalf("root model_provider = %q, want omitted built-in default in:\n%s", got, content) } if strings.Contains(content, codexProfileHeaderFor(codexAppProfileName)) { t.Fatalf("legacy app profile section should not be generated, got:\n%s", content) } if strings.Contains(content, codexProviderHeaderFor(codexAppProfileName)) { t.Fatalf("custom app provider should not be generated, got:\n%s", content) } if got := c.CurrentModel(); got != "llama3.2" { t.Fatalf("CurrentModel = %q, want llama3.2", got) } restoreData, err := os.ReadFile(codexAppRestoreStatePath()) if err != nil { t.Fatalf("expected restore state: %v", err) } if !strings.Contains(string(restoreData), `"profile": "default"`) { t.Fatalf("expected restore state to remember default profile, got %s", restoreData) } catalogData, err := os.ReadFile(catalogPath) if err != nil { t.Fatalf("expected model catalog: %v", err) } var catalog struct { Models []map[string]any `json:"models"` } if err := json.Unmarshal(catalogData, &catalog); err != nil { t.Fatalf("catalog should be valid JSON: %v", err) } if got := catalogSlugs(catalog.Models); strings.Join(got, ",") != "llama3.2,qwen3:8b,gpt-5.6-sol" { t.Fatalf("catalog slugs = %v, want Ollama models followed by native models", got) } ollamaEntry := catalog.Models[0] for key, want := range map[string]any{ "shell_type": "unified_exec", "supported_in_api": true, "include_skills_usage_instructions": true, "include_plugin_usage_instructions": true, "include_apps_usage_instructions": true, "supports_reasoning_summary_parameter": false, } { if got := ollamaEntry[key]; got != want { t.Fatalf("Ollama catalog %s = %#v, want %#v", key, got, want) } } truncationPolicy, ok := ollamaEntry["truncation_policy"].(map[string]any) if !ok || truncationPolicy["mode"] != "tokens" { t.Fatalf("Ollama truncation policy = %#v, want token mode", ollamaEntry["truncation_policy"]) } if _, ok := ollamaEntry["tool_mode"]; ok { t.Fatalf("Ollama catalog must not opt into a native-only code tool mode: %#v", ollamaEntry) } } func TestCodexAppReasoningEffortsExposeOffAndMaxWithoutDroppingUserChoices(t *testing.T) { tests := []struct { name string text string efforts []string want []string }{ { name: "new desktop setting", text: `model = "gpt-5.6-sol"` + "\n", efforts: []string{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}, want: []string{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}, }, { name: "existing section and multiline value", text: "[desktop]\n" + "enabled-reasoning-efforts = [\n" + " \"low\",\n" + " \"high\",\n" + "]\n" + `theme = "system"` + "\n", efforts: []string{"none", "low", "high", "max"}, want: []string{"none", "low", "high", "max"}, }, { name: "existing dotted root value", text: `desktop.enabled-reasoning-efforts = ["low", "ultra"]` + "\n", efforts: []string{"none", "low", "max", "ultra"}, want: []string{"none", "low", "max", "ultra"}, }, { name: "section without trailing newline", text: "[desktop]\n" + `theme = "system"`, efforts: []string{"none", "medium", "max"}, want: []string{"none", "medium", "max"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { updated := codexAppSetReasoningEfforts(tt.text, tt.efforts) config, err := codexParseConfig(updated) if err != nil { t.Fatalf("updated config is invalid: %v\n%s", err, updated) } got, ok := codexAppConfigReasoningEfforts(config) if !ok || !slices.Equal(got, tt.want) { t.Fatalf("reasoning efforts = %v, %v; want %v", got, ok, tt.want) } if count := strings.Count(updated, codexAppReasoningEffortsKey); count != 1 { t.Fatalf("reasoning setting count = %d, want 1 in:\n%s", count, updated) } if strings.Contains(tt.text, `theme = "system"`) { if theme, ok := config.String("desktop", "theme"); !ok || theme != "system" { t.Fatalf("desktop theme = %q, %v; want preserved", theme, ok) } } }) } if got, want := codexAppMergeReasoningEfforts([]string{"low", "high", "ultra", "persistent"}), []string{"none", "low", "high", "max", "ultra", "persistent"}; !slices.Equal(got, want) { t.Fatalf("merged reasoning efforts = %v, want %v", got, want) } if got, want := codexAppReasoningEffortsForConfig(""), []string{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}; !slices.Equal(got, want) { t.Fatalf("default desktop reasoning efforts = %v, want %v", got, want) } } func TestCodexAppConfigureUsesAppSpecificProfileWithoutTouchingCLIProfile(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999") configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } existing := "" + `profile = "default"` + "\n\n" + "[profiles.ollama-launch]\n" + `model = "cli-model"` + "\n" + `openai_base_url = "http://cli.invalid/v1/"` + "\n" + `model_provider = "ollama-launch"` + "\n\n" + "[model_providers.ollama-launch]\n" + `name = "CLI Ollama"` + "\n" + `base_url = "http://cli.invalid/v1/"` + "\n" + `wire_api = "responses"` + "\n\n" + "[profiles.default]\n" + `model = "gpt-5.5"` + "\n" if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { t.Fatal(err) } if err := (&CodexApp{}).ConfigureWithModels("llama3.2", testLaunchModels("llama3.2")); err != nil { t.Fatalf("ConfigureWithModels returned error: %v", err) } data, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } content := string(data) if got, ok := codexRootStringValueOK(content, "profile"); ok { t.Fatalf("legacy root profile should be removed, got %q in:\n%s", got, content) } if got := codexSectionStringValue(content, codexProfileHeader(), "openai_base_url"); got != "http://cli.invalid/v1/" { t.Fatalf("CLI profile base URL = %q, want preserved CLI URL in:\n%s", got, content) } if got := codexSectionStringValue(content, codexProviderHeader(), "name"); got != "CLI Ollama" { t.Fatalf("CLI provider name = %q, want preserved CLI provider in:\n%s", got, content) } if strings.Contains(content, codexProfileHeaderFor(codexAppProfileName)) { t.Fatalf("legacy app profile section should not be generated, got:\n%s", content) } if got := codexRootStringValue(content, "model"); got != "llama3.2" { t.Fatalf("root model = %q, want llama3.2", got) } if got := codexRootStringValue(content, codexRootOpenAIBaseURLKey); got != "http://127.0.0.1:9999/api/codex/v1" { t.Fatalf("openai_base_url = %q, want loopback Codex router", got) } if got, ok := codexRootStringValueOK(content, codexRootModelProviderKey); ok { t.Fatalf("model_provider = %q, want omitted built-in default", got) } if strings.Contains(content, codexProviderHeaderFor(codexAppProfileName)) { t.Fatalf("custom app provider should be removed, got:\n%s", content) } assertBackupContains(t, filepath.Join(fileutil.BackupDir(), codexAppIntegrationName, "config.toml.*"), `profile = "default"`) } func TestCodexAppConfigureIsIdempotentAndPreservesUnrelatedProvider(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999") configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } existing := "[model_providers.custom]\n" + `name = "Custom"` + "\n" + `base_url = "https://example.invalid/v1"` + "\n" + `env_key = "CUSTOM_API_KEY"` + "\n" if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { t.Fatal(err) } app := &CodexApp{} models := testLaunchModels("llama3.2", "qwen3:8b") if err := app.ConfigureWithModels("llama3.2", models); err != nil { t.Fatalf("first ConfigureWithModels returned error: %v", err) } first, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } if err := app.ConfigureWithModels("llama3.2", models); err != nil { t.Fatalf("second ConfigureWithModels returned error: %v", err) } second, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } if string(second) != string(first) { t.Fatalf("rerun changed generated config:\nfirst:\n%s\nsecond:\n%s", first, second) } parsed, err := codexParseConfig(string(second)) if err != nil { t.Fatal(err) } if got := parsed.ProviderString("custom", "env_key"); got != "CUSTOM_API_KEY" { t.Fatalf("custom provider env_key = %q, want preserved value", got) } if parsed.Exists("model_providers", codexAppProfileName, "env_key") { t.Fatalf("managed local Ollama provider should not require an API key:\n%s", second) } if got := app.CurrentModel(); got != "llama3.2" { t.Fatalf("CurrentModel = %q, want llama3.2", got) } } func TestCodexAppConfigurePersistsAutoReviewModel(t *testing.T) { for _, test := range []struct { name string configured string want string wantFallback string }{ {name: "selected model default", want: "selected", wantFallback: "glm-5.3:cloud"}, {name: "native explicit", configured: "native"}, {name: "chatgpt explicit", configured: "chatgpt"}, {name: "selected cloud model", configured: "selected", want: "selected", wantFallback: "glm-5.3:cloud"}, {name: "ollama alias", configured: "ollama", want: "glm-5.3:cloud"}, {name: "explicit configured model", configured: "qwen3:8b", want: "qwen3:8b"}, {name: "explicit configured cloud model", configured: "deepseek-v4-flash:cloud", want: "deepseek-v4-flash:cloud"}, } { t.Run(test.name, func(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv(codexAppAutoReviewModelEnv, test.configured) if err := (&CodexApp{}).ConfigureWithModels("glm-5.3:cloud", testLaunchModels("glm-5.3:cloud", "deepseek-v4-flash:cloud", "qwen3:8b")); err != nil { t.Fatal(err) } data, err := os.ReadFile(codexAppRoutingCatalogPathForConfig(filepath.Join(tmpDir, ".codex", "config.toml"))) if err != nil { t.Fatal(err) } var catalog struct { AutoReviewModel string `json:"auto_review_model"` AutoReviewFallbackModel string `json:"auto_review_fallback_model"` } if err := json.Unmarshal(data, &catalog); err != nil { t.Fatal(err) } if catalog.AutoReviewModel != test.want { t.Fatalf("auto_review_model = %q, want %q", catalog.AutoReviewModel, test.want) } if catalog.AutoReviewFallbackModel != test.wantFallback { t.Fatalf("auto_review_fallback_model = %q, want %q", catalog.AutoReviewFallbackModel, test.wantFallback) } }) } } func TestCodexAppConfigureRejectsUnknownAutoReviewModelBeforeWriting(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv(codexAppAutoReviewModelEnv, "missing-model") err := (&CodexApp{}).ConfigureWithModels("llama3.2", testLaunchModels("llama3.2")) if err == nil || !strings.Contains(err.Error(), codexAppAutoReviewModelEnv) { t.Fatalf("ConfigureWithModels error = %v, want invalid Auto-review model", err) } if _, err := os.Stat(filepath.Join(tmpDir, ".codex")); !os.IsNotExist(err) { t.Fatalf("invalid Auto-review model wrote configuration: %v", err) } } func TestCodexCLIConfigRefreshLeavesCodexAppConfigActive(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999") appModels := testLaunchModels("llama3.2", "gemma4") if err := (&CodexApp{}).ConfigureWithModels("llama3.2", appModels); err != nil { t.Fatalf("ConfigureWithModels returned error: %v", err) } configPath := filepath.Join(tmpDir, ".codex", "config.toml") appCatalogPath := mustCodexAppModelCatalogPath(t) if err := ensureCodexConfig("qwen3:8b", testLaunchModels("qwen3:8b")); err != nil { t.Fatalf("ensureCodexConfig returned error: %v", err) } data, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } content := string(data) if got, ok := codexRootStringValueOK(content, "profile"); ok { t.Fatalf("CLI config refresh should not activate a root profile, got %q in:\n%s", got, content) } for key, want := range map[string]string{ "model": "llama3.2", "model_catalog_json": appCatalogPath, "openai_base_url": "http://127.0.0.1:9999/api/codex/v1", } { if got := codexRootStringValue(content, key); got != want { t.Fatalf("root %s = %q, want %q in:\n%s", key, got, want, content) } } if got, ok := codexRootStringValueOK(content, codexRootModelProviderKey); ok { t.Fatalf("root model_provider = %q, want omitted built-in default in:\n%s", got, content) } if strings.Contains(content, codexProviderHeaderFor(codexAppProfileName)) { t.Fatalf("additive app config should not leave a custom provider section:\n%s", content) } cliCatalogPath := filepath.Join(tmpDir, ".codex", "model.json") if strings.Contains(content, codexProfileHeader()) { t.Fatalf("CLI legacy profile section should not be generated, got:\n%s", content) } if strings.Contains(content, codexProviderHeader()) { t.Fatalf("CLI provider should be isolated from app root config, got:\n%s", content) } cliProfilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml") cliProfileData, err := os.ReadFile(cliProfilePath) if err != nil { t.Fatalf("CLI profile config not created: %v", err) } cliProfile := string(cliProfileData) for key, want := range map[string]string{ "model": "qwen3:8b", "model_provider": codexProfileName, "model_catalog_json": cliCatalogPath, } { if got := codexRootStringValue(cliProfile, key); got != want { t.Fatalf("CLI profile %s = %q, want %q in:\n%s", key, got, want, cliProfile) } } if got := codexSectionStringValue(cliProfile, codexProviderHeader(), "base_url"); got != "http://127.0.0.1:9999/v1/" { t.Fatalf("CLI profile provider base URL = %q", got) } appCatalogData, err := os.ReadFile(appCatalogPath) if err != nil { t.Fatal(err) } var appCatalog struct { Models []map[string]any `json:"models"` } if err := json.Unmarshal(appCatalogData, &appCatalog); err != nil { t.Fatalf("app catalog should be valid JSON: %v", err) } if got := catalogSlugs(appCatalog.Models); strings.Join(got, ",") != "llama3.2,gemma4,gpt-5.6-sol" { t.Fatalf("app catalog slugs = %v, want Ollama models followed by the native catalog", got) } routingCatalogData, err := os.ReadFile(codexAppRoutingCatalogPathForConfig(configPath)) if err != nil { t.Fatal(err) } var routingCatalog struct { Models []map[string]any `json:"models"` } if err := json.Unmarshal(routingCatalogData, &routingCatalog); err != nil { t.Fatalf("routing catalog should be valid JSON: %v", err) } if got := catalogSlugs(routingCatalog.Models); strings.Join(got, ",") != "llama3.2,gemma4" { t.Fatalf("routing catalog slugs = %v, want only the selected Ollama models", got) } cliCatalogData, err := os.ReadFile(cliCatalogPath) if err != nil { t.Fatal(err) } var cliCatalog struct { Models []map[string]any `json:"models"` } if err := json.Unmarshal(cliCatalogData, &cliCatalog); err != nil { t.Fatalf("CLI catalog should be valid JSON: %v", err) } if got := catalogSlugs(cliCatalog.Models); strings.Join(got, ",") != "qwen3:8b" { t.Fatalf("CLI catalog slugs = %v, want qwen3:8b", got) } } func TestCodexAppConfigureUsesConnectableHostForUnspecifiedBindAddress(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv("OLLAMA_HOST", "http://0.0.0.0:11434") if err := (&CodexApp{}).ConfigureWithModels("llama3.2", testLaunchModels("llama3.2")); err != nil { t.Fatalf("ConfigureWithModels returned error: %v", err) } configPath := filepath.Join(tmpDir, ".codex", "config.toml") data, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } content := string(data) if strings.Contains(content, "0.0.0.0") { t.Fatalf("config should not write bind-only host, got:\n%s", content) } if strings.Contains(content, codexProfileHeaderFor(codexAppProfileName)) { t.Fatalf("legacy app profile section should not be generated, got:\n%s", content) } if got := codexRootStringValue(content, codexRootOpenAIBaseURLKey); got != "http://127.0.0.1:11434/api/codex/v1" { t.Fatalf("openai_base_url = %q, want connectable loopback router URL", got) } if got, ok := codexRootStringValueOK(content, codexRootModelProviderKey); ok { t.Fatalf("root model_provider = %q, want omitted built-in default", got) } } func TestCodexAppHostChangePreservesRestoreState(t *testing.T) { for _, host := range []string{ "http://127.0.0.1:11434", "http://localhost:11434", "http://localhost:11434/ollama", "http://127.0.0.1:11434/ollama", "http://[::1]:11434/ollama", } { t.Run(host, func(t *testing.T) { t.Run("restore", func(t *testing.T) { testCodexAppHostChangePreservesRestoreState(t, host, false) }) t.Run("reconfigure then restore", func(t *testing.T) { testCodexAppHostChangePreservesRestoreState(t, host, true) }) }) } } func testCodexAppHostChangePreservesRestoreState(t *testing.T, host string, reconfigure bool) { tmpDir := t.TempDir() setTestHome(t, tmpDir) withCodexAppPlatform(t, "darwin") withCodexAppProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { return nil }) configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } original := "" + `model = "gpt-5.6-sol"` + "\n" + `model_provider = "openai"` + "\n" + `openai_base_url = "https://api.openai.com/v1"` + "\n" if err := os.WriteFile(configPath, []byte(original), 0o644); err != nil { t.Fatal(err) } t.Setenv("OLLAMA_HOST", host) app := &CodexApp{} if err := app.ConfigureWithModels("llama3.2", testLaunchModels("llama3.2")); err != nil { t.Fatalf("initial ConfigureWithModels returned error: %v", err) } if !app.OllamaConfigured() { t.Fatal("generated configuration is not recognized as owned") } if got := app.CurrentModel(); got != "llama3.2" { t.Fatalf("CurrentModel after setup = %q, want llama3.2", got) } // A changed server address must not make the off-switch disappear or cause // the next update to save Ollama's managed root as the user's restore target. t.Setenv("OLLAMA_HOST", "http://127.0.0.1:22434") if !app.OllamaConfigured() { t.Fatal("OllamaConfigured = false after host change, want managed config to remain detectable") } if got := app.CurrentModel(); got != "" { t.Fatalf("CurrentModel after host change = %q, want empty to require launcher reconfiguration", got) } if reconfigure { if err := app.ConfigureWithModels("gemma4", testLaunchModels("gemma4")); err != nil { t.Fatalf("updated ConfigureWithModels returned error: %v", err) } updated, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } if got := codexRootStringValue(string(updated), codexRootOpenAIBaseURLKey); got != "http://127.0.0.1:22434/api/codex/v1" { t.Fatalf("updated endpoint = %q, want current host", got) } } if err := app.RestoreFromDesktop(false); err != nil { t.Fatalf("RestoreFromDesktop returned error: %v", err) } restored, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } for key, want := range map[string]string{ codexRootModelKey: "gpt-5.6-sol", codexRootModelProviderKey: "openai", codexRootOpenAIBaseURLKey: "https://api.openai.com/v1", } { if got := codexRootStringValue(string(restored), key); got != want { t.Fatalf("restored %s = %q, want %q in:\n%s", key, got, want, restored) } } if got, ok := codexRootStringValueOK(string(restored), codexRootModelCatalogJSONKey); ok { t.Fatalf("restored model catalog = %q, want managed catalog removed in:\n%s", got, restored) } if _, err := os.Stat(codexAppRestoreStatePath()); !os.IsNotExist(err) { t.Fatalf("restore state should be removed after successful restore, err=%v", err) } } func TestCodexAppManagedProxyURLRequiresLoopback(t *testing.T) { for _, tt := range []struct { name string url string want bool }{ {name: "IPv4 loopback", url: "http://127.0.0.1:11434/api/codex/v1", want: true}, {name: "localhost", url: "http://localhost:22434/api/codex/v1/", want: true}, {name: "IPv6 loopback", url: "https://[::1]:11434/api/codex/v1", want: true}, {name: "remote hostname", url: "https://example.com/api/codex/v1"}, {name: "private address", url: "http://192.168.1.5:11434/api/codex/v1"}, {name: "credentials", url: "http://user@127.0.0.1:11434/api/codex/v1"}, {name: "query", url: "http://127.0.0.1:11434/api/codex/v1?owned=true"}, {name: "wrong path", url: "http://127.0.0.1:11434/v1"}, } { t.Run(tt.name, func(t *testing.T) { if got := codexAppManagedProxyURL(tt.url); got != tt.want { t.Fatalf("codexAppManagedProxyURL(%q) = %t, want %t", tt.url, got, tt.want) } }) } } func TestCodexAppConfigureRejectsMalformedTomlBeforeSideEffects(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } existing := "profile = \n" if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { t.Fatal(err) } err := (&CodexApp{}).ConfigureWithModels("llama3.2", testLaunchModels("llama3.2")) if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") { t.Fatalf("ConfigureWithModels error = %v, want invalid TOML", err) } data, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } if string(data) != existing { t.Fatalf("malformed config should be left untouched, got:\n%s", data) } if _, err := os.Stat(codexAppRestoreStatePath()); !os.IsNotExist(err) { t.Fatalf("restore state should not be written before config validation, err=%v", err) } catalogPath, err := codexAppModelCatalogPath() if err != nil { t.Fatal(err) } if _, err := os.Stat(catalogPath); !os.IsNotExist(err) { t.Fatalf("model catalog should not be written before config validation, err=%v", err) } } func TestCodexAppConfigureRejectsMalformedTomlEvenWithExistingRestoreState(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } existing := "[profiles.ollama-launch\nmodel = \"llama3.2\"\n" if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil { t.Fatal(err) } restoreState := `{"had_profile":true,"profile":"default","had_model":true,"model":"gpt-5.5","had_model_provider":true,"model_provider":"openai","had_model_catalog_json":false}` if err := os.WriteFile(codexAppRestoreStatePath(), []byte(restoreState), 0o644); err != nil { t.Fatal(err) } err := (&CodexApp{}).ConfigureWithModels("llama3.2", testLaunchModels("llama3.2")) if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") { t.Fatalf("ConfigureWithModels error = %v, want invalid TOML", err) } data, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } if string(data) != existing { t.Fatalf("malformed config should be left untouched, got:\n%s", data) } stateData, err := os.ReadFile(codexAppRestoreStatePath()) if err != nil { t.Fatal(err) } if string(stateData) != restoreState { t.Fatalf("restore state should be left untouched, got:\n%s", stateData) } } func TestCodexAppCurrentModelRequiresManagedActiveProfile(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } content := "" + "profile = \"default\"\n\n" + codexProfileHeaderFor(codexAppProfileName) + "\n" + "model = \"llama3.2\"\n" + fmt.Sprintf("model_provider = %q\n\n", codexAppProfileName) + codexProviderHeaderFor(codexAppProfileName) + "\n" + "base_url = \"http://127.0.0.1:11434/v1/\"\n" if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { t.Fatal(err) } if got := (&CodexApp{}).CurrentModel(); got != "" { t.Fatalf("CurrentModel = %q, want empty when active profile is not managed", got) } } func TestCodexAppCurrentModelRecognizesManagedRootProviderForms(t *testing.T) { for _, tt := range []struct { name string providerLine string want string }{ {name: "omitted default provider", want: "qwen3:8b"}, {name: "legacy explicit provider", providerLine: `model_provider = "openai"` + "\n", want: "qwen3:8b"}, {name: "different explicit provider", providerLine: `model_provider = "custom"` + "\n"}, } { t.Run(tt.name, func(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } content := "" + `model = "qwen3:8b"` + "\n" + tt.providerLine + fmt.Sprintf(`model_catalog_json = %q`, mustWriteCodexAppTestCatalog(t, "qwen3:8b")) + "\n" + `openai_base_url = "http://127.0.0.1:11434/api/codex/v1"` + "\n" if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { t.Fatal(err) } if got := (&CodexApp{}).CurrentModel(); got != tt.want { t.Fatalf("CurrentModel = %q, want %q", got, tt.want) } }) } } func TestCodexAppOllamaConfiguredKeepsOffSwitchWhenCatalogIsMissing(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } content := "" + `model = "glm-5.3-flash:cloud"` + "\n" + fmt.Sprintf(`model_catalog_json = %q`, codexAppModelCatalogPathForConfig(configPath)) + "\n" + `openai_base_url = "http://127.0.0.1:11434/api/codex/v1"` + "\n" if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { t.Fatal(err) } app := &CodexApp{} if got := app.CurrentModel(); got != "" { t.Fatalf("CurrentModel = %q, want empty without a usable catalog", got) } if !app.OllamaConfigured() { t.Fatal("OllamaConfigured = false, want the damaged managed profile to remain removable") } } func TestCodexAppCurrentModelRequiresHealthyCatalog(t *testing.T) { for _, tt := range []struct { name string rootCatalog bool profileCatalog bool writeCatalog bool catalogData string }{ { name: "missing catalog reference", rootCatalog: false, profileCatalog: true, writeCatalog: true, catalogData: `{"models":[{"slug":"llama3.2"}]}`, }, { name: "deleted catalog file", rootCatalog: true, profileCatalog: true, writeCatalog: false, catalogData: `{"models":[{"slug":"llama3.2"}]}`, }, { name: "missing profile catalog reference", rootCatalog: true, profileCatalog: false, writeCatalog: true, catalogData: `{"models":[{"slug":"llama3.2"}]}`, }, { name: "corrupt catalog file", rootCatalog: true, profileCatalog: true, writeCatalog: true, catalogData: `{"models":`, }, { name: "empty catalog", rootCatalog: true, profileCatalog: true, writeCatalog: true, catalogData: `{"models":[]}`, }, } { t.Run(tt.name, func(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } catalogPath := mustCodexAppModelCatalogPath(t) if tt.writeCatalog { if err := os.WriteFile(catalogPath, []byte(tt.catalogData), 0o644); err != nil { t.Fatal(err) } } var rootCatalogLine, profileCatalogLine string if tt.rootCatalog { rootCatalogLine = fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n" } if tt.profileCatalog { profileCatalogLine = fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n" } content := "" + `model = "llama3.2"` + "\n" + fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n" + rootCatalogLine + "\n" + codexProfileHeaderFor(codexAppProfileName) + "\n" + fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n" + profileCatalogLine + "\n" + codexProviderHeaderFor(codexAppProfileName) + "\n" + `base_url = "http://127.0.0.1:11434/v1/"` + "\n" if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { t.Fatal(err) } if got := (&CodexApp{}).CurrentModel(); got != "" { t.Fatalf("CurrentModel = %q, want empty when catalog is unhealthy", got) } }) } } func TestCodexAppCurrentModelDetectsDriftedModel(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") catalogPath := mustWriteCodexAppTestCatalog(t, "llama3.2") configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } content := "" + `model = "gpt-5.5"` + "\n" + fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n\n" + fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n\n" + codexProviderHeaderFor(codexAppProfileName) + "\n" + `name = "Ollama"` + "\n" + `base_url = "http://127.0.0.1:11434/v1/"` + "\n" + `wire_api = "responses"` + "\n" if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { t.Fatal(err) } if got := (&CodexApp{}).CurrentModel(); got != "" { t.Fatalf("CurrentModel = %q, want empty when model has drifted from the Ollama catalog", got) } } func TestCodexAppCurrentModelAcceptsLatestSuffixDrift(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") catalogPath := mustWriteCodexAppTestCatalog(t, "llama3.2") configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } content := "" + `model = "llama3.2:latest"` + "\n" + fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n" + `openai_base_url = "http://127.0.0.1:11434/api/codex/v1"` + "\n" if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { t.Fatal(err) } if got := (&CodexApp{}).CurrentModel(); got != "llama3.2:latest" { t.Fatalf("CurrentModel = %q, want llama3.2:latest (:latest suffix should not be treated as drift)", got) } } func TestCodexAppConfigurePopulatesCatalogFromEnrichedModels(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) models := []LaunchModel{ {Name: "gemma4", ContextLength: 65536 + len("gemma4"), Capabilities: []model.Capability{model.CapabilityVision, model.CapabilityThinking}, ToolCapable: true}, {Name: "qwen3:8b"}, {Name: "llama3.2"}, } if err := (&CodexApp{}).ConfigureWithModels("gemma4", models); err != nil { t.Fatalf("ConfigureWithModels returned error: %v", err) } catalogPath, err := codexAppModelCatalogPath() if err != nil { t.Fatal(err) } data, err := os.ReadFile(catalogPath) if err != nil { t.Fatal(err) } var catalog struct { Models []map[string]any `json:"models"` } if err := json.Unmarshal(data, &catalog); err != nil { t.Fatalf("catalog should be valid JSON: %v", err) } if got := catalogSlugs(catalog.Models); strings.Join(got, ",") != "gemma4,qwen3:8b,llama3.2,gpt-5.6-sol" { t.Fatalf("catalog slugs = %v, want enriched Ollama models plus native models", got) } for _, model := range catalog.Models[:3] { slug, _ := model["slug"].(string) if model["display_name"] != slug { t.Fatalf("display_name should match slug for %q: %v", slug, model["display_name"]) } if model["visibility"] != "list" { t.Fatalf("visibility for %q = %v, want list", slug, model["visibility"]) } levels, ok := model["supported_reasoning_levels"].([]any) if !ok { t.Fatalf("supported_reasoning_levels for %q = %T, want list", slug, model["supported_reasoning_levels"]) } if slug == "gemma4" { if model["default_reasoning_level"] != "high" { t.Fatalf("default_reasoning_level for %q = %v, want high", slug, model["default_reasoning_level"]) } wantEfforts := []string{"none", "high"} gotEfforts := make([]string, 0, len(levels)) for _, level := range levels { entry, ok := level.(map[string]any) if !ok { t.Fatalf("reasoning level for %q = %T, want object", slug, level) } effort, _ := entry["effort"].(string) gotEfforts = append(gotEfforts, effort) if description, _ := entry["description"].(string); description == "" { t.Fatalf("reasoning level %q for %q has no description", effort, slug) } } if strings.Join(gotEfforts, ",") != strings.Join(wantEfforts, ",") { t.Fatalf("reasoning levels for %q = %v, want %v", slug, gotEfforts, wantEfforts) } } else { if model["default_reasoning_level"] != nil { t.Fatalf("default_reasoning_level for %q = %v, want nil", slug, model["default_reasoning_level"]) } if len(levels) != 0 { t.Fatalf("supported_reasoning_levels for %q = %v, want none", slug, levels) } } wantContext := float64(128000) wantModalities := []string{"text"} if slug == "gemma4" { wantContext = float64(65536 + len(slug)) wantModalities = []string{"text", "image"} } if model["context_window"] != wantContext { t.Fatalf("context_window for %q = %v, want %v", slug, model["context_window"], wantContext) } if got := catalogInputModalities(model); strings.Join(got, ",") != strings.Join(wantModalities, ",") { t.Fatalf("input_modalities for %q = %v, want %v", slug, got, wantModalities) } wantTools := slug == "gemma4" if got := model["supports_search_tool"]; got != wantTools { t.Fatalf("supports_search_tool for %q = %v, want %v", slug, got, wantTools) } if got := model["supports_parallel_tool_calls"]; got != wantTools { t.Fatalf("supports_parallel_tool_calls for %q = %v, want %v", slug, got, wantTools) } if got := model["web_search_tool_type"]; got != "text" { t.Fatalf("web_search_tool_type for %q = %v, want text", slug, got) } } configPath, err := codexConfigPath() if err != nil { t.Fatal(err) } routingData, err := os.ReadFile(codexAppRoutingCatalogPathForConfig(configPath)) if err != nil { t.Fatal(err) } var routingCatalog struct { Models []struct { Slug string `json:"slug"` Thinking struct { Supported bool `json:"supported"` Levels []string `json:"levels"` Values map[string]any `json:"values"` } `json:"thinking"` } `json:"models"` } if err := json.Unmarshal(routingData, &routingCatalog); err != nil { t.Fatalf("routing catalog should be valid JSON: %v", err) } if len(routingCatalog.Models) != 3 { t.Fatalf("routing catalog models = %#v, want 3 selected Ollama models", routingCatalog.Models) } gemmaThinking := routingCatalog.Models[0].Thinking if routingCatalog.Models[0].Slug != "gemma4" || !gemmaThinking.Supported || !slices.Equal(gemmaThinking.Levels, []string{"none", "high"}) { t.Fatalf("gemma4 routing thinking = %+v, want binary off/high metadata", gemmaThinking) } if gemmaThinking.Values["none"] != false || gemmaThinking.Values["high"] != true { t.Fatalf("gemma4 routing thinking values = %#v, want exact false/true values", gemmaThinking.Values) } for _, routed := range routingCatalog.Models[1:] { if routed.Thinking.Supported || len(routed.Thinking.Levels) != 0 { t.Fatalf("non-thinking model %q routing metadata = %+v, want unsupported", routed.Slug, routed.Thinking) } } } func TestCodexAppThinkingLevelsUseRecommendationsThenFallbacks(t *testing.T) { tests := []struct { name string modelName string family string thinking bool recommendation *api.ModelRecommendationThinking wantInitial string wantLevels []string wantValues map[string]any }{ {name: "non-thinking model"}, { name: "harmony descriptor without family fallback", recommendation: renderers.ThinkingForRenderer("harmony"), wantInitial: "medium", wantLevels: []string{"low", "medium", "high"}, wantValues: map[string]any{"low": "low", "medium": "medium", "high": "high"}, }, {name: "binary fallback", thinking: true, wantInitial: "high", wantLevels: []string{"none", "high"}, wantValues: map[string]any{"none": false, "high": true}}, { name: "recommendation with adjustable strings", recommendation: &api.ModelRecommendationThinking{Values: []any{"low", "high", "max"}, Default: "high"}, wantInitial: "high", wantLevels: []string{"low", "high", "max"}, wantValues: map[string]any{"low": "low", "high": "high", "max": "max"}, }, { name: "recommendation with mixed boolean and string values", recommendation: &api.ModelRecommendationThinking{Values: []any{false, true, "max"}, Default: true}, wantInitial: "high", wantLevels: []string{"none", "high", "max"}, wantValues: map[string]any{"none": false, "high": true, "max": "max"}, }, { name: "boolean on is distinct from named medium", recommendation: &api.ModelRecommendationThinking{Values: []any{false, true, "medium"}, Default: true}, wantInitial: "high", wantLevels: []string{"none", "high", "medium"}, wantValues: map[string]any{"none": false, "high": true, "medium": "medium"}, }, { name: "recommendation with binary thinking off by default", recommendation: &api.ModelRecommendationThinking{Values: []any{false, true}, Default: false}, wantInitial: "none", wantLevels: []string{"none", "high"}, wantValues: map[string]any{"none": false, "high": true}, }, { name: "xhigh is retained", recommendation: &api.ModelRecommendationThinking{Values: []any{false, "low", "medium", "xhigh"}, Default: "medium"}, wantInitial: "medium", wantLevels: []string{"none", "low", "medium", "xhigh"}, wantValues: map[string]any{"none": false, "low": "low", "medium": "medium", "xhigh": "xhigh"}, }, { name: "partial catalog keeps controls with unrepresentable default", recommendation: &api.ModelRecommendationThinking{Values: []any{"low", "high", "turbo"}, Default: "turbo"}, wantLevels: []string{"low", "high"}, wantValues: map[string]any{"low": "low", "high": "high"}, }, { name: "literal high takes its label", recommendation: &api.ModelRecommendationThinking{Values: []any{false, true, "high"}, Default: true}, wantLevels: []string{"none", "high"}, wantValues: map[string]any{"none": false, "high": "high"}, }, { name: "explicit non-thinking recommendation", thinking: true, recommendation: &api.ModelRecommendationThinking{Values: []any{false}, Default: false}, }, { name: "invalid recommendation uses capability fallback", thinking: true, recommendation: &api.ModelRecommendationThinking{Values: []any{"low", "high"}, Default: "max"}, wantInitial: "high", wantLevels: []string{"none", "high"}, wantValues: map[string]any{"none": false, "high": true}, }, {name: "model name alone does not infer thinking", modelName: "glm-5.3-flash:cloud"}, {name: "similar unverified tag uses fallback", modelName: "glm-5.3-flash:custom", thinking: true, wantInitial: "high", wantLevels: []string{"none", "high"}}, {name: "GLM 5.3 Flash family fallback", family: "glm5_next", thinking: true, wantInitial: "max", wantLevels: []string{"low", "high", "max"}}, {name: "GLM 5.3 family fallback", modelName: "glm-5.3:cloud", family: "glm_dsa_moe", thinking: true, wantInitial: "max", wantLevels: []string{"low", "high", "max"}}, {name: "GPT-OSS family", family: "gpt-oss", thinking: true, wantInitial: "medium", wantLevels: []string{"low", "medium", "high"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { launchModel := LaunchModel{Name: tt.modelName, Thinking: tt.recommendation} launchModel.Details.Family = tt.family if tt.thinking { launchModel.Capabilities = []model.Capability{model.CapabilityThinking} } metadata := codexAppModelMetadataFromLaunchModel(launchModel) gotInitial, gotLevels := metadata.thinking.defaultLevel, metadata.thinking.levels if gotInitial != tt.wantInitial || !slices.Equal(gotLevels, tt.wantLevels) { t.Fatalf("thinking levels = %q, %v; want %q, %v", gotInitial, gotLevels, tt.wantInitial, tt.wantLevels) } if tt.wantValues != nil { for level, want := range tt.wantValues { if got := metadata.thinking.values[level]; got != want { t.Fatalf("thinking value for %q = %#v, want %#v", level, got, want) } } } }) } } func TestCodexAppConfigureWritesRecommendationThinkingContract(t *testing.T) { setTestHome(t, t.TempDir()) model := LaunchModel{ Name: "deepseek-v4-flash:cloud", Thinking: &api.ModelRecommendationThinking{ Values: []any{false, true, "max"}, Default: true, }, } if err := (&CodexApp{}).ConfigureWithModels(model.Name, []LaunchModel{model}); err != nil { t.Fatal(err) } catalogData, err := os.ReadFile(mustCodexAppModelCatalogPath(t)) if err != nil { t.Fatal(err) } var catalog struct { Models []struct { Slug string `json:"slug"` DefaultReasoningLevel string `json:"default_reasoning_level"` SupportedReasoningLevels []struct { Effort string `json:"effort"` } `json:"supported_reasoning_levels"` } `json:"models"` } if err := json.Unmarshal(catalogData, &catalog); err != nil { t.Fatal(err) } if len(catalog.Models) == 0 || catalog.Models[0].Slug != model.Name { t.Fatalf("catalog models = %#v, want selected model first", catalog.Models) } if got := catalog.Models[0].DefaultReasoningLevel; got != "high" { t.Fatalf("default reasoning level = %q, want high for Ollama true", got) } var levels []string for _, level := range catalog.Models[0].SupportedReasoningLevels { levels = append(levels, level.Effort) } if !slices.Equal(levels, []string{"none", "high", "max"}) { t.Fatalf("reasoning levels = %v, want none/high/max", levels) } configPath, err := codexConfigPath() if err != nil { t.Fatal(err) } routingData, err := os.ReadFile(codexAppRoutingCatalogPathForConfig(configPath)) if err != nil { t.Fatal(err) } var routing struct { Models []struct { Thinking struct { Values map[string]any `json:"values"` Controls *api.ModelRecommendationThinking `json:"controls"` } `json:"thinking"` } `json:"models"` } if err := json.Unmarshal(routingData, &routing); err != nil { t.Fatal(err) } values := routing.Models[0].Thinking.Values if diff := cmp.Diff(model.Thinking, routing.Models[0].Thinking.Controls); diff != "" { t.Fatalf("routing lost the model contract: %s", diff) } if values["none"] != false || values["high"] != true || values["max"] != "max" { t.Fatalf("routing thinking values = %#v, want exact endpoint values", values) } } func TestDefaultCodexAppRunDebugModelsUsesScratchWorkingDirectory(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("shell fixture is Unix-only") } executable := filepath.Join(t.TempDir(), "codex-fixture") if err := os.WriteFile(executable, []byte("#!/bin/sh\npwd\n"), 0o755); err != nil { t.Fatal(err) } scratch := t.TempDir() output, err := defaultCodexAppRunDebugModels(executable, scratch, false) if err != nil { t.Fatal(err) } got := strings.TrimSpace(string(output)) gotInfo, gotErr := os.Stat(got) scratchInfo, scratchErr := os.Stat(scratch) if gotErr != nil || scratchErr != nil || !os.SameFile(gotInfo, scratchInfo) { t.Fatalf("working directory = %q, want scratch CODEX_HOME %q", got, scratch) } } func TestCodexAppConfigureCatalogIncludesExactSelectedModel(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) models := []LaunchModel{ {Name: "llama3.2:latest", ContextLength: 65_536}, {Name: "qwen3:8b"}, } if err := (&CodexApp{}).ConfigureWithModels("llama3.2", models); err != nil { t.Fatalf("ConfigureWithModels returned error: %v", err) } configPath, err := codexConfigPath() if err != nil { t.Fatal(err) } configData, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } if got := codexRootStringValue(string(configData), codexRootModelKey); got != "llama3.2" { t.Fatalf("root model = %q, want llama3.2", got) } catalogPath, err := codexAppModelCatalogPath() if err != nil { t.Fatal(err) } data, err := os.ReadFile(catalogPath) if err != nil { t.Fatal(err) } var catalog struct { Models []map[string]any `json:"models"` } if err := json.Unmarshal(data, &catalog); err != nil { t.Fatalf("catalog should be valid JSON: %v", err) } if got := catalogSlugs(catalog.Models); strings.Join(got, ",") != "llama3.2,qwen3:8b,gpt-5.6-sol" { t.Fatalf("catalog slugs = %v, want exact selected model plus native models without :latest duplicate", got) } if got := catalog.Models[0]["context_window"]; got != float64(65_536) { t.Fatalf("selected model context_window = %v, want 65536", got) } } func TestCodexAppConfigureUpgradesLegacyRestoreState(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999") configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } existing := "" + `model = "gpt-5.5"` + "\n" + `model_provider = "odc-resp-dev"` + "\n\n" + "[model_providers.odc-resp-dev]\n" + `base_url = "https://example.invalid/v1/"` + "\n" if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(codexAppRestoreStatePath(), []byte(`{"had_profile":false}`), 0o644); err != nil { t.Fatal(err) } if err := (&CodexApp{}).ConfigureWithModels("llama3.2", testLaunchModels("llama3.2")); err != nil { t.Fatalf("ConfigureWithModels returned error: %v", err) } state, err := loadCodexAppRestoreState() if err != nil { t.Fatal(err) } if state.HadProfile { t.Fatalf("HadProfile = true, want legacy false") } if !state.HadModel || state.Model != "gpt-5.5" { t.Fatalf("model restore state = (%v, %q), want previous root model", state.HadModel, state.Model) } if !state.HadModelProvider || state.ModelProvider != "odc-resp-dev" { t.Fatalf("model provider restore state = (%v, %q), want previous root provider", state.HadModelProvider, state.ModelProvider) } } func TestCodexAppConfigureMigratesLegacyManagedConfigWithoutPollutingRestoreState(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999") withCodexAppPlatform(t, "darwin") var openCalls int withCodexAppProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { openCalls++ return nil }, ) configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } catalogPath := mustCodexAppModelCatalogPath(t) existing := "" + fmt.Sprintf(`profile = %q`, codexAppProfileName) + "\n" + `model = "llama3.2"` + "\n" + fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n" + fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n\n" + codexProfileHeaderFor(codexAppProfileName) + "\n" + `model = "llama3.2"` + "\n" + fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n" + fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n\n" + codexProviderHeaderFor(codexAppProfileName) + "\n" + `name = "Ollama"` + "\n" + `base_url = "http://127.0.0.1:9999/v1/"` + "\n" + `wire_api = "responses"` + "\n\n" + "[profiles.default]\n" + `model = "gpt-5.5"` + "\n\n" + "[desktop]\n" + `enabled-reasoning-efforts = ["low", "high"]` + "\n" if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(codexAppRestoreStatePath(), []byte(`{"had_profile":true,"profile":"default"}`), 0o644); err != nil { t.Fatal(err) } c := &CodexApp{} if err := c.ConfigureWithModels("qwen3:8b", testLaunchModels("qwen3:8b")); err != nil { t.Fatalf("ConfigureWithModels returned error: %v", err) } state, err := loadCodexAppRestoreState() if err != nil { t.Fatal(err) } if !state.HadProfile || state.Profile != "default" { t.Fatalf("profile restore state = (%v, %q), want default", state.HadProfile, state.Profile) } if state.HadModel || state.HadModelProvider || state.HadModelCatalogJSON { t.Fatalf("legacy restore state should not capture managed root values: %+v", state) } if !state.HadDesktopReasoningEfforts || !slices.Equal(state.DesktopReasoningEfforts, []string{"low", "high"}) { t.Fatalf("desktop reasoning restore state = (%v, %v), want original user choices", state.HadDesktopReasoningEfforts, state.DesktopReasoningEfforts) } data, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } migrated := string(data) if got, ok := codexRootStringValueOK(migrated, "profile"); ok { t.Fatalf("legacy root profile should be removed during migration, got %q in:\n%s", got, migrated) } if strings.Contains(migrated, codexProfileHeaderFor(codexAppProfileName)) { t.Fatalf("legacy app profile section should be removed during migration, got:\n%s", migrated) } if err := c.Restore(); err != nil { t.Fatalf("Restore returned error: %v", err) } data, err = os.ReadFile(configPath) if err != nil { t.Fatal(err) } restored := string(data) if got := codexRootStringValue(restored, "profile"); got != "default" { t.Fatalf("root profile = %q, want default in:\n%s", got, restored) } for _, key := range []string{"model", "model_provider", "model_catalog_json"} { if got, ok := codexRootStringValueOK(restored, key); ok { t.Fatalf("root %s should be removed on restore, got %q in:\n%s", key, got, restored) } } if strings.Contains(restored, codexProfileHeaderFor(codexAppProfileName)) || strings.Contains(restored, codexProviderHeaderFor(codexAppProfileName)) { t.Fatalf("owned app config should be removed on restore, got:\n%s", restored) } restoredConfig, err := codexParseConfig(restored) if err != nil { t.Fatal(err) } if efforts, ok := codexAppConfigReasoningEfforts(restoredConfig); !ok || !slices.Equal(efforts, []string{"low", "high"}) { t.Fatalf("restored desktop reasoning efforts = %v, %v; want [low high]", efforts, ok) } if openCalls != 1 { t.Fatalf("open calls = %d, want 1", openCalls) } } func TestCodexAppRestoreRestoresPreviousProfile(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) withCodexAppPlatform(t, "darwin") var openCalls int withCodexAppProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { openCalls++ return nil }, ) configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } existing := "" + "profile = \"default\"\n" + "model = \"gpt-5.5\"\n" + "model_provider = \"openai\"\n" + "model_catalog_json = \"/tmp/original-catalog.json\"\n" + "openai_base_url = \"https://api.openai.com/v1\"\n\n" + "[profiles.default]\n" + "model = \"gpt-5.5\"\n" if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { t.Fatal(err) } c := &CodexApp{} if err := c.ConfigureWithModels("llama3.2", testLaunchModels("llama3.2")); err != nil { t.Fatalf("ConfigureWithModels returned error: %v", err) } if err := c.Restore(); err != nil { t.Fatalf("Restore returned error: %v", err) } data, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } if !strings.Contains(string(data), `profile = "default"`) || strings.Contains(string(data), fmt.Sprintf(`profile = %q`, codexAppProfileName)) { t.Fatalf("restore should restore previous active profile, got:\n%s", data) } restored := string(data) if strings.Contains(restored, codexProfileHeaderFor(codexAppProfileName)) || strings.Contains(restored, codexProviderHeaderFor(codexAppProfileName)) { t.Fatalf("restore should remove owned app sections, got:\n%s", restored) } for key, want := range map[string]string{ "profile": "default", "model": "gpt-5.5", "model_provider": "openai", "model_catalog_json": "/tmp/original-catalog.json", "openai_base_url": "https://api.openai.com/v1", } { if got := codexRootStringValue(restored, key); got != want { t.Fatalf("root %s = %q, want %q in:\n%s", key, got, want, restored) } } if openCalls != 1 { t.Fatalf("open calls = %d, want 1", openCalls) } if _, err := os.Stat(codexAppRestoreStatePath()); !os.IsNotExist(err) { t.Fatalf("restore state should be removed, got err=%v", err) } } func TestCodexAppRestoreRestoresDesktopReasoningEffortsExactly(t *testing.T) { tests := []struct { name string desktopValue string wantOriginal []string }{ { name: "existing user choices", desktopValue: `enabled-reasoning-efforts = ["minimal", "persistent"]` + "\n", wantOriginal: []string{"minimal", "persistent"}, }, {name: "setting originally absent"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") withCodexAppPlatform(t, "darwin") withCodexAppProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { return nil }) configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } existing := `model = "gpt-5.6-sol"` + "\n\n" + "[desktop]\n" + tt.desktopValue + `theme = "system"` + "\n" if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { t.Fatal(err) } app := &CodexApp{} if err := app.ConfigureWithModels("gemma4", []LaunchModel{ {Name: "gemma4", Capabilities: []model.Capability{model.CapabilityThinking}}, {Name: "custom-qwen", Thinking: &api.ModelRecommendationThinking{Values: []any{false, "low", "medium", "xhigh"}, Default: "medium"}}, }); err != nil { t.Fatalf("ConfigureWithModels returned error: %v", err) } managedData, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } managedConfig, err := codexParseConfig(string(managedData)) if err != nil { t.Fatal(err) } managedEfforts, ok := codexAppConfigReasoningEfforts(managedConfig) if !ok || !slices.Contains(managedEfforts, "none") || !slices.Contains(managedEfforts, "max") { t.Fatalf("managed reasoning efforts = %v, %v; want none and max enabled", managedEfforts, ok) } for _, level := range append(slices.Clone(tt.wantOriginal), "high", "low", "medium", "xhigh") { if !slices.Contains(managedEfforts, level) { t.Errorf("managed efforts %v hide %q", managedEfforts, level) } } if err := app.Restore(); err != nil { t.Fatalf("Restore returned error: %v", err) } restoredData, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } restoredConfig, err := codexParseConfig(string(restoredData)) if err != nil { t.Fatal(err) } gotOriginal, hadOriginal := codexAppConfigReasoningEfforts(restoredConfig) if tt.wantOriginal == nil { if hadOriginal { t.Fatalf("restored reasoning efforts = %v, want setting removed", gotOriginal) } } else if !hadOriginal || !slices.Equal(gotOriginal, tt.wantOriginal) { t.Fatalf("restored reasoning efforts = %v, %v; want %v", gotOriginal, hadOriginal, tt.wantOriginal) } if theme, ok := restoredConfig.String("desktop", "theme"); !ok || theme != "system" { t.Fatalf("desktop theme = %q, %v; want preserved", theme, ok) } }) } } func TestCodexAppRestorePreservesNativeModelSelectedWhileConnected(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999") configPath, err := codexConfigPath() if err != nil { t.Fatal(err) } catalogPath := codexAppModelCatalogPathForConfig(configPath) routingPath := codexAppRoutingCatalogPathForConfig(configPath) if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } state := codexAppRestoreState{ HadModel: true, Model: "gpt-5.5", HadModelProvider: true, ModelProvider: "openai", HadOpenAIBaseURL: true, OpenAIBaseURL: "https://api.openai.com/v1", HadModelCatalogJSON: false, } for _, tt := range []struct { name string current string routing string wantModel string }{ {name: "native selection", current: "gpt-5.6-sol", routing: `{"models":[{"slug":"glm-5.3-flash:cloud"}]}`, wantModel: "gpt-5.6-sol"}, {name: "Ollama selection", current: "glm-5.3-flash:cloud", routing: `{"models":[{"slug":"glm-5.3-flash:cloud"}]}`, wantModel: "gpt-5.5"}, {name: "damaged routing catalog", current: "glm-5.3-flash:cloud", routing: `{`, wantModel: "gpt-5.5"}, } { t.Run(tt.name, func(t *testing.T) { if err := os.WriteFile(routingPath, []byte(tt.routing), 0o600); err != nil { t.Fatal(err) } managed := fmt.Sprintf("model = %q\nmodel_catalog_json = %q\nopenai_base_url = %q\n", tt.current, catalogPath, codexAppProxyBaseURL()) restored := codexAppRestoreRootValues(managed, state) if got := codexRootStringValue(restored, codexRootModelKey); got != tt.wantModel { t.Fatalf("restored model = %q, want %q in:\n%s", got, tt.wantModel, restored) } }) } } func TestCodexAppRestoreMissingConfigRemovesRestoreState(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) withCodexAppPlatform(t, "darwin") var openCalls int withCodexAppProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { openCalls++ return nil }, ) if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil { t.Fatal(err) } restoreState := `{"had_profile":true,"profile":"stale","had_model":true,"model":"old","had_model_provider":true,"model_provider":"openai","had_model_catalog_json":false}` if err := os.WriteFile(codexAppRestoreStatePath(), []byte(restoreState), 0o644); err != nil { t.Fatal(err) } configPath := filepath.Join(tmpDir, ".codex", "config.toml") if created, err := ensureCodexAppManagedAuth(configPath); err != nil || !created { t.Fatalf("create managed auth = %v, %v; want true, nil", created, err) } if err := (&CodexApp{}).Restore(); err != nil { t.Fatalf("Restore returned error: %v", err) } if _, err := os.Stat(codexAppRestoreStatePath()); !os.IsNotExist(err) { t.Fatalf("restore state should be removed when config is missing, got err=%v", err) } if _, err := os.Stat(filepath.Join(filepath.Dir(configPath), "auth.json")); !os.IsNotExist(err) { t.Fatalf("managed auth should be removed when config is missing, got err=%v", err) } if openCalls != 1 { t.Fatalf("open calls = %d, want 1", openCalls) } } func TestCodexAppConfigureMissingConfigReplacesStaleRestoreState(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999") if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil { t.Fatal(err) } restoreState := `{"had_profile":true,"profile":"stale","had_model":true,"model":"old","had_model_provider":true,"model_provider":"openai","had_model_catalog_json":false}` if err := os.WriteFile(codexAppRestoreStatePath(), []byte(restoreState), 0o644); err != nil { t.Fatal(err) } if err := (&CodexApp{}).ConfigureWithModels("llama3.2", testLaunchModels("llama3.2")); err != nil { t.Fatalf("ConfigureWithModels returned error: %v", err) } state, err := loadCodexAppRestoreState() if err != nil { t.Fatal(err) } if state.HadProfile || state.HadModel || state.HadModelProvider || state.HadModelCatalogJSON { t.Fatalf("restore state = %+v, want empty snapshot when config was missing", state) } } func TestCodexAppConfigureRefreshesRestoreStateAfterManualProfileSwitch(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999") withCodexAppPlatform(t, "darwin") var openCalls int withCodexAppProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { openCalls++ return nil }, ) configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } initial := "" + `profile = "default"` + "\n" + `model = "gpt-5.5"` + "\n" + `model_provider = "openai"` + "\n\n" + "[profiles.default]\n" + `model = "gpt-5.5"` + "\n" if err := os.WriteFile(configPath, []byte(initial), 0o644); err != nil { t.Fatal(err) } if err := (&CodexApp{}).ConfigureWithModels("llama3.2", testLaunchModels("llama3.2")); err != nil { t.Fatalf("first ConfigureWithModels returned error: %v", err) } manual := "" + `profile = "manual"` + "\n" + `model = "manual-model"` + "\n" + `model_provider = "openai"` + "\n\n" + "[profiles.manual]\n" + `model = "manual-model"` + "\n" if err := os.WriteFile(configPath, []byte(manual), 0o644); err != nil { t.Fatal(err) } if err := (&CodexApp{}).ConfigureWithModels("qwen3:8b", testLaunchModels("qwen3:8b")); err != nil { t.Fatalf("second ConfigureWithModels returned error: %v", err) } if err := (&CodexApp{}).Restore(); err != nil { t.Fatalf("Restore returned error: %v", err) } data, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } restored := string(data) for key, want := range map[string]string{ "profile": "manual", "model": "manual-model", "model_provider": "openai", } { if got := codexRootStringValue(restored, key); got != want { t.Fatalf("root %s = %q, want %q in:\n%s", key, got, want, restored) } } if openCalls != 1 { t.Fatalf("open calls = %d, want 1", openCalls) } } func TestCodexAppRestoreRejectsMalformedTomlWithoutWriting(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) withCodexAppPlatform(t, "darwin") configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } existing := "model = \"unterminated\n" if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(codexAppRestoreStatePath(), []byte(`{"had_profile":false,"had_model":false,"had_model_provider":false,"had_model_catalog_json":false}`), 0o644); err != nil { t.Fatal(err) } err := (&CodexApp{}).Restore() if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") { t.Fatalf("Restore error = %v, want invalid TOML", err) } catalogPath, pathErr := codexAppModelCatalogPath() if pathErr != nil { t.Fatal(pathErr) } for _, want := range []string{ "Restore did not complete", "Codex config: " + configPath, "Restore state: " + codexAppRestoreStatePath(), "Model catalog: " + catalogPath, "Backups: " + filepath.Join(fileutil.BackupDir(), codexAppIntegrationName), } { if !strings.Contains(err.Error(), want) { t.Fatalf("Restore error missing %q:\n%v", want, err) } } data, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } if string(data) != existing { t.Fatalf("malformed config should be left untouched, got:\n%s", data) } if _, err := os.Stat(codexAppRestoreStatePath()); err != nil { t.Fatalf("restore state should remain after failed restore: %v", err) } } func TestCodexAppRestoreWithoutStateRemovesManagedRootModel(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) withCodexAppPlatform(t, "darwin") var openCalls int withCodexAppProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { openCalls++ return nil }, ) configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } catalogPath, err := codexAppModelCatalogPath() if err != nil { t.Fatal(err) } existing := "" + fmt.Sprintf(`profile = %q`, codexAppProfileName) + "\n" + `model = "llama3.2"` + "\n" + fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n" + fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n\n" + codexProfileHeaderFor(codexAppProfileName) + "\n" + `model = "llama3.2"` + "\n" + fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n\n" + codexProviderHeaderFor(codexAppProfileName) + "\n" + `base_url = "http://127.0.0.1:11434/v1/"` + "\n" if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Dir(catalogPath), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(catalogPath, []byte(`{"models":[]}`), 0o644); err != nil { t.Fatal(err) } if err := (&CodexApp{}).Restore(); err != nil { t.Fatalf("Restore returned error: %v", err) } data, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } content := string(data) for _, key := range []string{"profile", "model", "model_provider", "model_catalog_json"} { if got, ok := codexRootStringValueOK(content, key); ok { t.Fatalf("root %s should be removed, got %q in:\n%s", key, got, content) } } if strings.Contains(content, codexProfileHeaderFor(codexAppProfileName)) || strings.Contains(content, codexProviderHeaderFor(codexAppProfileName)) { t.Fatalf("owned app sections should be removed, got:\n%s", content) } if _, err := os.Stat(catalogPath); !os.IsNotExist(err) { t.Fatalf("owned catalog should be removed when unused, err=%v", err) } if openCalls != 1 { t.Fatalf("open calls = %d, want 1", openCalls) } } func TestCodexAppRestoreDoesNotStompUserChangedRootConfig(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) withCodexAppPlatform(t, "darwin") var openCalls int withCodexAppProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { openCalls++ return nil }, ) configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } catalogPath, err := codexAppModelCatalogPath() if err != nil { t.Fatal(err) } existing := "" + `profile = "manual"` + "\n" + `model = "gpt-5.5"` + "\n" + `model_provider = "openai"` + "\n\n" + codexProfileHeaderFor(codexAppProfileName) + "\n" + `model = "llama3.2"` + "\n" + fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n\n" + codexProviderHeaderFor(codexAppProfileName) + "\n" + `base_url = "http://127.0.0.1:11434/v1/"` + "\n\n" + "[profiles.manual]\n" + `model = "gpt-5.5"` + "\n" if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Dir(catalogPath), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(catalogPath, []byte(`{"models":[]}`), 0o644); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil { t.Fatal(err) } restoreState := `{"had_profile":true,"profile":"default","had_model":true,"model":"old","had_model_provider":true,"model_provider":"old-provider","had_model_catalog_json":false}` if err := os.WriteFile(codexAppRestoreStatePath(), []byte(restoreState), 0o644); err != nil { t.Fatal(err) } if err := (&CodexApp{}).Restore(); err != nil { t.Fatalf("Restore returned error: %v", err) } data, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } content := string(data) for key, want := range map[string]string{ "profile": "manual", "model": "gpt-5.5", "model_provider": "openai", } { if got := codexRootStringValue(content, key); got != want { t.Fatalf("root %s = %q, want %q in:\n%s", key, got, want, content) } } if strings.Contains(content, codexProfileHeaderFor(codexAppProfileName)) || strings.Contains(content, codexProviderHeaderFor(codexAppProfileName)) { t.Fatalf("owned app sections should be removed when no longer active, got:\n%s", content) } if _, err := os.Stat(catalogPath); !os.IsNotExist(err) { t.Fatalf("owned catalog should be removed when unused, err=%v", err) } if openCalls != 1 { t.Fatalf("open calls = %d, want 1", openCalls) } } func TestCodexAppRestoreDoesNotTreatCLIProfileAsOwned(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) withCodexAppPlatform(t, "darwin") withCodexAppProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { return nil }, ) configPath := filepath.Join(tmpDir, ".codex", "config.toml") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } existing := "" + `profile = "ollama-launch"` + "\n" + `model = "cli-model"` + "\n" + `model_provider = "ollama-launch"` + "\n\n" + "[profiles.ollama-launch]\n" + `model = "cli-model"` + "\n" + `openai_base_url = "http://cli.invalid/v1/"` + "\n" + `model_provider = "ollama-launch"` + "\n\n" + "[model_providers.ollama-launch]\n" + `name = "CLI Ollama"` + "\n" + `base_url = "http://cli.invalid/v1/"` + "\n" + `wire_api = "responses"` + "\n" if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil { t.Fatal(err) } restoreState := `{"had_profile":true,"profile":"default","had_model":true,"model":"gpt-5.5","had_model_provider":true,"model_provider":"openai","had_model_catalog_json":false}` if err := os.WriteFile(codexAppRestoreStatePath(), []byte(restoreState), 0o644); err != nil { t.Fatal(err) } if err := (&CodexApp{}).Restore(); err != nil { t.Fatalf("Restore returned error: %v", err) } data, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } if string(data) != existing { t.Fatalf("CLI Codex profile should be left untouched, got:\n%s", data) } } func TestCodexAppDarwinOpenArgs(t *testing.T) { for _, path := range []string{"", "/Applications/ChatGPT.app", "/Users/test/Apps/ChatGPT Preview.app"} { t.Run(path, func(t *testing.T) { setTestHome(t, t.TempDir()) withCodexAppPlatform(t, "darwin") t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") usualArgs := []string{"-b", codexAppBundleID} ollamaArgs := []string{"-b", codexAppBundleID, "codex://threads/new?mode=codex"} if path != "" { usualArgs = []string{path} ollamaArgs = []string{"-a", path, "codex://threads/new?mode=codex"} } if got := codexAppDarwinOpenArgs(path); !slices.Equal(got, usualArgs) { t.Fatalf("unconfigured open args = %q, want %q", got, usualArgs) } app := &CodexApp{} if err := app.ConfigureWithModels("qwen3:8b", testLaunchModels("qwen3:8b")); err != nil { t.Fatal(err) } if got := codexAppDarwinOpenArgs(path); !slices.Equal(got, ollamaArgs) { t.Fatalf("Ollama open args = %q, want %q", got, ollamaArgs) } if err := restoreCodexAppProfile(); err != nil { t.Fatal(err) } if got := codexAppDarwinOpenArgs(path); !slices.Equal(got, usualArgs) { t.Fatalf("restored open args = %q, want %q", got, usualArgs) } }) } } func TestCodexAppDesktopLaunchMode(t *testing.T) { setTestHome(t, t.TempDir()) withCodexAppPlatform(t, "darwin") withCodexAppRouterHealth(t, func() error { return nil }) t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") running := false var openedArgs [][]string withCodexAppProcessHooks(t, func() bool { return running }, func() error { running = false; return nil }, func() error { openedArgs = append(openedArgs, codexAppDarwinOpenArgs("/Applications/ChatGPT.app")) running = true return nil }, ) codexAppCanOpenID = func() bool { return true } app := &CodexApp{} if err := app.UseOllamaFromDesktop("qwen3:8b", testLaunchModels("qwen3:8b"), false); err != nil { t.Fatal(err) } if err := app.RestartFromDesktop(true); err != nil { t.Fatal(err) } if err := app.RestoreFromDesktop(true); err != nil { t.Fatal(err) } want := [][]string{ {"-a", "/Applications/ChatGPT.app", "codex://threads/new?mode=codex"}, {"-a", "/Applications/ChatGPT.app", "codex://threads/new?mode=codex"}, {"/Applications/ChatGPT.app"}, } if !slices.EqualFunc(openedArgs, want, slices.Equal[[]string]) { t.Fatalf("open args = %q, want first launch and restart in Codex, then a normal launch on restore: %q", openedArgs, want) } } func TestCodexAppRunRestartsRunningAppWhenConfirmed(t *testing.T) { withCodexAppPlatform(t, "darwin") restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true}) defer restoreConfirm() running := true var quitCalls, openCalls int withCodexAppProcessHooks(t, func() bool { return running }, func() error { quitCalls++ running = false return nil }, func() error { openCalls++ return nil }, ) if err := (&CodexApp{}).Run("qwen3.5", nil, nil); err != nil { t.Fatalf("Run returned error: %v", err) } if quitCalls != 1 || openCalls != 1 { t.Fatalf("quit/open calls = %d/%d, want 1/1", quitCalls, openCalls) } } func TestCodexAppRunWaitsForGracefulExitBeforeReopening(t *testing.T) { withCodexAppPlatform(t, "darwin") restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true}) defer restoreConfirm() oldSleep := codexAppSleep t.Cleanup(func() { codexAppSleep = oldSleep }) running := true var quitCalls, openCalls, sleepCalls int codexAppSleep = func(time.Duration) { sleepCalls++ if sleepCalls == 2 { running = false } } withCodexAppProcessHooks(t, func() bool { return running }, func() error { quitCalls++ return nil }, func() error { openCalls++ return nil }, ) if err := (&CodexApp{}).Run("qwen3.5", nil, nil); err != nil { t.Fatalf("Run returned error: %v", err) } if quitCalls != 1 || openCalls != 1 { t.Fatalf("quit/open calls = %d/%d, want 1/1", quitCalls, openCalls) } if sleepCalls == 0 { t.Fatal("expected restart to wait for Codex to exit before reopening") } } func TestCodexAppRunCtrlCAbortsEntireRestartFlow(t *testing.T) { withCodexAppPlatform(t, "darwin") restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true}) defer restoreConfirm() oldSleep := codexAppSleep oldDefaultSpinner := DefaultSpinner t.Cleanup(func() { codexAppSleep = oldSleep DefaultSpinner = oldDefaultSpinner }) // Simulate the user pressing Ctrl+C during the graceful-exit wait: the // shared spinner's cancellation channel is closed on the first poll, // which only happens inside the wait loop. cancel := make(chan struct{}) var spinnerStopped bool codexAppSleep = func(time.Duration) { select { case <-cancel: default: close(cancel) } } DefaultSpinner = func(string) *Spinner { return NewSpinner(func() { spinnerStopped = true }, cancel) } var calls []string withCodexAppProcessHooks(t, func() bool { return true }, // app stays "running" so the wait polls func() error { calls = append(calls, "quit"); return nil }, func() error { calls = append(calls, "open"); return nil }, ) codexAppExitTimeout = 5 * time.Second err := (&CodexApp{}).Run("qwen3.5", nil, nil) if !errors.Is(err, ErrCancelled) { t.Fatalf("Run error = %v, want ErrCancelled", err) } if !spinnerStopped { t.Fatal("expected the shared spinner to be stopped on cancel") } // The flow must abort after the graceful quit request without reopening. want := []string{"quit"} if !slices.Equal(calls, want) { t.Fatalf("calls = %v, want the whole flow to abort after quit: %v", calls, want) } } func TestCodexAppRunNeverForcesAStuckAppToQuit(t *testing.T) { for _, goos := range []string{"darwin", "windows"} { t.Run(goos, func(t *testing.T) { withCodexAppPlatform(t, goos) restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true}) defer restoreConfirm() calls := make([]string, 0) withCodexAppProcessHooks(t, func() bool { return true }, func() error { calls = append(calls, "quit"); return nil }, func() error { calls = append(calls, "open") return nil }, ) if goos == "windows" { codexAppStartID = func() string { return "OpenAI.Codex_2p2nqsd0c76g0!App" } } codexAppExitTimeout = 0 err := (&CodexApp{}).Run("qwen3.5", nil, nil) if err == nil || !strings.Contains(err.Error(), "did not quit") { t.Fatalf("Run error = %v, want a manual-close error", err) } if want := []string{"quit"}; !slices.Equal(calls, want) { t.Fatalf("calls = %v, want only the graceful quit request %v", calls, want) } }) } } func TestCodexAppDesktopRestartTimeoutPreservesProfile(t *testing.T) { withCodexAppPlatform(t, "darwin") codexAppExitTimeout = 0 changeCalls := 0 openCalls := 0 withCodexAppProcessHooks(t, func() bool { return true }, func() error { return nil }, func() error { openCalls++; return nil }, ) err := codexAppApplyProfileFromDesktop(func() error { changeCalls++ return nil }, true, true) if err == nil || !strings.Contains(err.Error(), "did not quit") { t.Fatalf("restart error = %v, want a manual-close error", err) } if changeCalls != 0 || openCalls != 0 { t.Fatalf("profile changes/open calls = %d/%d, want 0/0", changeCalls, openCalls) } } func TestCodexAppRunOpensOnWindowsWhenNotRunning(t *testing.T) { withCodexAppPlatform(t, "windows") var openCalls int withCodexAppProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { openCalls++ return nil }, ) if err := (&CodexApp{}).Run("qwen3.5", nil, nil); err != nil { t.Fatalf("Run returned error: %v", err) } if openCalls != 1 { t.Fatalf("open calls = %d, want 1", openCalls) } } func TestCodexAppRunRestartsWindowsStartAppID(t *testing.T) { withCodexAppPlatform(t, "windows") restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true}) defer restoreConfirm() running := true var quitCalls, openCalls int withCodexAppProcessHooks(t, func() bool { return running }, func() error { quitCalls++ running = false return nil }, func() error { openCalls++ return nil }, ) codexAppStartID = func() string { return "OpenAI.Codex_2p2nqsd0c76g0!App" } codexAppRunPath = func() string { return `C:\Program Files\WindowsApps\OpenAI.Codex_26.429.8261.0_x64__2p2nqsd0c76g0\app\Codex.exe` } var openedStartID, openedPath string codexAppOpenStart = func(appID string) error { openedStartID = appID return nil } codexAppOpenPath = func(path string) error { openedPath = path return nil } if err := (&CodexApp{}).Run("qwen3.5", nil, nil); err != nil { t.Fatalf("Run returned error: %v", err) } if quitCalls != 1 { t.Fatalf("quit calls = %d, want 1", quitCalls) } if openedStartID != "OpenAI.Codex_2p2nqsd0c76g0!App" { t.Fatalf("opened Start AppID = %q", openedStartID) } if openedPath != "" { t.Fatalf("opened path = %q, want Start AppID path only", openedPath) } } func TestCodexAppRunRejectsExtraArgs(t *testing.T) { withCodexAppPlatform(t, "darwin") err := (&CodexApp{}).Run("qwen3.5", nil, []string{"--foo"}) if err == nil || !strings.Contains(err.Error(), "does not accept extra arguments") { t.Fatalf("Run error = %v, want extra args rejection", err) } } func TestDefaultCodexAppIsRunningProcesses(t *testing.T) { otherPID := os.Getpid() + 1 for _, tt := range []struct { name string goos string output string exitCode string nameMatchExit string want bool }{ {name: "no processes", goos: "darwin"}, {name: "apps stopped or absent", goos: "darwin", output: "1 /sbin/launchd\n100 /Applications/Ollama.app/Contents/MacOS/Ollama\n"}, {name: "ChatGPT main", goos: "darwin", output: fmt.Sprintf("%d /Applications/ChatGPT.app/Contents/MacOS/ChatGPT\n", otherPID), want: true}, {name: "Codex main", goos: "darwin", output: fmt.Sprintf("%d /Applications/Codex.app/Contents/MacOS/Codex\n", otherPID), want: true}, {name: "renamed ChatGPT bundle", goos: "darwin", output: fmt.Sprintf("%d /Applications/ChatGPT Preview.app/Contents/MacOS/ChatGPT\n", otherPID), nameMatchExit: "0", want: true}, {name: "renamed Codex bundle", goos: "darwin", output: fmt.Sprintf("%d /Applications/Codex Preview.app/Contents/MacOS/Codex\n", otherPID), nameMatchExit: "0", want: true}, {name: "Codex installed as ChatGPT", goos: "darwin", output: fmt.Sprintf("%d /Applications/ChatGPT.app/Contents/MacOS/Codex\n", otherPID), nameMatchExit: "0", want: true}, {name: "ChatGPT app server", goos: "darwin", output: fmt.Sprintf("%d /Applications/ChatGPT.app/Contents/Resources/codex app-server --analytics-default-enabled\n", otherPID), want: true}, {name: "Codex app server", goos: "darwin", output: fmt.Sprintf("%d /Applications/Codex.app/Contents/Resources/codex app-server --analytics-default-enabled\n", otherPID), want: true}, {name: "ChatGPT helper only", goos: "darwin", output: "105 /Applications/ChatGPT.app/Contents/Frameworks/ChatGPT Helper.app/Contents/MacOS/ChatGPT Helper\n"}, {name: "Codex helpers only", goos: "darwin", output: "106 /Applications/Codex.app/Contents/Frameworks/Codex Helper.app/Contents/MacOS/Codex Helper\n107 /Applications/Codex.app/Contents/Frameworks/Electron Framework.framework/Helpers/chrome_crashpad_handler\n"}, {name: "current process", goos: "darwin", output: fmt.Sprintf("%d /Applications/ChatGPT.app/Contents/MacOS/ChatGPT\n", os.Getpid())}, {name: "malformed process list", goos: "darwin", output: "\n108\ninvalid /Applications/Codex.app/Contents/MacOS/Codex\n"}, {name: "failed process lookup", goos: "darwin", output: fmt.Sprintf("%d /Applications/ChatGPT.app/Contents/MacOS/ChatGPT\n", otherPID), exitCode: "1"}, {name: "name match with failed process lookup", goos: "darwin", exitCode: "1", nameMatchExit: "0", want: true}, {name: "failed name lookup falls back to process list", goos: "darwin", output: fmt.Sprintf("%d /Applications/ChatGPT.app/Contents/MacOS/ChatGPT\n", otherPID), nameMatchExit: "3", want: true}, {name: "Windows stopped", goos: "windows"}, {name: "Windows running", goos: "windows", output: fmt.Sprintf("%d\r\n", otherPID), want: true}, {name: "Windows current process", goos: "windows", output: fmt.Sprintf("%d\r\n", os.Getpid())}, {name: "unsupported platform", goos: "linux", output: fmt.Sprintf("%d /Applications/Codex.app/Contents/MacOS/Codex\n", otherPID)}, } { t.Run(tt.name, func(t *testing.T) { withCodexAppPlatform(t, tt.goos) command := "ps" if tt.goos == "windows" { command = "powershell.exe" } commands := map[string]string{ command: "printf '%s' \"$OLLAMA_TEST_CODEX_APP_PROCESSES\"\nexit \"${OLLAMA_TEST_CODEX_APP_EXIT_CODE:-0}\"\n", } if tt.goos == "darwin" { // Exact names exclude helpers; -a includes the app if it launched Ollama. commands["pgrep"] = "[ \"$*\" = '-a -x ChatGPT|Codex' ] || exit 2\nexit \"${OLLAMA_TEST_CODEX_APP_NAME_EXIT_CODE:-1}\"\n" } stubCodexAppProcessCommands(t, commands) t.Setenv("OLLAMA_TEST_CODEX_APP_PROCESSES", tt.output) t.Setenv("OLLAMA_TEST_CODEX_APP_EXIT_CODE", tt.exitCode) t.Setenv("OLLAMA_TEST_CODEX_APP_NAME_EXIT_CODE", tt.nameMatchExit) if got := defaultCodexAppIsRunning(); got != tt.want { t.Fatalf("defaultCodexAppIsRunning() = %v, want %v", got, tt.want) } }) } } func TestDefaultCodexAppIsRunningDoesNotCallAppleScript(t *testing.T) { withCodexAppPlatform(t, "darwin") marker := filepath.Join(t.TempDir(), "osascript-called") t.Setenv("OLLAMA_TEST_CODEX_APP_OSASCRIPT_MARKER", marker) stubCodexAppProcessCommands(t, map[string]string{ "ps": "exit 0\n", // Simulate slow System Events without contacting the real application. "osascript": "printf called > \"$OLLAMA_TEST_CODEX_APP_OSASCRIPT_MARKER\"\nexec /bin/sleep 1\n", }) if defaultCodexAppIsRunning() { t.Fatal("reported a running app with no matching processes") } if _, err := os.Stat(marker); !os.IsNotExist(err) { t.Fatalf("running-state detection invoked osascript: %v", err) } } func TestCodexAppProcessMatchesMainAndAppServer(t *testing.T) { for _, command := range []string{ "/Applications/ChatGPT.app/Contents/MacOS/ChatGPT", "/Applications/ChatGPT.app/Contents/Resources/codex app-server --analytics-default-enabled", "/Applications/Codex.app/Contents/MacOS/Codex", "/Applications/Codex.app/Contents/Resources/codex app-server --analytics-default-enabled", `C:\Users\parth\AppData\Local\Programs\Codex\Codex.exe`, `"C:\Users\parth\AppData\Local\Codex\app-26.429.30905\resources\codex.exe" app-server --analytics-default-enabled`, `"C:\Users\parth\AppData\Local\openai-codex-electron\resources\codex.exe" "app-server"`, } { if !codexAppProcessMatches(command) { t.Fatalf("expected command to match Codex App process: %s", command) } } for _, command := range []string{ "/Applications/ChatGPT.app/Contents/Frameworks/ChatGPT Helper.app/Contents/MacOS/ChatGPT Helper", "/Applications/Codex.app/Contents/Frameworks/Codex Helper.app/Contents/MacOS/Codex Helper", "/Applications/Codex.app/Contents/Frameworks/Electron Framework.framework/Helpers/chrome_crashpad_handler", `"C:\Program Files\WindowsApps\OpenAI.Codex_26.429.8261.0_x64__2p2nqsd0c76g0\app\Codex.exe" --type=renderer --user-data-dir="C:\Users\parth\AppData\Roaming\Codex"`, `"C:\Program Files\WindowsApps\OpenAI.Codex_26.429.8261.0_x64__2p2nqsd0c76g0\app\Codex.exe" --type=crashpad-handler`, } { if codexAppProcessMatches(command) { t.Fatalf("expected helper command not to match Codex App process: %s", command) } } } func TestCodexAppCandidatesIncludeChatGPT(t *testing.T) { withCodexAppPlatform(t, "darwin") candidates := codexAppDarwinAppCandidates() if len(candidates) == 0 || candidates[0] != "/Applications/ChatGPT.app" { t.Fatalf("darwin candidates = %v, want ChatGPT first", candidates) } if !slices.Contains(candidates, "/Applications/Codex.app") { t.Fatalf("darwin candidates = %v, want legacy Codex app", candidates) } withCodexAppPlatform(t, "windows") local := filepath.Join(t.TempDir(), "LocalAppData") t.Setenv("LOCALAPPDATA", local) if candidates := codexAppWindowsAppCandidates(); !slices.Contains(candidates, filepath.Join(local, "Programs", "ChatGPT", "ChatGPT.exe")) { t.Fatalf("windows candidates = %v, want ChatGPT app", candidates) } } func catalogSlugs(models []map[string]any) []string { slugs := make([]string, 0, len(models)) for _, model := range models { if slug, _ := model["slug"].(string); slug != "" { slugs = append(slugs, slug) } } return slugs } func catalogInputModalities(entry map[string]any) []string { raw, _ := entry["input_modalities"].([]any) modalities := make([]string, 0, len(raw)) for _, item := range raw { if modality, _ := item.(string); modality != "" { modalities = append(modalities, modality) } } return modalities } func mustCodexAppModelCatalogPath(t *testing.T) string { t.Helper() catalogPath, err := codexAppModelCatalogPath() if err != nil { t.Fatal(err) } return catalogPath } func mustWriteCodexAppTestCatalog(t *testing.T, slugs ...string) string { t.Helper() catalogPath := mustCodexAppModelCatalogPath(t) if err := os.MkdirAll(filepath.Dir(catalogPath), 0o755); err != nil { t.Fatal(err) } models := make([]map[string]string, 0, len(slugs)) for _, slug := range slugs { models = append(models, map[string]string{"slug": slug}) } data, err := json.Marshal(map[string]any{"models": models}) if err != nil { t.Fatal(err) } if err := os.WriteFile(catalogPath, data, 0o644); err != nil { t.Fatal(err) } configPath, err := codexConfigPath() if err != nil { t.Fatal(err) } if err := os.WriteFile(codexAppRoutingCatalogPathForConfig(configPath), data, 0o644); err != nil { t.Fatal(err) } return catalogPath }