Files
ollama/cmd/launch/codex_app_test.go

3775 lines
128 KiB
Go

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
}