mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 01:45:52 +02:00
* feat(agent): persist thinking_level per agent (MUL-2339) Adds a nullable `thinking_level` column to the `agent` table so the backend can route a runtime-native reasoning/effort token (e.g. Claude's `xhigh`, Codex's `minimal`) through to the agent CLI on every dispatch. The column is intentionally TEXT rather than an enum — Claude and Codex publish overlapping but distinct vocabularies and we want the persisted value to round-trip exactly through whichever CLI receives it. NULL is the "use runtime default" sentinel that every downstream consumer reads as "do not inject --effort / reasoning_effort". This commit is just the storage layer (migration + sqlc); subsequent commits wire it through the API, daemon, and agent backends. Co-authored-by: multica-agent <github@multica.ai> * feat(agent-backend): inject reasoning effort for claude + codex (MUL-2339) Extends ExecOptions with a runtime-native ThinkingLevel string and wires it into the Claude and Codex backends. Discovery is driven by the local CLI so the daemon advertises whatever the host install supports rather than a hand-maintained list that goes stale. Per Elon's PR1 review: - Claude: parses `claude --help` to learn the `--effort` superset and projects through a per-model allow-list (xhigh is Opus-only; max is session-only on the smaller models). Falls back to a conservative static list when the binary is missing or help drift hides the line. - Codex: drives `codex debug models --output json` so per-model reasoning subsets and the documented default come directly from the CLI. The older config-error probe trick is gone — the JSON path is stable and doesn't pollute stderr with an intentional misconfig. - Cache key includes (provider, executablePath, cliVersion) so a CLI upgrade invalidates entries that referenced the older help / catalog. Per Trump's PR1 constraint, all three Codex injection points (thread/start.config, thread/resume.config, turn/start.effort) flow through one helper (`applyCodexReasoningEffort`) so they cannot drift independently. The shared `codexReasoningCases` fixture in `thinking_test.go` asserts the same value→{shape, key} contract at each site for every level the runtimes know about. Claude's `--effort` is also added to `claudeBlockedArgs` so a user custom_args entry can't silently outvote the daemon-injected value. Co-authored-by: multica-agent <github@multica.ai> * feat(api): wire thinking_level through API + daemon contract (MUL-2339) End-to-end plumbing for the per-agent reasoning/effort setting: - AgentResponse / TaskAgentData now carry `thinking_level`; the daemon's claim response includes it and the daemon's executor passes it through to agent.ExecOptions, where the Claude and Codex backends already know what to do with it. - ModelEntry on the runtime-models wire format gains a `thinking` block carrying `supported_levels` + `default_level` per model so the UI can render a runtime-aware picker without the server having to know about the local CLI install. `handleModelList` projects the agent-package catalog (including the new Thinking field) into the wire shape. - CreateAgent / UpdateAgent gate the field with a synchronous provider enum check (claude / codex only today). UpdateAgent is tri-state: field omitted = no change, "" = explicit clear (new `ClearAgentThinkingLevel` query, mirrors the existing mcp_config null pattern), non-empty = validate then set. Per Trump's PR1 review, the API NEVER auto-clears on a runtime/model swap and ALWAYS returns 400 on an unknown literal value — same shape across CreateAgent, UpdateAgent, and combined patches that move runtime + level in one request. Per-model combination failures (e.g. `xhigh` against a model that only supports up to `high`) surface as a daemon-side task error, not a silent server-side rewrite. TS types follow the same shape: `Agent.thinking_level`, `CreateAgentRequest`/`UpdateAgentRequest` add the field, `RuntimeModel` grows a `thinking` block. Older backends omit the field, which the front-end treats as "no picker for this model" — installed desktop builds keep working. Co-authored-by: multica-agent <github@multica.ai> * fix(agent): correct codex debug models argv + pin via runner test (MUL-2339) `codex debug models --output json` is rejected by codex-cli 0.131.0 — the subcommand emits JSON on stdout by default and has no `--output` flag. Drop the flag and add `--bundled` to skip the network refresh discovery doesn't need. Move the argv to a package-level var and add a test that runs a fake `codex` to assert the binary actually receives exactly `debug models --bundled`, so the contract can't silently drift on the next refactor. Also teach ValidateThinkingLevel to resolve an empty model to the provider's default model entry. Without this, every default-model task with a persisted thinking_level would be misjudged "unknown model" by the daemon guard. Co-authored-by: multica-agent <github@multica.ai> * fix(api): reject runtime switch that would leave invalid thinking_level (MUL-2339) A PATCH that changed `runtime_id` without touching `thinking_level` used to silently keep the existing value, so a Claude agent storing `max` could land on a Codex runtime where `max` is not a recognised token at all, and the daemon would receive a literal-invalid level. Hold the same "always 400 on literal-invalid, never silent coerce" rule on this implicit path. When runtime_id changes and the existing value is not in the new provider's enum, return 400 with the recovery options (clear via `thinking_level=""` or re-set in the same PATCH). Add coverage for both the kept-when-still-valid and the rejected cases, plus the two recovery paths (clear and replace). Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): guard runTask with per-model thinking_level validator (MUL-2339) ValidateThinkingLevel existed but had no call site — `task.Agent. ThinkingLevel` flowed straight into ExecOptions, so `xhigh` configured on a non-Opus Claude model, or API-side stale values that escaped the provider enum gate, would be injected anyway. Run the validator before building ExecOptions. Invalid combinations log a warning and drop the level instead of failing the task: the agent still runs, just at the runtime's default reasoning effort. Discovery errors fail open (keep the level, let the CLI surface any objection) so a transient `claude --help` failure can't strand work. Empty model is forwarded as-is; the validator resolves it to the provider's default model internally per the cross-package contract. Co-authored-by: multica-agent <github@multica.ai> * chore(agent): drop stale `--output json` comments + unused scanner (MUL-2339) Codex CLI's `debug models` subcommand emits JSON without an `--output` flag, and `parseCodexDebugModels` never read from the bufio.Scanner. Sync the comments with the actual invocation and remove the dead init. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
651 lines
21 KiB
Go
651 lines
21 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime"
|
|
"testing"
|
|
)
|
|
|
|
// ── Claude help parsing ──────────────────────────────────────────────
|
|
|
|
func TestParseClaudeEffortHelp_OldFormat(t *testing.T) {
|
|
t.Parallel()
|
|
// claude 2.1.109 — the older help omits xhigh.
|
|
help := `Usage: claude [options]
|
|
|
|
Options:
|
|
--model <model> Model to use
|
|
--effort <level> Effort level for the current session (low, medium, high, max)
|
|
--verbose
|
|
`
|
|
got := parseClaudeEffortHelp(help)
|
|
want := []string{"low", "medium", "high", "max"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("parseClaudeEffortHelp: got %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestParseClaudeEffortHelp_NewFormat(t *testing.T) {
|
|
t.Parallel()
|
|
// claude 2.1.121 — the newer help adds xhigh.
|
|
help := `Usage: claude [options]
|
|
|
|
Options:
|
|
--effort <level> Effort level for the current session (low, medium, high, xhigh, max)
|
|
`
|
|
got := parseClaudeEffortHelp(help)
|
|
want := []string{"low", "medium", "high", "xhigh", "max"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("parseClaudeEffortHelp: got %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestParseClaudeEffortHelp_Missing(t *testing.T) {
|
|
t.Parallel()
|
|
help := `Usage: claude [options]
|
|
|
|
Options:
|
|
--model <model> Model to use
|
|
--verbose
|
|
`
|
|
got := parseClaudeEffortHelp(help)
|
|
if got != nil {
|
|
t.Fatalf("parseClaudeEffortHelp: expected nil, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestProjectClaudeLevels_PerModelSubset(t *testing.T) {
|
|
t.Parallel()
|
|
superset := []string{"low", "medium", "high", "xhigh", "max"}
|
|
// Sonnet should drop xhigh per claudeModelEffortAllow.
|
|
got := projectClaudeLevels(superset, claudeModelEffortAllow["claude-sonnet-4-6"])
|
|
values := make([]string, 0, len(got))
|
|
for _, lvl := range got {
|
|
values = append(values, lvl.Value)
|
|
}
|
|
want := []string{"low", "medium", "high", "max"}
|
|
if !reflect.DeepEqual(values, want) {
|
|
t.Fatalf("projectClaudeLevels: got %v, want %v", values, want)
|
|
}
|
|
// Opus keeps xhigh.
|
|
got = projectClaudeLevels(superset, claudeModelEffortAllow["claude-opus-4-7"])
|
|
values = values[:0]
|
|
for _, lvl := range got {
|
|
values = append(values, lvl.Value)
|
|
}
|
|
if !reflect.DeepEqual(values, superset) {
|
|
t.Fatalf("projectClaudeLevels for Opus: got %v, want %v", values, superset)
|
|
}
|
|
}
|
|
|
|
// ── Codex discovery argv ────────────────────────────────────────────
|
|
//
|
|
// Elon's PR1 review found that `codex debug models --output json` is
|
|
// rejected by codex-cli 0.131.0 — there is no `--output` flag on the
|
|
// subcommand. The fix was to drop the flag and add `--bundled` (which
|
|
// just skips network refresh). These two tests pin the contract:
|
|
//
|
|
// - TestCodexDebugModelsArgs_Pinned asserts the literal argv we pass
|
|
// so a future "let's add a flag" refactor breaks loudly instead of
|
|
// silently swallowing the discovery output.
|
|
// - TestRunCodexDebugModels_ArgvSeenByBinary plugs a fake `codex`
|
|
// binary on PATH and verifies that what *actually* reaches the
|
|
// process matches the pinned argv, not just what the var holds.
|
|
|
|
func TestCodexDebugModelsArgs_Pinned(t *testing.T) {
|
|
t.Parallel()
|
|
want := []string{"debug", "models", "--bundled"}
|
|
if !reflect.DeepEqual(codexDebugModelsArgs, want) {
|
|
t.Fatalf("codexDebugModelsArgs drifted: got %v, want %v", codexDebugModelsArgs, want)
|
|
}
|
|
for _, arg := range codexDebugModelsArgs {
|
|
if arg == "--output" || arg == "-o" {
|
|
t.Errorf("--output / -o leaked back into argv (codex CLI does not accept it): %v", codexDebugModelsArgs)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRunCodexDebugModels_ArgvSeenByBinary executes runCodexDebugModels
|
|
// against a shell-script stand-in for `codex` that records its argv to
|
|
// a file and prints a minimal valid JSON payload. The check is on what
|
|
// the binary actually received (one argument per element, no merging
|
|
// or splitting), not just the package var — the original bug surfaced
|
|
// because a real codex saw `--output json` as two extra unknown args.
|
|
func TestRunCodexDebugModels_ArgvSeenByBinary(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("shell-script fake binary requires a POSIX shell")
|
|
}
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
argvFile := filepath.Join(dir, "argv.txt")
|
|
fake := filepath.Join(dir, "codex")
|
|
script := "#!/bin/sh\n" +
|
|
"printf '%s\\n' \"$@\" > '" + argvFile + "'\n" +
|
|
"echo '{\"models\":[]}'\n"
|
|
if err := os.WriteFile(fake, []byte(script), 0o755); err != nil {
|
|
t.Fatalf("write fake codex: %v", err)
|
|
}
|
|
|
|
raw, err := runCodexDebugModels(context.Background(), fake)
|
|
if err != nil {
|
|
t.Fatalf("runCodexDebugModels: %v (output=%q)", err, raw)
|
|
}
|
|
|
|
data, err := os.ReadFile(argvFile)
|
|
if err != nil {
|
|
t.Fatalf("read argv file: %v", err)
|
|
}
|
|
got := splitNonEmptyLines(string(data))
|
|
want := []string{"debug", "models", "--bundled"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("fake codex received argv %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func splitNonEmptyLines(s string) []string {
|
|
var out []string
|
|
start := 0
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == '\n' {
|
|
if i > start {
|
|
out = append(out, s[start:i])
|
|
}
|
|
start = i + 1
|
|
}
|
|
}
|
|
if start < len(s) {
|
|
out = append(out, s[start:])
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ── Codex debug models JSON parsing ──────────────────────────────────
|
|
|
|
func TestParseCodexDebugModels(t *testing.T) {
|
|
t.Parallel()
|
|
raw := []byte(`{
|
|
"models": [
|
|
{
|
|
"slug": "gpt-5.5",
|
|
"default_reasoning_level": "medium",
|
|
"supported_reasoning_levels": [
|
|
{"effort": "low", "description": "Fast"},
|
|
{"effort": "medium", "description": "Balanced"},
|
|
{"effort": "high", "description": "Deeper"},
|
|
{"effort": "xhigh", "description": "Maximum"}
|
|
]
|
|
},
|
|
{
|
|
"slug": "gpt-5",
|
|
"default_reasoning_level": "low",
|
|
"supported_reasoning_levels": [
|
|
{"effort": "minimal", "description": "Quick"},
|
|
{"effort": "low", "description": "Fast"}
|
|
]
|
|
},
|
|
{
|
|
"slug": "no-reasoning",
|
|
"supported_reasoning_levels": []
|
|
}
|
|
]
|
|
}`)
|
|
got := parseCodexDebugModels(raw)
|
|
|
|
gpt55, ok := got["gpt-5.5"]
|
|
if !ok || gpt55 == nil {
|
|
t.Fatalf("missing gpt-5.5 entry: %+v", got)
|
|
}
|
|
if gpt55.DefaultLevel != "medium" {
|
|
t.Errorf("gpt-5.5 default: got %q, want medium", gpt55.DefaultLevel)
|
|
}
|
|
if len(gpt55.SupportedLevels) != 4 {
|
|
t.Errorf("gpt-5.5 supported count: got %d, want 4", len(gpt55.SupportedLevels))
|
|
}
|
|
// Labels should come from codexEffortLabel mapping, not from raw effort.
|
|
for _, lvl := range gpt55.SupportedLevels {
|
|
if lvl.Value == "xhigh" && lvl.Label != "Extra high" {
|
|
t.Errorf("xhigh label: got %q, want Extra high", lvl.Label)
|
|
}
|
|
}
|
|
|
|
gpt5, ok := got["gpt-5"]
|
|
if !ok || gpt5 == nil {
|
|
t.Fatalf("missing gpt-5 entry: %+v", got)
|
|
}
|
|
if gpt5.DefaultLevel != "low" {
|
|
t.Errorf("gpt-5 default: got %q, want low", gpt5.DefaultLevel)
|
|
}
|
|
|
|
// Models with empty supported_reasoning_levels should be omitted to
|
|
// keep the wire payload small and avoid rendering empty pickers.
|
|
if _, ok := got["no-reasoning"]; ok {
|
|
t.Errorf("no-reasoning should be omitted, got %+v", got["no-reasoning"])
|
|
}
|
|
}
|
|
|
|
func TestParseCodexDebugModels_Malformed(t *testing.T) {
|
|
t.Parallel()
|
|
got := parseCodexDebugModels([]byte("not json"))
|
|
if len(got) != 0 {
|
|
t.Fatalf("expected empty map on malformed input, got %+v", got)
|
|
}
|
|
}
|
|
|
|
// ── IsKnownThinkingValue (server-side enum gate) ─────────────────────
|
|
|
|
func TestIsKnownThinkingValue(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
provider string
|
|
value string
|
|
want bool
|
|
}{
|
|
{"claude", "", true},
|
|
{"claude", "low", true},
|
|
{"claude", "xhigh", true},
|
|
{"claude", "max", true},
|
|
{"claude", "none", false}, // Codex-only token rejected for Claude
|
|
{"codex", "", true},
|
|
{"codex", "none", true},
|
|
{"codex", "minimal", true},
|
|
{"codex", "xhigh", true},
|
|
{"codex", "max", false}, // Claude-only token rejected for Codex
|
|
{"hermes", "", true},
|
|
{"hermes", "low", false}, // hermes has no thinking concept
|
|
}
|
|
for _, tc := range tests {
|
|
if got := IsKnownThinkingValue(tc.provider, tc.value); got != tc.want {
|
|
t.Errorf("IsKnownThinkingValue(%q, %q) = %v, want %v",
|
|
tc.provider, tc.value, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── ValidateThinkingLevel default-model handling ─────────────────────
|
|
//
|
|
// Elon's PR1 review called out that an empty model on a default-model
|
|
// task must not be misjudged as "unknown model → reject". The fix is to
|
|
// resolve empty model to the catalog's default entry inside the
|
|
// validator. Both the daemon's per-model guard and the server's API
|
|
// layer call this; if it gets default-model wrong, any agent without an
|
|
// explicit model set would have its thinking_level dropped silently.
|
|
|
|
func TestValidateThinkingLevel_EmptyModelResolvesToDefault(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("shell-script fake binary requires a POSIX shell")
|
|
}
|
|
t.Parallel()
|
|
|
|
// We need a `claude` whose --help advertises the full superset
|
|
// (low/medium/high/xhigh/max) so per-model projection actually has
|
|
// something to filter. A non-existent path falls back to a conservative
|
|
// [low,medium,high] which would hide the per-model behaviour we're
|
|
// trying to verify.
|
|
fakeClaude := writeFakeClaudeHelpBinary(t)
|
|
resetThinkingCacheForTests()
|
|
defer resetThinkingCacheForTests()
|
|
|
|
ctx := context.Background()
|
|
|
|
t.Run("valid level on default model passes", func(t *testing.T) {
|
|
// Claude's catalog flags Sonnet 4.6 as Default. Sonnet supports
|
|
// low/medium/high/max (no xhigh) per claudeModelEffortAllow, so
|
|
// "high" must round-trip when model is left empty.
|
|
ok, err := ValidateThinkingLevel(ctx, "claude", fakeClaude, "", "high")
|
|
if err != nil {
|
|
t.Fatalf("unexpected err: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Errorf("default-model high should be valid for claude; got false")
|
|
}
|
|
})
|
|
|
|
t.Run("invalid level on default model fails", func(t *testing.T) {
|
|
// "xhigh" is opus-only; resolving "" to default (sonnet 4.6)
|
|
// should reject it, not silently accept.
|
|
ok, err := ValidateThinkingLevel(ctx, "claude", fakeClaude, "", "xhigh")
|
|
if err != nil {
|
|
t.Fatalf("unexpected err: %v", err)
|
|
}
|
|
if ok {
|
|
t.Errorf("xhigh should be invalid on sonnet (the default model); got true")
|
|
}
|
|
})
|
|
|
|
t.Run("empty value always valid", func(t *testing.T) {
|
|
// Empty value means "use runtime default" — should pass
|
|
// regardless of model resolution.
|
|
ok, err := ValidateThinkingLevel(ctx, "claude", fakeClaude, "", "")
|
|
if err != nil {
|
|
t.Fatalf("unexpected err: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Errorf("empty value must always be valid")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestValidateThinkingLevel_ExplicitModel(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("shell-script fake binary requires a POSIX shell")
|
|
}
|
|
t.Parallel()
|
|
fakeClaude := writeFakeClaudeHelpBinary(t)
|
|
resetThinkingCacheForTests()
|
|
defer resetThinkingCacheForTests()
|
|
|
|
ctx := context.Background()
|
|
|
|
// xhigh IS valid on Opus 4.7.
|
|
ok, err := ValidateThinkingLevel(ctx, "claude", fakeClaude, "claude-opus-4-7", "xhigh")
|
|
if err != nil {
|
|
t.Fatalf("unexpected err: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Errorf("xhigh should be valid on opus-4-7; got false")
|
|
}
|
|
|
|
// xhigh is NOT valid on Sonnet — should fail.
|
|
ok, err = ValidateThinkingLevel(ctx, "claude", fakeClaude, "claude-sonnet-4-6", "xhigh")
|
|
if err != nil {
|
|
t.Fatalf("unexpected err: %v", err)
|
|
}
|
|
if ok {
|
|
t.Errorf("xhigh must not be valid on sonnet-4-6; got true")
|
|
}
|
|
|
|
// An unknown model with a valid token still fails closed (no guess).
|
|
ok, err = ValidateThinkingLevel(ctx, "claude", fakeClaude, "claude-nonexistent", "high")
|
|
if err != nil {
|
|
t.Fatalf("unexpected err: %v", err)
|
|
}
|
|
if ok {
|
|
t.Errorf("unknown model must fail closed; got true")
|
|
}
|
|
}
|
|
|
|
// writeFakeClaudeHelpBinary writes a small shell script that mimics
|
|
// `claude --help`, emitting the full effort superset line so per-model
|
|
// projection has something to filter. Returns the path to the executable.
|
|
func writeFakeClaudeHelpBinary(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "claude")
|
|
script := "#!/bin/sh\n" +
|
|
"cat <<'EOF'\n" +
|
|
"Usage: claude [options]\n" +
|
|
"\n" +
|
|
"Options:\n" +
|
|
" --model <model> Model to use\n" +
|
|
" --effort <level> Effort level for the current session (low, medium, high, xhigh, max)\n" +
|
|
"EOF\n"
|
|
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
|
t.Fatalf("write fake claude: %v", err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
// ── Cache key invalidation ───────────────────────────────────────────
|
|
|
|
func TestThinkingCacheKeyDistinct(t *testing.T) {
|
|
t.Parallel()
|
|
resetThinkingCacheForTests()
|
|
defer resetThinkingCacheForTests()
|
|
|
|
a := thinkingCacheKey{provider: "claude", executablePath: "/bin/claude", cliVersion: "2.1.121"}
|
|
b := thinkingCacheKey{provider: "claude", executablePath: "/bin/claude", cliVersion: "2.1.122"}
|
|
c := thinkingCacheKey{provider: "claude", executablePath: "/opt/claude", cliVersion: "2.1.121"}
|
|
|
|
thinkingCachePut(a, map[string]*ModelThinking{"x": {DefaultLevel: "a"}})
|
|
thinkingCachePut(b, map[string]*ModelThinking{"x": {DefaultLevel: "b"}})
|
|
thinkingCachePut(c, map[string]*ModelThinking{"x": {DefaultLevel: "c"}})
|
|
|
|
if got, _ := thinkingCacheGet(a); got["x"].DefaultLevel != "a" {
|
|
t.Errorf("cache key A: got %q, want a", got["x"].DefaultLevel)
|
|
}
|
|
if got, _ := thinkingCacheGet(b); got["x"].DefaultLevel != "b" {
|
|
t.Errorf("cache key B: got %q, want b", got["x"].DefaultLevel)
|
|
}
|
|
if got, _ := thinkingCacheGet(c); got["x"].DefaultLevel != "c" {
|
|
t.Errorf("cache key C: got %q, want c", got["x"].DefaultLevel)
|
|
}
|
|
}
|
|
|
|
// ── Shared injection fixture (Trump's MUL-2339 constraint) ───────────
|
|
//
|
|
// The three Codex injection points (thread/start.config,
|
|
// thread/resume.config, turn/start.effort) must encode the same
|
|
// thinking_level value, in the same shape per call type, with no
|
|
// drift. This fixture defines the expected payload once and asserts
|
|
// it across all three sites so a future refactor of any one site
|
|
// breaks the test if the other two aren't kept in sync.
|
|
|
|
// codexReasoningInjection is the shared expectation table for the
|
|
// three Codex injection points. value→{turnStartEffort, configKey}.
|
|
// One row per scenario.
|
|
type codexReasoningCase struct {
|
|
name string
|
|
level string
|
|
}
|
|
|
|
var codexReasoningCases = []codexReasoningCase{
|
|
{"empty-level-is-noop", ""},
|
|
{"low", "low"},
|
|
{"medium", "medium"},
|
|
{"high", "high"},
|
|
{"xhigh", "xhigh"},
|
|
{"none-codex-only", "none"},
|
|
}
|
|
|
|
func TestApplyCodexReasoningEffort_ThreePoints(t *testing.T) {
|
|
t.Parallel()
|
|
for _, tc := range codexReasoningCases {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
// 1. thread/start params shape.
|
|
startParams := map[string]any{
|
|
"model": "gpt-5.5",
|
|
"cwd": "/work",
|
|
}
|
|
applyCodexReasoningEffort(startParams, tc.level)
|
|
assertCodexThreadConfigEffort(t, "thread/start", startParams, tc.level)
|
|
|
|
// 2. thread/resume params shape.
|
|
resumeParams := map[string]any{
|
|
"threadId": "thr_prior",
|
|
"cwd": "/work",
|
|
"model": "gpt-5.5",
|
|
}
|
|
applyCodexReasoningEffort(resumeParams, tc.level)
|
|
assertCodexThreadConfigEffort(t, "thread/resume", resumeParams, tc.level)
|
|
|
|
// 3. turn/start params shape.
|
|
turnParams := map[string]any{
|
|
"threadId": "thr_x",
|
|
"input": []map[string]any{{"type": "text", "text": "hi"}},
|
|
}
|
|
applyCodexReasoningEffort(turnParams, tc.level)
|
|
assertCodexTurnEffort(t, "turn/start", turnParams, tc.level)
|
|
})
|
|
}
|
|
}
|
|
|
|
// assertCodexThreadConfigEffort verifies the nested
|
|
// `config.model_reasoning_effort` shape used by thread/start and
|
|
// thread/resume. Empty level means the helper must be a no-op
|
|
// (no key emitted), not an empty-string value.
|
|
func assertCodexThreadConfigEffort(t *testing.T, method string, params map[string]any, want string) {
|
|
t.Helper()
|
|
cfgAny, hasCfg := params["config"]
|
|
if want == "" {
|
|
// Empty level → helper must not touch `config`. We allow the
|
|
// caller to have pre-populated config with other keys, but the
|
|
// reasoning effort key must NOT appear.
|
|
if !hasCfg {
|
|
return
|
|
}
|
|
cfg, _ := cfgAny.(map[string]any)
|
|
if _, has := cfg["model_reasoning_effort"]; has {
|
|
t.Errorf("%s: empty level must not emit model_reasoning_effort, got %v", method, cfg["model_reasoning_effort"])
|
|
}
|
|
return
|
|
}
|
|
if !hasCfg {
|
|
t.Fatalf("%s: expected config block when level=%q", method, want)
|
|
}
|
|
cfg, ok := cfgAny.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("%s: config has wrong type %T", method, cfgAny)
|
|
}
|
|
got, ok := cfg["model_reasoning_effort"]
|
|
if !ok {
|
|
t.Fatalf("%s: missing config.model_reasoning_effort for level=%q (params=%+v)", method, want, params)
|
|
}
|
|
if got != want {
|
|
t.Errorf("%s: config.model_reasoning_effort = %v, want %q", method, got, want)
|
|
}
|
|
// `effort` (turn/start key) must NOT leak into a thread call.
|
|
if _, leaked := params["effort"]; leaked {
|
|
t.Errorf("%s: top-level effort key leaked into thread params: %+v", method, params)
|
|
}
|
|
}
|
|
|
|
// assertCodexTurnEffort verifies the top-level `effort` shape used by
|
|
// turn/start. Empty level means the helper must be a no-op (no key
|
|
// emitted), not an empty-string value.
|
|
func assertCodexTurnEffort(t *testing.T, method string, params map[string]any, want string) {
|
|
t.Helper()
|
|
got, has := params["effort"]
|
|
if want == "" {
|
|
if has {
|
|
t.Errorf("%s: empty level must not emit effort, got %v", method, got)
|
|
}
|
|
// Nested config must also stay empty for the turn/start shape.
|
|
if cfg, hasCfg := params["config"]; hasCfg {
|
|
t.Errorf("%s: turn-shape params must not gain a config block, got %v", method, cfg)
|
|
}
|
|
return
|
|
}
|
|
if !has {
|
|
t.Fatalf("%s: missing top-level effort for level=%q (params=%+v)", method, want, params)
|
|
}
|
|
if got != want {
|
|
t.Errorf("%s: effort = %v, want %q", method, got, want)
|
|
}
|
|
// `config.model_reasoning_effort` must NOT leak into a turn call.
|
|
if cfg, hasCfg := params["config"]; hasCfg {
|
|
cfgMap, _ := cfg.(map[string]any)
|
|
if _, leaked := cfgMap["model_reasoning_effort"]; leaked {
|
|
t.Errorf("%s: config.model_reasoning_effort leaked into turn params: %+v", method, params)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestApplyCodexReasoningEffort_NilParamsSafe(t *testing.T) {
|
|
t.Parallel()
|
|
// Must not panic — defensive against future call sites passing nil.
|
|
applyCodexReasoningEffort(nil, "high")
|
|
}
|
|
|
|
func TestApplyCodexReasoningEffort_PreservesPreExistingConfig(t *testing.T) {
|
|
t.Parallel()
|
|
// thread/start may already have other config keys (e.g. future Codex
|
|
// fields). Reasoning effort must be additive, not destructive.
|
|
startParams := map[string]any{
|
|
"model": "gpt-5.5",
|
|
"config": map[string]any{
|
|
"some_future_key": "preserve_me",
|
|
},
|
|
}
|
|
applyCodexReasoningEffort(startParams, "high")
|
|
cfg, _ := startParams["config"].(map[string]any)
|
|
if cfg["some_future_key"] != "preserve_me" {
|
|
t.Errorf("pre-existing config key was clobbered: %+v", cfg)
|
|
}
|
|
if cfg["model_reasoning_effort"] != "high" {
|
|
t.Errorf("reasoning effort not injected: %+v", cfg)
|
|
}
|
|
}
|
|
|
|
// ── End-to-end: build*Args + thinking_level wiring ───────────────────
|
|
|
|
func TestBuildClaudeArgs_InjectsEffort(t *testing.T) {
|
|
t.Parallel()
|
|
args := buildClaudeArgs(ExecOptions{Model: "claude-opus-4-7", ThinkingLevel: "xhigh"}, slog.Default())
|
|
if !containsAdjacent(args, "--effort", "xhigh") {
|
|
t.Errorf("expected --effort xhigh in args: %v", args)
|
|
}
|
|
// Must appear after --model (cosmetic but enforced for log readability).
|
|
modelIdx := argIndexOf(args, "--model")
|
|
effortIdx := argIndexOf(args, "--effort")
|
|
if modelIdx < 0 || effortIdx < 0 || modelIdx > effortIdx {
|
|
t.Errorf("expected --model before --effort: %v", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildClaudeArgs_OmitsEffortWhenEmpty(t *testing.T) {
|
|
t.Parallel()
|
|
args := buildClaudeArgs(ExecOptions{Model: "claude-sonnet-4-6"}, slog.Default())
|
|
if argIndexOf(args, "--effort") >= 0 {
|
|
t.Errorf("expected no --effort when level empty: %v", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildClaudeArgs_BlocksUserEffortOverride(t *testing.T) {
|
|
t.Parallel()
|
|
args := buildClaudeArgs(ExecOptions{
|
|
Model: "claude-opus-4-7",
|
|
ThinkingLevel: "high",
|
|
CustomArgs: []string{"--effort", "max", "--keep-me"},
|
|
}, slog.Default())
|
|
// Daemon-injected --effort survives.
|
|
if !containsAdjacent(args, "--effort", "high") {
|
|
t.Errorf("daemon-injected --effort high should remain: %v", args)
|
|
}
|
|
// User attempt to override is filtered out: no second --effort,
|
|
// no `max` token.
|
|
count := 0
|
|
for _, a := range args {
|
|
if a == "--effort" {
|
|
count++
|
|
}
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("expected exactly one --effort, got %d: %v", count, args)
|
|
}
|
|
if argIndexOf(args, "max") >= 0 {
|
|
t.Errorf("filtered user --effort value still appears: %v", args)
|
|
}
|
|
// Other custom args pass through.
|
|
if argIndexOf(args, "--keep-me") < 0 {
|
|
t.Errorf("non-blocked custom arg was dropped: %v", args)
|
|
}
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
func containsAdjacent(haystack []string, a, b string) bool {
|
|
for i := 0; i < len(haystack)-1; i++ {
|
|
if haystack[i] == a && haystack[i+1] == b {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func argIndexOf(slice []string, target string) int {
|
|
for i, v := range slice {
|
|
if v == target {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|