mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user