初始化仓库

This commit is contained in:
2026-06-02 23:14:41 +08:00
commit 0bc3f02670
520 changed files with 191097 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+171
View File
@@ -0,0 +1,171 @@
package codex
import (
"context"
"encoding/json"
"testing"
"github.com/chenhg5/cc-connect/core"
)
func TestAppServerSession_ApplyThreadRuntimeState(t *testing.T) {
s := &appServerSession{}
effort := "xhigh"
s.applyThreadRuntimeState("/tmp/project", "gpt-5.4", &effort)
if got := s.GetWorkDir(); got != "/tmp/project" {
t.Fatalf("GetWorkDir() = %q, want /tmp/project", got)
}
if got := s.GetModel(); got != "gpt-5.4" {
t.Fatalf("GetModel() = %q, want gpt-5.4", got)
}
if got := s.GetReasoningEffort(); got != "xhigh" {
t.Fatalf("GetReasoningEffort() = %q, want xhigh", got)
}
}
func TestAppServerSession_HandleRateLimitsUpdatedCachesUsage(t *testing.T) {
s := &appServerSession{}
raw, err := json.Marshal(appServerRateLimitsResponse{
RateLimits: appServerRateLimitSnapshot{
LimitID: "codex",
PlanType: "pro",
Primary: &appServerRateLimitWindow{UsedPercent: 25, WindowDurationMins: 15, ResetsAt: 1730947200},
Secondary: &appServerRateLimitWindow{UsedPercent: 42, WindowDurationMins: 60, ResetsAt: 1730950800},
Credits: &appServerCreditsSnapshot{HasCredits: true, Unlimited: false},
},
})
if err != nil {
t.Fatalf("marshal notification: %v", err)
}
s.handleNotification("account/rateLimits/updated", raw)
report, err := s.GetUsage(context.Background())
if err != nil {
t.Fatalf("GetUsage() returned error: %v", err)
}
if report.Provider != "codex" {
t.Fatalf("provider = %q, want codex", report.Provider)
}
if report.Plan != "pro" {
t.Fatalf("plan = %q, want pro", report.Plan)
}
if len(report.Buckets) != 1 {
t.Fatalf("buckets = %d, want 1", len(report.Buckets))
}
if got := report.Buckets[0].Name; got != "codex" {
t.Fatalf("bucket name = %q, want codex", got)
}
if got := report.Buckets[0].Windows[0].WindowSeconds; got != 15*60 {
t.Fatalf("primary window seconds = %d, want %d", got, 15*60)
}
if got := report.Buckets[0].Windows[1].UsedPercent; got != 42 {
t.Fatalf("secondary used percent = %d, want 42", got)
}
if report.Credits == nil || !report.Credits.HasCredits {
t.Fatalf("credits = %#v, want has credits", report.Credits)
}
}
func TestAppServerSession_HandleThreadTokenUsageUpdatedCachesContextUsage(t *testing.T) {
s := &appServerSession{}
raw, err := json.Marshal(appServerThreadTokenUsageNotification{
ThreadID: "thread-1",
TurnID: "turn-1",
TokenUsage: struct {
Total codexTokenUsage `json:"total"`
Last codexTokenUsage `json:"last"`
ModelContextWindow int `json:"modelContextWindow"`
}{
Total: codexTokenUsage{
TotalTokens: 52011395,
InputTokens: 51847383,
CachedInputTokens: 48187904,
OutputTokens: 164012,
ReasoningOutputTokens: 78910,
},
Last: codexTokenUsage{
TotalTokens: 41061,
InputTokens: 40849,
CachedInputTokens: 36864,
OutputTokens: 212,
ReasoningOutputTokens: 32,
},
ModelContextWindow: 258400,
},
})
if err != nil {
t.Fatalf("marshal notification: %v", err)
}
s.handleNotification("thread/tokenUsage/updated", raw)
usage := s.GetContextUsage()
if usage == nil {
t.Fatal("GetContextUsage() = nil, want cached context usage")
}
if usage.UsedTokens != 41061 {
t.Fatalf("used tokens = %d, want 41061", usage.UsedTokens)
}
if usage.BaselineTokens != codexContextBaselineTokens {
t.Fatalf("baseline tokens = %d, want %d", usage.BaselineTokens, codexContextBaselineTokens)
}
if usage.TotalTokens != 41061 {
t.Fatalf("total tokens = %d, want 41061", usage.TotalTokens)
}
if usage.ContextWindow != 258400 {
t.Fatalf("context window = %d, want 258400", usage.ContextWindow)
}
if usage.CachedInputTokens != 36864 {
t.Fatalf("cached input tokens = %d, want 36864", usage.CachedInputTokens)
}
if usage.InputTokens != 40849 {
t.Fatalf("input tokens = %d, want 40849", usage.InputTokens)
}
}
func TestMapAppServerRateLimits_PrefersMultiBucketView(t *testing.T) {
report := mapAppServerRateLimits(appServerRateLimitsResponse{
RateLimits: appServerRateLimitSnapshot{
LimitID: "legacy",
PlanType: "team",
Primary: &appServerRateLimitWindow{UsedPercent: 99, WindowDurationMins: 15},
},
RateLimitsByLimitID: map[string]appServerRateLimitSnapshot{
"codex": {
LimitID: "codex",
LimitName: "Codex",
PlanType: "team",
Primary: &appServerRateLimitWindow{UsedPercent: 10, WindowDurationMins: 15},
},
"codex_other": {
LimitID: "codex_other",
PlanType: "team",
Primary: &appServerRateLimitWindow{UsedPercent: 20, WindowDurationMins: 60},
},
},
})
if report.Plan != "team" {
t.Fatalf("plan = %q, want team", report.Plan)
}
if len(report.Buckets) != 2 {
t.Fatalf("buckets = %d, want 2", len(report.Buckets))
}
if report.Buckets[0].Name != "Codex" {
t.Fatalf("first bucket = %q, want Codex", report.Buckets[0].Name)
}
if report.Buckets[1].Name != "codex_other" {
t.Fatalf("second bucket = %q, want codex_other", report.Buckets[1].Name)
}
}
var _ interface {
GetUsage(context.Context) (*core.UsageReport, error)
} = (*appServerSession)(nil)
var _ interface {
GetContextUsage() *core.ContextUsage
} = (*appServerSession)(nil)
+684
View File
@@ -0,0 +1,684 @@
package codex
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"github.com/chenhg5/cc-connect/core"
)
func init() {
core.RegisterAgent("codex", New)
}
// Agent drives OpenAI Codex CLI using `codex exec --json`.
//
// Modes (maps to codex exec flags):
// - "suggest": default, no special flags (safe commands only)
// - "auto-edit": --full-auto (sandbox-protected auto execution)
// - "full-auto": --full-auto (sandbox-protected auto execution)
// - "yolo": --dangerously-bypass-approvals-and-sandbox
type Agent struct {
workDir string
model string
reasoningEffort string
mode string // "suggest" | "auto-edit" | "full-auto" | "yolo"
backend string // "exec" | "app_server"
appServerURL string
codexHome string
cliBin string // CLI binary name, default "codex"
cliExtraArgs []string // extra args parsed from cli_path after the binary
providers []core.ProviderConfig
activeIdx int // -1 = no provider set
configEnv []string // env vars from [projects.agent.options.env] — persists across SetSessionEnv calls
sessionEnv []string
mu sync.RWMutex
}
func New(opts map[string]any) (core.Agent, error) {
workDir, _ := opts["work_dir"].(string)
if workDir == "" {
workDir = "."
}
model, _ := opts["model"].(string)
reasoningEffort, _ := opts["reasoning_effort"].(string)
mode, _ := opts["mode"].(string)
backend, _ := opts["backend"].(string)
appServerURL, _ := opts["app_server_url"].(string)
codexHome, _ := opts["codex_home"].(string)
mode = normalizeMode(mode)
backend = normalizeBackend(backend)
appServerURL = normalizeAppServerURL(appServerURL)
// cli_path allows overriding the binary, e.g. "omx" or "omx --flag val"
cliBin := "codex"
var cliExtraArgs []string
if cliPath, _ := opts["cli_path"].(string); strings.TrimSpace(cliPath) != "" {
parts := strings.Fields(cliPath)
cliBin = parts[0]
if len(parts) > 1 {
cliExtraArgs = parts[1:]
}
}
if _, err := exec.LookPath(cliBin); err != nil {
return nil, fmt.Errorf("codex: %q CLI not found in PATH, install with: npm install -g @openai/codex", cliBin)
}
// Parse project-level env from opts["env"] (set via [projects.agent.options.env] in config.toml).
// Stored separately from runtime sessionEnv so SetSessionEnv calls cannot overwrite it.
// MergeEnv semantics ensure these override any same-named keys inherited from os.Environ()
// when the codex subprocess is spawned (e.g. user-scoped HTTPS_PROXY leaking into the agent).
var configEnv []string
if envMap, ok := opts["env"].(map[string]string); ok {
for k, v := range envMap {
configEnv = append(configEnv, k+"="+v)
}
} else if envMap, ok := opts["env"].(map[string]any); ok {
for k, v := range envMap {
if s, ok := v.(string); ok {
configEnv = append(configEnv, k+"="+s)
}
}
}
return &Agent{
workDir: workDir,
model: model,
reasoningEffort: normalizeReasoningEffort(reasoningEffort),
mode: mode,
backend: backend,
appServerURL: appServerURL,
codexHome: strings.TrimSpace(codexHome),
cliBin: cliBin,
cliExtraArgs: cliExtraArgs,
configEnv: configEnv,
activeIdx: -1,
}, nil
}
func normalizeBackend(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "app-server", "app_server", "appserver", "ws":
return "app_server"
default:
return "exec"
}
}
func normalizeAppServerURL(raw string) string {
url := strings.TrimSpace(raw)
if url == "" {
return "ws://127.0.0.1:3845"
}
if strings.EqualFold(url, "stdio") {
return "stdio://"
}
return url
}
func normalizeMode(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "auto-edit", "autoedit", "auto_edit", "edit":
return "auto-edit"
case "full-auto", "fullauto", "full_auto", "auto":
return "full-auto"
case "yolo", "bypass", "dangerously-bypass":
return "yolo"
default:
return "suggest"
}
}
func normalizeReasoningEffort(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "":
return ""
case "low":
return "low"
case "medium", "med":
return "medium"
case "high":
return "high"
case "xhigh", "x-high", "very-high":
return "xhigh"
default:
return ""
}
}
func (a *Agent) Name() string { return "codex" }
func (a *Agent) SetWorkDir(dir string) {
a.mu.Lock()
defer a.mu.Unlock()
a.workDir = dir
slog.Info("codex: work_dir changed", "work_dir", dir)
}
func (a *Agent) GetWorkDir() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.workDir
}
func (a *Agent) SetModel(model string) {
a.mu.Lock()
defer a.mu.Unlock()
a.model = model
slog.Info("codex: model changed", "model", model)
}
func (a *Agent) GetModel() string {
a.mu.Lock()
defer a.mu.Unlock()
return core.GetProviderModel(a.providers, a.activeIdx, a.model)
}
func (a *Agent) SetReasoningEffort(effort string) {
a.mu.Lock()
defer a.mu.Unlock()
a.reasoningEffort = normalizeReasoningEffort(effort)
slog.Info("codex: reasoning effort changed", "reasoning_effort", a.reasoningEffort)
}
func (a *Agent) GetReasoningEffort() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.reasoningEffort
}
func (a *Agent) AvailableReasoningEfforts() []string {
return []string{"low", "medium", "high", "xhigh"}
}
func (a *Agent) configuredModels() []core.ModelOption {
a.mu.RLock()
defer a.mu.RUnlock()
return core.GetProviderModels(a.providers, a.activeIdx)
}
func (a *Agent) AvailableModels(ctx context.Context) []core.ModelOption {
if models := a.configuredModels(); len(models) > 0 {
return models
}
if models := a.fetchModelsFromAPI(ctx); len(models) > 0 {
return models
}
if models := readCodexCachedModels(); len(models) > 0 {
return models
}
return []core.ModelOption{
{Name: "o4-mini", Desc: "O4 Mini (fast reasoning)"},
{Name: "o3", Desc: "O3 (most capable reasoning)"},
{Name: "gpt-4.1", Desc: "GPT-4.1 (balanced)"},
{Name: "gpt-4.1-mini", Desc: "GPT-4.1 Mini (fast)"},
{Name: "gpt-4.1-nano", Desc: "GPT-4.1 Nano (fastest)"},
{Name: "codex-mini-latest", Desc: "Codex Mini (code-optimized)"},
}
}
var openaiChatModels = map[string]bool{
"o4-mini": true, "o3": true, "o3-mini": true, "o1": true, "o1-mini": true,
"gpt-4.1": true, "gpt-4.1-mini": true, "gpt-4.1-nano": true,
"gpt-4o": true, "gpt-4o-mini": true,
"codex-mini-latest": true,
}
func (a *Agent) fetchModelsFromAPI(ctx context.Context) []core.ModelOption {
a.mu.Lock()
apiKey := ""
baseURL := ""
if a.activeIdx >= 0 && a.activeIdx < len(a.providers) {
apiKey = a.providers[a.activeIdx].APIKey
baseURL = a.providers[a.activeIdx].BaseURL
}
a.mu.Unlock()
if apiKey == "" {
apiKey = os.Getenv("OPENAI_API_KEY")
}
if apiKey == "" {
return nil
}
if baseURL == "" {
baseURL = os.Getenv("OPENAI_BASE_URL")
}
if baseURL == "" {
baseURL = "https://api.openai.com"
}
baseURL = strings.TrimRight(baseURL, "/")
req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/v1/models", nil)
if err != nil {
return nil
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
slog.Debug("codex: failed to fetch models", "error", err)
return nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil
}
var result struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil
}
var models []core.ModelOption
for _, m := range result.Data {
if openaiChatModels[m.ID] {
models = append(models, core.ModelOption{Name: m.ID})
}
}
sort.Slice(models, func(i, j int) bool { return models[i].Name < models[j].Name })
return models
}
func readCodexCachedModels() []core.ModelOption {
codexHome := os.Getenv("CODEX_HOME")
if codexHome == "" {
home, err := os.UserHomeDir()
if err != nil {
return nil
}
codexHome = filepath.Join(home, ".codex")
}
path := filepath.Join(codexHome, "models_cache.json")
b, err := os.ReadFile(path)
if err != nil {
return nil
}
var payload struct {
Models []struct {
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Visibility string `json:"visibility"`
SupportedInAPI bool `json:"supported_in_api"`
} `json:"models"`
}
if err := json.Unmarshal(b, &payload); err != nil {
return nil
}
var models []core.ModelOption
seen := make(map[string]struct{}, len(payload.Models))
for _, m := range payload.Models {
name := strings.TrimSpace(m.Slug)
if name == "" {
name = strings.TrimSpace(m.DisplayName)
}
if name == "" {
continue
}
if m.Visibility != "" && m.Visibility != "list" {
continue
}
if !m.SupportedInAPI {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
models = append(models, core.ModelOption{
Name: name,
Desc: strings.TrimSpace(m.Description),
})
}
return models
}
func (a *Agent) SetSessionEnv(env []string) {
a.mu.Lock()
defer a.mu.Unlock()
a.sessionEnv = env
}
func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) {
a.mu.Lock()
mode := a.mode
model := a.model
reasoningEffort := a.reasoningEffort
backend := a.backend
appServerURL := a.appServerURL
codexHome := a.codexHome
cliBin := a.cliBin
cliExtraArgs := a.cliExtraArgs
workDir := a.workDir
// Order matters for MergeEnv override semantics (later wins):
// 1. configEnv — static env from [projects.agent.options.env]
// 2. providerEnv — per-provider keys (OPENAI_API_KEY etc.)
// 3. sessionEnv — runtime overrides from /env or admin actions
extraEnv := append([]string(nil), a.configEnv...)
extraEnv = append(extraEnv, a.providerEnvLocked()...)
extraEnv = append(extraEnv, a.sessionEnv...)
var baseURL string
if a.activeIdx >= 0 && a.activeIdx < len(a.providers) {
if m := a.providers[a.activeIdx].Model; m != "" {
model = m
}
baseURL = a.providers[a.activeIdx].BaseURL
}
provName, provAPIKey, provWireAPI, provHeaders := a.activeProviderCodexConfig()
a.mu.Unlock()
if provName != "" {
if err := ensureCodexProviderConfig(codexHome, provName, baseURL, provWireAPI, provHeaders); err != nil {
slog.Warn("codex: failed to write provider config", "provider", provName, "error", err)
}
if err := ensureCodexAuth(codexHome, provAPIKey); err != nil {
slog.Warn("codex: failed to write auth.json", "provider", provName, "error", err)
}
}
if backend == "app_server" {
return newAppServerSession(ctx, appServerURL, workDir, model, reasoningEffort, mode, sessionID, baseURL, provName, extraEnv, codexHome)
}
if codexHome != "" {
extraEnv = append(extraEnv, "CODEX_HOME="+codexHome)
}
return newCodexSession(ctx, cliBin, cliExtraArgs, workDir, model, reasoningEffort, mode, sessionID, baseURL, extraEnv, provName)
}
func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {
a.mu.RLock()
codexHome := a.codexHome
workDir := a.workDir
a.mu.RUnlock()
return listCodexSessions(workDir, codexHome)
}
func (a *Agent) GetSessionHistory(_ context.Context, sessionID string, limit int) ([]core.HistoryEntry, error) {
a.mu.RLock()
codexHome := a.codexHome
a.mu.RUnlock()
return getSessionHistory(sessionID, codexHome, limit)
}
func (a *Agent) DeleteSession(_ context.Context, sessionID string) error {
a.mu.RLock()
codexHome := a.codexHome
a.mu.RUnlock()
path := findSessionFile(sessionID, codexHome)
if path == "" {
return fmt.Errorf("session file not found: %s", sessionID)
}
return os.Remove(path)
}
func (a *Agent) Stop() error { return nil }
// SetMode changes the approval mode for future sessions.
func (a *Agent) SetMode(mode string) {
a.mu.Lock()
defer a.mu.Unlock()
a.mode = normalizeMode(mode)
slog.Info("codex: approval mode changed", "mode", a.mode)
}
func (a *Agent) GetMode() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.mode
}
func (a *Agent) WorkspaceAgentOptions() map[string]any {
a.mu.RLock()
defer a.mu.RUnlock()
opts := map[string]any{
"mode": a.mode,
"backend": a.backend,
}
if a.model != "" {
opts["model"] = a.model
}
if a.reasoningEffort != "" {
opts["reasoning_effort"] = a.reasoningEffort
}
if a.appServerURL != "" {
opts["app_server_url"] = a.appServerURL
}
if a.codexHome != "" {
opts["codex_home"] = a.codexHome
}
return opts
}
// ── SkillProvider implementation ──────────────────────────────
func (a *Agent) SkillDirs() []string {
a.mu.RLock()
workDir := a.workDir
codexHome := a.codexHome
a.mu.RUnlock()
absDir, err := filepath.Abs(workDir)
if err != nil {
absDir = workDir
}
return codexSkillDirs(absDir, codexHome)
}
// ── ContextCompressor implementation ──────────────────────────
// CompressCommand returns "" because Codex native slash commands (/compact, /clear)
// are not reliably executed in exec/resume mode — they may be treated as plain text.
// See: https://github.com/chenhg5/cc-connect/issues/378
func (a *Agent) CompressCommand() string { return "" }
func codexSkillDirs(workDir, explicitCodexHome string) []string {
homeDir, _ := os.UserHomeDir()
codexHome := strings.TrimSpace(explicitCodexHome)
if codexHome == "" {
codexHome = strings.TrimSpace(os.Getenv("CODEX_HOME"))
}
if codexHome == "" && homeDir != "" {
codexHome = filepath.Join(homeDir, ".codex")
}
projectDirs := walkUpCodexProjectSkillDirs(workDir, homeDir)
userDirs := make([]string, 0, 2)
if codexHome != "" {
userDirs = append(userDirs, filepath.Join(codexHome, "skills"))
}
if homeDir != "" {
userDirs = append(userDirs, filepath.Join(homeDir, ".agents", "skills"))
}
return uniqueCodexSkillDirs(append(projectDirs, userDirs...))
}
func walkUpCodexProjectSkillDirs(workDir, homeDir string) []string {
current := filepath.Clean(workDir)
homeDir = filepath.Clean(homeDir)
stopAt := findCodexProjectRoot(current)
var dirs []string
for {
if homeDir != "" && sameCodexPath(current, homeDir) {
break
}
dirs = append(dirs,
filepath.Join(current, ".agents", "skills"),
filepath.Join(current, ".codex", "skills"),
)
if stopAt != "" && sameCodexPath(current, stopAt) {
break
}
parent := filepath.Dir(current)
if parent == current {
break
}
current = parent
}
return uniqueCodexSkillDirs(dirs)
}
func findCodexProjectRoot(start string) string {
current := filepath.Clean(start)
for {
for _, marker := range []string{".git", ".jj"} {
if _, err := os.Stat(filepath.Join(current, marker)); err == nil {
return current
}
}
parent := filepath.Dir(current)
if parent == current {
return ""
}
current = parent
}
}
func sameCodexPath(a, b string) bool {
if a == "" || b == "" {
return false
}
return filepath.Clean(a) == filepath.Clean(b)
}
func uniqueCodexSkillDirs(paths []string) []string {
seen := make(map[string]struct{}, len(paths))
out := make([]string, 0, len(paths))
for _, path := range paths {
if path == "" {
continue
}
clean := filepath.Clean(path)
if _, ok := seen[clean]; ok {
continue
}
seen[clean] = struct{}{}
out = append(out, clean)
}
return out
}
// ── MemoryFileProvider implementation ─────────────────────────
func (a *Agent) ProjectMemoryFile() string {
absDir, err := filepath.Abs(a.workDir)
if err != nil {
absDir = a.workDir
}
return filepath.Join(absDir, "AGENTS.md")
}
func (a *Agent) GlobalMemoryFile() string {
homeDir, err := os.UserHomeDir()
if err != nil {
return ""
}
codexHome := os.Getenv("CODEX_HOME")
if codexHome == "" {
codexHome = filepath.Join(homeDir, ".codex")
}
return filepath.Join(codexHome, "AGENTS.md")
}
// ── ProviderSwitcher implementation ──────────────────────────
func (a *Agent) SetProviders(providers []core.ProviderConfig) {
a.mu.Lock()
defer a.mu.Unlock()
a.providers = providers
}
func (a *Agent) SetActiveProvider(name string) bool {
a.mu.Lock()
defer a.mu.Unlock()
if name == "" {
a.activeIdx = -1
slog.Info("codex: provider cleared")
return true
}
for i, p := range a.providers {
if p.Name == name {
a.activeIdx = i
slog.Info("codex: provider switched", "provider", name)
return true
}
}
return false
}
func (a *Agent) GetActiveProvider() *core.ProviderConfig {
a.mu.Lock()
defer a.mu.Unlock()
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
return nil
}
p := a.providers[a.activeIdx]
return &p
}
func (a *Agent) ListProviders() []core.ProviderConfig {
a.mu.Lock()
defer a.mu.Unlock()
result := make([]core.ProviderConfig, len(a.providers))
copy(result, a.providers)
return result
}
func (a *Agent) providerEnvLocked() []string {
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
return nil
}
p := a.providers[a.activeIdx]
var env []string
if p.APIKey != "" {
env = append(env, "OPENAI_API_KEY="+p.APIKey)
}
if p.BaseURL != "" {
env = append(env, "OPENAI_BASE_URL="+p.BaseURL)
}
for k, v := range p.Env {
env = append(env, k+"="+v)
}
return env
}
// activeProviderCodexConfig returns Codex-specific config for the active provider.
// Returns non-empty name when the provider has codex config (wire_api, headers)
// OR when it has a BaseURL (third-party provider needing auth.json).
func (a *Agent) activeProviderCodexConfig() (name string, apiKey string, wireAPI string, headers map[string]string) {
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
return
}
p := a.providers[a.activeIdx]
hasCodexConfig := p.CodexWireAPI != "" || len(p.CodexHTTPHeaders) > 0
isThirdParty := p.BaseURL != "" && p.APIKey != ""
if !hasCodexConfig && !isThirdParty {
return
}
return p.Name, p.APIKey, p.CodexWireAPI, p.CodexHTTPHeaders
}
func (a *Agent) PermissionModes() []core.PermissionModeInfo {
return []core.PermissionModeInfo{
{Key: "suggest", Name: "Suggest", NameZh: "建议", Desc: "Ask permission for every tool call", DescZh: "每次工具调用都需确认"},
{Key: "auto-edit", Name: "Auto Edit", NameZh: "自动编辑", Desc: "Auto-approve file edits, ask for shell commands", DescZh: "自动允许文件编辑,Shell 命令需确认"},
{Key: "full-auto", Name: "Full Auto", NameZh: "全自动", Desc: "Auto-approve with workspace sandbox", DescZh: "自动通过(工作区沙箱)"},
{Key: "yolo", Name: "YOLO", NameZh: "YOLO 模式", Desc: "Bypass all approvals and sandbox", DescZh: "跳过所有审批和沙箱"},
}
}
+42
View File
@@ -0,0 +1,42 @@
package codex
import (
"context"
"net/http"
"net/http/httptest"
"os"
"testing"
)
func TestAvailableModels_FallbackToModelsCache(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "forbidden", http.StatusForbidden)
}))
defer srv.Close()
tmp := t.TempDir()
cache := `{
"models": [
{"slug":"gpt-5.4","description":"Latest frontier agentic coding model.","visibility":"list","supported_in_api":true},
{"slug":"gpt-5.3-codex","description":"Great for coding","visibility":"list","supported_in_api":true},
{"slug":"hidden-internal","visibility":"hidden","supported_in_api":true},
{"slug":"tool-only","visibility":"list","supported_in_api":false}
]
}`
if err := os.WriteFile(tmp+"/models_cache.json", []byte(cache), 0o600); err != nil {
t.Fatalf("write models_cache.json: %v", err)
}
t.Setenv("CODEX_HOME", tmp)
t.Setenv("OPENAI_API_KEY", "test-key")
t.Setenv("OPENAI_BASE_URL", srv.URL)
a := &Agent{activeIdx: -1}
models := a.AvailableModels(context.Background())
if len(models) != 2 {
t.Fatalf("models length = %d, want 2, models=%v", len(models), models)
}
if models[0].Name != "gpt-5.4" || models[1].Name != "gpt-5.3-codex" {
t.Fatalf("models = %v, want [gpt-5.4 gpt-5.3-codex]", models)
}
}
+83
View File
@@ -0,0 +1,83 @@
package codex
import (
"testing"
"github.com/chenhg5/cc-connect/core"
)
func TestConfiguredModels_BoundaryConditions(t *testing.T) {
a := &Agent{
providers: []core.ProviderConfig{
{Models: []core.ModelOption{{Name: "first"}}},
{Models: []core.ModelOption{{Name: "second"}}},
},
}
tests := []struct {
name string
activeIdx int
wantNil bool
wantName string
}{
{name: "negative index", activeIdx: -1, wantNil: true},
{name: "out of range", activeIdx: 2, wantNil: true},
{name: "valid index", activeIdx: 1, wantName: "second"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
a.activeIdx = tt.activeIdx
got := a.configuredModels()
if tt.wantNil {
if got != nil {
t.Fatalf("configuredModels() = %v, want nil", got)
}
return
}
if len(got) != 1 || got[0].Name != tt.wantName {
t.Fatalf("configuredModels() = %v, want %q", got, tt.wantName)
}
})
}
}
func TestGetModel_PrefersActiveProviderModel(t *testing.T) {
a := &Agent{
model: "gpt-4.1-mini",
providers: []core.ProviderConfig{
{Name: "openai", Model: "gpt-5.4"},
},
activeIdx: 0,
}
if got := a.GetModel(); got != "gpt-5.4" {
t.Fatalf("GetModel() = %q, want gpt-5.4", got)
}
}
func TestNormalizeAppServerURL_StdIOIsExplicit(t *testing.T) {
for _, raw := range []string{"stdio", " stdio "} {
if got := normalizeAppServerURL(raw); got != "stdio://" {
t.Fatalf("normalizeAppServerURL(%q) = %q, want stdio://", raw, got)
}
}
}
func TestNormalizeAppServerURL_EmptyKeepsWebSocketDefault(t *testing.T) {
if got := normalizeAppServerURL(""); got != "ws://127.0.0.1:3845" {
t.Fatalf("normalizeAppServerURL(empty) = %q, want ws://127.0.0.1:3845", got)
}
}
func TestWorkspaceAgentOptions_PreservesStdIOAppServerURL(t *testing.T) {
a := &Agent{
backend: "app_server",
appServerURL: normalizeAppServerURL("stdio"),
}
opts := a.WorkspaceAgentOptions()
if got := opts["app_server_url"]; got != "stdio://" {
t.Fatalf("WorkspaceAgentOptions()[app_server_url] = %#v, want stdio://", got)
}
}
+291
View File
@@ -0,0 +1,291 @@
package codex
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"github.com/chenhg5/cc-connect/core"
)
const codexRolloutTailBytes int64 = 1 << 20
const codexContextBaselineTokens = 12000
type codexTokenUsage struct {
TotalTokens int `json:"totalTokens"`
InputTokens int `json:"inputTokens"`
CachedInputTokens int `json:"cachedInputTokens"`
OutputTokens int `json:"outputTokens"`
ReasoningOutputTokens int `json:"reasoningOutputTokens"`
}
type codexSnakeTokenUsage struct {
TotalTokens int `json:"total_tokens"`
InputTokens int `json:"input_tokens"`
CachedInputTokens int `json:"cached_input_tokens"`
OutputTokens int `json:"output_tokens"`
ReasoningOutputTokens int `json:"reasoning_output_tokens"`
}
type appServerThreadTokenUsageNotification struct {
ThreadID string `json:"threadId"`
TurnID string `json:"turnId"`
TokenUsage struct {
Total codexTokenUsage `json:"total"`
Last codexTokenUsage `json:"last"`
ModelContextWindow int `json:"modelContextWindow"`
} `json:"tokenUsage"`
}
func mapAppServerTokenUsage(notif appServerThreadTokenUsageNotification) *core.ContextUsage {
return contextUsageFromCamel(notif.TokenUsage.Last, notif.TokenUsage.ModelContextWindow)
}
func contextUsageFromCamel(usage codexTokenUsage, contextWindow int) *core.ContextUsage {
return contextUsageFromParts(
currentContextTokens(usage.TotalTokens, usage.InputTokens, usage.OutputTokens),
usage.TotalTokens,
usage.InputTokens,
usage.CachedInputTokens,
usage.OutputTokens,
usage.ReasoningOutputTokens,
contextWindow,
)
}
func contextUsageFromSnake(usage codexSnakeTokenUsage, contextWindow int) *core.ContextUsage {
return contextUsageFromParts(
currentContextTokens(usage.TotalTokens, usage.InputTokens, usage.OutputTokens),
usage.TotalTokens,
usage.InputTokens,
usage.CachedInputTokens,
usage.OutputTokens,
usage.ReasoningOutputTokens,
contextWindow,
)
}
func currentContextTokens(totalTokens, inputTokens, outputTokens int) int {
if totalTokens > 0 {
return totalTokens
}
if inputTokens > 0 || outputTokens > 0 {
return inputTokens + outputTokens
}
return 0
}
func contextUsageFromParts(usedTokens, totalTokens, inputTokens, cachedInputTokens, outputTokens, reasoningOutputTokens, contextWindow int) *core.ContextUsage {
if totalTokens <= 0 && inputTokens <= 0 && outputTokens <= 0 {
return nil
}
if contextWindow <= 0 {
return nil
}
return &core.ContextUsage{
UsedTokens: usedTokens,
BaselineTokens: codexContextBaselineTokens,
TotalTokens: totalTokens,
InputTokens: inputTokens,
CachedInputTokens: cachedInputTokens,
OutputTokens: outputTokens,
ReasoningOutputTokens: reasoningOutputTokens,
ContextWindow: contextWindow,
}
}
func cloneContextUsage(usage *core.ContextUsage) *core.ContextUsage {
if usage == nil {
return nil
}
cloned := *usage
return &cloned
}
func loadContextUsageFromRollout(extraEnv []string, sessionID, cachedPath string) (*core.ContextUsage, string, error) {
path := strings.TrimSpace(cachedPath)
if path != "" {
usage, err := readContextUsageFromRollout(path)
if err == nil && usage != nil {
return usage, path, nil
}
}
codexHome, err := resolveCodexHome(extraEnv)
if err != nil {
return nil, "", err
}
path = findSessionFileInCodexHome(codexHome, sessionID)
if path == "" {
return nil, "", fmt.Errorf("session file not found for %s", sessionID)
}
usage, err := readContextUsageFromRollout(path)
if err != nil {
return nil, path, err
}
if usage == nil {
return nil, path, fmt.Errorf("context usage not found in rollout")
}
return usage, path, nil
}
func resolveCodexHome(extraEnv []string) (string, error) {
if value := getenvFromList(extraEnv, "CODEX_HOME"); value != "" {
return strings.TrimSpace(value), nil
}
if value := strings.TrimSpace(os.Getenv("CODEX_HOME")); value != "" {
return value, nil
}
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
return filepath.Join(homeDir, ".codex"), nil
}
func getenvFromList(env []string, key string) string {
prefix := key + "="
for i := len(env) - 1; i >= 0; i-- {
entry := env[i]
if strings.HasPrefix(entry, prefix) {
return strings.TrimSpace(strings.TrimPrefix(entry, prefix))
}
}
return ""
}
func findSessionFileInCodexHome(codexHome, sessionID string) string {
if strings.TrimSpace(codexHome) == "" || strings.TrimSpace(sessionID) == "" {
return ""
}
pattern := filepath.Join(codexHome, "sessions", "*", "*", "*", "rollout-*"+sessionID+".jsonl")
if matches, _ := filepath.Glob(pattern); len(matches) > 0 {
sort.Strings(matches)
return matches[len(matches)-1]
}
sessionsDir := filepath.Join(codexHome, "sessions")
var found string
_ = filepath.Walk(sessionsDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info == nil || info.IsDir() || found != "" {
return nil
}
if strings.Contains(filepath.Base(path), sessionID) {
found = path
}
return nil
})
return found
}
func readContextUsageFromRollout(path string) (*core.ContextUsage, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
if usage, err := readContextUsageFromRolloutTail(f); err != nil {
return nil, err
} else if usage != nil {
return usage, nil
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
return nil, err
}
return scanContextUsageFromRollout(f)
}
func readContextUsageFromRolloutTail(f *os.File) (*core.ContextUsage, error) {
info, err := f.Stat()
if err != nil {
return nil, err
}
if info.Size() <= 0 {
return nil, nil
}
start := int64(0)
if info.Size() > codexRolloutTailBytes {
start = info.Size() - codexRolloutTailBytes
}
buf := make([]byte, int(info.Size()-start))
n, err := f.ReadAt(buf, start)
if err != nil && err != io.EOF {
return nil, err
}
buf = buf[:n]
if start > 0 {
if idx := bytes.IndexByte(buf, '\n'); idx >= 0 {
buf = buf[idx+1:]
}
}
return parseContextUsageFromRolloutBytes(buf), nil
}
func parseContextUsageFromRolloutBytes(data []byte) *core.ContextUsage {
lines := bytes.Split(data, []byte{'\n'})
for i := len(lines) - 1; i >= 0; i-- {
line := bytes.TrimSpace(lines[i])
if len(line) == 0 {
continue
}
if usage := parseContextUsageFromRolloutLine(line); usage != nil {
return usage
}
}
return nil
}
func scanContextUsageFromRollout(r io.Reader) (*core.ContextUsage, error) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), 2*1024*1024)
var last *core.ContextUsage
for scanner.Scan() {
if usage := parseContextUsageFromRolloutLine(scanner.Bytes()); usage != nil {
last = usage
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
return last, nil
}
func parseContextUsageFromRolloutLine(line []byte) *core.ContextUsage {
var entry struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
if err := json.Unmarshal(line, &entry); err != nil {
return nil
}
if entry.Type != "event_msg" {
return nil
}
var payload struct {
Type string `json:"type"`
Info *struct {
TotalTokenUsage codexSnakeTokenUsage `json:"total_token_usage"`
LastTokenUsage codexSnakeTokenUsage `json:"last_token_usage"`
ModelContextWindow int `json:"model_context_window"`
} `json:"info"`
}
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
return nil
}
if payload.Type != "token_count" || payload.Info == nil {
return nil
}
return contextUsageFromSnake(payload.Info.LastTokenUsage, payload.Info.ModelContextWindow)
}
+109
View File
@@ -0,0 +1,109 @@
package codex
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// TestIntegration_CodexProviderFlow verifies the full provider config flow:
// 1. ensureCodexProviderConfig writes correct config.toml
// 2. ensureCodexAuth writes correct auth.json
// 3. Codex CLI can authenticate and respond using the written config
//
// Requires: SHENGSUANYUN_API_KEY env var and `codex` CLI in PATH.
// Skip with: go test ./agent/codex/ -run TestIntegration -v
func TestIntegration_CodexProviderFlow(t *testing.T) {
apiKey := os.Getenv("SHENGSUANYUN_API_KEY")
if apiKey == "" {
t.Skip("SHENGSUANYUN_API_KEY not set, skipping integration test")
}
if _, err := exec.LookPath("codex"); err != nil {
t.Skip("codex CLI not in PATH, skipping integration test")
}
home := filepath.Join(t.TempDir(), ".codex")
// Step 1: write config.toml via our function
err := ensureCodexProviderConfig(home, "shengsuanyun-codex",
"https://router.shengsuanyun.com/api/v1", "responses", nil)
if err != nil {
t.Fatalf("ensureCodexProviderConfig: %v", err)
}
// Verify config.toml content
cfgData, err := os.ReadFile(filepath.Join(home, "config.toml"))
if err != nil {
t.Fatalf("read config.toml: %v", err)
}
cfgContent := string(cfgData)
t.Logf("Generated config.toml:\n%s", cfgContent)
for _, want := range []string{
`[model_providers.shengsuanyun-codex]`,
`base_url = "https://router.shengsuanyun.com/api/v1"`,
`wire_api = "responses"`,
`env_key = "OPENAI_API_KEY"`,
} {
if !strings.Contains(cfgContent, want) {
t.Errorf("config.toml missing: %s", want)
}
}
// Step 2: write auth.json via our function
err = ensureCodexAuth(home, apiKey)
if err != nil {
t.Fatalf("ensureCodexAuth: %v", err)
}
// Verify auth.json content
authData, err := os.ReadFile(filepath.Join(home, "auth.json"))
if err != nil {
t.Fatalf("read auth.json: %v", err)
}
var authMap map[string]any
if err := json.Unmarshal(authData, &authMap); err != nil {
t.Fatalf("parse auth.json: %v", err)
}
if authMap["auth_mode"] != "apikey" {
t.Errorf("auth_mode = %v, want apikey", authMap["auth_mode"])
}
if authMap["OPENAI_API_KEY"] != apiKey {
t.Errorf("OPENAI_API_KEY not set correctly in auth.json")
}
// Step 3: run codex exec with the generated config
workDir := filepath.Join(t.TempDir(), "repo")
os.MkdirAll(workDir, 0o755)
gitInit := exec.Command("git", "init", "-q")
gitInit.Dir = workDir
if err := gitInit.Run(); err != nil {
t.Fatalf("git init: %v", err)
}
cmd := exec.Command("codex", "exec",
"--model", "openai/gpt-5.3-codex",
"-c", `model_provider="shengsuanyun-codex"`,
"-c", `openai_base_url="https://router.shengsuanyun.com/api/v1"`,
"--full-auto",
)
cmd.Dir = workDir
cmd.Env = append(os.Environ(),
"CODEX_HOME="+home,
"OPENAI_API_KEY="+apiKey,
)
cmd.Stdin = strings.NewReader("reply with exactly 'integration-ok' and nothing else")
output, err := cmd.CombinedOutput()
t.Logf("Codex output:\n%s", string(output))
if err != nil {
t.Fatalf("codex exec failed: %v\noutput: %s", err, output)
}
if !strings.Contains(string(output), "integration-ok") {
t.Errorf("expected 'integration-ok' in output, got:\n%s", output)
}
}
+321
View File
@@ -0,0 +1,321 @@
package codex
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/chenhg5/cc-connect/core"
)
// resolveCodexHomeDir returns the effective CODEX_HOME directory.
// Priority: explicit config value > CODEX_HOME env > ~/.codex
func resolveCodexHomeDir(explicit string) string {
if h := strings.TrimSpace(explicit); h != "" {
return h
}
if h := os.Getenv("CODEX_HOME"); h != "" {
return h
}
homeDir, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(homeDir, ".codex")
}
// listCodexSessions scans the codex sessions directory for JSONL transcript
// files whose cwd matches workDir.
func listCodexSessions(workDir, codexHome string) ([]core.AgentSessionInfo, error) {
absWorkDir, err := filepath.Abs(workDir)
if err != nil {
absWorkDir = workDir
}
sessionsDir := filepath.Join(resolveCodexHomeDir(codexHome), "sessions")
var files []string
_ = filepath.Walk(sessionsDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
if strings.HasSuffix(path, ".jsonl") {
files = append(files, path)
}
return nil
})
if len(files) == 0 {
return nil, nil
}
var sessions []core.AgentSessionInfo
for _, f := range files {
info := parseCodexSessionFile(f, absWorkDir)
if info != nil {
patchSessionSource(info.ID, codexHome)
sessions = append(sessions, *info)
}
}
sort.Slice(sessions, func(i, j int) bool {
return sessions[i].ModifiedAt.After(sessions[j].ModifiedAt)
})
return sessions, nil
}
// parseCodexSessionFile reads a Codex JSONL transcript.
// Returns nil if the session's cwd doesn't match filterCwd.
func parseCodexSessionFile(path, filterCwd string) *core.AgentSessionInfo {
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
return nil
}
var sessionID string
var sessionCwd string
var summary string
var msgCount int
userMsgSeen := 0
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 256*1024), 256*1024)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
var entry struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
if err := json.Unmarshal([]byte(line), &entry); err != nil {
continue
}
switch entry.Type {
case "session_meta":
var meta struct {
ID string `json:"id"`
Cwd string `json:"cwd"`
}
if json.Unmarshal(entry.Payload, &meta) == nil {
sessionID = meta.ID
sessionCwd = meta.Cwd
}
case "response_item":
var item struct {
Role string `json:"role"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
}
if json.Unmarshal(entry.Payload, &item) == nil {
if item.Role == "user" {
userMsgSeen++
msgCount++
// The actual user prompt is the last user response_item
// (earlier ones are system/AGENTS.md instructions).
// Pick the last content block that looks like a real prompt.
for _, c := range item.Content {
if c.Type == "input_text" && c.Text != "" && isUserPrompt(c.Text) {
summary = c.Text
}
}
} else if item.Role == "assistant" {
msgCount++
}
}
}
}
// Filter by cwd
if filterCwd != "" && sessionCwd != "" && sessionCwd != filterCwd {
return nil
}
if sessionID == "" {
return nil
}
if len([]rune(summary)) > 60 {
summary = string([]rune(summary)[:60]) + "..."
}
return &core.AgentSessionInfo{
ID: sessionID,
Summary: summary,
MessageCount: msgCount,
ModifiedAt: stat.ModTime(),
}
}
// findSessionFile locates the JSONL transcript for a given session ID.
func findSessionFile(sessionID, codexHome string) string {
sessionsDir := filepath.Join(resolveCodexHomeDir(codexHome), "sessions")
var found string
_ = filepath.Walk(sessionsDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || found != "" {
return nil
}
if strings.Contains(filepath.Base(path), sessionID) {
found = path
}
return nil
})
return found
}
// getSessionHistory reads the JSONL transcript and returns user/assistant messages.
func getSessionHistory(sessionID, codexHome string, limit int) ([]core.HistoryEntry, error) {
path := findSessionFile(sessionID, codexHome)
if path == "" {
return nil, fmt.Errorf("session file not found for %s", sessionID)
}
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var entries []core.HistoryEntry
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 256*1024), 256*1024)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
var raw struct {
Timestamp string `json:"timestamp"`
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
if json.Unmarshal([]byte(line), &raw) != nil {
continue
}
if raw.Type != "response_item" {
continue
}
var item struct {
Role string `json:"role"`
Type string `json:"type"`
Text string `json:"text"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
}
if json.Unmarshal(raw.Payload, &item) != nil {
continue
}
ts, _ := time.Parse(time.RFC3339Nano, raw.Timestamp)
switch {
case item.Role == "user" && len(item.Content) > 0:
for _, c := range item.Content {
if c.Type == "input_text" && c.Text != "" && isUserPrompt(c.Text) {
entries = append(entries, core.HistoryEntry{
Role: "user", Content: c.Text, Timestamp: ts,
})
}
}
case item.Role == "assistant" && len(item.Content) > 0:
for _, c := range item.Content {
if c.Type == "output_text" && c.Text != "" {
entries = append(entries, core.HistoryEntry{
Role: "assistant", Content: c.Text, Timestamp: ts,
})
}
}
case item.Type == "reasoning" && item.Text != "":
// skip reasoning items
}
}
if limit > 0 && len(entries) > limit {
entries = entries[len(entries)-limit:]
}
return entries, nil
}
// patchSessionSource rewrites the session_meta line in a Codex JSONL transcript
// so that source="cli" and originator="codex_cli_rs", making the session visible
// in the interactive `codex` terminal.
func patchSessionSource(sessionID, codexHome string) {
path := findSessionFile(sessionID, codexHome)
if path == "" {
return
}
data, err := os.ReadFile(path)
if err != nil {
return
}
idx := bytes.IndexByte(data, '\n')
if idx < 0 {
return
}
firstLine := data[:idx]
// Only patch if it's actually an exec-sourced session
if !bytes.Contains(firstLine, []byte(`"source":"exec"`)) {
return
}
patched := bytes.Replace(firstLine, []byte(`"source":"exec"`), []byte(`"source":"cli"`), 1)
patched = bytes.Replace(patched, []byte(`"originator":"codex_exec"`), []byte(`"originator":"codex_cli_rs"`), 1)
if bytes.Equal(patched, firstLine) {
return
}
out := make([]byte, 0, len(patched)+len(data)-idx)
out = append(out, patched...)
out = append(out, data[idx:]...)
_ = os.WriteFile(path, out, 0o644)
}
// isUserPrompt returns true if the text looks like an actual user prompt
// rather than system context (AGENTS.md, environment_context, permissions, etc.)
func isUserPrompt(text string) bool {
t := strings.TrimSpace(text)
if t == "" {
return false
}
// Skip XML-style system context
if strings.HasPrefix(t, "<") {
return false
}
// Skip AGENTS.md instructions injected by Codex
if strings.HasPrefix(t, "# AGENTS.md") || strings.HasPrefix(t, "#AGENTS.md") {
return false
}
return true
}
+75
View File
@@ -0,0 +1,75 @@
package codex
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestPatchSessionSource(t *testing.T) {
tmpDir := t.TempDir()
sessionID := "test-session-abc123"
sessionsDir := filepath.Join(tmpDir, ".codex", "sessions")
if err := os.MkdirAll(sessionsDir, 0o755); err != nil {
t.Fatal(err)
}
fname := filepath.Join(sessionsDir, "rollout-"+sessionID+".jsonl")
line1 := `{"timestamp":"2026-01-01T00:00:00Z","type":"session_meta","payload":{"id":"` + sessionID + `","source":"exec","originator":"codex_exec","cwd":"/tmp"}}`
line2 := `{"timestamp":"2026-01-01T00:00:01Z","type":"response_item","payload":{"role":"user"}}`
content := line1 + "\n" + line2 + "\n"
if err := os.WriteFile(fname, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
codexHome := filepath.Join(tmpDir, ".codex")
patchSessionSource(sessionID, codexHome)
data, err := os.ReadFile(fname)
if err != nil {
t.Fatal(err)
}
lines := strings.SplitN(string(data), "\n", 2)
if !strings.Contains(lines[0], `"source":"cli"`) {
t.Errorf("expected source:cli, got first line: %s", lines[0])
}
if !strings.Contains(lines[0], `"originator":"codex_cli_rs"`) {
t.Errorf("expected originator:codex_cli_rs, got first line: %s", lines[0])
}
if strings.Contains(lines[0], `"source":"exec"`) {
t.Error("source:exec was not replaced")
}
// Second line should be untouched
if !strings.HasPrefix(lines[1], `{"timestamp":"2026-01-01T00:00:01Z"`) {
t.Errorf("second line was corrupted: %s", lines[1])
}
}
func TestPatchSessionSource_Idempotent(t *testing.T) {
tmpDir := t.TempDir()
sessionID := "test-idempotent-xyz"
sessionsDir := filepath.Join(tmpDir, ".codex", "sessions")
os.MkdirAll(sessionsDir, 0o755)
fname := filepath.Join(sessionsDir, "rollout-"+sessionID+".jsonl")
line1 := `{"type":"session_meta","payload":{"id":"` + sessionID + `","source":"cli","originator":"codex_cli_rs"}}`
if err := os.WriteFile(fname, []byte(line1+"\n"), 0o644); err != nil {
t.Fatal(err)
}
codexHome := filepath.Join(tmpDir, ".codex")
patchSessionSource(sessionID, codexHome)
data, _ := os.ReadFile(fname)
if string(data) != line1+"\n" {
t.Errorf("file was modified when it shouldn't have been")
}
}
+30
View File
@@ -0,0 +1,30 @@
//go:build unix
package codex
import (
"errors"
"os"
"os/exec"
"syscall"
)
func prepareCmdForKill(cmd *exec.Cmd) {
if cmd == nil {
return
}
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
}
func forceKillCmd(cmd *exec.Cmd) error {
if cmd == nil || cmd.Process == nil {
return nil
}
if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil && !errors.Is(err, os.ErrProcessDone) && !errors.Is(err, syscall.ESRCH) {
return err
}
return nil
}
+54
View File
@@ -0,0 +1,54 @@
//go:build windows
package codex
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"syscall"
)
func prepareCmdForKill(cmd *exec.Cmd) {
if cmd == nil {
return
}
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.CreationFlags |= syscall.CREATE_NEW_PROCESS_GROUP
}
func forceKillCmd(cmd *exec.Cmd) error {
if cmd == nil || cmd.Process == nil {
return nil
}
killCmd := exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(cmd.Process.Pid))
output, err := killCmd.CombinedOutput()
if err == nil {
return nil
}
if bytes.Contains(bytes.ToLower(output), []byte("there is no running instance")) {
return nil
}
if bytes.Contains(bytes.ToLower(output), []byte("not found")) {
return nil
}
if killErr := cmd.Process.Kill(); killErr == nil || errors.Is(killErr, os.ErrProcessDone) {
return nil
} else {
return fmt.Errorf("taskkill failed: %w: %s; process kill fallback failed: %w", err, processKillOutput(output), killErr)
}
}
func processKillOutput(output []byte) string {
trimmed := strings.TrimSpace(string(output))
if trimmed == "" {
return "(empty output)"
}
return trimmed
}
+98
View File
@@ -0,0 +1,98 @@
package codex
import (
"testing"
)
// TestNew_ParsesProjectEnvFromOpts verifies that env vars declared under
// [projects.agent.options.env] in config.toml are loaded into the agent's
// configEnv field. Without this, user-scoped env (e.g. HTTPS_PROXY in the
// shell that launched cc-connect) silently overrides the values intended
// for the codex subprocess.
//
// Regression for: codex agent ignoring opts["env"] in factory.
func TestNew_ParsesProjectEnvFromOpts(t *testing.T) {
// Use "go" as cliBin to satisfy exec.LookPath without requiring codex
// to be installed on the test runner.
opts := map[string]any{
"work_dir": t.TempDir(),
"cli_path": "go",
"env": map[string]string{
"HTTPS_PROXY": "http://127.0.0.1:10808",
"HTTP_PROXY": "http://127.0.0.1:10808",
"ALL_PROXY": "http://127.0.0.1:10808",
},
}
a, err := New(opts)
if err != nil {
t.Fatalf("New() error: %v", err)
}
agent := a.(*Agent)
agent.mu.RLock()
got := envSliceToMap(agent.configEnv)
agent.mu.RUnlock()
if len(got) != 3 {
t.Fatalf("expected 3 env vars, got %d: %v", len(got), agent.configEnv)
}
if v := got["HTTPS_PROXY"]; v != "http://127.0.0.1:10808" {
t.Errorf("HTTPS_PROXY = %q, want http://127.0.0.1:10808", v)
}
if v := got["ALL_PROXY"]; v != "http://127.0.0.1:10808" {
t.Errorf("ALL_PROXY = %q, want http://127.0.0.1:10808", v)
}
}
// TestNew_ParsesProjectEnvFromMapStringAny covers the TOML decoder path
// where the env table arrives as map[string]any rather than map[string]string.
func TestNew_ParsesProjectEnvFromMapStringAny(t *testing.T) {
opts := map[string]any{
"work_dir": t.TempDir(),
"cli_path": "go",
"env": map[string]any{
"OPENAI_BASE_URL": "https://api.example.com/v1",
"CUSTOM_FLAG": "yes",
},
}
a, err := New(opts)
if err != nil {
t.Fatalf("New() error: %v", err)
}
agent := a.(*Agent)
agent.mu.RLock()
got := envSliceToMap(agent.configEnv)
agent.mu.RUnlock()
if v := got["OPENAI_BASE_URL"]; v != "https://api.example.com/v1" {
t.Errorf("OPENAI_BASE_URL = %q", v)
}
if v := got["CUSTOM_FLAG"]; v != "yes" {
t.Errorf("CUSTOM_FLAG = %q", v)
}
}
// TestNew_NoEnvOpts ensures the absence of an env block produces an empty
// configEnv slice (no panics, no surprise inheritance).
func TestNew_NoEnvOpts(t *testing.T) {
opts := map[string]any{
"work_dir": t.TempDir(),
"cli_path": "go",
}
a, err := New(opts)
if err != nil {
t.Fatalf("New() error: %v", err)
}
agent := a.(*Agent)
agent.mu.RLock()
defer agent.mu.RUnlock()
if len(agent.configEnv) != 0 {
t.Fatalf("expected 0 env vars, got %d: %v", len(agent.configEnv), agent.configEnv)
}
}
+136
View File
@@ -0,0 +1,136 @@
package codex
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
)
// ensureCodexProviderConfig writes or updates a [model_providers.<name>] section
// in $CODEX_HOME/config.toml so that Codex CLI can use the provider's wire_api
// and http_headers settings.
func ensureCodexProviderConfig(codexHome, name, baseURL, wireAPI string, headers map[string]string) error {
if name == "" {
return nil
}
home, err := resolveCodexHomeForConfig(codexHome)
if err != nil {
return fmt.Errorf("codex: resolve codex home: %w", err)
}
if err := os.MkdirAll(home, 0o755); err != nil {
return fmt.Errorf("codex: mkdir codex home: %w", err)
}
cfgPath := filepath.Join(home, "config.toml")
raw, _ := os.ReadFile(cfgPath)
content := string(raw)
section := buildProviderSection(name, baseURL, wireAPI, headers)
updated := upsertProviderSection(content, name, section)
if err := os.WriteFile(cfgPath, []byte(updated), 0o644); err != nil {
return fmt.Errorf("codex: write config.toml: %w", err)
}
slog.Debug("codex: wrote provider config", "provider", name, "path", cfgPath)
return nil
}
// ensureCodexAuth writes $CODEX_HOME/auth.json with the provider's API key,
// matching cc-switch's approach: {"OPENAI_API_KEY": "...", "auth_mode": "api_key"}.
// This is the standard way to authenticate Codex CLI with third-party providers.
func ensureCodexAuth(codexHome, apiKey string) error {
if apiKey == "" {
return nil
}
home, err := resolveCodexHomeForConfig(codexHome)
if err != nil {
return fmt.Errorf("codex: resolve codex home: %w", err)
}
if err := os.MkdirAll(home, 0o755); err != nil {
return fmt.Errorf("codex: mkdir codex home: %w", err)
}
authPath := filepath.Join(home, "auth.json")
payload := map[string]any{
"OPENAI_API_KEY": apiKey,
"auth_mode": "apikey",
}
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return fmt.Errorf("codex: marshal auth.json: %w", err)
}
if err := os.WriteFile(authPath, append(data, '\n'), 0o600); err != nil {
return fmt.Errorf("codex: write auth.json: %w", err)
}
slog.Debug("codex: wrote auth.json", "path", authPath)
return nil
}
func resolveCodexHomeForConfig(explicit string) (string, error) {
if h := strings.TrimSpace(explicit); h != "" {
return h, nil
}
if h := strings.TrimSpace(os.Getenv("CODEX_HOME")); h != "" {
return h, nil
}
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(homeDir, ".codex"), nil
}
func buildProviderSection(name, baseURL, wireAPI string, headers map[string]string) string {
var sb strings.Builder
fmt.Fprintf(&sb, "[model_providers.%s]\n", name)
fmt.Fprintf(&sb, "name = %q\n", name)
if baseURL != "" {
fmt.Fprintf(&sb, "base_url = %q\n", baseURL)
}
fmt.Fprintf(&sb, "env_key = %q\n", "OPENAI_API_KEY")
if wireAPI != "" {
fmt.Fprintf(&sb, "wire_api = %q\n", wireAPI)
}
if len(headers) > 0 {
fmt.Fprintf(&sb, "\n[model_providers.%s.http_headers]\n", name)
for k, v := range headers {
fmt.Fprintf(&sb, "%q = %q\n", k, v)
}
}
return sb.String()
}
// upsertProviderSection replaces an existing [model_providers.<name>] section
// or appends a new one at the end of the config content.
func upsertProviderSection(content, name, newSection string) string {
sectionHeader := fmt.Sprintf("[model_providers.%s]", name)
subSectionPrefix := fmt.Sprintf("[model_providers.%s.", name)
if !strings.Contains(content, sectionHeader) {
trimmed := strings.TrimRight(content, "\n\t ")
if trimmed == "" {
return newSection
}
return trimmed + "\n\n" + newSection
}
idx := strings.Index(content, sectionHeader)
after := content[idx+len(sectionHeader):]
end := len(content)
lines := strings.Split(after, "\n")
offset := idx + len(sectionHeader)
for _, line := range lines {
offset += len(line) + 1
trimmed := strings.TrimSpace(line)
if len(trimmed) > 0 && trimmed[0] == '[' && !strings.HasPrefix(trimmed, subSectionPrefix) && trimmed != sectionHeader {
end = offset - len(line) - 1
break
}
}
return strings.TrimRight(content[:idx], "\n") + "\n\n" + newSection + "\n" + content[end:]
}
+215
View File
@@ -0,0 +1,215 @@
package codex
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestEnsureCodexProviderConfig_CreatesNewFile(t *testing.T) {
home := filepath.Join(t.TempDir(), ".codex")
err := ensureCodexProviderConfig(home, "shengsuanyun",
"https://router.shengsuanyun.com/api/v1", "responses",
map[string]string{"HTTP-Referer": "https://openai.com/zh-Hans-CN/codex/", "X-Title": "CodeX"})
if err != nil {
t.Fatalf("ensureCodexProviderConfig: %v", err)
}
data, err := os.ReadFile(filepath.Join(home, "config.toml"))
if err != nil {
t.Fatalf("read config.toml: %v", err)
}
content := string(data)
for _, want := range []string{
`[model_providers.shengsuanyun]`,
`env_key = "OPENAI_API_KEY"`,
`wire_api = "responses"`,
`base_url = "https://router.shengsuanyun.com/api/v1"`,
`[model_providers.shengsuanyun.http_headers]`,
`"HTTP-Referer" = "https://openai.com/zh-Hans-CN/codex/"`,
`"X-Title" = "CodeX"`,
} {
if !strings.Contains(content, want) {
t.Errorf("config.toml missing %q\ngot:\n%s", want, content)
}
}
}
func TestEnsureCodexProviderConfig_UpdatesExistingSection(t *testing.T) {
home := filepath.Join(t.TempDir(), ".codex")
if err := os.MkdirAll(home, 0o755); err != nil {
t.Fatal(err)
}
initial := `model = "gpt-5.4"
[model_providers.shengsuanyun]
name = "shengsuanyun"
env_key = "OLD_KEY"
wire_api = "chat"
[some_other_section]
key = "value"
`
if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte(initial), 0o644); err != nil {
t.Fatal(err)
}
err := ensureCodexProviderConfig(home, "shengsuanyun",
"https://router.shengsuanyun.com/api/v1", "responses", nil)
if err != nil {
t.Fatalf("ensureCodexProviderConfig: %v", err)
}
data, err := os.ReadFile(filepath.Join(home, "config.toml"))
if err != nil {
t.Fatal(err)
}
content := string(data)
if !strings.Contains(content, `env_key = "OPENAI_API_KEY"`) {
t.Errorf("updated config missing new env_key\ngot:\n%s", content)
}
if strings.Contains(content, `env_key = "OLD_KEY"`) {
t.Errorf("updated config still has old env_key\ngot:\n%s", content)
}
if !strings.Contains(content, `wire_api = "responses"`) {
t.Errorf("updated config missing new wire_api\ngot:\n%s", content)
}
if !strings.Contains(content, `[some_other_section]`) {
t.Errorf("updated config lost other section\ngot:\n%s", content)
}
if !strings.Contains(content, `model = "gpt-5.4"`) {
t.Errorf("updated config lost top-level key\ngot:\n%s", content)
}
}
func TestEnsureCodexProviderConfig_DefaultEnvKey(t *testing.T) {
home := filepath.Join(t.TempDir(), ".codex")
err := ensureCodexProviderConfig(home, "dmxapi", "https://www.dmxapi.cn/v1", "responses", nil)
if err != nil {
t.Fatalf("ensureCodexProviderConfig: %v", err)
}
data, err := os.ReadFile(filepath.Join(home, "config.toml"))
if err != nil {
t.Fatal(err)
}
content := string(data)
if !strings.Contains(content, `env_key = "OPENAI_API_KEY"`) {
t.Errorf("config should contain default env_key OPENAI_API_KEY\ngot:\n%s", content)
}
if strings.Contains(content, "requires_openai_auth") {
t.Errorf("config should NOT contain requires_openai_auth\ngot:\n%s", content)
}
}
func TestEnsureCodexProviderConfig_PreservesOtherProviders(t *testing.T) {
home := filepath.Join(t.TempDir(), ".codex")
if err := os.MkdirAll(home, 0o755); err != nil {
t.Fatal(err)
}
initial := `[model_providers.other]
name = "other"
env_key = "OTHER_KEY"
[model_providers.other.http_headers]
"X-Custom" = "val"
`
if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte(initial), 0o644); err != nil {
t.Fatal(err)
}
err := ensureCodexProviderConfig(home, "shengsuanyun", "", "responses", nil)
if err != nil {
t.Fatalf("ensureCodexProviderConfig: %v", err)
}
data, err := os.ReadFile(filepath.Join(home, "config.toml"))
if err != nil {
t.Fatal(err)
}
content := string(data)
if !strings.Contains(content, `[model_providers.other]`) {
t.Errorf("lost other provider section\ngot:\n%s", content)
}
if !strings.Contains(content, `[model_providers.shengsuanyun]`) {
t.Errorf("new provider not added\ngot:\n%s", content)
}
}
func TestEnsureCodexProviderConfig_SkipsWhenEmpty(t *testing.T) {
err := ensureCodexProviderConfig("", "", "", "", nil)
if err != nil {
t.Fatalf("unexpected error for empty name: %v", err)
}
}
func TestEnsureCodexAuth_WritesAuthJSON(t *testing.T) {
home := filepath.Join(t.TempDir(), ".codex")
err := ensureCodexAuth(home, "sk-test-key-123")
if err != nil {
t.Fatalf("ensureCodexAuth: %v", err)
}
data, err := os.ReadFile(filepath.Join(home, "auth.json"))
if err != nil {
t.Fatalf("read auth.json: %v", err)
}
content := string(data)
for _, want := range []string{
`"OPENAI_API_KEY": "sk-test-key-123"`,
`"auth_mode": "apikey"`,
} {
if !strings.Contains(content, want) {
t.Errorf("auth.json missing %q\ngot:\n%s", want, content)
}
}
}
func TestEnsureCodexAuth_SkipsEmptyKey(t *testing.T) {
err := ensureCodexAuth(t.TempDir(), "")
if err != nil {
t.Fatalf("unexpected error for empty key: %v", err)
}
}
func TestEnsureCodexAuth_OverwritesExisting(t *testing.T) {
home := filepath.Join(t.TempDir(), ".codex")
if err := os.MkdirAll(home, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(home, "auth.json"), []byte(`{"auth_mode":"chatgpt"}`), 0o644); err != nil {
t.Fatal(err)
}
err := ensureCodexAuth(home, "new-api-key")
if err != nil {
t.Fatalf("ensureCodexAuth: %v", err)
}
data, err := os.ReadFile(filepath.Join(home, "auth.json"))
if err != nil {
t.Fatal(err)
}
content := string(data)
if strings.Contains(content, "chatgpt") {
t.Errorf("auth.json still has old auth_mode\ngot:\n%s", content)
}
if !strings.Contains(content, `"auth_mode": "apikey"`) {
t.Errorf("auth.json missing apikey mode\ngot:\n%s", content)
}
if !strings.Contains(content, `"OPENAI_API_KEY": "new-api-key"`) {
t.Errorf("auth.json missing new key\ngot:\n%s", content)
}
}
+358
View File
@@ -0,0 +1,358 @@
package codex
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/chenhg5/cc-connect/config"
"github.com/chenhg5/cc-connect/core"
)
func skipIfNoConfig(t *testing.T) *config.Config {
t.Helper()
if os.Getenv("CC_SKIP_INTEGRATION") == "1" {
t.Skip("CC_SKIP_INTEGRATION=1")
}
if os.Getenv("CC_RUN_PROVIDER_INTEGRATION") != "1" {
t.Skip("set CC_RUN_PROVIDER_INTEGRATION=1 to run provider integration tests")
}
cfgPath := os.ExpandEnv("$HOME/.cc-connect/config.toml")
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
t.Skipf("config not found at %s", cfgPath)
}
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
cfg.ResolveProviderRefs()
return cfg
}
func configToCoreProv(p config.ProviderConfig) core.ProviderConfig {
cp := core.ProviderConfig{
Name: p.Name,
APIKey: p.APIKey,
BaseURL: p.BaseURL,
Model: p.Model,
Env: p.Env,
}
if p.Codex != nil {
cp.CodexWireAPI = p.Codex.WireAPI
cp.CodexHTTPHeaders = p.Codex.HTTPHeaders
}
for _, m := range p.Models {
cp.Models = append(cp.Models, core.ModelOption{Name: m.Model})
}
return cp
}
func envSliceToMap(env []string) map[string]string {
out := make(map[string]string, len(env))
for _, entry := range env {
key, value, ok := strings.Cut(entry, "=")
if !ok {
continue
}
out[key] = value
}
return out
}
func findCodexProject(cfg *config.Config) (name string, providers []core.ProviderConfig, workDir, codexHome string) {
for i := range cfg.Projects {
proj := &cfg.Projects[i]
if proj.Agent.Type != "codex" || len(proj.Agent.Providers) == 0 {
continue
}
for _, p := range proj.Agent.Providers {
providers = append(providers, configToCoreProv(p))
}
name = proj.Name
if wd, ok := proj.Agent.Options["work_dir"].(string); ok {
workDir = wd
}
if ch, ok := proj.Agent.Options["codex_home"].(string); ok {
codexHome = ch
}
return
}
return
}
func TestIntegration_Codex_ProviderSwitch_EnvVars(t *testing.T) {
cfg := skipIfNoConfig(t)
name, providers, _, _ := findCodexProject(cfg)
if name == "" {
t.Skip("no codex project with providers found")
}
if len(providers) < 2 {
t.Skipf("project %q has only %d provider(s), need at least 2", name, len(providers))
}
a := &Agent{
providers: providers,
activeIdx: -1,
}
p0, p1 := providers[0], providers[1]
t.Logf("provider[0]: name=%s model=%s base_url=%s", p0.Name, p0.Model, p0.BaseURL)
t.Logf("provider[1]: name=%s model=%s base_url=%s", p1.Name, p1.Model, p1.BaseURL)
a.SetActiveProvider(p0.Name)
a.mu.RLock()
env0 := envSliceToMap(a.providerEnvLocked())
a.mu.RUnlock()
if p0.BaseURL != "" {
if got := env0["OPENAI_BASE_URL"]; got != p0.BaseURL {
t.Errorf("provider[0] OPENAI_BASE_URL = %q, want %q", got, p0.BaseURL)
}
}
if p0.APIKey != "" {
if got := env0["OPENAI_API_KEY"]; got == "" {
t.Error("provider[0] OPENAI_API_KEY not set")
}
}
a.SetActiveProvider(p1.Name)
a.mu.RLock()
env1 := envSliceToMap(a.providerEnvLocked())
a.mu.RUnlock()
if p1.BaseURL != "" {
if got := env1["OPENAI_BASE_URL"]; got != p1.BaseURL {
t.Errorf("provider[1] OPENAI_BASE_URL = %q, want %q", got, p1.BaseURL)
}
}
if p1.APIKey != "" {
if got := env1["OPENAI_API_KEY"]; got == "" {
t.Error("provider[1] OPENAI_API_KEY not set")
}
}
if p0.BaseURL != p1.BaseURL {
if env0["OPENAI_BASE_URL"] == env1["OPENAI_BASE_URL"] {
t.Error("providers have different base_urls but OPENAI_BASE_URL didn't change after switch")
}
}
}
func TestIntegration_Codex_ProviderSwitch_SessionArgs(t *testing.T) {
cfg := skipIfNoConfig(t)
name, providers, workDir, codexHome := findCodexProject(cfg)
if name == "" {
t.Skip("no codex project with providers found")
}
if workDir == "" {
workDir = t.TempDir()
}
for _, prov := range providers {
t.Run(prov.Name, func(t *testing.T) {
a := &Agent{
model: "default-model",
providers: providers,
activeIdx: -1,
workDir: workDir,
codexHome: codexHome,
mode: "suggest",
backend: "exec",
}
a.SetActiveProvider(prov.Name)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
sess, err := a.StartSession(ctx, "")
if err != nil {
t.Fatalf("StartSession failed: %v", err)
}
defer sess.Close()
cs := sess.(*codexSession)
if prov.Model != "" && cs.model != prov.Model {
t.Errorf("session model = %q, want %q", cs.model, prov.Model)
}
if prov.BaseURL != "" && cs.baseURL != prov.BaseURL {
t.Errorf("session baseURL = %q, want %q", cs.baseURL, prov.BaseURL)
}
envMap := envSliceToMap(cs.extraEnv)
if prov.APIKey != "" {
if got := envMap["OPENAI_API_KEY"]; got == "" {
t.Error("OPENAI_API_KEY not set in session extraEnv")
}
}
if prov.BaseURL != "" {
if got := envMap["OPENAI_BASE_URL"]; got != prov.BaseURL {
t.Errorf("OPENAI_BASE_URL = %q, want %q", got, prov.BaseURL)
}
}
t.Logf("session OK: model=%s baseURL=%s modelProvider=%s",
cs.model, cs.baseURL, cs.modelProvider)
})
}
}
func TestIntegration_Codex_ProviderConfig_WrittenCorrectly(t *testing.T) {
cfg := skipIfNoConfig(t)
name, providers, _, _ := findCodexProject(cfg)
if name == "" {
t.Skip("no codex project with providers found")
}
for _, prov := range providers {
t.Run(prov.Name, func(t *testing.T) {
if prov.BaseURL == "" || prov.APIKey == "" {
t.Skip("provider has no base_url or api_key")
}
home := filepath.Join(t.TempDir(), ".codex")
err := ensureCodexProviderConfig(home, prov.Name, prov.BaseURL, prov.CodexWireAPI, prov.CodexHTTPHeaders)
if err != nil {
t.Fatalf("ensureCodexProviderConfig failed: %v", err)
}
cfgData, err := os.ReadFile(filepath.Join(home, "config.toml"))
if err != nil {
t.Fatalf("read config.toml: %v", err)
}
cfgContent := string(cfgData)
if !strings.Contains(cfgContent, `[model_providers.`+prov.Name+`]`) {
t.Errorf("config.toml missing provider section for %s", prov.Name)
}
if !strings.Contains(cfgContent, prov.BaseURL) {
t.Errorf("config.toml missing base_url %s", prov.BaseURL)
}
err = ensureCodexAuth(home, prov.APIKey)
if err != nil {
t.Fatalf("ensureCodexAuth failed: %v", err)
}
authData, err := os.ReadFile(filepath.Join(home, "auth.json"))
if err != nil {
t.Fatalf("read auth.json: %v", err)
}
var authMap map[string]any
if err := json.Unmarshal(authData, &authMap); err != nil {
t.Fatalf("parse auth.json: %v", err)
}
if authMap["OPENAI_API_KEY"] != prov.APIKey {
t.Error("OPENAI_API_KEY not written correctly to auth.json")
}
t.Logf("provider config written OK: name=%s wireAPI=%s", prov.Name, prov.CodexWireAPI)
})
}
}
func TestIntegration_Codex_ProviderSwitch_SendMessage(t *testing.T) {
cfg := skipIfNoConfig(t)
if _, err := exec.LookPath("codex"); err != nil {
t.Skip("codex CLI not in PATH")
}
name, providers, workDir, codexHome := findCodexProject(cfg)
if name == "" {
t.Skip("no codex project with providers found")
}
if codexHome == "" {
home, _ := os.UserHomeDir()
codexHome = filepath.Join(home, ".codex")
}
if workDir == "" {
workDir = t.TempDir()
gitInit := exec.Command("git", "init", "-q")
gitInit.Dir = workDir
if err := gitInit.Run(); err != nil {
t.Fatalf("git init: %v", err)
}
}
for _, prov := range providers {
t.Run(prov.Name, func(t *testing.T) {
if prov.APIKey == "" || prov.BaseURL == "" {
t.Skip("provider missing api_key or base_url")
}
a := &Agent{
model: "default-model",
providers: providers,
activeIdx: -1,
workDir: workDir,
codexHome: codexHome,
mode: "full-auto",
backend: "exec",
}
a.SetActiveProvider(prov.Name)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
sess, err := a.StartSession(ctx, "")
if err != nil {
t.Fatalf("StartSession failed: %v", err)
}
defer sess.Close()
err = sess.Send("reply with exactly 'codex-provider-ok' and nothing else", nil, nil)
if err != nil {
t.Fatalf("Send failed: %v", err)
}
timer := time.NewTimer(25 * time.Second)
defer timer.Stop()
var allText strings.Builder
for {
select {
case ev, ok := <-sess.Events():
if !ok {
if allText.Len() > 0 && strings.Contains(allText.String(), "codex-provider-ok") {
t.Logf("provider %s: send+receive OK (from text events)", prov.Name)
return
}
t.Fatalf("event channel closed, collected text: %s", allText.String())
return
}
t.Logf("event: type=%s content_len=%d", ev.Type, len(ev.Content))
if ev.Type == core.EventText || ev.Type == core.EventResult {
allText.WriteString(ev.Content)
}
if ev.Type == core.EventResult {
if strings.Contains(allText.String(), "codex-provider-ok") {
t.Logf("provider %s: send+receive OK", prov.Name)
} else {
t.Errorf("expected 'codex-provider-ok' in output, got: %s", allText.String())
}
return
}
if ev.Type == core.EventError {
t.Fatalf("agent error: %s", ev.Content)
}
case <-timer.C:
if strings.Contains(allText.String(), "codex-provider-ok") {
t.Logf("provider %s: send+receive OK (timeout but got result)", prov.Name)
return
}
t.Fatalf("timeout waiting for codex response, collected: %s", allText.String())
}
}
})
}
}
+945
View File
@@ -0,0 +1,945 @@
package codex
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"github.com/chenhg5/cc-connect/core"
)
// codexSession manages a multi-turn Codex conversation.
// First Send() uses `codex exec`, subsequent ones use `codex exec resume <threadID>`.
type codexSession struct {
workDir string
model string
effort string
mode string
baseURL string // provider base URL; passed as -c openai_base_url=<url>
modelProvider string // Codex model_provider name; passed as -c model_provider=<name>
cliBin string // CLI binary, default "codex"
cliExtraArgs []string // extra args from cli_path, prepended before exec args
extraEnv []string
events chan core.Event
threadID atomic.Value // stores string — Codex thread_id
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
alive atomic.Bool
closeOnce sync.Once
cmdMu sync.Mutex
cmds map[*exec.Cmd]struct{}
pendingMsgs []string // buffered agent_message texts awaiting classification
runtimeCfgMu sync.Mutex
runtimeCfgModel string
runtimeCfgEffort string
runtimeCfgFetched time.Time
runtimeCfgFetchErr error
contextMu sync.RWMutex
contextUsage *core.ContextUsage
sessionFile string
}
var codexSessionCloseTimeout = 8 * time.Second
var codexSessionForceKillWait = 2 * time.Second
var codexRuntimeConfigCacheTTL = 5 * time.Second
var codexRuntimeConfigTimeout = 1500 * time.Millisecond
var codexContextUsageRetryDelay = 50 * time.Millisecond
var codexContextUsageRetryCount = 4
func newCodexSession(ctx context.Context, cliBin string, cliExtraArgs []string, workDir, model, effort, mode, resumeID, baseURL string, extraEnv []string, modelProvider string) (*codexSession, error) {
sessionCtx, cancel := context.WithCancel(ctx)
cs := &codexSession{
workDir: workDir,
model: model,
effort: effort,
mode: mode,
baseURL: baseURL,
modelProvider: modelProvider,
cliBin: cliBin,
cliExtraArgs: cliExtraArgs,
extraEnv: extraEnv,
events: make(chan core.Event, 64),
ctx: sessionCtx,
cancel: cancel,
cmds: make(map[*exec.Cmd]struct{}),
}
cs.alive.Store(true)
if resumeID != "" && resumeID != core.ContinueSession {
cs.threadID.Store(resumeID)
}
return cs, nil
}
// Send launches a codex subprocess.
// If a threadID exists (from a prior turn or resume), uses `codex exec resume <id> <prompt>`.
// Otherwise uses `codex exec <prompt>` to start a new conversation.
func (cs *codexSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error {
if len(files) > 0 {
filePaths := core.SaveFilesToDisk(cs.workDir, files)
prompt = core.AppendFileRefs(prompt, filePaths)
}
if !cs.alive.Load() {
return fmt.Errorf("session is closed")
}
prompt, imagePaths, err := cs.stageImages(prompt, images)
if err != nil {
return err
}
isResume := cs.CurrentSessionID() != ""
args := cs.buildExecArgs(prompt, imagePaths)
if len(cs.cliExtraArgs) > 0 {
args = append(append([]string{}, cs.cliExtraArgs...), args...)
}
bin := cs.cliBin
if bin == "" {
bin = "codex"
}
slog.Debug("codexSession: launching", "resume", isResume, "args", core.RedactArgs(args))
cmd := exec.CommandContext(cs.ctx, bin, args...)
cmd.Dir = cs.workDir
prepareCmdForKill(cmd)
if len(cs.extraEnv) > 0 {
cmd.Env = core.MergeEnv(os.Environ(), cs.extraEnv)
}
cmd.Stdin = strings.NewReader(prompt)
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("codexSession: stdout pipe: %w", err)
}
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
return fmt.Errorf("codexSession: start: %w", err)
}
cs.addCmd(cmd)
cs.wg.Add(1)
go cs.readLoop(cmd, stdout, &stderrBuf)
return nil
}
func (cs *codexSession) stageImages(prompt string, images []core.ImageAttachment) (string, []string, error) {
if len(images) == 0 {
return prompt, nil, nil
}
imgDir := filepath.Join(cs.workDir, ".cc-connect", "images")
if err := os.MkdirAll(imgDir, 0o755); err != nil {
return "", nil, fmt.Errorf("codexSession: create image dir: %w", err)
}
imagePaths := make([]string, 0, len(images))
for i, img := range images {
ext := codexImageExt(img.MimeType)
fname := fmt.Sprintf("img_%d_%d%s", time.Now().UnixMilli(), i, ext)
fpath := filepath.Join(imgDir, fname)
if err := os.WriteFile(fpath, img.Data, 0o644); err != nil {
return "", nil, fmt.Errorf("codexSession: save image: %w", err)
}
imagePaths = append(imagePaths, fpath)
}
if strings.TrimSpace(prompt) == "" {
prompt = "Please analyze the attached image(s)."
}
return prompt, imagePaths, nil
}
func (cs *codexSession) buildExecArgs(prompt string, imagePaths []string) []string {
tid := cs.CurrentSessionID()
isResume := tid != ""
var args []string
if isResume {
// For resume: codex exec resume ... <thread_id> [--image ...] --json --cd <dir> <prompt>
// The codex CLI requires --json after the thread_id positional argument.
args = []string{"exec", "resume", "--skip-git-repo-check"}
} else {
args = []string{"exec", "--skip-git-repo-check"}
}
switch cs.mode {
case "auto-edit", "full-auto":
args = append(args, "--full-auto")
case "yolo":
args = append(args, "--dangerously-bypass-approvals-and-sandbox")
}
if cs.model != "" {
args = append(args, "--model", cs.model)
}
if cs.modelProvider != "" {
args = append(args, "-c", fmt.Sprintf("model_provider=%q", cs.modelProvider))
}
if cs.baseURL != "" {
args = append(args, "-c", fmt.Sprintf("openai_base_url=%q", cs.baseURL))
}
if cs.effort != "" {
args = append(args, "-c", fmt.Sprintf("model_reasoning_effort=%q", cs.effort))
}
if isResume {
args = append(args, tid)
for _, imagePath := range imagePaths {
args = append(args, "--image", imagePath)
}
// codex exec resume does not support --cd; cmd.Dir handles cwd instead.
// Use stdin ("-") so multiline prompts are preserved reliably on Windows.
args = append(args, "--json", "-")
} else {
for _, imagePath := range imagePaths {
args = append(args, "--image", imagePath)
}
args = append(args, "--json", "--cd", cs.workDir, "-")
}
return args
}
func codexImageExt(mime string) string {
switch mime {
case "image/jpeg":
return ".jpg"
case "image/gif":
return ".gif"
case "image/webp":
return ".webp"
default:
return ".png"
}
}
func (cs *codexSession) readLoop(cmd *exec.Cmd, stdout io.ReadCloser, stderrBuf *bytes.Buffer) {
defer cs.wg.Done()
defer func() {
defer cs.removeCmd(cmd)
if err := cmd.Wait(); err != nil {
stderrMsg := strings.TrimSpace(stderrBuf.String())
if stderrMsg != "" {
slog.Error("codexSession: process failed", "error", err, "stderr", stderrMsg)
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
}
if tid := cs.CurrentSessionID(); tid != "" {
patchSessionSource(tid, getenvFromList(cs.extraEnv, "CODEX_HOME"))
}
}()
if err := readJSONLines(stdout, func(line []byte) error {
lineText := string(line)
if lineText == "" {
return nil
}
slog.Debug("codexSession: raw", "line", truncate(lineText, 500))
var raw map[string]any
if err := json.Unmarshal(line, &raw); err != nil {
slog.Debug("codexSession: non-JSON line", "line", lineText)
return nil
}
cs.handleEvent(raw)
return nil
}); err != nil {
slog.Error("codexSession: read stdout error", "error", err)
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("read stdout: %w", err)}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
}
func readJSONLines(r io.Reader, handle func([]byte) error) error {
reader := bufio.NewReader(r)
for {
line, err := reader.ReadBytes('\n')
if errors.Is(err, io.EOF) && len(line) == 0 {
return nil
}
if err != nil && !errors.Is(err, io.EOF) {
return err
}
line = bytes.TrimRight(line, "\r\n")
if len(line) > 0 {
if err := handle(line); err != nil {
return err
}
}
if errors.Is(err, io.EOF) {
return nil
}
}
}
func (cs *codexSession) handleEvent(raw map[string]any) {
eventType, _ := raw["type"].(string)
switch eventType {
case "thread.started":
if tid, ok := raw["thread_id"].(string); ok {
cs.threadID.Store(tid)
cs.contextMu.Lock()
cs.sessionFile = ""
cs.contextUsage = nil
cs.contextMu.Unlock()
slog.Debug("codexSession: thread started", "thread_id", tid)
}
case "turn.started":
cs.pendingMsgs = cs.pendingMsgs[:0]
cs.contextMu.Lock()
cs.contextUsage = nil
cs.contextMu.Unlock()
slog.Debug("codexSession: turn started")
case "item.started":
cs.handleItemStarted(raw)
case "item.completed":
cs.handleItemCompleted(raw)
case "turn.completed":
cs.refreshContextUsageFromRollout()
cs.flushPendingAsText()
evt := core.Event{Type: core.EventResult, SessionID: cs.CurrentSessionID(), Done: true}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
case "turn.failed":
errMsg := ""
if errObj, ok := raw["error"].(map[string]any); ok {
errMsg, _ = errObj["message"].(string)
}
if errMsg == "" {
errMsg = "turn failed (no details)"
}
slog.Warn("codexSession: turn failed", "error", errMsg)
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", errMsg)}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
case "error":
msg, _ := raw["message"].(string)
if strings.Contains(msg, "Reconnecting") || strings.Contains(msg, "Falling back") {
slog.Debug("codexSession: transient error", "message", msg)
} else {
slog.Warn("codexSession: error event", "message", msg)
}
default:
slog.Debug("codexSession: unhandled event type", "type", eventType)
}
}
// flushPendingAsThinking emits all buffered agent_messages as EventThinking.
func (cs *codexSession) flushPendingAsThinking() {
if cs.ctx.Err() != nil {
return
}
for _, text := range cs.pendingMsgs {
if cs.ctx.Err() != nil {
return
}
evt := core.Event{Type: core.EventThinking, Content: text}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
cs.pendingMsgs = cs.pendingMsgs[:0]
}
// flushPendingAsText emits all buffered agent_messages as EventText (final response).
func (cs *codexSession) flushPendingAsText() {
if cs.ctx.Err() != nil {
return
}
for _, text := range cs.pendingMsgs {
if cs.ctx.Err() != nil {
return
}
evt := core.Event{Type: core.EventText, Content: text}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
cs.pendingMsgs = cs.pendingMsgs[:0]
}
var codexToolNames = map[string]string{
"web_search": "WebSearch",
"file_search": "FileSearch",
"code_interpreter": "CodeInterpreter",
"computer_use": "ComputerUse",
"mcp_tool": "MCP",
}
func (cs *codexSession) handleItemStarted(raw map[string]any) {
item, ok := raw["item"].(map[string]any)
if !ok {
slog.Debug("codexSession: item.started missing item field")
return
}
itemType, _ := item["type"].(string)
slog.Debug("codexSession: item.started", "item_type", itemType)
if itemType == "agent_message" || itemType == "message" || itemType == "reasoning" {
return
}
// Any non-message item is a tool use; flush pending messages as thinking first.
cs.flushPendingAsThinking()
switch itemType {
case "command_execution":
command, _ := item["command"].(string)
evt := core.Event{Type: core.EventToolUse, ToolName: "Bash", ToolInput: command}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
case "function_call":
name, _ := item["name"].(string)
args, _ := item["arguments"].(string)
evt := core.Event{Type: core.EventToolUse, ToolName: name, ToolInput: args}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
// Other tool types (web_search etc.) have empty fields at start;
// their EventToolUse is emitted from handleItemCompleted instead.
}
func (cs *codexSession) handleItemCompleted(raw map[string]any) {
item, ok := raw["item"].(map[string]any)
if !ok {
slog.Debug("codexSession: item.completed missing item field")
return
}
itemType, _ := item["type"].(string)
slog.Debug("codexSession: item.completed", "item_type", itemType)
switch itemType {
case "reasoning":
text := extractItemText(item, "summary", "summary_text")
if text != "" {
evt := core.Event{Type: core.EventThinking, Content: text}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
case "agent_message", "message":
text := extractItemText(item, "content", "output_text")
if text != "" {
cs.pendingMsgs = append(cs.pendingMsgs, text)
}
case "command_execution":
command, _ := item["command"].(string)
status, _ := item["status"].(string)
output, _ := item["aggregated_output"].(string)
exitCode, _ := item["exit_code"].(float64)
code := int(exitCode)
success := codexToolSuccess(status, &code)
slog.Debug("codexSession: command completed",
"command", truncate(command, 100),
"status", status,
"exit_code", code,
"output_len", len(output),
)
evt := core.Event{
Type: core.EventToolResult,
ToolName: "Bash",
ToolResult: truncate(strings.TrimSpace(output), 500),
ToolStatus: strings.TrimSpace(status),
ToolExitCode: &code,
ToolSuccess: &success,
}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
case "function_call":
name, _ := item["name"].(string)
status, _ := item["status"].(string)
output, _ := item["output"].(string)
success := codexToolSuccess(status, nil)
slog.Debug("codexSession: function_call completed",
"name", name, "status", status, "output_len", len(output),
)
evt := core.Event{
Type: core.EventToolResult,
ToolName: name,
ToolResult: truncate(strings.TrimSpace(output), 500),
ToolStatus: strings.TrimSpace(status),
ToolSuccess: &success,
}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
case "function_call_output":
slog.Debug("codexSession: function_call_output")
case "error":
msg, _ := item["message"].(string)
if msg != "" && !strings.Contains(msg, "Falling back") {
slog.Warn("codexSession: item error", "message", msg)
}
default:
if toolName, known := codexToolNames[itemType]; known {
input := codexExtractToolInput(item)
evt := core.Event{Type: core.EventToolUse, ToolName: toolName, ToolInput: input}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
} else {
slog.Debug("codexSession: unhandled item type", "item_type", itemType)
}
}
}
// codexExtractToolInput extracts a human-readable input from a Codex tool item.
// For web_search, it reads action.queries[] or falls back to the top-level query.
func codexExtractToolInput(item map[string]any) string {
if action, ok := item["action"].(map[string]any); ok {
if queries, ok := action["queries"].([]any); ok && len(queries) > 0 {
var parts []string
for _, q := range queries {
if s, ok := q.(string); ok && s != "" {
parts = append(parts, s)
}
}
if len(parts) > 0 {
return strings.Join(parts, "\n")
}
}
if q, _ := action["query"].(string); q != "" {
return q
}
}
if q, _ := item["query"].(string); q != "" {
return q
}
if n, _ := item["name"].(string); n != "" {
return n
}
return ""
}
func codexToolSuccess(status string, exitCode *int) bool {
s := strings.ToLower(strings.TrimSpace(status))
if exitCode != nil {
return *exitCode == 0
}
return s == "completed" || s == "success" || s == "succeeded" || s == "ok"
}
func loadCodexRuntimeConfig(ctx context.Context, workDir string, extraEnv []string) (string, string, error) {
cmd := exec.CommandContext(ctx, "codex", "app-server")
cmd.Dir = workDir
prepareCmdForKill(cmd)
if len(extraEnv) > 0 {
cmd.Env = core.MergeEnv(os.Environ(), extraEnv)
}
stdin, err := cmd.StdinPipe()
if err != nil {
return "", "", fmt.Errorf("runtime config stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return "", "", fmt.Errorf("runtime config stdout pipe: %w", err)
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return "", "", fmt.Errorf("runtime config start app-server: %w", err)
}
defer func() {
_ = stdin.Close()
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
}()
reader := bufio.NewReader(stdout)
nextID := int64(1)
if err := rpcRequestOverIO(stdin, reader, nextID, "initialize", map[string]any{
"clientInfo": map[string]any{
"name": "cc-connect-codex-runtime-config",
"title": "CC Connect Codex Runtime Config",
"version": "0.1.0",
},
}, nil); err != nil {
return "", "", err
}
nextID++
if err := rpcNotifyOverIO(stdin, "initialized", map[string]any{}); err != nil {
return "", "", err
}
var resp struct {
Config struct {
Model string `json:"model"`
ModelReasoningEffort *string `json:"model_reasoning_effort"`
} `json:"config"`
}
if err := rpcRequestOverIO(stdin, reader, nextID, "config/read", map[string]any{
"includeLayers": false,
}, &resp); err != nil {
return "", "", err
}
return strings.TrimSpace(resp.Config.Model), normalizeRuntimeReasoningEffort(stringValue(resp.Config.ModelReasoningEffort)), nil
}
func rpcRequestOverIO(stdin io.Writer, reader *bufio.Reader, id int64, method string, params any, out any) error {
payload := map[string]any{
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
}
if err := writeRPCMessage(stdin, payload); err != nil {
return err
}
for {
line, err := reader.ReadBytes('\n')
if err != nil {
return fmt.Errorf("%s read response: %w", method, err)
}
var probe map[string]json.RawMessage
if err := json.Unmarshal(bytes.TrimSpace(line), &probe); err != nil {
continue
}
if _, ok := probe["id"]; !ok {
continue
}
var resp rpcResponseEnvelope
if err := json.Unmarshal(bytes.TrimSpace(line), &resp); err != nil {
continue
}
respID, ok := rpcIDToInt64(resp.ID)
if !ok || respID != id {
continue
}
if resp.Error != nil {
return fmt.Errorf("%s: %s", method, strings.TrimSpace(resp.Error.Message))
}
if out != nil {
if err := json.Unmarshal(resp.Result, out); err != nil {
return fmt.Errorf("%s decode response: %w", method, err)
}
}
return nil
}
}
func rpcNotifyOverIO(stdin io.Writer, method string, params any) error {
payload := map[string]any{
"jsonrpc": "2.0",
"method": method,
"params": params,
}
return writeRPCMessage(stdin, payload)
}
func writeRPCMessage(w io.Writer, payload any) error {
b, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("encode rpc message: %w", err)
}
if _, err := w.Write(append(b, '\n')); err != nil {
return fmt.Errorf("write rpc message: %w", err)
}
return nil
}
// RespondPermission is a no-op for Codex — permissions are handled via CLI flags.
func (cs *codexSession) RespondPermission(_ string, _ core.PermissionResult) error {
return nil
}
func (cs *codexSession) Events() <-chan core.Event {
return cs.events
}
func (cs *codexSession) CurrentSessionID() string {
v, _ := cs.threadID.Load().(string)
return v
}
func (cs *codexSession) GetWorkDir() string {
return cs.workDir
}
func (cs *codexSession) GetModel() string {
if model := strings.TrimSpace(cs.model); model != "" {
return model
}
model, _ := cs.runtimeConfig()
return model
}
func (cs *codexSession) GetReasoningEffort() string {
if effort := strings.TrimSpace(cs.effort); effort != "" {
return effort
}
_, effort := cs.runtimeConfig()
return effort
}
func (cs *codexSession) Alive() bool {
return cs.alive.Load()
}
func (cs *codexSession) GetContextUsage() *core.ContextUsage {
cs.contextMu.RLock()
defer cs.contextMu.RUnlock()
return cloneContextUsage(cs.contextUsage)
}
func (cs *codexSession) runtimeConfig() (string, string) {
cs.runtimeCfgMu.Lock()
defer cs.runtimeCfgMu.Unlock()
if !cs.runtimeCfgFetched.IsZero() && time.Since(cs.runtimeCfgFetched) < codexRuntimeConfigCacheTTL {
return cs.runtimeCfgModel, cs.runtimeCfgEffort
}
ctx, cancel := context.WithTimeout(cs.ctx, codexRuntimeConfigTimeout)
defer cancel()
model, effort, err := loadCodexRuntimeConfig(ctx, cs.workDir, cs.extraEnv)
if err == nil {
cs.runtimeCfgModel = model
cs.runtimeCfgEffort = effort
cs.runtimeCfgFetchErr = nil
cs.runtimeCfgFetched = time.Now()
return model, effort
}
cs.runtimeCfgFetchErr = err
if !cs.runtimeCfgFetched.IsZero() {
return cs.runtimeCfgModel, cs.runtimeCfgEffort
}
return "", ""
}
func (cs *codexSession) refreshContextUsageFromRollout() {
sessionID := strings.TrimSpace(cs.CurrentSessionID())
if sessionID == "" {
return
}
for attempt := 0; attempt < codexContextUsageRetryCount; attempt++ {
cs.contextMu.RLock()
cachedPath := cs.sessionFile
cs.contextMu.RUnlock()
usage, path, err := loadContextUsageFromRollout(cs.extraEnv, sessionID, cachedPath)
if err == nil && usage != nil {
cs.contextMu.Lock()
cs.sessionFile = path
cs.contextUsage = cloneContextUsage(usage)
cs.contextMu.Unlock()
return
}
if attempt == codexContextUsageRetryCount-1 {
if err != nil {
slog.Debug("codexSession: context usage unavailable", "thread_id", sessionID, "error", err)
}
return
}
select {
case <-time.After(codexContextUsageRetryDelay):
case <-cs.ctx.Done():
return
}
}
}
func (cs *codexSession) Close() error {
cs.alive.Store(false)
cs.cancel()
done := make(chan struct{})
go func() {
cs.wg.Wait()
close(done)
}()
select {
case <-done:
// readLoop has exited; safe to close the events channel.
cs.closeOnce.Do(func() {
close(cs.events)
})
return nil
case <-time.After(codexSessionCloseTimeout):
cmds := cs.activeCmds()
slog.Warn("codexSession: graceful close timed out, killing active process groups",
"wait", codexSessionCloseTimeout,
"count", len(cmds))
if err := forceKillAllCmds(cmds); err != nil {
slog.Debug("codexSession: force kill failed", "error", err)
}
select {
case <-done:
cs.closeOnce.Do(func() {
close(cs.events)
})
return nil
case <-time.After(codexSessionForceKillWait):
// Do not close(cs.events) here: readLoop may still be in handleEvent
// (e.g. turn.completed -> flushPendingAsText) and would panic on send.
slog.Warn("codexSession: force kill wait timed out, deferring events channel close until readLoop exits",
"wait", codexSessionForceKillWait)
go func() {
<-done
cs.closeOnce.Do(func() {
close(cs.events)
})
}()
return nil
}
}
}
func (cs *codexSession) addCmd(cmd *exec.Cmd) {
cs.cmdMu.Lock()
defer cs.cmdMu.Unlock()
cs.cmds[cmd] = struct{}{}
}
func (cs *codexSession) removeCmd(cmd *exec.Cmd) {
cs.cmdMu.Lock()
defer cs.cmdMu.Unlock()
delete(cs.cmds, cmd)
}
func (cs *codexSession) activeCmds() []*exec.Cmd {
cs.cmdMu.Lock()
defer cs.cmdMu.Unlock()
cmds := make([]*exec.Cmd, 0, len(cs.cmds))
for cmd := range cs.cmds {
cmds = append(cmds, cmd)
}
return cmds
}
func forceKillAllCmds(cmds []*exec.Cmd) error {
var errs []error
for _, cmd := range cmds {
if err := forceKillCmd(cmd); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// extractItemText extracts text from an item's array field (e.g. "summary" or "content").
// It looks for elements matching the given elementType and concatenates their "text" fields.
// Falls back to the item's top-level "text" field if the array is missing or empty.
func extractItemText(item map[string]any, arrayField, elementType string) string {
if arr, ok := item[arrayField].([]any); ok {
var parts []string
for _, elem := range arr {
m, ok := elem.(map[string]any)
if !ok {
continue
}
if elementType != "" {
if t, _ := m["type"].(string); t != elementType {
continue
}
}
if t, _ := m["text"].(string); t != "" {
parts = append(parts, t)
}
}
if len(parts) > 0 {
return strings.Join(parts, "\n")
}
}
text, _ := item["text"].(string)
return text
}
func truncate(s string, maxRunes int) string {
if utf8.RuneCountInString(s) <= maxRunes {
return s
}
return string([]rune(s)[:maxRunes]) + "..."
}
+811
View File
@@ -0,0 +1,811 @@
package codex
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/chenhg5/cc-connect/core"
)
func TestNormalizeReasoningEffort_RejectsMinimal(t *testing.T) {
if got := normalizeReasoningEffort("minimal"); got != "" {
t.Fatalf("normalizeReasoningEffort(minimal) = %q, want empty", got)
}
if got := normalizeReasoningEffort("min"); got != "" {
t.Fatalf("normalizeReasoningEffort(min) = %q, want empty", got)
}
}
func TestAvailableReasoningEfforts_ExcludesMinimal(t *testing.T) {
agent := &Agent{}
got := agent.AvailableReasoningEfforts()
want := []string{"low", "medium", "high", "xhigh"}
if len(got) != len(want) {
t.Fatalf("AvailableReasoningEfforts len = %d, want %d, got=%v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("AvailableReasoningEfforts[%d] = %q, want %q, got=%v", i, got[i], want[i], got)
}
}
}
func TestBuildExecArgs_IncludesReasoningEffort(t *testing.T) {
cs, err := newCodexSession(context.Background(), "codex", nil, "/tmp/project", "o3", "high", "full-auto", "", "", nil, "")
if err != nil {
t.Fatalf("newCodexSession: %v", err)
}
args := cs.buildExecArgs("hello", nil)
want := []string{
"exec",
"--skip-git-repo-check",
"--full-auto",
"--model",
"o3",
"-c",
`model_reasoning_effort="high"`,
"--json",
"--cd",
"/tmp/project",
"-",
}
if len(args) != len(want) {
t.Fatalf("args len = %d, want %d, args=%v", len(args), len(want), args)
}
for i := range want {
if args[i] != want[i] {
t.Fatalf("args[%d] = %q, want %q, args=%v", i, args[i], want[i], args)
}
}
}
func TestBuildExecArgs_IncludesBaseURL(t *testing.T) {
cs, err := newCodexSession(context.Background(), "codex", nil, "/tmp/project", "o3", "high", "full-auto", "", "https://custom.api.example.com", nil, "")
if err != nil {
t.Fatalf("newCodexSession: %v", err)
}
args := cs.buildExecArgs("hello", nil)
if !containsSequence(args, []string{"-c", `openai_base_url="https://custom.api.example.com"`}) {
t.Fatalf("args missing openai_base_url config flag: %v", args)
}
}
func TestBuildExecArgs_IncludesModelProvider(t *testing.T) {
cs, err := newCodexSession(context.Background(), "codex", nil, "/tmp/project", "openai/gpt-5.3-codex", "", "full-auto", "", "https://router.example.com/api/v1", nil, "shengsuanyun")
if err != nil {
t.Fatalf("newCodexSession: %v", err)
}
args := cs.buildExecArgs("hello", nil)
if !containsSequence(args, []string{"-c", `model_provider="shengsuanyun"`}) {
t.Fatalf("args missing model_provider config flag: %v", args)
}
if !containsSequence(args, []string{"-c", `openai_base_url="https://router.example.com/api/v1"`}) {
t.Fatalf("args missing openai_base_url config flag: %v", args)
}
}
func TestBuildExecArgs_ResumeOmitsCdFlag(t *testing.T) {
cs, err := newCodexSession(context.Background(), "codex", nil, "/tmp/project", "", "", "full-auto", "thread-abc", "", nil, "")
if err != nil {
t.Fatalf("newCodexSession: %v", err)
}
args := cs.buildExecArgs("hello", nil)
// codex exec resume does not support --cd; verify it's absent.
for i, arg := range args {
if arg == "--cd" {
t.Fatalf("resume args should not contain --cd, but found at index %d: %v", i, args)
}
}
// --json and stdin marker must still be present.
if !containsSequence(args, []string{"--json", "-"}) {
t.Fatalf("resume args missing --json + stdin marker: %v", args)
}
}
func TestGetModelAndReasoningEffort_FromRuntimeConfigWhenUnset(t *testing.T) {
workDir := t.TempDir()
binDir := filepath.Join(workDir, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("mkdir bin: %v", err)
}
script := `#!/bin/sh
while IFS= read -r line; do
id=$(printf '%s' "$line" | sed -n 's/.*"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p')
case "$line" in
*'"method":"initialize"'*)
printf '{"id":%s,"result":{"protocolVersion":"2"}}\n' "$id"
;;
*'"method":"config/read"'*)
printf '{"id":%s,"result":{"config":{"model":"gpt-5.4","model_reasoning_effort":"xhigh"},"origins":{}}}\n' "$id"
;;
esac
done
`
powershellScript := `
while (($line = [Console]::In.ReadLine()) -ne $null) {
if ($line -like '*"method":"initialize"*') {
[Console]::Out.WriteLine('{"id":1,"result":{"protocolVersion":"2"}}')
} elseif ($line -like '*"method":"config/read"*') {
[Console]::Out.WriteLine('{"id":2,"result":{"config":{"model":"gpt-5.4","model_reasoning_effort":"xhigh"},"origins":{}}}')
}
}
`
writeFakeCodexScript(t, binDir, script, powershellScript)
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
cs, err := newCodexSession(context.Background(), "codex", nil, workDir, "", "", "", "", "", nil, "")
if err != nil {
t.Fatalf("newCodexSession: %v", err)
}
defer cs.Close()
if got := cs.GetModel(); got != "gpt-5.4" {
t.Fatalf("GetModel() = %q, want gpt-5.4", got)
}
if got := cs.GetReasoningEffort(); got != "xhigh" {
t.Fatalf("GetReasoningEffort() = %q, want xhigh", got)
}
}
func TestRefreshContextUsageFromRollout_UsesLastTokenCount(t *testing.T) {
workDir := t.TempDir()
codexHome := filepath.Join(workDir, ".codex")
rolloutDir := filepath.Join(codexHome, "sessions", "2026", "04", "12")
if err := os.MkdirAll(rolloutDir, 0o755); err != nil {
t.Fatalf("mkdir rollout dir: %v", err)
}
sessionID := "019d8019-d05a-7612-ace2-db549494c0f9"
rolloutPath := filepath.Join(rolloutDir, "rollout-2026-04-12T05-11-08-"+sessionID+".jsonl")
rollout := strings.Join([]string{
`{"type":"session_meta","payload":{"id":"` + sessionID + `","cwd":"/tmp/project"}}`,
`{"type":"event_msg","payload":{"type":"token_count","info":null,"rate_limits":{"limit_id":"codex"}}}`,
`{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":50665316,"cached_input_tokens":46971872,"output_tokens":156453,"reasoning_output_tokens":75023,"total_tokens":50821769},"last_token_usage":{"input_tokens":180805,"cached_input_tokens":139776,"output_tokens":619,"reasoning_output_tokens":32,"total_tokens":181424},"model_context_window":258400},"rate_limits":{"limit_id":"codex"}}}`,
"",
}, "\n")
if err := os.WriteFile(rolloutPath, []byte(rollout), 0o644); err != nil {
t.Fatalf("write rollout: %v", err)
}
cs, err := newCodexSession(context.Background(), "codex", nil, workDir, "", "", "", sessionID, "", []string{"CODEX_HOME=" + codexHome}, "")
if err != nil {
t.Fatalf("newCodexSession: %v", err)
}
defer cs.Close()
cs.refreshContextUsageFromRollout()
usage := cs.GetContextUsage()
if usage == nil {
t.Fatal("GetContextUsage() = nil, want rollout token count")
}
if usage.UsedTokens != 181424 {
t.Fatalf("used tokens = %d, want 181424", usage.UsedTokens)
}
if usage.BaselineTokens != codexContextBaselineTokens {
t.Fatalf("baseline tokens = %d, want %d", usage.BaselineTokens, codexContextBaselineTokens)
}
if usage.TotalTokens != 181424 {
t.Fatalf("total tokens = %d, want 181424", usage.TotalTokens)
}
if usage.InputTokens != 180805 {
t.Fatalf("input tokens = %d, want 180805", usage.InputTokens)
}
if usage.ContextWindow != 258400 {
t.Fatalf("context window = %d, want 258400", usage.ContextWindow)
}
}
func TestSend_WithImages_PassesImageArgsAndDefaultPrompt(t *testing.T) {
workDir := t.TempDir()
binDir := filepath.Join(workDir, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("mkdir bin: %v", err)
}
argsFile := filepath.Join(workDir, "args.txt")
script := "#!/bin/sh\n" +
"printf '%s\\n' \"$@\" > \"$CODEX_ARGS_FILE\"\n" +
"printf '%s\\n' '{\"type\":\"thread.started\",\"thread_id\":\"thread-1\"}'\n" +
"printf '%s\\n' '{\"type\":\"turn.completed\"}'\n"
powershellScript := `
[IO.File]::WriteAllLines($env:CODEX_ARGS_FILE, (fakeCodexArgs))
[Console]::Out.WriteLine('{"type":"thread.started","thread_id":"thread-1"}')
[Console]::Out.WriteLine('{"type":"turn.completed"}')
`
writeFakeCodexScript(t, binDir, script, powershellScript)
t.Setenv("CODEX_ARGS_FILE", argsFile)
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
cs, err := newCodexSession(context.Background(), "codex", nil, workDir, "", "", "", "", "", nil, "")
if err != nil {
t.Fatalf("newCodexSession: %v", err)
}
defer cs.Close()
img := core.ImageAttachment{
MimeType: "image/png",
Data: []byte("png-bytes"),
FileName: "sample.png",
}
if err := cs.Send("", []core.ImageAttachment{img}, nil); err != nil {
t.Fatalf("Send: %v", err)
}
args := waitForArgsFile(t, argsFile)
if !containsSequence(args, []string{"exec", "--skip-git-repo-check"}) {
t.Fatalf("args missing exec prelude: %v", args)
}
if !containsSequence(args, []string{"--json", "--cd"}) {
t.Fatalf("args missing --json --cd sequence: %v", args)
}
imagePath := valueAfter(args, "--image")
if imagePath == "" {
t.Fatalf("args missing --image: %v", args)
}
if !strings.HasPrefix(imagePath, filepath.Join(workDir, ".cc-connect", "images")+string(filepath.Separator)) {
t.Fatalf("image path = %q, want under work dir image cache", imagePath)
}
data, err := os.ReadFile(imagePath)
if err != nil {
t.Fatalf("read staged image: %v", err)
}
if string(data) != string(img.Data) {
t.Fatalf("staged image content = %q, want %q", string(data), string(img.Data))
}
if got := args[len(args)-1]; got != "-" {
t.Fatalf("last arg = %q, want stdin marker '-'; args=%v", got, args)
}
}
func TestSend_ResumeWithImages_PlacesSessionBeforeImageFlags(t *testing.T) {
workDir := t.TempDir()
binDir := filepath.Join(workDir, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("mkdir bin: %v", err)
}
argsFile := filepath.Join(workDir, "args.txt")
script := "#!/bin/sh\n" +
"printf '%s\\n' \"$@\" > \"$CODEX_ARGS_FILE\"\n" +
"printf '%s\\n' '{\"type\":\"turn.completed\"}'\n"
powershellScript := `
[IO.File]::WriteAllLines($env:CODEX_ARGS_FILE, (fakeCodexArgs))
[Console]::Out.WriteLine('{"type":"turn.completed"}')
`
writeFakeCodexScript(t, binDir, script, powershellScript)
t.Setenv("CODEX_ARGS_FILE", argsFile)
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
cs, err := newCodexSession(context.Background(), "codex", nil, workDir, "", "", "", "thread-123", "", nil, "")
if err != nil {
t.Fatalf("newCodexSession: %v", err)
}
defer cs.Close()
if err := cs.Send("describe this", []core.ImageAttachment{{MimeType: "image/jpeg", Data: []byte("jpg")}}, nil); err != nil {
t.Fatalf("Send: %v", err)
}
args := waitForArgsFile(t, argsFile)
if !containsSequence(args, []string{"exec", "resume", "--skip-git-repo-check"}) {
t.Fatalf("args missing resume prelude: %v", args)
}
tidIndex := indexOf(args, "thread-123")
imageIndex := indexOf(args, "--image")
jsonIndex := indexOf(args, "--json")
promptIndex := indexOf(args, "-")
if tidIndex == -1 || imageIndex == -1 || jsonIndex == -1 || promptIndex == -1 {
t.Fatalf("missing resume/image/json/stdin args: %v", args)
}
// Verify order: thread-id -> --image -> --json -> --cd -> prompt
if !(tidIndex < imageIndex && imageIndex < jsonIndex && jsonIndex < promptIndex) {
t.Fatalf("unexpected arg order: %v", args)
}
}
func TestSend_UsesStdinForMultilinePrompt(t *testing.T) {
workDir := t.TempDir()
binDir := filepath.Join(workDir, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("mkdir bin: %v", err)
}
argsFile := filepath.Join(workDir, "args.txt")
stdinFile := filepath.Join(workDir, "stdin.txt")
script := "#!/bin/sh\n" +
"printf '%s\\n' \"$@\" > \"$CODEX_ARGS_FILE\"\n" +
"cat > \"$CODEX_STDIN_FILE\"\n" +
"printf '%s\\n' '{\"type\":\"thread.started\",\"thread_id\":\"thread-stdin\"}'\n" +
"printf '%s\\n' '{\"type\":\"turn.completed\"}'\n"
powershellScript := `
[IO.File]::WriteAllLines($env:CODEX_ARGS_FILE, (fakeCodexArgs))
[IO.File]::WriteAllText($env:CODEX_STDIN_FILE, [Console]::In.ReadToEnd())
[Console]::Out.WriteLine('{"type":"thread.started","thread_id":"thread-stdin"}')
[Console]::Out.WriteLine('{"type":"turn.completed"}')
`
writeFakeCodexScript(t, binDir, script, powershellScript)
t.Setenv("CODEX_ARGS_FILE", argsFile)
t.Setenv("CODEX_STDIN_FILE", stdinFile)
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
cs, err := newCodexSession(context.Background(), "codex", nil, workDir, "", "", "", "thread-stdin", "", nil, "")
if err != nil {
t.Fatalf("newCodexSession: %v", err)
}
defer cs.Close()
prompt := "line1\nline2"
if err := cs.Send(prompt, nil, nil); err != nil {
t.Fatalf("Send: %v", err)
}
args := waitForArgsFile(t, argsFile)
if !containsSequence(args, []string{"--json", "-"}) {
t.Fatalf("args missing stdin marker: %v", args)
}
// cat > file creates the path before stdin is fully read; polling until
// content matches avoids racing an empty read (flaky under -cover / CI).
waitForFileEquals(t, stdinFile, prompt)
}
func TestSend_HandlesLargeJSONLines(t *testing.T) {
workDir := t.TempDir()
binDir := filepath.Join(workDir, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("mkdir bin: %v", err)
}
largeText := strings.Repeat("x", 11*1024*1024)
encodedText, err := json.Marshal(largeText)
if err != nil {
t.Fatalf("marshal large text: %v", err)
}
payload := strings.Join([]string{
`{"type":"thread.started","thread_id":"thread-large"}`,
`{"type":"item.completed","item":{"type":"agent_message","content":[{"type":"output_text","text":` + string(encodedText) + `}]}}`,
`{"type":"turn.completed"}`,
}, "\n") + "\n"
payloadFile := filepath.Join(workDir, "payload.jsonl")
if err := os.WriteFile(payloadFile, []byte(payload), 0o644); err != nil {
t.Fatalf("write payload: %v", err)
}
script := "#!/bin/sh\ncat \"$CODEX_PAYLOAD_FILE\"\n"
powershellScript := `[Console]::Out.Write([IO.File]::ReadAllText($env:CODEX_PAYLOAD_FILE))
`
writeFakeCodexScript(t, binDir, script, powershellScript)
t.Setenv("CODEX_PAYLOAD_FILE", payloadFile)
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
cs, err := newCodexSession(context.Background(), "codex", nil, workDir, "", "", "", "", "", nil, "")
if err != nil {
t.Fatalf("newCodexSession: %v", err)
}
defer cs.Close()
if err := cs.Send("hello", nil, nil); err != nil {
t.Fatalf("Send: %v", err)
}
var gotTextLen int
var gotResult bool
timeout := time.After(5 * time.Second)
for !gotResult {
select {
case evt := <-cs.Events():
if evt.Type == core.EventError {
t.Fatalf("unexpected error event: %v", evt.Error)
}
if evt.Type == core.EventText {
gotTextLen = len(evt.Content)
}
if evt.Type == core.EventResult && evt.Done {
gotResult = true
}
case <-timeout:
t.Fatal("timed out waiting for large JSON line events")
}
}
if gotTextLen != len(largeText) {
t.Fatalf("text len = %d, want %d", gotTextLen, len(largeText))
}
if got := cs.CurrentSessionID(); got != "thread-large" {
t.Fatalf("CurrentSessionID() = %q, want thread-large", got)
}
}
func TestWaitForArgsFile_WaitsForNonEmptyContent(t *testing.T) {
workDir := t.TempDir()
argsFile := filepath.Join(workDir, "args.txt")
if err := os.WriteFile(argsFile, []byte(""), 0o644); err != nil {
t.Fatalf("write empty args file: %v", err)
}
go func() {
time.Sleep(100 * time.Millisecond)
_ = os.WriteFile(argsFile, []byte("exec\n--json\n"), 0o644)
}()
args := waitForArgsFile(t, argsFile)
if !containsSequence(args, []string{"exec", "--json"}) {
t.Fatalf("expected non-empty args sequence, got: %v", args)
}
}
func TestWriteFakeCodexScript_PreservesArgsWithSpaces(t *testing.T) {
workDir := t.TempDir()
binDir := filepath.Join(workDir, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("mkdir bin: %v", err)
}
argsFile := filepath.Join(workDir, "args.txt")
script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$CODEX_ARGS_FILE\"\n"
powershellScript := `[IO.File]::WriteAllLines($env:CODEX_ARGS_FILE, (fakeCodexArgs))
`
writeFakeCodexScript(t, binDir, script, powershellScript)
t.Setenv("CODEX_ARGS_FILE", argsFile)
cmd := exec.Command(filepath.Join(binDir, "codex"), "exec", "--cd", filepath.Join(workDir, "dir with spaces"), "-")
if err := cmd.Run(); err != nil {
t.Fatalf("fake codex run: %v", err)
}
args := waitForArgsFile(t, argsFile)
wantPath := filepath.Join(workDir, "dir with spaces")
if !containsSequence(args, []string{"exec", "--cd", wantPath, "-"}) {
t.Fatalf("args = %v, want path with spaces preserved as %q", args, wantPath)
}
}
const fakeCodexPowerShellPrelude = `
function fakeCodexArgs {
if ([string]::IsNullOrWhiteSpace($env:CODEX_FAKE_ARGS_FILE) -or -not (Test-Path -LiteralPath $env:CODEX_FAKE_ARGS_FILE)) {
return @()
}
return @(Get-Content -LiteralPath $env:CODEX_FAKE_ARGS_FILE)
}
`
func writeFakeCodexScript(t *testing.T, dir, shellScript, powershellScript string) {
t.Helper()
if runtime.GOOS == "windows" {
psPath := filepath.Join(dir, "codex.ps1")
if err := os.WriteFile(psPath, []byte(fakeCodexPowerShellPrelude+powershellScript), 0o644); err != nil {
t.Fatalf("write fake codex powershell script: %v", err)
}
cmdPath := filepath.Join(dir, "codex.cmd")
cmdScript := "@echo off\r\n" +
"setlocal\r\n" +
"set \"CODEX_FAKE_SCRIPT=%~dp0codex.ps1\"\r\n" +
"set \"CODEX_FAKE_ARGS_FILE=%TEMP%\\codex-fake-args-%RANDOM%-%RANDOM%.txt\"\r\n" +
"type nul > \"%CODEX_FAKE_ARGS_FILE%\"\r\n" +
":args\r\n" +
"if \"%~1\"==\"\" goto run\r\n" +
">> \"%CODEX_FAKE_ARGS_FILE%\" echo(%~1\r\n" +
"shift\r\n" +
"goto args\r\n" +
":run\r\n" +
"powershell -NoProfile -ExecutionPolicy Bypass -File \"%CODEX_FAKE_SCRIPT%\"\r\n" +
"set \"CODEX_FAKE_EXIT=%ERRORLEVEL%\"\r\n" +
"del \"%CODEX_FAKE_ARGS_FILE%\" >nul 2>nul\r\n" +
"exit /b %CODEX_FAKE_EXIT%\r\n"
if err := os.WriteFile(cmdPath, []byte(cmdScript), 0o755); err != nil {
t.Fatalf("write fake codex cmd shim: %v", err)
}
return
}
scriptPath := filepath.Join(dir, "codex")
if err := os.WriteFile(scriptPath, []byte(shellScript), 0o755); err != nil {
t.Fatalf("write fake codex: %v", err)
}
}
func waitForArgsFile(t *testing.T, path string) []string {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
data, err := os.ReadFile(path)
if err == nil {
text := strings.TrimSpace(string(data))
if text != "" {
lines := strings.Split(text, "\n")
args := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
args = append(args, line)
}
}
if len(args) > 0 {
return args
}
}
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("timed out waiting for non-empty args file: %s", path)
return nil
}
func waitForFileEquals(t *testing.T, path, want string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
data, err := os.ReadFile(path)
if err == nil && string(data) == want {
return
}
time.Sleep(20 * time.Millisecond)
}
data, _ := os.ReadFile(path)
t.Fatalf("stdin file %s: got %q, want %q", path, string(data), want)
}
func containsSequence(args, want []string) bool {
if len(want) == 0 {
return true
}
for i := 0; i+len(want) <= len(args); i++ {
match := true
for j := range want {
if args[i+j] != want[j] {
match = false
break
}
}
if match {
return true
}
}
return false
}
func valueAfter(args []string, key string) string {
for i := 0; i+1 < len(args); i++ {
if args[i] == key {
return args[i+1]
}
}
return ""
}
func indexOf(args []string, target string) int {
for i, arg := range args {
if arg == target {
return i
}
}
return -1
}
func TestCodexSession_ContinueSessionTreatedAsFresh(t *testing.T) {
s, err := newCodexSession(context.Background(), "codex", nil, "/tmp", "", "", "full-auto", core.ContinueSession, "", nil, "")
if err != nil {
t.Fatalf("newCodexSession: %v", err)
}
defer s.Close()
if got := s.CurrentSessionID(); got != "" {
t.Errorf("ContinueSession should be treated as fresh: threadID = %q, want empty", got)
}
}
func TestClose_ForceKillsProcessGroupAfterGracefulTimeout(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("process-group semantics differ on windows")
}
workDir := t.TempDir()
binDir := filepath.Join(workDir, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("mkdir bin: %v", err)
}
script := "#!/bin/sh\n" +
"printf '%s\\n' '{\"type\":\"thread.started\",\"thread_id\":\"thread-close\"}'\n" +
"(sleep 0.12; printf '%s\\n' '{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"late child output\"}}'; sleep 30) &\n" +
"wait\n"
scriptPath := filepath.Join(binDir, "codex")
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
t.Fatalf("write fake codex: %v", err)
}
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
oldCloseTimeout := codexSessionCloseTimeout
oldForceKillWait := codexSessionForceKillWait
codexSessionCloseTimeout = 50 * time.Millisecond
codexSessionForceKillWait = 500 * time.Millisecond
t.Cleanup(func() {
codexSessionCloseTimeout = oldCloseTimeout
codexSessionForceKillWait = oldForceKillWait
})
cs, err := newCodexSession(context.Background(), "codex", nil, workDir, "", "", "", "", "", nil, "")
if err != nil {
t.Fatalf("newCodexSession: %v", err)
}
if err := cs.Send("hello", nil, nil); err != nil {
t.Fatalf("Send: %v", err)
}
waitForThreadID(t, cs, "thread-close")
closeStarted := time.Now()
if err := cs.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if elapsed := time.Since(closeStarted); elapsed > time.Second {
t.Fatalf("Close took too long after force kill: %v", elapsed)
}
select {
case evt, ok := <-cs.Events():
if ok {
t.Fatalf("unexpected event after Close: %#v", evt)
}
case <-time.After(700 * time.Millisecond):
t.Fatal("timed out waiting for events channel to close")
}
}
func TestClose_ForceKillsAllTrackedProcessesAfterCmdOverwrite(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("process-group semantics differ on windows")
}
workDir := t.TempDir()
binDir := filepath.Join(workDir, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("mkdir bin: %v", err)
}
startsFile := filepath.Join(workDir, "starts.txt")
// Prompt is passed on stdin (--json -), not as a trailing argv argument.
script := "#!/bin/sh\n" +
"prompt=$(cat)\n" +
"printf '%s\\n' \"$prompt\" >> \"$CODEX_STARTS_FILE\"\n" +
"if [ \"$prompt\" = \"first\" ]; then\n" +
" printf '%s\\n' '{\"type\":\"thread.started\",\"thread_id\":\"thread-overlap\"}'\n" +
" printf '%s\\n' '{\"type\":\"turn.completed\"}'\n" +
"fi\n" +
"sleep 30\n"
scriptPath := filepath.Join(binDir, "codex")
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
t.Fatalf("write fake codex: %v", err)
}
t.Setenv("CODEX_STARTS_FILE", startsFile)
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
oldCloseTimeout := codexSessionCloseTimeout
oldForceKillWait := codexSessionForceKillWait
codexSessionCloseTimeout = 50 * time.Millisecond
codexSessionForceKillWait = 500 * time.Millisecond
t.Cleanup(func() {
codexSessionCloseTimeout = oldCloseTimeout
codexSessionForceKillWait = oldForceKillWait
})
cs, err := newCodexSession(context.Background(), "codex", nil, workDir, "", "", "", "", "", nil, "")
if err != nil {
t.Fatalf("newCodexSession: %v", err)
}
if err := cs.Send("first", nil, nil); err != nil {
t.Fatalf("Send(first): %v", err)
}
waitForThreadID(t, cs, "thread-overlap")
waitForDoneResult(t, cs.Events())
if err := cs.Send("second", nil, nil); err != nil {
t.Fatalf("Send(second): %v", err)
}
waitForFileLines(t, startsFile, 2)
closeStarted := time.Now()
if err := cs.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if elapsed := time.Since(closeStarted); elapsed > time.Second {
t.Fatalf("Close took too long after force killing tracked processes: %v", elapsed)
}
select {
case evt, ok := <-cs.Events():
if ok {
t.Fatalf("unexpected event after Close: %#v", evt)
}
case <-time.After(700 * time.Millisecond):
t.Fatal("timed out waiting for events channel to close")
}
}
func waitForThreadID(t *testing.T, cs *codexSession, want string) {
t.Helper()
timeout := time.After(5 * time.Second)
for {
select {
case <-time.After(10 * time.Millisecond):
if cs.CurrentSessionID() == want {
return
}
case <-timeout:
t.Fatalf("timed out waiting for thread id %q", want)
}
}
}
func waitForDoneResult(t *testing.T, events <-chan core.Event) {
t.Helper()
timeout := time.After(5 * time.Second)
for {
select {
case evt, ok := <-events:
if !ok {
t.Fatal("events channel closed before done result")
}
if evt.Type == core.EventError {
t.Fatalf("unexpected error event: %v", evt.Error)
}
if evt.Type == core.EventResult && evt.Done {
return
}
case <-timeout:
t.Fatal("timed out waiting for done result")
}
}
}
func waitForFileLines(t *testing.T, path string, want int) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
data, err := os.ReadFile(path)
if err == nil {
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
count := 0
for _, line := range lines {
if strings.TrimSpace(line) != "" {
count++
}
}
if count >= want {
return
}
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("timed out waiting for %d lines in %s", want, path)
}
+121
View File
@@ -0,0 +1,121 @@
package codex
import (
"os"
"path/filepath"
"runtime"
"sync"
"testing"
)
func TestSkillDirs_UsesProjectAgentAndCodexHomes(t *testing.T) {
tmp := t.TempDir()
home := filepath.Join(tmp, "home")
codexHome := filepath.Join(tmp, "codex-home")
repo := filepath.Join(tmp, "repo")
workDir := filepath.Join(repo, "nested", "pkg")
setTestHome(t, home)
t.Setenv("CODEX_HOME", "")
for _, dir := range []string{
filepath.Join(repo, "nested", "pkg"),
filepath.Join(repo, "nested"),
repo,
codexHome,
} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
}
if err := os.WriteFile(filepath.Join(repo, ".git"), []byte("gitdir: fake\n"), 0o644); err != nil {
t.Fatalf("write .git: %v", err)
}
a := &Agent{workDir: workDir, codexHome: codexHome}
got := a.SkillDirs()
want := []string{
filepath.Join(workDir, ".agents", "skills"),
filepath.Join(workDir, ".codex", "skills"),
filepath.Join(repo, "nested", ".agents", "skills"),
filepath.Join(repo, "nested", ".codex", "skills"),
filepath.Join(repo, ".agents", "skills"),
filepath.Join(repo, ".codex", "skills"),
filepath.Join(codexHome, "skills"),
filepath.Join(home, ".agents", "skills"),
}
if len(got) != len(want) {
t.Fatalf("len(SkillDirs()) = %d, want %d\n got=%v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("SkillDirs()[%d] = %q, want %q\nfull=%v", i, got[i], want[i], got)
}
}
}
func TestSkillDirs_FallsBackToEnvCodexHome(t *testing.T) {
tmp := t.TempDir()
home := filepath.Join(tmp, "home")
codexHome := filepath.Join(tmp, "profile-home")
workDir := filepath.Join(tmp, "workspace")
if err := os.MkdirAll(workDir, 0o755); err != nil {
t.Fatalf("mkdir workdir: %v", err)
}
setTestHome(t, home)
t.Setenv("CODEX_HOME", codexHome)
a := &Agent{workDir: workDir}
got := a.SkillDirs()
found := false
for _, dir := range got {
if dir == filepath.Join(codexHome, "skills") {
found = true
break
}
}
if !found {
t.Fatalf("SkillDirs() missing CODEX_HOME skills dir: %v", got)
}
}
func setTestHome(t *testing.T, home string) {
t.Helper()
t.Setenv("HOME", home)
if runtime.GOOS == "windows" {
t.Setenv("USERPROFILE", home)
t.Setenv("HOMEDRIVE", "")
t.Setenv("HOMEPATH", "")
}
}
// TestSkillDirs_RaceFreeAgainstSetWorkDir pins the bug where SkillDirs
// read a.workDir and a.codexHome without holding a.mu, while
// SetWorkDir writes a.workDir under the lock. Run with -race to detect
// the data race; with the production fix the test stays clean.
func TestSkillDirs_RaceFreeAgainstSetWorkDir(t *testing.T) {
tmp := t.TempDir()
a := &Agent{workDir: tmp, codexHome: filepath.Join(tmp, "codex")}
var wg sync.WaitGroup
for i := 0; i < 30; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
if i%2 == 0 {
a.SetWorkDir(filepath.Join(tmp, "a"))
} else {
a.SetWorkDir(filepath.Join(tmp, "b"))
}
}(i)
}
for i := 0; i < 30; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = a.SkillDirs()
}()
}
wg.Wait()
}
+195
View File
@@ -0,0 +1,195 @@
package codex
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/chenhg5/cc-connect/core"
)
const codexUsageURL = "https://chatgpt.com/backend-api/wham/usage"
type codexOAuthTokens struct {
AccessToken string
AccountID string
}
type codexUsageResponse struct {
UserID string `json:"user_id"`
AccountID string `json:"account_id"`
Email string `json:"email"`
PlanType string `json:"plan_type"`
RateLimit *codexUsageBucket `json:"rate_limit"`
CodeReviewRateLimit *codexUsageBucket `json:"code_review_rate_limit"`
Credits *codexUsageCredits `json:"credits"`
}
type codexUsageBucket struct {
Allowed bool `json:"allowed"`
LimitReached bool `json:"limit_reached"`
PrimaryWindow *codexUsageWindow `json:"primary_window"`
SecondaryWindow *codexUsageWindow `json:"secondary_window"`
}
type codexUsageWindow struct {
UsedPercent int `json:"used_percent"`
LimitWindowSeconds int `json:"limit_window_seconds"`
ResetAfterSeconds int `json:"reset_after_seconds"`
ResetAt int64 `json:"reset_at"`
}
type codexUsageCredits struct {
HasCredits bool `json:"has_credits"`
Unlimited bool `json:"unlimited"`
Balance any `json:"balance"`
}
func (a *Agent) GetUsage(ctx context.Context) (*core.UsageReport, error) {
tokens, err := a.readOAuthTokens(os.ReadFile)
if err != nil {
return nil, err
}
return a.fetchUsage(ctx, http.DefaultClient, tokens)
}
func (a *Agent) readOAuthTokens(readFile func(string) ([]byte, error)) (codexOAuthTokens, error) {
path, err := codexAuthPath()
if err != nil {
return codexOAuthTokens{}, err
}
data, err := readFile(path)
if err != nil {
return codexOAuthTokens{}, fmt.Errorf("read %s: %w", path, err)
}
var payload struct {
Tokens struct {
AccessToken string `json:"access_token"`
AccountID string `json:"account_id"`
} `json:"tokens"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return codexOAuthTokens{}, fmt.Errorf("parse auth.json: %w", err)
}
if strings.TrimSpace(payload.Tokens.AccessToken) == "" {
return codexOAuthTokens{}, fmt.Errorf("auth.json missing tokens.access_token")
}
if strings.TrimSpace(payload.Tokens.AccountID) == "" {
return codexOAuthTokens{}, fmt.Errorf("auth.json missing tokens.account_id")
}
return codexOAuthTokens{
AccessToken: payload.Tokens.AccessToken,
AccountID: payload.Tokens.AccountID,
}, nil
}
func (a *Agent) fetchUsage(ctx context.Context, client *http.Client, tokens codexOAuthTokens) (*core.UsageReport, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, codexUsageURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+tokens.AccessToken)
req.Header.Set("ChatGPT-Account-Id", tokens.AccountID)
req.Header.Set("User-Agent", "codex-cli")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request usage endpoint: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("usage endpoint returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var payload codexUsageResponse
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, fmt.Errorf("decode usage response: %w", err)
}
return mapCodexUsage(payload), nil
}
func mapCodexUsage(payload codexUsageResponse) *core.UsageReport {
report := &core.UsageReport{
Provider: "codex",
AccountID: payload.AccountID,
UserID: payload.UserID,
Email: payload.Email,
Plan: payload.PlanType,
}
if payload.RateLimit != nil {
report.Buckets = append(report.Buckets, core.UsageBucket{
Name: "Rate limit",
Allowed: payload.RateLimit.Allowed,
LimitReached: payload.RateLimit.LimitReached,
Windows: mapCodexUsageWindows(payload.RateLimit),
})
}
if payload.CodeReviewRateLimit != nil {
report.Buckets = append(report.Buckets, core.UsageBucket{
Name: "Code review",
Allowed: payload.CodeReviewRateLimit.Allowed,
LimitReached: payload.CodeReviewRateLimit.LimitReached,
Windows: mapCodexUsageWindows(payload.CodeReviewRateLimit),
})
}
if payload.Credits != nil {
report.Credits = &core.UsageCredits{
HasCredits: payload.Credits.HasCredits,
Unlimited: payload.Credits.Unlimited,
}
if payload.Credits.Balance != nil {
report.Credits.Balance = fmt.Sprintf("%v", payload.Credits.Balance)
}
}
return report
}
func mapCodexUsageWindows(bucket *codexUsageBucket) []core.UsageWindow {
if bucket == nil {
return nil
}
var windows []core.UsageWindow
if bucket.PrimaryWindow != nil {
windows = append(windows, core.UsageWindow{
Name: "Primary",
UsedPercent: bucket.PrimaryWindow.UsedPercent,
WindowSeconds: bucket.PrimaryWindow.LimitWindowSeconds,
ResetAfterSeconds: bucket.PrimaryWindow.ResetAfterSeconds,
ResetAtUnix: bucket.PrimaryWindow.ResetAt,
})
}
if bucket.SecondaryWindow != nil {
windows = append(windows, core.UsageWindow{
Name: "Secondary",
UsedPercent: bucket.SecondaryWindow.UsedPercent,
WindowSeconds: bucket.SecondaryWindow.LimitWindowSeconds,
ResetAfterSeconds: bucket.SecondaryWindow.ResetAfterSeconds,
ResetAtUnix: bucket.SecondaryWindow.ResetAt,
})
}
return windows
}
func codexAuthPath() (string, error) {
codexHome := os.Getenv("CODEX_HOME")
if codexHome == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
codexHome = filepath.Join(home, ".codex")
}
return filepath.Join(codexHome, "auth.json"), nil
}
+139
View File
@@ -0,0 +1,139 @@
package codex
import (
"context"
"io"
"net/http"
"strings"
"testing"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestFetchUsage_Success(t *testing.T) {
agent := &Agent{}
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if got := req.URL.String(); got != "https://chatgpt.com/backend-api/wham/usage" {
t.Fatalf("request URL = %q", got)
}
if got := req.Header.Get("Authorization"); got != "Bearer token-123" {
t.Fatalf("authorization = %q", got)
}
if got := req.Header.Get("ChatGPT-Account-Id"); got != "acct-123" {
t.Fatalf("account id header = %q", got)
}
body := `{
"user_id": "user-1",
"account_id": "acct-123",
"email": "dev@example.com",
"plan_type": "team",
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {
"used_percent": 23,
"limit_window_seconds": 18000,
"reset_after_seconds": 6665,
"reset_at": 1773292109
},
"secondary_window": {
"used_percent": 42,
"limit_window_seconds": 604800,
"reset_after_seconds": 512698,
"reset_at": 1773798142
}
},
"code_review_rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {
"used_percent": 0,
"limit_window_seconds": 604800,
"reset_after_seconds": 604800,
"reset_at": 1773890244
},
"secondary_window": null
},
"credits": {
"has_credits": false,
"unlimited": false,
"balance": null
}
}`
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
}, nil
}),
}
report, err := agent.fetchUsage(context.Background(), client, codexOAuthTokens{
AccessToken: "token-123",
AccountID: "acct-123",
})
if err != nil {
t.Fatalf("fetchUsage returned error: %v", err)
}
if report.Provider != "codex" {
t.Fatalf("provider = %q, want codex", report.Provider)
}
if report.Plan != "team" {
t.Fatalf("plan = %q, want team", report.Plan)
}
if len(report.Buckets) != 2 {
t.Fatalf("buckets = %d, want 2", len(report.Buckets))
}
if got := report.Buckets[0].Windows[0].UsedPercent; got != 23 {
t.Fatalf("primary used percent = %d, want 23", got)
}
if got := report.Buckets[0].Windows[1].UsedPercent; got != 42 {
t.Fatalf("secondary used percent = %d, want 42", got)
}
}
func TestFetchUsage_HTTPError(t *testing.T) {
agent := &Agent{}
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusUnauthorized,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"error":"unauthorized"}`)),
}, nil
}),
}
_, err := agent.fetchUsage(context.Background(), client, codexOAuthTokens{
AccessToken: "token-123",
AccountID: "acct-123",
})
if err == nil || !strings.Contains(err.Error(), "status 401") {
t.Fatalf("err = %v, want status 401", err)
}
}
func TestReadOAuthTokens_MissingFields(t *testing.T) {
agent := &Agent{}
_, err := agent.readOAuthTokens(func(string) ([]byte, error) {
return []byte(`{"tokens":{"access_token":"token-only"}}`), nil
})
if err == nil || !strings.Contains(err.Error(), "account_id") {
t.Fatalf("err = %v, want missing account_id", err)
}
}
func TestReadOAuthTokens_InvalidJSON(t *testing.T) {
agent := &Agent{}
_, err := agent.readOAuthTokens(func(string) ([]byte, error) {
return []byte(`{`), nil
})
if err == nil || !strings.Contains(err.Error(), "parse") {
t.Fatalf("err = %v, want parse error", err)
}
}