fix(agent/codex): route custom_args -m/--model to thread/start payload

Codex agents spawn via `codex app-server --listen stdio://`, which does
not accept `-m` / `--model` (those belong to the normal Codex CLI). When
a user's custom_args still carried those tokens the process exited
before the JSON-RPC initialize handshake with `codex process exited`,
with no actionable error.

Extract `-m <v>`, `--model <v>`, and `--model=<v>` from opts.CustomArgs
before invoking app-server and promote the value into opts.Model, so
that startOrResumeThread can pass it through the `thread/start` payload
where Codex actually reads the field.

Fixes #1308.
This commit is contained in:
Jiang Bohan
2026-04-18 13:53:23 +08:00
parent 2317533da4
commit f18355b2f0
2 changed files with 137 additions and 8 deletions

View File

@@ -20,6 +20,37 @@ var codexBlockedArgs = map[string]blockedArgMode{
"--listen": blockedWithValue, // stdio:// transport for daemon communication
}
// extractCodexModelArg pulls `-m <v>`, `--model <v>`, and `--model=<v>` 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"

View File

@@ -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
}