初始化仓库
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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: "跳过所有审批和沙箱"},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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]) + "..."
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user