diff --git a/server/pkg/agent/codex.go b/server/pkg/agent/codex.go index a178a8969..ef8848a67 100644 --- a/server/pkg/agent/codex.go +++ b/server/pkg/agent/codex.go @@ -20,6 +20,37 @@ var codexBlockedArgs = map[string]blockedArgMode{ "--listen": blockedWithValue, // stdio:// transport for daemon communication } +// extractCodexModelArg pulls `-m `, `--model `, and `--model=` out of +// user-supplied custom_args and returns the model plus the remaining args. +// Codex's `app-server` subcommand does not accept these flags — the model +// belongs in the `thread/start` JSON-RPC payload. If multiple model specifiers +// are present, the last one wins. If the flag appears without a value, it is +// dropped silently (malformed input). +func extractCodexModelArg(args []string, logger *slog.Logger) (model string, remaining []string) { + if len(args) == 0 { + return "", args + } + remaining = make([]string, 0, len(args)) + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "-m" || arg == "--model": + if i+1 < len(args) { + model = args[i+1] + i++ + } + case strings.HasPrefix(arg, "--model="): + model = strings.TrimPrefix(arg, "--model=") + default: + remaining = append(remaining, arg) + } + } + if model != "" && logger != nil { + logger.Info("codex: migrating --model from custom_args to thread/start payload", "model", model) + } + return model, remaining +} + // codexBackend implements Backend by spawning `codex app-server --listen stdio://` // and communicating via JSON-RPC 2.0 over stdin/stdout. type codexBackend struct { @@ -41,6 +72,16 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti } runCtx, cancel := context.WithTimeout(ctx, timeout) + // Codex's `app-server` subcommand does not accept `-m` / `--model`; the + // model is selected via the `thread/start` JSON-RPC payload. If a user's + // custom_args still carry those tokens (historically the UI suggested + // CLI-style model overrides), extract them here so the process doesn't + // exit during initialize. + if extractedModel, trimmedCustom := extractCodexModelArg(opts.CustomArgs, b.cfg.Logger); extractedModel != "" { + opts.Model = extractedModel + opts.CustomArgs = trimmedCustom + } + codexArgs := append([]string{"app-server", "--listen", "stdio://"}, filterCustomArgs(opts.CustomArgs, codexBlockedArgs, b.cfg.Logger)...) cmd := exec.CommandContext(runCtx, execPath, codexArgs...) b.cfg.Logger.Debug("agent command", "exec", execPath, "args", codexArgs) @@ -315,14 +356,14 @@ func (c *codexClient) startOrResumeThread(ctx context.Context, opts ExecOptions, // ── codexClient: JSON-RPC 2.0 transport ── type codexClient struct { - cfg Config - stdin interface{ Write([]byte) (int, error) } - mu sync.Mutex - nextID int - pending map[int]*pendingRPC - threadID string - turnID string - onMessage func(Message) + cfg Config + stdin interface{ Write([]byte) (int, error) } + mu sync.Mutex + nextID int + pending map[int]*pendingRPC + threadID string + turnID string + onMessage func(Message) onTurnDone func(aborted bool) notificationProtocol string // "unknown", "legacy", "raw" diff --git a/server/pkg/agent/codex_test.go b/server/pkg/agent/codex_test.go index 20361ae27..e3e7e4990 100644 --- a/server/pkg/agent/codex_test.go +++ b/server/pkg/agent/codex_test.go @@ -911,3 +911,91 @@ func TestCodexProtocolDetectionLegacyBlocksRaw(t *testing.T) { t.Fatal("raw notification should be ignored in legacy mode") } } + +func TestExtractCodexModelArg(t *testing.T) { + t.Parallel() + + logger := slog.Default() + + cases := []struct { + name string + in []string + wantModel string + wantRest []string + }{ + { + name: "short flag with value", + in: []string{"-m", "gpt-5.4-mini"}, + wantModel: "gpt-5.4-mini", + wantRest: []string{}, + }, + { + name: "long flag with separate value", + in: []string{"--model", "gpt-5.4-mini"}, + wantModel: "gpt-5.4-mini", + wantRest: []string{}, + }, + { + name: "long flag with inline value", + in: []string{"--model=gpt-5.4-mini"}, + wantModel: "gpt-5.4-mini", + wantRest: []string{}, + }, + { + name: "model flag among other args", + in: []string{"--verbose", "-m", "o3", "--experimental"}, + wantModel: "o3", + wantRest: []string{"--verbose", "--experimental"}, + }, + { + name: "last model specifier wins", + in: []string{"-m", "first", "--model=second"}, + wantModel: "second", + wantRest: []string{}, + }, + { + name: "no model flag passes through untouched", + in: []string{"--verbose", "--experimental"}, + wantModel: "", + wantRest: []string{"--verbose", "--experimental"}, + }, + { + name: "orphan -m with no value is dropped", + in: []string{"-m"}, + wantModel: "", + wantRest: []string{}, + }, + { + name: "empty input", + in: nil, + wantModel: "", + wantRest: nil, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + gotModel, gotRest := extractCodexModelArg(tc.in, logger) + if gotModel != tc.wantModel { + t.Errorf("model: got %q, want %q", gotModel, tc.wantModel) + } + if !equalStringSlices(gotRest, tc.wantRest) { + t.Errorf("remaining: got %v, want %v", gotRest, tc.wantRest) + } + }) + } +} + +func equalStringSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +}