From 5e10d4d5a9858c3f1c638d9db1ae95f582ac9017 Mon Sep 17 00:00:00 2001 From: Alvaro Saurin Date: Sun, 5 Jul 2026 21:28:28 +0200 Subject: [PATCH 001/240] fix(acpproc): widen set_model retry schedule for large-context model warm-up (mitto-8qp) Resolves RPC context-deadline storms (~157/day, sonnet-5) caused by an aggressive shrinking retry budget that timed out during large-context model warm-up. The prior attempt-1 timeout (12s) was smaller than the observed warm-up latency of large-context models (e.g. claude-sonnet-5-0-500k, >12s), so every attempt of the shrinking {12s,8s,5s} schedule timed out even though most of the outer 90s setModelAsyncCallerBudget sat unused. Widen the per-attempt schedule to {20s,15s,8s} (~44s total per caller), which still stays within the contention bound covered by setModelAsyncCallerBudget at up to 4 concurrent callers. Update all mirrored comments/docs describing the old 25s math, and add a cross-package mirrored-constants-drift convention note so future schedule changes remember to update internal/conversation's local mirror. Add TestSetModelSchedule_LargeContextModelSucceedsWithinOuterBudget to internal/acpproc/acp_process_manager_test.go. --- .augment/rules/01-go-conventions.md | 6 ++ internal/acpproc/acp_process_manager.go | 4 +- internal/acpproc/acp_process_manager_test.go | 98 ++++++++++++++++++-- internal/acpproc/shared_acp_process.go | 37 ++++---- internal/conversation/constraints_test.go | 5 +- 5 files changed, 122 insertions(+), 28 deletions(-) diff --git a/.augment/rules/01-go-conventions.md b/.augment/rules/01-go-conventions.md index 9fb096822..647be22f9 100644 --- a/.augment/rules/01-go-conventions.md +++ b/.augment/rules/01-go-conventions.md @@ -81,6 +81,12 @@ for attempt := 1; attempt <= maxAttempts; attempt++ { **Key principle**: Only *tighten* deadlines, never extend. `shouldFailFastCreateAttempt` bails when remaining budget < per-attempt timeout. +### Cross-Package Mirrored Constants Drift + +When a package can't import another (e.g. `internal/conversation` can't import `internal/acpproc`), it may hardcode a **local mirror** of a schedule/budget constant, flagged with a comment like `// Mirror of shared_acp_process.go set_model constants` (see `internal/conversation/constraints_test.go`'s `scheduleSum`). Changing the source constant does **not** auto-update the mirror. + +**Rule**: When changing a retry schedule/timeout constant, `grep` for comments referencing it (e.g. the old sum/values) across the whole module — not just the owning package — and update every mirror + stale doc comment describing the old math in the same change. + ## Explicit Lock Management in Retry Loops `defer mu.Unlock()` does **not** compose safely with manual unlock + retry. If the locked variable is reassigned during retry, defer fires on the wrong object → double-unlock panic. diff --git a/internal/acpproc/acp_process_manager.go b/internal/acpproc/acp_process_manager.go index bf2e7917b..21195eded 100644 --- a/internal/acpproc/acp_process_manager.go +++ b/internal/acpproc/acp_process_manager.go @@ -856,8 +856,8 @@ func (m *ACPProcessManager) getOrCreateAuxiliarySession(ctx context.Context, wor // Budget: setModelAsyncCallerBudget (90s) derived from m.ctx (NOT the caller // ctx, which is short-lived and may expire before the goroutine runs). // Worst-case: setModelSem queued behind ~3 other holders each taking up to - // 3×8s + jitter backoff (≤25s each) → ~75s wait before the semaphore is - // acquired. Since this is off the critical path, a generous budget has no + // the schedule {20s,15s,8s} + jitter backoff (~44s each) before the semaphore + // is acquired. Since this is off the critical path, a generous budget has no // UX cost. m.ctx cancels on manager shutdown as a safety backstop. capturedWorkspaceUUID := workspaceUUID capturedPurpose := purpose diff --git a/internal/acpproc/acp_process_manager_test.go b/internal/acpproc/acp_process_manager_test.go index c61b1b5a5..a8ad4b982 100644 --- a/internal/acpproc/acp_process_manager_test.go +++ b/internal/acpproc/acp_process_manager_test.go @@ -808,8 +808,9 @@ func TestSetModelAsyncBudgetMath(t *testing.T) { } // TestSetModelAttemptTimeoutSchedule asserts structural invariants of the per-attempt -// deadline schedule (mitto-f7q): length tied to max-attempts, attempt-1 sized for cold -// warm-up, total ≤ 25s (unchanged from prior 3×8s), and non-increasing order. +// deadline schedule (mitto-f7q; attempt-1 lower bound raised for mitto-8qp): length tied +// to max-attempts, attempt-1 sized above large-context warm-up latency, total bounded so +// setModelAsyncCallerBudget contention math stays valid, and non-increasing order. func TestSetModelAttemptTimeoutSchedule(t *testing.T) { schedule := setSessionModelAttemptTimeouts @@ -818,18 +819,31 @@ func TestSetModelAttemptTimeoutSchedule(t *testing.T) { got, setSessionModelMaxAttempts) } - // Attempt-1 must be sized above the observed 8s cold-model clamp (p95 evidence). - if schedule[0] < 12*time.Second { - t.Errorf("attempt-1 timeout = %v, want >= 12s (sized for cold warm-up p95)", schedule[0]) + // Attempt-1 must be sized above the genuine warm-up latency of large-context models + // (e.g. claude-sonnet-5-0-500k, observed >12s). The prior 12s bound was smaller than + // that latency, so attempt-1 always timed out and the shrinking retries were guaranteed + // to fail (mitto-8qp). 16s leaves headroom above the observed 12s+ warm-up. + if schedule[0] < 16*time.Second { + t.Errorf("attempt-1 timeout = %v, want >= 16s (sized above large-context warm-up, mitto-8qp)", schedule[0]) } - // Total must not exceed 25s so setModelAsyncCallerBudget contention math is valid. + // The schedule sum must stay within the contention bound the async budget can cover + // (derived exactly as in TestSetModelAsyncBudgetMath): with N=4 concurrent callers the + // expected contention coverage is (N-2)×(scheduleSum + maxJitteredBackoff), and that + // must not exceed setModelAsyncCallerBudget. Rearranged: scheduleSum must not exceed + // budget/(N-2) − maxJitteredBackoff. This replaces the old fixed 25s cap, which forced + // attempt-1 too small to cover real warm-up latency (mitto-8qp). + const maxConcurrentCallers = 4 + maxJitteredBackoff := time.Duration(float64(setSessionModelRetryBaseDelay)*float64(setSessionModelMaxAttempts-1)*(1+setSessionModelRetryJitterRatio)) + setSessionModelRetryBaseDelay + maxScheduleSum := setModelAsyncCallerBudget/time.Duration(maxConcurrentCallers-2) - maxJitteredBackoff + var total time.Duration for _, d := range schedule { total += d } - if total > 25*time.Second { - t.Errorf("sum(setSessionModelAttemptTimeouts) = %v, want <= 25s (total must not grow)", total) + if total > maxScheduleSum { + t.Errorf("sum(setSessionModelAttemptTimeouts) = %v, want <= %v (contention bound; setModelAsyncCallerBudget math)", + total, maxScheduleSum) } // Timeouts must be non-increasing (front-loaded for cold start). @@ -841,6 +855,74 @@ func TestSetModelAttemptTimeoutSchedule(t *testing.T) { } } +// simulateSetModelRetryLoop mirrors the per-attempt deadline decision of +// SharedACPProcess.SetSessionModel: each attempt is granted setSessionModelAttemptTimeouts[i] +// of budget, an attempt "succeeds" only when the model's real warm-up latency fits within +// that attempt's budget, and the whole call must complete within outerBudget. It returns +// the 1-based attempt number that succeeded, or 0 if all attempts timed out / the outer +// budget was exhausted. Pure (no real sleeps) so the schedule's behavioural contract can +// be unit-tested deterministically. +func simulateSetModelRetryLoop(rpcLatency, outerBudget time.Duration) (succeededAttempt int) { + var elapsed time.Duration + for attempt := 1; attempt <= setSessionModelMaxAttempts; attempt++ { + perAttempt := setSessionModelAttemptTimeouts[attempt-1] + // The attempt is bounded by both its own per-attempt deadline and the remaining + // outer budget (context.WithTimeout(ctx, perAttempt) with ctx carrying outerBudget). + remaining := outerBudget - elapsed + if remaining <= 0 { + return 0 + } + effective := perAttempt + if remaining < effective { + effective = remaining + } + if rpcLatency <= effective { + return attempt // RPC completed within this attempt's budget. + } + // Attempt timed out after consuming its effective budget. + elapsed += effective + } + return 0 +} + +// TestSetModelSchedule_LargeContextModelSucceedsWithinOuterBudget reproduces mitto-8qp: +// the "Aux set_model RPC context-deadline storm". A large-context model (e.g. +// claude-sonnet-5-0-500k) has a genuine set_model warm-up latency that exceeds attempt-1's +// budget. Because the schedule SHRINKS (12s -> 8s -> 5s), every subsequent retry has an +// even smaller budget than the first, so all three attempts are GUARANTEED to fail with +// "context deadline exceeded" — even though the outer async caller budget +// (setModelAsyncCallerBudget, ~90s) has 60-90s of unused headroom (per the bead's log +// evidence: ctx_remaining_ms stayed 68-90s throughout). +// +// Expected (correct) behaviour: a model whose warm-up latency is well within the outer +// budget must eventually succeed via retry. This test asserts that contract and therefore +// FAILS on the current shrinking schedule (no attempt is >= the model's latency) and will +// PASS once the fix widens/flattens the per-attempt schedule to cover realistic warm-up +// latency within the ample outer budget. +func TestSetModelSchedule_LargeContextModelSucceedsWithinOuterBudget(t *testing.T) { + // Observed warm-up latency for a 500k-context model per the bead's logs: attempt-1 + // consistently burned its full 12s budget (rpc_ms=12000) without completing, i.e. the + // true latency is > 12s. 13s is a conservative representative value that is still far + // below the ~90s outer async budget. + const largeModelWarmupLatency = 13 * time.Second + + if largeModelWarmupLatency >= setModelAsyncCallerBudget { + t.Fatalf("test premise invalid: model latency %v must be < outer budget %v", + largeModelWarmupLatency, setModelAsyncCallerBudget) + } + + attempt := simulateSetModelRetryLoop(largeModelWarmupLatency, setModelAsyncCallerBudget) + if attempt == 0 { + t.Fatalf("mitto-8qp reproduced: set_model for a %v-warm-up model never succeeded across "+ + "%d attempts (schedule %v), despite %v of outer budget — the shrinking per-attempt "+ + "schedule guarantees failure for models whose warm-up exceeds attempt-1's budget", + largeModelWarmupLatency, setSessionModelMaxAttempts, + setSessionModelAttemptTimeouts, setModelAsyncCallerBudget) + } + t.Logf("set_model for a %v-warm-up model succeeded on attempt %d (schedule %v, outer budget %v)", + largeModelWarmupLatency, attempt, setSessionModelAttemptTimeouts, setModelAsyncCallerBudget) +} + // TestSetModelRetryJitter verifies that the jittered backoff delay applied in // SetSessionModel's retry loop stays within the expected bounds (mitto-f7q, Option 3). // diff --git a/internal/acpproc/shared_acp_process.go b/internal/acpproc/shared_acp_process.go index 61cf31946..f11633433 100644 --- a/internal/acpproc/shared_acp_process.go +++ b/internal/acpproc/shared_acp_process.go @@ -32,8 +32,9 @@ const ( processStartRetryJitterRatio = 0.3 // setSessionModelMaxAttempts is the maximum number of set_model RPC attempts per call. - // Schedule {12s,8s,5s} totals 25s per caller + jitter (≤900ms) ≈ 25s, unchanged from - // the prior 3×8s budget — so setModelSem contention at wakeup is unaffected. + // Schedule {20s,15s,8s} totals 43s per caller + jitter (≤1.2s) ≈ 44s (attempt-1 widened + // for mitto-8qp so large-context warm-up fits). The total stays within the contention + // bound covered by setModelAsyncCallerBudget (see TestSetModelAsyncBudgetMath). setSessionModelMaxAttempts = 3 // setSessionModelRetryBaseDelay is the base backoff between set_model retry attempts. setSessionModelRetryBaseDelay = 300 * time.Millisecond @@ -41,7 +42,7 @@ const ( // added to each retry backoff. Jitter in [0, base×ratio) de-correlates concurrent callers // that would otherwise retry in lock-step (mitto-f7q, Option 3). // With ratio=0.5: attempt-2 delay ∈ [300ms, 450ms), attempt-3 ∈ [600ms, 750ms). - // Total per-caller worst-case: sum(schedule) + 750ms ≈ 25s. + // Total per-caller worst-case: sum(schedule) + ~1.2s jitter ≈ 44s. setSessionModelRetryJitterRatio = 0.5 // sessionCreateMaxAttempts is the maximum number of session/new RPC attempts per call. @@ -68,9 +69,9 @@ const ( // setModelAsyncCallerBudget is the context timeout given to the background goroutine // that performs the aux-session model switch asynchronously (mitto-f7q, Option 4). // Budget reasoning: the capacity-1 setModelSem may be held by up to ~3 concurrent callers, - // each taking at most ~25s (3×8s + jitter). Semaphore wait ≤ 3×25s = 75s; adding slack - // for our own retries gives ~100s worst-case. 90s covers the expected contention at server - // wakeup (≤4 concurrent aux sessions) while avoiding an indefinite hang if the process + // each taking at most ~44s (schedule {20s,15s,8s} + jitter). 90s covers the EXPECTED + // contention at server wakeup — (N-2)×perCallerMax with N=4, i.e. 2×~44s ≈ 88s ≤ 90s + // (see TestSetModelAsyncBudgetMath) — while avoiding an indefinite hang if the process // is unhealthy. m.ctx cancels on manager shutdown as a hard backstop. setModelAsyncCallerBudget = 90 * time.Second @@ -91,7 +92,8 @@ const ( // This mirrors the child-session de-stagger pattern (constraintModelSwitchChildStartupJitter // in internal/conversation/bgsession_config.go, introduced for mitto-x4e). The jitter // waits on m.ctx — not the budget context — so it does NOT consume the 90 s budget. - // Do NOT increase the sum of setSessionModelAttemptTimeouts — the total must stay ≈25s. + // Do NOT increase the sum of setSessionModelAttemptTimeouts beyond the contention bound + // enforced by TestSetModelAsyncBudgetMath (≈43.8s at 4 concurrent callers). auxModelSwitchStartupJitter = 10 * time.Second // processInitializeAttemptTimeout is the per-attempt deadline for the ACP Initialize @@ -133,16 +135,19 @@ const ( ) // setSessionModelAttemptTimeouts is the per-attempt deadline schedule for set_model RPCs -// (mitto-f7q). Attempt-1 is sized above the observed cold-model warm-up p95 (~8s) so a -// cold claude-haiku-4-5 can complete on the first attempt; later attempts shrink to keep -// the total (12+8+5 = 25s) ≈ constant vs the prior 3×8s, leaving setModelSem contention -// unchanged. The array length is tied to setSessionModelMaxAttempts at compile time. -// Do NOT increase the sum — the total must remain ≈25s so setModelAsyncCallerBudget (90s) -// contention math stays valid. +// (mitto-f7q; attempt-1 widened for mitto-8qp). Attempt-1 is sized above the genuine +// warm-up latency of large-context models (e.g. claude-sonnet-5-0-500k, observed >12s): +// the prior 12s attempt-1 was smaller than that latency, so every attempt of the +// then-shrinking 12/8/5 schedule timed out with "context deadline exceeded" even though +// ~60-90s of the outer setModelAsyncCallerBudget (90s) sat unused (mitto-8qp). Later +// attempts shrink to keep the total bounded so setModelSem contention stays covered by the +// async budget. The array length is tied to setSessionModelMaxAttempts at compile time. +// Do NOT let the sum exceed the contention bound enforced by TestSetModelAsyncBudgetMath +// (≈43.8s at 4 concurrent callers) or setModelAsyncCallerBudget (90s) math stops holding. var setSessionModelAttemptTimeouts = [setSessionModelMaxAttempts]time.Duration{ - 12 * time.Second, // attempt 1: sized for cold-model warm-up p95 - 8 * time.Second, // attempt 2: standard - 5 * time.Second, // attempt 3: final, minimal budget + 20 * time.Second, // attempt 1: sized for large-context model warm-up (mitto-8qp) + 15 * time.Second, // attempt 2: standard retry + 8 * time.Second, // attempt 3: final, minimal budget } // auxStartupJitter returns a random duration in [0, max) to de-stagger concurrent diff --git a/internal/conversation/constraints_test.go b/internal/conversation/constraints_test.go index f49a07861..573354ea0 100644 --- a/internal/conversation/constraints_test.go +++ b/internal/conversation/constraints_test.go @@ -213,9 +213,10 @@ func TestConstraintModelSwitchBudgetMath(t *testing.T) { const ( maxConcurrentCallers = 4 // from bead: ~4 concurrent sessions at wakeup // Mirror of internal/acpproc/shared_acp_process.go set_model constants. - // Attempt schedule {12s,8s,5s} sums to 25s — same total as the prior 3×8s (mitto-f7q). + // Attempt schedule {20s,15s,8s} sums to 43s (attempt-1 widened for mitto-8qp so + // large-context warm-up fits, within the contention bound covered by the budget). maxRetries = 3 // setSessionModelMaxAttempts - scheduleSum = 25 * time.Second // sum(setSessionModelAttemptTimeouts) + scheduleSum = 43 * time.Second // sum(setSessionModelAttemptTimeouts) retryBaseDelay = 300 * time.Millisecond // setSessionModelRetryBaseDelay retryJitterRatio = 0.5 // setSessionModelRetryJitterRatio ) From af118245913027ca8d50c9a3430acab0ebdafe95 Mon Sep 17 00:00:00 2001 From: Alvaro Saurin Date: Sun, 5 Jul 2026 21:28:41 +0200 Subject: [PATCH 002/240] fix(agents): correct mcp-list.sh config paths for claude-code, codex, cline (mitto-llr) Audit of vendor-specific MCP server discovery scripts found suspicious config paths causing missing MCP servers in agent discovery. Corrects claude-code (now reads ~/.claude.json) and fixes codex and cline discovery scripts. Add TestMCPList_RealConfigPaths_mitto_llr table-driven regression test in internal/agents/manager_test.go. --- .../builtin/claude-code/cmds/mcp-list.sh | 101 ++++++--- config/agents/builtin/cline/cmds/mcp-list.sh | 103 ++++++--- config/agents/builtin/codex/cmds/mcp-list.sh | 199 +++++++++++++++--- internal/agents/manager_test.go | 100 +++++++++ 4 files changed, 423 insertions(+), 80 deletions(-) diff --git a/config/agents/builtin/claude-code/cmds/mcp-list.sh b/config/agents/builtin/claude-code/cmds/mcp-list.sh index ac409b52e..e218f1fec 100755 --- a/config/agents/builtin/claude-code/cmds/mcp-list.sh +++ b/config/agents/builtin/claude-code/cmds/mcp-list.sh @@ -3,35 +3,84 @@ # Input: {"path": "/optional/workspace/path"} (optional, via stdin) # Output: {"servers": [{"name": "...", "command": "...", "args": [...], "url": "...", "env": {...}}]} +# Claude Code stores MCP servers under "mcpServers" in: +# user: ~/.claude.json (top-level mcpServers) +# local: ~/.claude.json (per-project entry: projects..mcpServers) +# project: /.mcp.json (top-level mcpServers, shared/checked-in) +# NOTE: ~/.claude/settings.json is NOT read for mcpServers (silently ignored by Claude Code). +# Later scopes override earlier ones by server name. + INPUT=$(cat 2>/dev/null || echo '{}') -CONFIG_FILE="${HOME}/.claude/settings.json" -if [ ! -f "$CONFIG_FILE" ]; then - echo '{"servers": []}' - exit 0 +# Extract optional workspace path from input +WORKSPACE_PATH=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('path',''))" 2>/dev/null) + +USER_CONFIG="$HOME/.claude.json" +PROJECT_CONFIG="" +if [ -n "$WORKSPACE_PATH" ]; then + PROJECT_CONFIG="$WORKSPACE_PATH/.mcp.json" fi +# Merge mcpServers from all scopes (paths passed via env to avoid quoting issues). +MITTO_USER_CONFIG="$USER_CONFIG" \ +MITTO_PROJECT_CONFIG="$PROJECT_CONFIG" \ +MITTO_WORKSPACE_PATH="$WORKSPACE_PATH" \ python3 -c " -import json, sys -try: - with open('$CONFIG_FILE') as f: - config = json.load(f) - servers = config.get('mcpServers', {}) - result = [] - for name, cfg in servers.items(): - entry = {'name': name} - if 'command' in cfg: - entry['command'] = cfg['command'] - if 'args' in cfg: - entry['args'] = cfg['args'] - if 'url' in cfg: - entry['url'] = cfg['url'] - if 'env' in cfg: - entry['env'] = cfg['env'] - if 'headers' in cfg: - entry['headers'] = cfg['headers'] - result.append(entry) - print(json.dumps({'servers': result})) -except Exception: - print(json.dumps({'servers': []})) +import json, os + +def load_json(path): + if not path or not os.path.isfile(path): + return None + try: + with open(path) as f: + return json.load(f) + except Exception: + return None + +def servers_of(data): + if not isinstance(data, dict): + return {} + servers = data.get('mcpServers', {}) + return servers if isinstance(servers, dict) else {} + +merged = {} + +# 1) user scope: top-level mcpServers in ~/.claude.json +user_data = load_json(os.environ.get('MITTO_USER_CONFIG', '')) +for name, cfg in servers_of(user_data).items(): + if isinstance(cfg, dict): + merged[name] = cfg + +# 2) local scope: per-project mcpServers keyed by workspace path in ~/.claude.json +ws = os.environ.get('MITTO_WORKSPACE_PATH', '') +if ws and isinstance(user_data, dict): + projects = user_data.get('projects', {}) + if isinstance(projects, dict): + proj = projects.get(ws, {}) + for name, cfg in servers_of(proj).items(): + if isinstance(cfg, dict): + merged[name] = cfg + +# 3) project scope: /.mcp.json +proj_data = load_json(os.environ.get('MITTO_PROJECT_CONFIG', '')) +for name, cfg in servers_of(proj_data).items(): + if isinstance(cfg, dict): + merged[name] = cfg + +result = [] +for name, cfg in merged.items(): + entry = {'name': name} + if cfg.get('command'): + entry['command'] = cfg['command'] + if cfg.get('args'): + entry['args'] = cfg['args'] + if cfg.get('url'): + entry['url'] = cfg['url'] + if cfg.get('env'): + entry['env'] = cfg['env'] + if cfg.get('headers'): + entry['headers'] = cfg['headers'] + result.append(entry) + +print(json.dumps({'servers': result})) " diff --git a/config/agents/builtin/cline/cmds/mcp-list.sh b/config/agents/builtin/cline/cmds/mcp-list.sh index b8cfa2c74..d5d20df55 100755 --- a/config/agents/builtin/cline/cmds/mcp-list.sh +++ b/config/agents/builtin/cline/cmds/mcp-list.sh @@ -3,35 +3,82 @@ # Input: {"path": "/optional/workspace/path"} (optional, via stdin) # Output: {"servers": [{"name": "...", "command": "...", "args": [...], "url": "...", "env": {...}}]} -INPUT=$(cat 2>/dev/null || echo '{}') -CONFIG_FILE="${HOME}/.cline/mcp_settings.json" +# Cline (VSCode extension "saoudrizwan.claude-dev") stores MCP servers under +# "mcpServers" in its globalStorage settings file. Location is OS-specific: +# macOS: ~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json +# Linux: ~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json +# Windows: %APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json +# CLI/SDK variant: ~/.cline/data/settings/cline_mcp_settings.json +# Overrides honored: CLINE_MCP_SETTINGS_PATH (full file path), CLINE_DIR (base dir). +# The first existing candidate wins. -if [ ! -f "$CONFIG_FILE" ]; then - echo '{"servers": []}' - exit 0 -fi +INPUT=$(cat 2>/dev/null || echo '{}') python3 -c " -import json, sys -try: - with open('$CONFIG_FILE') as f: - config = json.load(f) - servers = config.get('mcpServers', {}) - result = [] - for name, cfg in servers.items(): - entry = {'name': name} - if 'command' in cfg: - entry['command'] = cfg['command'] - if 'args' in cfg: - entry['args'] = cfg['args'] - if 'url' in cfg: - entry['url'] = cfg['url'] - if 'env' in cfg: - entry['env'] = cfg['env'] - if 'headers' in cfg: - entry['headers'] = cfg['headers'] - result.append(entry) - print(json.dumps({'servers': result})) -except Exception: - print(json.dumps({'servers': []})) +import json, os, sys + +home = os.path.expanduser('~') +rel = os.path.join('saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json') + +candidates = [] + +# Explicit overrides first. +override = os.environ.get('CLINE_MCP_SETTINGS_PATH', '') +if override: + candidates.append(override) +cline_dir = os.environ.get('CLINE_DIR', '') +if cline_dir: + candidates.append(os.path.join(cline_dir, 'data', 'settings', 'cline_mcp_settings.json')) + +# OS-specific VSCode globalStorage location. +if sys.platform == 'darwin': + candidates.append(os.path.join(home, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', rel)) +elif sys.platform.startswith('win'): + appdata = os.environ.get('APPDATA', os.path.join(home, 'AppData', 'Roaming')) + candidates.append(os.path.join(appdata, 'Code', 'User', 'globalStorage', rel)) +else: + candidates.append(os.path.join(home, '.config', 'Code', 'User', 'globalStorage', rel)) + +# CLI/SDK variant. +candidates.append(os.path.join(home, '.cline', 'data', 'settings', 'cline_mcp_settings.json')) + +def load(path): + if not path or not os.path.isfile(path): + return None + try: + with open(path) as f: + return json.load(f) + except Exception: + return None + +config = None +for path in candidates: + config = load(path) + if config is not None: + break + +servers = {} +if isinstance(config, dict): + s = config.get('mcpServers', {}) + if isinstance(s, dict): + servers = s + +result = [] +for name, cfg in servers.items(): + if not isinstance(cfg, dict): + continue + entry = {'name': name} + if cfg.get('command'): + entry['command'] = cfg['command'] + if cfg.get('args'): + entry['args'] = cfg['args'] + if cfg.get('url'): + entry['url'] = cfg['url'] + if cfg.get('env'): + entry['env'] = cfg['env'] + if cfg.get('headers'): + entry['headers'] = cfg['headers'] + result.append(entry) + +print(json.dumps({'servers': result})) " diff --git a/config/agents/builtin/codex/cmds/mcp-list.sh b/config/agents/builtin/codex/cmds/mcp-list.sh index 045265098..ceee3858d 100755 --- a/config/agents/builtin/codex/cmds/mcp-list.sh +++ b/config/agents/builtin/codex/cmds/mcp-list.sh @@ -3,35 +3,182 @@ # Input: {"path": "/optional/workspace/path"} (optional, via stdin) # Output: {"servers": [{"name": "...", "command": "...", "args": [...], "url": "...", "env": {...}}]} +# Codex stores MCP servers in TOML under [mcp_servers.] tables in: +# user: ~/.codex/config.toml +# project: /.codex/config.toml (trusted projects) +# Each table has: command, args = [...], url, and an [mcp_servers..env] subtable. +# Later scopes override earlier ones by server name. +# TOML is parsed via tomllib/tomli when available, else a minimal embedded parser +# (the machine's python3 may lack tomllib, so we must not depend on py3.11+). + INPUT=$(cat 2>/dev/null || echo '{}') -CONFIG_FILE="${HOME}/.codex/config.json" -if [ ! -f "$CONFIG_FILE" ]; then - echo '{"servers": []}' - exit 0 +# Extract optional workspace path from input +WORKSPACE_PATH=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('path',''))" 2>/dev/null) + +USER_CONFIG="$HOME/.codex/config.toml" +PROJECT_CONFIG="" +if [ -n "$WORKSPACE_PATH" ]; then + PROJECT_CONFIG="$WORKSPACE_PATH/.codex/config.toml" fi +MITTO_USER_CONFIG="$USER_CONFIG" \ +MITTO_PROJECT_CONFIG="$PROJECT_CONFIG" \ python3 -c " -import json, sys -try: - with open('$CONFIG_FILE') as f: - config = json.load(f) - servers = config.get('mcpServers', {}) - result = [] - for name, cfg in servers.items(): - entry = {'name': name} - if 'command' in cfg: - entry['command'] = cfg['command'] - if 'args' in cfg: - entry['args'] = cfg['args'] - if 'url' in cfg: - entry['url'] = cfg['url'] - if 'env' in cfg: - entry['env'] = cfg['env'] - if 'headers' in cfg: - entry['headers'] = cfg['headers'] - result.append(entry) - print(json.dumps({'servers': result})) -except Exception: - print(json.dumps({'servers': []})) +import json, os, re + +def parse_toml(text): + # Prefer a real TOML parser when present. + try: + import tomllib # py3.11+ + return tomllib.loads(text) + except Exception: + pass + try: + import tomli # backport + return tomli.loads(text) + except Exception: + pass + return _mini_toml(text) + +def _strip_comment(s): + # Remove an unquoted trailing '#' comment. + out = [] + in_str = False + quote = '' + i = 0 + while i < len(s): + c = s[i] + if in_str: + out.append(c) + if c == quote: + in_str = False + else: + if c in ('\"', \"'\"): + in_str = True + quote = c + out.append(c) + elif c == '#': + break + else: + out.append(c) + i += 1 + return ''.join(out) + +def _parse_value(v): + v = v.strip() + if not v: + return '' + if v[0] == '[' and v[-1] == ']': + inner = v[1:-1].strip() + if not inner: + return [] + # Split top-level commas (values here are simple strings/numbers). + items, buf, in_str, quote = [], [], False, '' + for c in inner: + if in_str: + buf.append(c) + if c == quote: + in_str = False + elif c in ('\"', \"'\"): + in_str = True + quote = c + buf.append(c) + elif c == ',': + items.append(''.join(buf).strip()) + buf = [] + else: + buf.append(c) + if buf: + items.append(''.join(buf).strip()) + return [_parse_scalar(x) for x in items if x != ''] + return _parse_scalar(v) + +def _parse_scalar(v): + v = v.strip() + if len(v) >= 2 and v[0] == v[-1] and v[0] in ('\"', \"'\"): + return v[1:-1] + if v == 'true': + return True + if v == 'false': + return False + try: + if re.fullmatch(r'-?[0-9]+', v): + return int(v) + return float(v) + except Exception: + return v + +def _mini_toml(text): + root = {} + cur = root + for raw in text.splitlines(): + line = _strip_comment(raw).strip() + if not line: + continue + if line.startswith('[') and line.endswith(']'): + path = line[1:-1].strip() + # Split on unquoted dots. + parts, buf, in_str, quote = [], [], False, '' + for c in path: + if in_str: + buf.append(c) + if c == quote: + in_str = False + elif c in ('\"', \"'\"): + in_str = True + quote = c + elif c == '.': + parts.append(''.join(buf).strip()) + buf = [] + else: + buf.append(c) + if buf: + parts.append(''.join(buf).strip()) + cur = root + for p in parts: + cur = cur.setdefault(p, {}) + continue + if '=' in line: + k, _, v = line.partition('=') + cur[k.strip()] = _parse_value(v) + return root + +def servers_of(data): + if not isinstance(data, dict): + return {} + s = data.get('mcp_servers', {}) + return s if isinstance(s, dict) else {} + +def load(path): + if not path or not os.path.isfile(path): + return {} + try: + with open(path) as f: + return parse_toml(f.read()) + except Exception: + return {} + +merged = {} +for var in ('MITTO_USER_CONFIG', 'MITTO_PROJECT_CONFIG'): + for name, cfg in servers_of(load(os.environ.get(var, ''))).items(): + if isinstance(cfg, dict): + merged[name] = cfg + +result = [] +for name, cfg in merged.items(): + entry = {'name': name} + if cfg.get('command'): + entry['command'] = cfg['command'] + if cfg.get('args'): + entry['args'] = cfg['args'] + if cfg.get('url'): + entry['url'] = cfg['url'] + if cfg.get('env'): + entry['env'] = cfg['env'] + if cfg.get('headers'): + entry['headers'] = cfg['headers'] + result.append(entry) + +print(json.dumps({'servers': result})) " diff --git a/internal/agents/manager_test.go b/internal/agents/manager_test.go index 042524f87..534146eb4 100644 --- a/internal/agents/manager_test.go +++ b/internal/agents/manager_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "runtime" "testing" "time" ) @@ -585,6 +586,105 @@ func TestMCPServer_HeadersUnmarshal(t *testing.T) { } } +// TestMCPList_RealConfigPaths_mitto_llr reproduces mitto-llr: the mcp-list.sh +// scripts for claude-code, codex, and cline read the WRONG on-disk config +// location (and, for codex, the wrong format), so a genuinely-configured MCP +// server is silently never surfaced — ListMCPServers fails-soft to an empty +// list. Each sub-test writes a server config at the tool's REAL location +// (verified against current vendor docs) under a fake HOME, then runs the real +// builtin mcp-list.sh via the Manager and asserts the server is surfaced. +// +// These sub-tests FAIL until the paths/format are fixed: +// - claude-code: reads ~/.claude/settings.json; real is ~/.claude.json +// - codex: reads ~/.codex/config.json (JSON); real is ~/.codex/config.toml (TOML [mcp_servers.*]) +// - cline: reads ~/.cline/mcp_settings.json; real is the VSCode extension globalStorage file +func TestMCPList_RealConfigPaths_mitto_llr(t *testing.T) { + agentsDir, err := filepath.Abs(filepath.Join("..", "..", "config", "agents")) + if err != nil { + t.Fatalf("failed to resolve agents dir: %v", err) + } + if _, err := os.Stat(filepath.Join(agentsDir, "builtin")); err != nil { + t.Fatalf("builtin agents dir not found at %s: %v", agentsDir, err) + } + + // clineRealConfigPath returns the canonical Cline VSCode extension MCP + // settings location (globalStorage) for the current OS. + clineRealConfigPath := func(home string) string { + switch runtime.GOOS { + case "darwin": + return filepath.Join(home, "Library", "Application Support", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json") + case "windows": + return filepath.Join(home, "AppData", "Roaming", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json") + default: + return filepath.Join(home, ".config", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json") + } + } + + tests := []struct { + agent string + serverName string + // configFor returns (absolute path, file contents) for the config to + // write under the given fake HOME. + configFor func(home string) (string, string) + }{ + { + agent: "claude-code", + serverName: "repro-claude", + configFor: func(home string) (string, string) { + return filepath.Join(home, ".claude.json"), + `{"mcpServers":{"repro-claude":{"command":"node","args":["srv.js"],"env":{"API_KEY":"secret"}}}}` + }, + }, + { + agent: "codex", + serverName: "repro-codex", + configFor: func(home string) (string, string) { + return filepath.Join(home, ".codex", "config.toml"), + "[mcp_servers.repro-codex]\ncommand = \"node\"\nargs = [\"srv.js\"]\n" + }, + }, + { + agent: "cline", + serverName: "repro-cline", + configFor: func(home string) (string, string) { + return clineRealConfigPath(home), + `{"mcpServers":{"repro-cline":{"command":"node","args":["srv.js"]}}}` + }, + }, + } + + for _, tc := range tests { + t.Run(tc.agent, func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + cfgPath, cfgBody := tc.configFor(home) + if err := os.MkdirAll(filepath.Dir(cfgPath), 0755); err != nil { + t.Fatalf("mkdir for config: %v", err) + } + if err := os.WriteFile(cfgPath, []byte(cfgBody), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + + m := NewManager(agentsDir, nil) + out, err := m.ListMCPServers(context.Background(), tc.agent, &MCPListInput{Path: home}) + if err != nil { + t.Fatalf("ListMCPServers(%s) error: %v", tc.agent, err) + } + + var names []string + for _, s := range out.Servers { + names = append(names, s.Name) + if s.Name == tc.serverName { + return // surfaced — behavior is correct + } + } + t.Fatalf("mitto-llr: %s mcp-list.sh did not surface server %q configured at real path %s; got servers=%v (script reads the wrong location)", + tc.agent, tc.serverName, cfgPath, names) + }) + } +} + // TestMCPServer_EnvOmitEmpty verifies that an MCPServer with no env vars marshals // without an "env" key (json:",omitempty"), keeping the listing output clean. func TestMCPServer_EnvOmitEmpty(t *testing.T) { From 3b48ef13dcb6d11dc13da91e62eaa5539025abde Mon Sep 17 00:00:00 2001 From: Alvaro Saurin Date: Sun, 5 Jul 2026 21:29:00 +0200 Subject: [PATCH 003/240] fix(acpproc): recycle degraded shared ACP process via GC health-tier saturation poll (mitto-tfb) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The saturation-detection infrastructure from mitto-13ck.2 tracked consecutive session/new + LoadSession + set_model context-deadlines but only ever failed fast — it never recycled the degraded shared process, so a saturated process could hang around indefinitely. Add a public, non-mutating IsSaturated() accessor on SharedACPProcess (distinct from the private isSaturated(), which self-clears to probe mode) so the GC's health tier can poll saturation state without perturbing the state machine, and wire it into the GC to proactively recycle degraded idle shared processes. Add TestGCHealthTier_RecyclesSaturatedIdleProcess. --- internal/acpproc/acp_process_gc.go | 111 ++++++++++++++++++++++++ internal/acpproc/acp_process_gc_test.go | 84 ++++++++++++++++++ internal/acpproc/shared_acp_process.go | 14 +++ 3 files changed, 209 insertions(+) diff --git a/internal/acpproc/acp_process_gc.go b/internal/acpproc/acp_process_gc.go index 08ad28b7b..f593c6854 100644 --- a/internal/acpproc/acp_process_gc.go +++ b/internal/acpproc/acp_process_gc.go @@ -234,6 +234,13 @@ func (m *ACPProcessManager) gcLoop() { // loop prompt due soon), its sessions are GC-suspended and closed and the // process is stopped to reclaim memory. Disabled when MemoryRecycleThreshold is 0. // +// Tier 5 proactively recycles degraded (saturated) idle processes (mitto-tfb): when +// a shared process has been flagged saturated by the mitto-13ck.2 infra (repeated +// NewSession/LoadSession deadlines) and is fully idle (same gates as Tier 4), its +// sessions are GC-suspended and closed and the process is stopped so the next +// NewSession lazily builds a fresh, healthy process — instead of the degraded one +// continuing to starve resumes/loop-prompts. +// // Tier 3 cleans up auxiliary sessions that have been idle longer than AuxIdleTimeout. // Cleaned-up sessions are lazily re-created on next use via getOrCreateAuxiliarySession. func (m *ACPProcessManager) RunGCOnce() { @@ -625,6 +632,110 @@ gcTier1: } } + // ---------------------------------------------------------------- + // Tier 5: proactively recycle degraded (saturated) idle processes (mitto-tfb) + // The saturation infra (mitto-13ck.2) only fails fast on new requests; it never + // recycles the degraded process, so a shared process that has starved one resume + // keeps starving the next resume/loop-prompt. Convert repeated NewSession/ + // LoadSession deadlines into a fresh process: when a process is flagged saturated + // AND fully idle (same hard safety gates as Tier 4), close its sessions and stop + // it so the next NewSession lazily builds a healthy replacement. Re-query sessions + // so any closed by earlier tiers are excluded. + // ---------------------------------------------------------------- + { + sessionsByWorkspace = m.sessionQuery() + + m.mu.RLock() + healthUUIDs := make([]string, 0, len(m.processes)) + for uuid := range m.processes { + healthUUIDs = append(healthUUIDs, uuid) + } + m.mu.RUnlock() + + for _, workspaceUUID := range healthUUIDs { + p := m.GetProcess(workspaceUUID) + if p == nil { + continue + } + + // Only act on degraded processes. + if !p.IsSaturated() { + continue + } + + // Hard safety gates: only recycle a fully-idle process (same as Tier 4). + if rpcs := p.ActiveRPCs(); rpcs > 0 { + if m.logger != nil { + m.logger.Debug("GC: skipping health recycle (busy)", + "workspace_uuid", workspaceUUID, + "reason", "in-flight RPCs", + "active_rpcs", rpcs) + } + continue + } + sessions := sessionsByWorkspace[workspaceUUID] + busy := false + for _, s := range sessions { + if s.IsPrompting { + if m.logger != nil { + m.logger.Debug("GC: skipping health recycle (busy)", + "workspace_uuid", workspaceUUID, + "reason", "session prompting", + "session_id", s.SessionID) + } + busy = true + break + } + if s.QueueLength > 0 { + if m.logger != nil { + m.logger.Debug("GC: skipping health recycle (busy)", + "workspace_uuid", workspaceUUID, + "reason", "non-empty queue", + "session_id", s.SessionID, + "queue_length", s.QueueLength) + } + busy = true + break + } + if s.NextLoopAt != nil && s.NextLoopAt.Before(now.Add(2*m.gcConfig.Interval)) { + if m.logger != nil { + m.logger.Debug("GC: skipping health recycle (busy)", + "workspace_uuid", workspaceUUID, + "reason", "loop prompt due soon", + "session_id", s.SessionID, + "next_loop_at", s.NextLoopAt) + } + busy = true + break + } + } + if busy { + continue + } + + // Saturated and idle — recycle to reclaim a healthy process. + if m.logger != nil { + m.logger.Info("GC: recycling saturated idle shared ACP process", + "workspace_uuid", workspaceUUID, + "session_count", len(sessions)) + } + // Mark each session GC-suspended BEFORE closing so the WebSocket + // auto-resume handler skips resume and avoids a thrash loop — same + // ordering as Tier 1's loop-suspend and Tier 4's memory-recycle paths. + for _, s := range sessions { + m.MarkGCSuspended(s.SessionID) + m.sessionClose(s.SessionID) + } + // Stop the now-sessionless process; the next NewSession lazily builds a + // fresh one with zeroed saturation state. + m.StopProcess(workspaceUUID) + // Keep sessionless bookkeeping consistent. + m.gcMu.Lock() + delete(m.lastSessionSeen, workspaceUUID) + m.gcMu.Unlock() + } + } + // ---------------------------------------------------------------- // Tier 3: clean up idle auxiliary sessions // ---------------------------------------------------------------- diff --git a/internal/acpproc/acp_process_gc_test.go b/internal/acpproc/acp_process_gc_test.go index 3a50b6319..7d14177a9 100644 --- a/internal/acpproc/acp_process_gc_test.go +++ b/internal/acpproc/acp_process_gc_test.go @@ -1474,3 +1474,87 @@ func TestGCTier4_DisabledWhenThresholdZero(t *testing.T) { t.Error("process should NOT be recycled when memory recycling is disabled") } } + +// TestGCHealthTier_RecyclesSaturatedIdleProcess reproduces mitto-tfb: a shared +// ACP process that has become saturated/degraded (repeated session/new + LoadSession +// context-deadlines tracked by mitto-13ck.2) is NOT recycled by GC, even though it +// is fully idle and safe to recycle. The saturation infra only fails fast on new +// requests; nothing converts "repeated deadlines" into a fresh process, so the +// degraded process keeps starving the next resume / loop-prompt. +// +// This test drives the process to saturation via recordRPCTimeout() (mirroring the +// signal from real session/new timeouts) while keeping RSS well BELOW the Tier 4 +// memory threshold — isolating the HEALTH signal from the memory signal. With the +// process fully idle (no in-flight RPCs, no prompting/queued/loop-due sessions), a +// proactive-health GC tier SHOULD recycle it so the next resume lands on a fresh, +// healthy process. +// +// Before the fix this FAILS: no health-based recycle tier exists, so the saturated +// process survives GC. After the fix it passes: the idle saturated process is +// GC-suspended, its sessions closed, and the process stopped. +func TestGCHealthTier_RecyclesSaturatedIdleProcess(t *testing.T) { + workspaceUUID := "ws-saturated" + proc := newTestSharedProcess() + + // Drive the process to saturation: consecutive RPC timeouts up to the + // threshold trip the saturated state (same path real session/new deadlines + // take via recordRPCTimeout). + for i := 0; i < sessionSaturationTimeoutThreshold; i++ { + proc.recordRPCTimeout() + } + if !proc.isSaturated() { + t.Fatalf("test setup: process should be saturated after %d timeouts", sessionSaturationTimeoutThreshold) + } + // isSaturated() self-clears to a probe when the cooldown elapses; re-trip so + // the process is unambiguously saturated for the GC pass below. + for i := 0; i < sessionSaturationTimeoutThreshold; i++ { + proc.recordRPCTimeout() + } + + sessions := map[string][]conversation.SessionInfo{ + workspaceUUID: { + {SessionID: "s1", WorkspaceUUID: workspaceUUID, HasObservers: true}, + {SessionID: "s2", WorkspaceUUID: workspaceUUID, HasObservers: true}, + }, + } + + var mu sync.Mutex + closed := make(map[string]bool) + + m := newTestGCManager( + func() map[string][]conversation.SessionInfo { return sessions }, + func(id string) { + mu.Lock() + defer mu.Unlock() + closed[id] = true + }, + ) + m.mu.Lock() + m.processes[workspaceUUID] = proc + m.mu.Unlock() + + // Keep RSS BELOW the memory threshold so Tier 4 does NOT fire — only the + // health/saturation signal should drive the recycle. + m.gcConfig.MemoryRecycleThreshold = gcTier4Threshold + m.rssSampler = func(p *SharedACPProcess) (uint64, error) { return gcTier4Threshold / 2, nil } + + m.RunGCOnce() + + m.mu.RLock() + _, exists := m.processes[workspaceUUID] + m.mu.RUnlock() + if exists { + t.Error("saturated idle process should have been recycled (stopped) by the health tier") + } + + mu.Lock() + defer mu.Unlock() + for _, id := range []string{"s1", "s2"} { + if !closed[id] { + t.Errorf("expected session %s to be closed during health recycle", id) + } + if !m.IsGCSuspended(id) { + t.Errorf("expected session %s to be marked GC-suspended before close", id) + } + } +} diff --git a/internal/acpproc/shared_acp_process.go b/internal/acpproc/shared_acp_process.go index f11633433..f3009fee3 100644 --- a/internal/acpproc/shared_acp_process.go +++ b/internal/acpproc/shared_acp_process.go @@ -859,6 +859,20 @@ func (p *SharedACPProcess) isSaturated() bool { return true } +// IsSaturated reports whether the shared process is currently flagged saturated +// (mitto-tfb Phase 2). Unlike the private isSaturated(), this is a NON-mutating read: +// it never self-clears to probe mode, so the GC's proactive health-recycle tier can +// poll it without perturbing the saturation state machine. It returns true while the +// cooldown window (saturatedUntil) is set and has not yet elapsed. +func (p *SharedACPProcess) IsSaturated() bool { + p.saturationMu.Lock() + defer p.saturationMu.Unlock() + if p.saturatedUntil.IsZero() { + return false + } + return time.Now().Before(p.saturatedUntil) +} + // rpcErrorCode extracts the JSON-RPC error code from err when it (or any error it // wraps) is an *acp.RequestError. The second return reports whether a code was // found. Used to surface a structured, queryable rpc_code on NewSession failures From 3328eddb214aa0e09637a7c3b091509f140efb82 Mon Sep 17 00:00:00 2001 From: Alvaro Saurin Date: Sun, 5 Jul 2026 21:30:30 +0200 Subject: [PATCH 004/240] feat(config): make model-capability tags always-available and runtime-consumed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add config.DefaultModelProfiles() as the canonical, hardcoded set of 7 model-capability profiles (Claude/Opus/Sonnet/Haiku/GPT-5/GPT-4/ Gemini), kept in sync with config/config.default.yaml's `models:` block by `make check-model-tags`. Unlike config.default.yaml, which only seeds settings.json on first run, these profiles are always available via the new (*Config).EffectiveModelProfiles(), which unions settings.json's Models with the canonical defaults (user profiles win on name collision). Route all tag/name resolution (ModelProfileByName, ModelProfilesByTag, ResolveModelTags) through EffectiveModelProfiles so a prompt's preferredModels (modelTag/modelName) resolves even when settings.json predates or omits `models:` — previously this silently no-opped to the baseline model. Add config.CanonicalModelTags() (sorted, de-duped tag set) and wire it into GET /api/config as model_tags so SettingsDialog.js can suggest canonical tags via a when editing a profile's tags. Add `make check-model-tags` target validating builtin prompt modelTag references and Go/YAML drift. --- .augment/rules/08-config.md | 6 +- CLAUDE.md | 4 +- Makefile | 9 +- config/config.default.yaml | 8 +- internal/config/config.go | 100 ++++++++++++- internal/config/config_test.go | 172 ++++++++++++++++++++-- internal/conversation/bgsession_prompt.go | 11 +- internal/web/handlers/config_get.go | 3 + web/static/components/SettingsDialog.js | 15 ++ 9 files changed, 296 insertions(+), 32 deletions(-) diff --git a/.augment/rules/08-config.md b/.augment/rules/08-config.md index 4dd2610b9..d2e651b7e 100644 --- a/.augment/rules/08-config.md +++ b/.augment/rules/08-config.md @@ -101,7 +101,7 @@ Note: `/mitto/api/settings` manages global `settings.json`. For per-session feat models: - name: Claude Opus # UI label (read-only) criteria: { matchMode: contains, pattern: Opus } # Case-insensitive pattern matching - tags: [Smartest, Reasoning, Expensive] # Interface-only semantic tags + tags: [Smartest, Reasoning, Expensive] # Capability tags, consumed at runtime ``` **Tag union matching** (additive): If a model name matches multiple profiles (e.g., `Claude Opus 4.5`): @@ -109,7 +109,9 @@ models: - Then: Matches `Opus` profile → Adds `[Smartest, Reasoning, Expensive]` - Result: `[Anthropic, Smartest, Reasoning, Expensive]` (union) -Use `matchMode: contains` for robust cross-version matching. Tags are interface-only; runtime consumption is tracked separately (see `mitto-2cc`). Shipped defaults include: Claude, Opus, Sonnet, Haiku, GPT-5, GPT-4, Gemini. Test: `TestParse_EmbeddedDefaultModelProfiles()` in `internal/config/config_test.go`. +Use `matchMode: contains` for robust cross-version matching. Shipped defaults include: Claude, Opus, Sonnet, Haiku, GPT-5, GPT-4, Gemini. Test: `TestParse_EmbeddedDefaultModelProfiles()` in `internal/config/config_test.go`. + +**Canonical Go defaults (always available, not just first-run seed)**: `config.DefaultModelProfiles()` hardcodes the same 7 profiles in Go — the single source of truth, kept in sync with `config.default.yaml`'s `models:` block by `make check-model-tags`. `(*Config).EffectiveModelProfiles()` returns `settings.json`'s `Models` unioned with these defaults (user profile wins on name collision, defaults fill gaps; nil-safe). All tag/name resolution — `ModelProfileByName`, `ModelProfilesByTag`, `ResolveModelTags` — routes through `EffectiveModelProfiles()`, so a prompt's `preferredModels: {modelTag: Coding}` resolves even when `settings.json` predates or omits `models:` (previously it silently no-opped to the baseline model — this was the root cause of `preferredModels` "not working" for users with pre-existing settings). `config.CanonicalModelTags()` returns the sorted, de-duped tag set; `make check-model-tags` rejects any builtin prompt's `modelTag` that isn't in this set. ## ACP Server Constraints diff --git a/CLAUDE.md b/CLAUDE.md index 2bb1b8b17..54bd04ca8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ go test -v -tags integration ./tests/integration/inprocess/ - **Log authoritative source**: Check `events.jsonl` (session dir) when debugging; server logs rotate and have gaps. - **daisyUI drawer GPU bug**: `.drawer-side` + fixed-position overlay compete for pointer events → blank artifacts. Fix: See `web/static/styles.css` for verified pattern. Do NOT use `translateZ(0)`. - **Zombie WebSocket recovery**: When phone sleeps or app backgrounded, WS may enter "zombie" state (appearing open but dead). On visibility change or app activate, force-close and reconnect. This is expected behavior — not a bug. See `.augment/rules/23-web-frontend-mobile.md` for resilience patterns. -- **Verify prior edits actually persisted**: Don't trust that a previous turn's file edits are still on disk (session gaps, restarts, or reverted stashes can silently drop them). Before continuing/relying on earlier work, re-check with `git status`/`git diff` or re-view the file rather than assuming. +- **Verify prior edits actually persisted**: Don't trust that a previous turn's file edits are still on disk — session gaps, restarts, or a **concurrent loop conversation** (e.g. a PR-babysitting/cleanup loop sharing the same repo) stashing/resetting the working directory mid-task can silently drop them. Re-check with `git status`/`git diff`/re-view before relying on earlier work; if files vanish unexpectedly, check `git stash list` first — work is often auto-stashed, not lost. ## New Agent Capability Checklist @@ -140,6 +140,8 @@ Per-agent `mcp-list.sh` config paths/keys are **not** interchangeable across age - `app.js` line ~1928: `headerLoopState()` returns `{ state, label, badgeClass }` pill object - Issue `mitto-36nm` tracks UI clarity improvement (prompt visibility + pill disambiguation) +**Persistence symmetry (LoopStore, `internal/session/loop.go`)**: un-loop calls `Detach()` (saves settings to a slot, clears active config); re-loop/restore reads it back via `GetSaved()`. A **fresh** loop create must call `ClearSaved()` right after `Set()` so a stale saved slot doesn't leak into a later un-loop — done identically in REST (`session_loop_write.go` `handleSetLoop`) and MCP (`mcpserver/server.go` create-loop path) to keep both interfaces symmetric. + ## Tokensave Rule (Mandatory) **NEVER use Explore agents for code research when tokensave is available.** Use `tokensave_context`, `tokensave_search`, `tokensave_callees`, `tokensave_callers`, `tokensave_impact`, `tokensave_node`, `tokensave_files`, or `tokensave_affected` first. See CLAUDE.md in project root for full details. diff --git a/Makefile b/Makefile index a74e0c96b..d5eb68410 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build install test test-go test-js test-integration test-integration-go test-integration-cli test-integration-api test-integration-client test-ui test-ui-headed test-ui-debug test-ui-report test-all test-ci test-setup test-clean clean run fmt fmt-check fmt-docs fmt-docs-check lint lint-go lint-frontend deps-go deps-js deps tailwind vendor-codemirror build-mac-app clean-mac-app test-webviewlog build-mock-acp ci install-hooks homebrew-generate homebrew-test homebrew-test-style homebrew-test-install homebrew-test-cask homebrew-tap-setup homebrew-clean smoke-build smoke-test-cli smoke-test smoke-clean +.PHONY: build install test test-go test-js check-model-tags test-integration test-integration-go test-integration-cli test-integration-api test-integration-client test-ui test-ui-headed test-ui-debug test-ui-report test-all test-ci test-setup test-clean clean run fmt fmt-check fmt-docs fmt-docs-check lint lint-go lint-frontend deps-go deps-js deps tailwind vendor-codemirror build-mac-app clean-mac-app test-webviewlog build-mock-acp ci install-hooks homebrew-generate homebrew-test homebrew-test-style homebrew-test-install homebrew-test-cask homebrew-tap-setup homebrew-clean smoke-build smoke-test-cli smoke-test smoke-clean # Binary name BINARY_NAME=mitto @@ -44,6 +44,13 @@ test-js: deps-js @echo "Running JavaScript tests..." $(NPM) test +# Validate builtin prompt model tags against the canonical Go tag set. +# Fails if any builtin prompt references a modelTag not in config.CanonicalModelTags(), +# or if config/config.default.yaml `models:` drifts from config.DefaultModelProfiles(). +check-model-tags: + @echo "Validating builtin prompt model tags..." + $(GOTEST) -run 'TestBuiltinPrompts_ModelTagsAreCanonical|TestDefaultModelProfiles_MatchesEmbeddedYAML|TestCanonicalModelTags|TestEffectiveModelProfiles_MergeAndPrecedence' ./internal/config/ + # ============================================================================= # Integration & UI Tests # ============================================================================= diff --git a/config/config.default.yaml b/config/config.default.yaml index b80b8e457..4dd35c556 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -33,8 +33,12 @@ acp: [] # first run); existing settings.json files are left untouched. Tags overlap by design: # a model name is matched against every profile and the union of matching tags applies # (e.g. "Claude Opus 4.x" resolves to Anthropic + Smartest + Reasoning + Expensive). -# Tags are interface-only today (parsed and exposed via the Go API, not yet consumed -# at runtime). Edit or extend these to match the models you use. +# +# Tags ARE consumed at runtime: a prompt's `preferredModels` (modelTag/modelName) +# resolves against these profiles to pick the model a prompt runs on. This YAML is only +# the first-run seed — the canonical, always-available set lives in Go +# (config.DefaultModelProfiles); the two are kept in sync by `make check-model-tags`. +# Edit or extend these to match the models you use. models: - name: Claude criteria: diff --git a/internal/config/config.go b/internal/config/config.go index 6edcb8ec5..2ac836382 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strings" "gopkg.in/yaml.v3" @@ -36,6 +37,77 @@ type ModelProfile struct { Tags []string `json:"tags,omitempty"` } +// DefaultModelProfiles returns the canonical, hardcoded set of model profiles. +// This is the single Go source of truth for well-known model-capability tags and +// mirrors the `models:` block in config/config.default.yaml (kept in sync by the +// `make check-model-tags` target). +// +// Unlike config.default.yaml — which only seeds settings.json on first run — these +// profiles are always available at runtime via EffectiveModelProfiles, so tag-based +// prompt preferredModels (e.g. `modelTag: Coding`) resolve even when the user's +// settings.json has an empty or partial `Models` list. A fresh copy is returned on +// each call so callers may mutate the result freely. +func DefaultModelProfiles() []ModelProfile { + contains := func(pattern string) *ACPServerConstraint { + return &ACPServerConstraint{MatchMode: "contains", Pattern: pattern} + } + return []ModelProfile{ + {Name: "Claude", Criteria: contains("Claude"), Tags: []string{"Anthropic"}}, + {Name: "Claude Opus", Criteria: contains("Opus"), Tags: []string{"Smartest", "Reasoning", "Expensive"}}, + {Name: "Claude Sonnet", Criteria: contains("Sonnet"), Tags: []string{"Smart", "Coding"}}, + {Name: "Claude Haiku", Criteria: contains("Haiku"), Tags: []string{"Fast", "Cheap"}}, + {Name: "GPT-5", Criteria: contains("GPT-5"), Tags: []string{"Smart", "Reasoning", "Coding"}}, + {Name: "GPT-4", Criteria: contains("GPT-4"), Tags: []string{"Smart", "Coding"}}, + {Name: "Gemini", Criteria: contains("Gemini"), Tags: []string{"Smart", "LongContext"}}, + } +} + +// CanonicalModelTags returns the sorted, de-duplicated set of capability tags carried +// by DefaultModelProfiles. It is the authoritative list of tags that a prompt's +// preferredModels `modelTag:` may reference and is used by the `make check-model-tags` +// validator to reject unknown tags in builtin prompts. +func CanonicalModelTags() []string { + seen := make(map[string]struct{}) + var tags []string + for _, p := range DefaultModelProfiles() { + for _, t := range p.Tags { + if _, dup := seen[t]; dup { + continue + } + seen[t] = struct{}{} + tags = append(tags, t) + } + } + sort.Strings(tags) + return tags +} + +// EffectiveModelProfiles returns the model profiles that should be used for tag/name +// resolution: the user-configured profiles (Config.Models) unioned with the canonical +// DefaultModelProfiles as a fallback. User profiles take precedence — a default profile +// is only appended when no user profile shares its name (case-insensitive). This +// guarantees well-known tags always resolve even when settings.json omits `models:`, +// without overriding any customisation the user has made. Safe to call on a nil Config. +func (c *Config) EffectiveModelProfiles() []ModelProfile { + var user []ModelProfile + if c != nil { + user = c.Models + } + out := make([]ModelProfile, len(user)) + copy(out, user) + haveName := make(map[string]struct{}, len(user)) + for _, p := range user { + haveName[strings.ToLower(p.Name)] = struct{}{} + } + for _, d := range DefaultModelProfiles() { + if _, ok := haveName[strings.ToLower(d.Name)]; ok { + continue + } + out = append(out, d) + } + return out +} + // ConstraintMatchesName reports whether name matches the constraint's Pattern under // its MatchMode. It is the single-string core of the constraint match engine, shared by // MatchConstraintOption (which applies it across a list of option names) and by model-tag @@ -1883,31 +1955,43 @@ func ProfilesByTag(profiles []ModelProfile, tag string) []ModelProfile { // ModelProfileByName returns the model profile with the given name (case-insensitive). // The bool is false when no profile matches. Intended for consumers that need to look up -// a profile's tags or criteria by its display name. +// a profile's tags or criteria by its display name. Resolution uses EffectiveModelProfiles +// so well-known profiles resolve even when settings.json omits `models:`. func (c *Config) ModelProfileByName(name string) (*ModelProfile, bool) { - p := ProfileByName(c.Models, name) + profiles := c.EffectiveModelProfiles() + p := ProfileByName(profiles, name) return p, p != nil } // ModelProfilesByTag returns all model profiles carrying the given tag (case-insensitive), // mirroring how ACP server tags are compared elsewhere. Returns an empty slice when none match. +// Resolution uses EffectiveModelProfiles so well-known tags resolve even when settings.json +// omits `models:`. func (c *Config) ModelProfilesByTag(tag string) []ModelProfile { - return ProfilesByTag(c.Models, tag) + return ProfilesByTag(c.EffectiveModelProfiles(), tag) } // ResolveModelTags returns the UNION of capability tags from every model profile whose // Criteria matches modelName (using the shared ConstraintMatchesName engine). Tags are -// de-duplicated case-insensitively, preserving first-seen order. It is a pure function of -// (profiles, name) so config never needs to import conversation. Returns nil when modelName -// is empty, no profile has criteria, or nothing matches (a nil slice is safe to range/index). +// de-duplicated case-insensitively, preserving first-seen order. Resolution uses +// EffectiveModelProfiles so well-known tags resolve even when settings.json omits `models:`. +// Returns nil when modelName is empty or nothing matches (a nil slice is safe to range/index). func (c *Config) ResolveModelTags(modelName string) []string { if modelName == "" { return nil } + return resolveModelTags(c.EffectiveModelProfiles(), modelName) +} + +// resolveModelTags is the pure, slice-based core shared by (*Config).ResolveModelTags. +// It returns the union (case-insensitive de-dup, first-seen order) of tags from every +// profile whose Criteria matches modelName. Kept separate so callers with a plain +// []ModelProfile (and tests) can resolve without the canonical-default merge. +func resolveModelTags(profiles []ModelProfile, modelName string) []string { var tags []string seen := make(map[string]struct{}) - for i := range c.Models { - p := &c.Models[i] + for i := range profiles { + p := &profiles[i] if !ConstraintMatchesName(p.Criteria, modelName) { continue } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c9ce7736d..98635f3c3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,9 +1,13 @@ package config import ( + "io/fs" "os" "path/filepath" + "sort" + "strings" "testing" + "time" defaultConfig "github.com/inercia/mitto/config" ) @@ -2500,39 +2504,43 @@ func TestModelProfileByName(t *testing.T) { } // TestModelProfilesByTag covers case-insensitive tag filtering, including a tag shared -// by multiple profiles. +// by multiple profiles. It exercises the pure slice-based engine (ProfilesByTag) so it +// stays free of the canonical-default merge that (*Config).ModelProfilesByTag applies; +// the merge itself is covered by TestEffectiveModelProfiles_MergeAndPrecedence. func TestModelProfilesByTag(t *testing.T) { - cfg := &Config{Models: []ModelProfile{ + profiles := []ModelProfile{ {Name: "Opus", Tags: []string{"Smartest", "Expensive"}}, {Name: "Sonnet", Tags: []string{"Smart", "Cheap"}}, {Name: "Haiku", Tags: []string{"Fast", "Cheap"}}, - }} + } - cheap := cfg.ModelProfilesByTag("cheap") + cheap := ProfilesByTag(profiles, "cheap") if len(cheap) != 2 { - t.Fatalf("ModelProfilesByTag(cheap) count = %d, want 2", len(cheap)) + t.Fatalf("ProfilesByTag(cheap) count = %d, want 2", len(cheap)) } if cheap[0].Name != "Sonnet" || cheap[1].Name != "Haiku" { - t.Errorf("ModelProfilesByTag(cheap) = [%s %s], want [Sonnet Haiku]", cheap[0].Name, cheap[1].Name) + t.Errorf("ProfilesByTag(cheap) = [%s %s], want [Sonnet Haiku]", cheap[0].Name, cheap[1].Name) } - if got := cfg.ModelProfilesByTag("missing"); len(got) != 0 { - t.Errorf("ModelProfilesByTag(missing) count = %d, want 0", len(got)) + if got := ProfilesByTag(profiles, "missing"); len(got) != 0 { + t.Errorf("ProfilesByTag(missing) count = %d, want 0", len(got)) } } // TestResolveModelTags covers tag resolution across every match mode, the union (with // case-insensitive de-dup) across multiple matching profiles, the no-match / empty cases, -// and that criteria-less profiles never contribute tags. +// and that criteria-less profiles never contribute tags. It exercises the pure slice-based +// core (resolveModelTags) so it stays free of the canonical-default merge that +// (*Config).ResolveModelTags applies; the merge is covered elsewhere. func TestResolveModelTags(t *testing.T) { - cfg := &Config{Models: []ModelProfile{ + profiles := []ModelProfile{ {Name: "Opus", Criteria: &ACPServerConstraint{MatchMode: "contains", Pattern: "Opus"}, Tags: []string{"Smart", "Expensive"}}, {Name: "Claude", Criteria: &ACPServerConstraint{MatchMode: "regex", Pattern: "opus|sonnet"}, Tags: []string{"Anthropic", "smart"}}, {Name: "Sonnet", Criteria: &ACPServerConstraint{MatchMode: "exact", Pattern: "Sonnet 4.6"}, Tags: []string{"Cheap"}}, {Name: "Pro", Criteria: &ACPServerConstraint{MatchMode: "startsWith", Pattern: "opus"}, Tags: []string{"Pro"}}, {Name: "Look", Criteria: &ACPServerConstraint{MatchMode: "lookAlike", Pattern: "Opus 4.8"}, Tags: []string{"Latest"}}, {Name: "TagsOnly", Tags: []string{"NeverApplied"}}, // nil criteria → never matches - }} + } tests := []struct { name string @@ -2549,7 +2557,13 @@ func TestResolveModelTags(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := cfg.ResolveModelTags(tt.modelName) + if tt.modelName == "" { + if got := (&Config{Models: profiles}).ResolveModelTags(""); got != nil { + t.Fatalf("ResolveModelTags(\"\") = %v, want nil", got) + } + return + } + got := resolveModelTags(profiles, tt.modelName) if len(got) != len(tt.want) { t.Fatalf("ResolveModelTags(%q) = %v, want %v", tt.modelName, got, tt.want) } @@ -2659,3 +2673,137 @@ func TestParse_EmbeddedDefaultShortcuts(t *testing.T) { } } } + +// TestDefaultModelProfiles_MatchesEmbeddedYAML asserts the hardcoded Go source of +// truth (DefaultModelProfiles) stays in sync with the shipped config.default.yaml +// `models:` block — same profile names, criteria, and tags in the same order. This is +// the drift guard invoked by `make check-model-tags`. +func TestDefaultModelProfiles_MatchesEmbeddedYAML(t *testing.T) { + cfg, err := Parse(defaultConfig.DefaultConfigYAML) + if err != nil { + t.Fatalf("Parse(embedded default) failed: %v", err) + } + got := DefaultModelProfiles() + if len(got) != len(cfg.Models) { + t.Fatalf("DefaultModelProfiles() count = %d, config.default.yaml models = %d", len(got), len(cfg.Models)) + } + for i := range got { + g, y := got[i], cfg.Models[i] + if g.Name != y.Name { + t.Errorf("profile[%d] name = %q (Go) vs %q (YAML)", i, g.Name, y.Name) + } + if g.Criteria == nil || y.Criteria == nil { + t.Errorf("profile[%d] %q missing criteria (Go=%v YAML=%v)", i, g.Name, g.Criteria, y.Criteria) + continue + } + if g.Criteria.MatchMode != y.Criteria.MatchMode || g.Criteria.Pattern != y.Criteria.Pattern { + t.Errorf("profile[%d] %q criteria = %+v (Go) vs %+v (YAML)", i, g.Name, g.Criteria, y.Criteria) + } + if strings.Join(g.Tags, ",") != strings.Join(y.Tags, ",") { + t.Errorf("profile[%d] %q tags = %v (Go) vs %v (YAML)", i, g.Name, g.Tags, y.Tags) + } + } +} + +// TestCanonicalModelTags pins the canonical capability-tag set (sorted, de-duplicated) +// derived from DefaultModelProfiles. +func TestCanonicalModelTags(t *testing.T) { + want := []string{"Anthropic", "Cheap", "Coding", "Expensive", "Fast", "LongContext", "Reasoning", "Smart", "Smartest"} + got := CanonicalModelTags() + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("CanonicalModelTags() = %v, want %v", got, want) + } +} + +// TestEffectiveModelProfiles_MergeAndPrecedence verifies that user-configured profiles +// win on name collision and canonical defaults fill the gaps, including the empty and +// nil-Config cases (the exact scenario behind tag routing silently no-oping when +// settings.json omits `models:`). +func TestEffectiveModelProfiles_MergeAndPrecedence(t *testing.T) { + // Nil Config → all canonical defaults. + var nilCfg *Config + if got := nilCfg.EffectiveModelProfiles(); len(got) != len(DefaultModelProfiles()) { + t.Fatalf("nil Config EffectiveModelProfiles() = %d, want %d", len(got), len(DefaultModelProfiles())) + } + + // Empty Models → all canonical defaults, and a tag resolves. + empty := &Config{} + if got := empty.ModelProfilesByTag("Coding"); len(got) == 0 { + t.Errorf("empty Config: modelTag Coding resolved to no profile (regression: routing no-ops)") + } + + // User override on a colliding name wins; a non-colliding user profile is preserved; + // defaults fill the rest. + user := &Config{Models: []ModelProfile{ + {Name: "Claude Sonnet", Criteria: &ACPServerConstraint{MatchMode: "exact", Pattern: "My Sonnet"}, Tags: []string{"Custom"}}, + {Name: "MyLocal", Criteria: &ACPServerConstraint{MatchMode: "contains", Pattern: "local"}, Tags: []string{"Cheap"}}, + }} + eff := user.EffectiveModelProfiles() + // User profiles come first, in order. + if eff[0].Name != "Claude Sonnet" || eff[0].Criteria.Pattern != "My Sonnet" || eff[0].Tags[0] != "Custom" { + t.Errorf("user override not preserved/first: %+v", eff[0]) + } + if eff[1].Name != "MyLocal" { + t.Errorf("non-colliding user profile not preserved at index 1: %+v", eff[1]) + } + // The colliding default (Claude Sonnet) must NOT be appended again. + sonnetCount := 0 + for _, p := range eff { + if p.Name == "Claude Sonnet" { + sonnetCount++ + } + } + if sonnetCount != 1 { + t.Errorf("Claude Sonnet appears %d times, want 1 (default should be dropped on collision)", sonnetCount) + } + // A default with a unique name (e.g. Claude Opus) is still present. + if p, ok := user.ModelProfileByName("Claude Opus"); !ok || p == nil { + t.Errorf("canonical default 'Claude Opus' missing after merge") + } +} + +// TestBuiltinPrompts_ModelTagsAreCanonical is the validator behind `make check-model-tags`: +// every `modelTag:` used by any embedded builtin prompt must be a known canonical tag. +// This fails CI if a prompt references a tag that no model profile can carry. +func TestBuiltinPrompts_ModelTagsAreCanonical(t *testing.T) { + canonical := make(map[string]struct{}) + for _, tag := range CanonicalModelTags() { + canonical[strings.ToLower(tag)] = struct{}{} + } + + entries, err := fs.ReadDir(defaultConfig.BuiltinPromptsFS, defaultConfig.BuiltinPromptsDir) + if err != nil { + t.Fatalf("read embedded builtin prompts: %v", err) + } + if len(entries) == 0 { + t.Fatal("no embedded builtin prompts found") + } + + var unknown []string + for _, e := range entries { + if e.IsDir() { + continue + } + data, err := fs.ReadFile(defaultConfig.BuiltinPromptsFS, defaultConfig.BuiltinPromptsDir+"/"+e.Name()) + if err != nil { + t.Fatalf("read %s: %v", e.Name(), err) + } + pf, err := ParsePromptFile(e.Name(), data, time.Time{}) + if err != nil { + t.Fatalf("parse %s: %v", e.Name(), err) + } + for _, pm := range pf.PreferredModels { + if pm.ModelTag == "" { + continue + } + if _, ok := canonical[strings.ToLower(pm.ModelTag)]; !ok { + unknown = append(unknown, e.Name()+": "+pm.ModelTag) + } + } + } + if len(unknown) > 0 { + sort.Strings(unknown) + t.Fatalf("builtin prompts reference unknown modelTag(s) not in CanonicalModelTags():\n %s", + strings.Join(unknown, "\n ")) + } +} diff --git a/internal/conversation/bgsession_prompt.go b/internal/conversation/bgsession_prompt.go index fde71c06b..0025be242 100644 --- a/internal/conversation/bgsession_prompt.go +++ b/internal/conversation/bgsession_prompt.go @@ -862,13 +862,12 @@ func (bs *BackgroundSession) pdResolvePreferredModels(promptName string) []confi return bs.preferredModelsResolver(promptName, bs.workingDir) } -// pdModelProfiles exposes the global model profiles (Settings → Models) so -// SelectPreferredModel can resolve PromptPreferredModel entries by name/tag. +// pdModelProfiles exposes the model profiles used to resolve PromptPreferredModel +// entries by name/tag. It returns the user-configured profiles (Settings → Models) +// unioned with the canonical DefaultModelProfiles as a fallback, so well-known tags +// (e.g. "Coding", "Cheap") always resolve even when settings.json omits `models:`. func (bs *BackgroundSession) pdModelProfiles() []config.ModelProfile { - if bs.mittoConfig == nil { - return nil - } - return bs.mittoConfig.Models + return bs.mittoConfig.EffectiveModelProfiles() } func (bs *BackgroundSession) pdResolvePromptParameters(promptName string) []config.PromptParameter { diff --git a/internal/web/handlers/config_get.go b/internal/web/handlers/config_get.go index c8019c223..e556648a8 100644 --- a/internal/web/handlers/config_get.go +++ b/internal/web/handlers/config_get.go @@ -71,6 +71,9 @@ func (h *Handlers) HandleGetConfig(w http.ResponseWriter, r *http.Request) { "config_readonly": h.deps.ConfigReadOnly, "api_prefix": h.deps.APIPrefix, // Include API prefix for frontend to use "models": []configPkg.ModelProfile{}, + // Canonical capability tags (single Go source of truth) so the frontend can + // suggest them when editing model-profile tags, without duplicating the list. + "model_tags": configPkg.CanonicalModelTags(), } // Include RC file path if config is from an RC file diff --git a/web/static/components/SettingsDialog.js b/web/static/components/SettingsDialog.js index f08e0a6a3..938a44444 100644 --- a/web/static/components/SettingsDialog.js +++ b/web/static/components/SettingsDialog.js @@ -1260,6 +1260,11 @@ export function SettingsDialog({ const [acpServers, setAcpServers] = useState([]); // Model profiles (named profiles pairing criteria with capability tags) const [modelProfiles, setModelProfiles] = useState([]); + // Canonical capability tags suggested when editing a profile's tags. Sourced + // from the backend (config.model_tags → config.CanonicalModelTags), so the + // suggestion list stays in sync with Go and is always present. Free-text entry + // of other tags remains allowed (these are only hints). + const [modelTags, setModelTags] = useState([]); // Accordion: index of the single expanded model profile (-1 = all collapsed) const [expandedProfileIndex, setExpandedProfileIndex] = useState(-1); // Raw text drafts for the tags input, keyed by profile index — lets the @@ -1644,6 +1649,7 @@ export function SettingsDialog({ servers.forEach(assignStableKey); setAcpServers(servers); setModelProfiles(Array.isArray(config.models) ? config.models : []); + setModelTags(Array.isArray(config.model_tags) ? config.model_tags : []); setExpandedProfileIndex(-1); setTagDrafts({}); @@ -4868,6 +4874,14 @@ export function SettingsDialog({ Mitto can branch on tags instead of raw model names.

+ + + ${modelTags.map( + (t) => html``, + )} + + ${modelProfiles.map((p, i) => { const isExpanded = expandedProfileIndex === i; const trimmedName = (p.name || "").trim(); @@ -4972,6 +4986,7 @@ export function SettingsDialog({ type="text" class="input input-sm w-full" placeholder="e.g., Smart, Cheap" + list="model-tag-suggestions" value=${tagDrafts[i] !== undefined ? tagDrafts[i] : tags.join(", ")} From 81507fcb1894336031efbf4760720b297a9f1b61 Mon Sep 17 00:00:00 2001 From: Alvaro Saurin Date: Sun, 5 Jul 2026 21:30:45 +0200 Subject: [PATCH 005/240] fix(web): hide overflow shortcut buttons on narrow screens Per-folder/global shortcut toolbars (conversation, beads-issue, tasks-list) can carry several shortcut buttons that overflow the toolbar on phone-width viewports. Keep only the first shortcut visible below 640px via a new .mitto-shortcut-extra utility applied to buttons at index > 0. Also document the un-loop/re-loop LoopStore persistence symmetry requirement and note that a concurrent loop conversation sharing the same repo can stash/reset the working directory mid-task, silently dropping a prior turn's uncommitted edits. --- .augment/rules/02-session.md | 2 ++ CLAUDE.md | 10 +++------- web/static/app.js | 3 +++ web/static/components/BeadsView.js | 6 ++++++ web/static/styles-v2.css | 10 ++++++++++ 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/.augment/rules/02-session.md b/.augment/rules/02-session.md index cf3c8d072..f480254b5 100644 --- a/.augment/rules/02-session.md +++ b/.augment/rules/02-session.md @@ -130,6 +130,8 @@ Stored in `loop.json`. Only top-level sessions may have loop prompts (child → **Critical**: Changing `LoopStore.Update()` signature requires updating BOTH `session_loop_api.go` (PATCH handler) AND `mcpserver/server.go` (MCP tool) — both call `Update()`. +**Un-loop/re-loop persistence symmetry**: `Detach()` saves settings to a slot and clears the active config (un-loop); `GetSaved()`/restore reads it back. A fresh loop `Set()` (not a restore) must be followed by `ClearSaved()` so a stale saved slot doesn't leak in later — required in both `session_loop_write.go` (`handleSetLoop`) and `mcpserver/server.go` (MCP create-loop path). + ## Auxiliary Package The `internal/auxiliary` package provides a hidden ACP session for utility tasks. Lazy init, auto-approve permissions, file writes denied, thread-safe. diff --git a/CLAUDE.md b/CLAUDE.md index 54bd04ca8..ef0ecefb3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,15 +101,11 @@ Used in `BeadsView.js` (list actions + issue-detail header). ## Model Selection & Preferred Models -Prompts can declare `preferredModels:` to route to specific ACP models. `selectPreferredModel()` in `constraints.go` picks the best match using configurable match modes (`"contains"`, `"exact"`, `"startsWith"`, `"regex"`, `"lookAlike"`). **Key insight**: If the active model already satisfies the preference, it's kept; otherwise the preference is applied. This avoids unnecessary model switches in multi-model sessions. +Prompts can declare `preferredModels:` to route to specific ACP models. `selectPreferredModel()` in `constraints.go` picks the best match using configurable match modes (`"contains"`, `"exact"`, `"startsWith"`, `"regex"`, `"lookAlike"`). If the active model already satisfies the preference, it's kept; otherwise applied — avoids unnecessary switches in multi-model sessions. -**Per-prompt transient overrides**: When a prompt declares `preferredModels`, `setActiveModelOnly()` temporarily switches models for that prompt's execution **without** recording a `session_change` event. This is **intentional**: -- Baseline model (conversation-level setting) remains unchanged -- No "Model changed to X" message in timeline (silent override) -- After prompt completes, `restoreBaselineIfOverride()` flips model back to baseline -- Result: Heavy-lift work runs on cheaper models (e.g., Sonnet) while conversation stays on your chosen baseline (e.g., Opus) +**Per-prompt transient overrides**: `setActiveModelOnly()` switches models for a prompt's execution **without** recording a `session_change` event (silent; conversation-level baseline is untouched). `restoreBaselineIfOverride()` flips back after the prompt completes. **Contrast**: manual UI selection → `applyConfigOption()` → `cmRecordSessionChange()` → persistent event, updates baseline. -**Contrast**: Manual model selection (via UI dropdown) → `applyConfigOption()` → `cmRecordSessionChange()` → records persistent `session_change` event and updates baseline. +**Config-level tag resolution**: `(*Config).EffectiveModelProfiles()` unions `settings.json`'s `Models` with hardcoded `config.DefaultModelProfiles()` (7 canonical profiles), user wins by name — so `modelTag:` always resolves even when `settings.json` predates/omits `models:`. `make check-model-tags` keeps `config.default.yaml` and the Go defaults in sync and rejects unknown tags in builtin prompts. See `.augment/rules/08-config.md`. ## CEL Tool Evaluation (Fail-Open Behavior) diff --git a/web/static/app.js b/web/static/app.js index 5e37b11e5..fec08c7df 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -2594,6 +2594,9 @@ function App() { return { kind: "button", testId: `conversation-shortcut-btn-${i}`, + // On phone-width screens only the first shortcut is shown; the rest are + // hidden (see .mitto-shortcut-extra in styles-v2.css) to avoid overflow. + className: i > 0 ? "mitto-shortcut-extra" : undefined, icon: html`<${Icon} className="w-4 h-4" />`, tip: found ? sc.prompt : `Prompt "${sc.prompt}" not found`, ariaLabel: found diff --git a/web/static/components/BeadsView.js b/web/static/components/BeadsView.js index 2019b9c41..b498d69d9 100644 --- a/web/static/components/BeadsView.js +++ b/web/static/components/BeadsView.js @@ -967,6 +967,9 @@ export function BeadsDetailPanel({ return { kind: "button", testId: `beads-issue-shortcut-btn-${i}`, + // On phone-width screens only the first shortcut is shown; the rest are + // hidden (see .mitto-shortcut-extra in styles-v2.css) to avoid overflow. + className: i > 0 ? "mitto-shortcut-extra" : undefined, icon: html`<${Icon} className="w-4 h-4" />`, tip: found ? sc.prompt : `Prompt "${sc.prompt}" not found`, ariaLabel: found @@ -4710,6 +4713,9 @@ export function BeadsView({ return { kind: "button", testId: `beads-shortcut-btn-${i}`, + // On phone-width screens only the first shortcut is shown; the rest are + // hidden (see .mitto-shortcut-extra in styles-v2.css) to avoid overflow. + className: i > 0 ? "mitto-shortcut-extra" : undefined, icon: html`<${Icon} className="w-4 h-4" />`, tip: found ? sc.prompt : `Prompt "${sc.prompt}" not found`, ariaLabel: found diff --git a/web/static/styles-v2.css b/web/static/styles-v2.css index 23c3390db..22a09e16b 100644 --- a/web/static/styles-v2.css +++ b/web/static/styles-v2.css @@ -740,6 +740,16 @@ a:hover { background: #334155; } +/* Shortcut buttons overflow on narrow screens. A toolbar may carry several + per-folder/global shortcut buttons; on phone-width viewports only the first + shortcut is kept and the rest are hidden so they don't overflow the toolbar. + Applied via the item className on shortcut buttons at index > 0. */ +@media (max-width: 640px) { + .mitto-toolbar > button.mitto-shortcut-extra { + display: none; + } +} + /* daisyUI menu items are - - `; - })} - - `; + // Group the beadsList prompts by their `group` into per-group submenus, + // identical to the conversation menu and the detail-panel kebab. ContextMenu + // renders the hover flyouts and per-prompt loop toggles from these items. + const listPromptGroupItems = listPromptsLoading + ? [{ label: "Loading…", disabled: true }] + : (() => { + const groups = buildPromptGroupMenuItems( + listPrompts, + handleRunListPrompt, + html`<${PlusIcon} />`, + ); + return groups.length === 0 + ? [{ label: "No task prompts", disabled: true }] + : groups; + })(); const listToolbarItems = [ { @@ -4835,14 +4740,12 @@ export function BeadsView({ onClick: openCreate, }, { - kind: "dropdown", + kind: "button", testId: "beads-list-prompts-btn", icon: html`<${LightningIcon} className="w-4 h-4" />`, tip: "Run a prompt over the issue list in a new conversation", ariaLabel: "Run a prompt over the issue list in a new conversation", - open: showListPrompts, - onToggle: handleListPromptsToggle, - menu: listPromptsMenu, + onClick: openListPromptsMenu, }, { kind: "button", @@ -4888,11 +4791,10 @@ export function BeadsView({ + the sidebar toolbar. The prompts button opens a ContextMenu, which + handles its own outside-click / Escape dismissal. -->
<${Toolbar} @@ -5249,6 +5151,17 @@ export function BeadsView({ /> ` } + ${ + listPromptsAnchor && + html` + <${ContextMenu} + x=${listPromptsAnchor.x} + y=${listPromptsAnchor.y} + items=${listPromptGroupItems} + onClose=${() => setListPromptsAnchor(null)} + /> + ` + } <${ConfirmDialog} isOpen=${showCleanupConfirm} diff --git a/web/static/components/BeadsView.test.js b/web/static/components/BeadsView.test.js index 74209d52e..56be3a22e 100644 --- a/web/static/components/BeadsView.test.js +++ b/web/static/components/BeadsView.test.js @@ -7,12 +7,6 @@ * cryptic "The string did not match the expected pattern." error. */ -import { - promptLoopMode, - promptLoopIsToggleable, - promptLoopDefaultOn, -} from "../utils/prompts.js"; - // ============================================================================= // readBeadsResponse logic // ============================================================================= @@ -661,76 +655,3 @@ describe("cleanup progress toast — terminal outcomes reset state", () => { }); }); -// ============================================================================= -// beadsList per-item loop control — toggle vs locked badge vs nothing -// (mitto-92x.4) -// ============================================================================= - -/** - * Mirrors the IIFE used in BeadsView's beadsList dropdown item rendering: decides - * whether to render an interactive toggle ("toggle"), a locked badge ("badge"), or - * nothing ("none") for a given prompt + per-item toggle-state map. Uses the real - * promptLoopMode/promptLoopDefaultOn helpers (not a duplicate). - */ -function decideListPromptLoopControl(p, listLoopOn) { - const mode = promptLoopMode(p); - if (mode === "none") return { kind: "none" }; - if (mode === "optional") { - const on = - listLoopOn[p.name] !== undefined - ? listLoopOn[p.name] - : promptLoopDefaultOn(p); - return { kind: "toggle", checked: on }; - } - return { kind: "badge" }; -} - -describe("beadsList per-item loop control", () => { - test("mode: optional, default:false renders an unchecked toggle", () => { - const p = { name: "maybe", loop: { mode: "optional", default: false } }; - expect(promptLoopIsToggleable(p)).toBe(true); - expect(decideListPromptLoopControl(p, {})).toEqual({ - kind: "toggle", - checked: false, - }); - }); - - test("mode: optional, default:true renders a checked toggle", () => { - const p = { name: "maybe", loop: { mode: "optional", default: true } }; - expect(decideListPromptLoopControl(p, {})).toEqual({ - kind: "toggle", - checked: true, - }); - }); - - test("mode: optional, no default renders a checked toggle (default => true)", () => { - const p = { name: "maybe", loop: { mode: "optional" } }; - expect(decideListPromptLoopControl(p, {})).toEqual({ - kind: "toggle", - checked: true, - }); - }); - - test("mode: optional honors the per-item listLoopOn override over the default", () => { - const p = { name: "maybe", loop: { mode: "optional", default: true } }; - expect( - decideListPromptLoopControl(p, { maybe: false }), - ).toEqual({ kind: "toggle", checked: false }); - }); - - test("mode: always renders the locked badge (no checkbox toggle)", () => { - const p = { name: "always-on", loop: { mode: "always" } }; - expect(promptLoopIsToggleable(p)).toBe(false); - expect(decideListPromptLoopControl(p, {})).toEqual({ kind: "badge" }); - }); - - test("loop block with no mode renders the locked badge (absent => always)", () => { - const p = { name: "legacy-loop", loop: { value: 1, unit: "hours" } }; - expect(decideListPromptLoopControl(p, {})).toEqual({ kind: "badge" }); - }); - - test("non-loop prompt renders neither toggle nor badge", () => { - const p = { name: "plain" }; - expect(decideListPromptLoopControl(p, {})).toEqual({ kind: "none" }); - }); -}); From 45070b8e40d3fb684582767ed659b3aed8092248 Mon Sep 17 00:00:00 2001 From: Alvaro Saurin Date: Wed, 8 Jul 2026 09:36:49 +0200 Subject: [PATCH 038/240] refactor(web): rename "Make loop" conversation menu action to "Loop" Shorten the conversation context menu label from "Make loop" to "Loop" and update the make-loop UI spec's title/selectors to match (matching on the exact "Loop" text). --- tests/ui/specs/make-loop.spec.ts | 28 ++++++++++++------------- web/static/hooks/useConversationMenu.js | 2 +- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/ui/specs/make-loop.spec.ts b/tests/ui/specs/make-loop.spec.ts index 32781eb58..f3dd69029 100644 --- a/tests/ui/specs/make-loop.spec.ts +++ b/tests/ui/specs/make-loop.spec.ts @@ -2,10 +2,10 @@ import { testWithCleanup as test, expect } from "../fixtures/test-fixtures"; import { apiUrl } from "../utils/selectors"; /** - * "Make loop" context menu action tests. + * "Loop" context menu action tests. * * Verifies that right-clicking a regular (non-loop, non-child) conversation - * and selecting "Make loop" from the context menu: + * and selecting "Loop" from the context menu: * 1. Sends PUT /api/sessions/{id}/loop with the draft body. * 2. The loop_updated broadcast triggers the frontend to flip * session.loop_enabled=true. @@ -18,7 +18,7 @@ import { apiUrl } from "../utils/selectors"; // daisyUI context menus render as fixed-position
- ${needsRestart && + ${activeTab === "mcp" && + selectedWorkspace?.uuid && + hasLiveAcp && html`
- -
-

- Initial Model -

-
- -

- Switch each new conversation to a specific model as its - baseline, replacing the ACP agent's default. -

-
-
- <${ModelProfileSelect} - value=${initialModelProfile} - profiles=${modelProfiles} - className="w-full" - onChange=${(name) => { - setInitialModelProfile(name); - if (name) setInitialModelTag(""); - }} - /> -
- - or by tag - -
- <${ModelTagSelect} - value=${initialModelTag} - profiles=${modelProfiles} - className="w-full" - onChange=${(tag) => { - setInitialModelTag(tag); - if (tag) setInitialModelProfile(""); - }} - /> -
-
-
-
-

@@ -4900,6 +4881,151 @@ export function SettingsDialog({ >${" "} as placeholder for the workspace path

+ + +
+
+ Open In targets +
+
+ Configure which apps appear in the folder "Open ▸" + menu. Toggle rows to enable/disable; click Edit to + change a target's command. +
+
+ ${openInTargets.map( + (t) => html` +
+
+
+
+ ${t.label} +
+
+ ${t.id}${t.builtin ? "" : " (custom)"} +
+
+ + ${!t.builtin && + html` + + `} + + setOpenInTargets((list) => + list.map((x) => + x.id === t.id + ? { + ...x, + enabled: e.target.checked, + } + : x, + ), + )} + /> +
+ ${openInExpanded[t.id] && + html` +
+ ${!t.builtin && + html` + + setOpenInTargets((list) => + list.map((x) => + x.id === t.id + ? { ...x, label: e.target.value } + : x, + ), + )} + /> + `} + + setOpenInTargets((list) => + list.map((x) => + x.id === t.id + ? { ...x, command: e.target.value } + : x, + ), + )} + /> +

+ Use${" "} + \${MITTO_WORKING_DIR}${" "} as placeholder for the workspace + path +

+
+ `} +
+ `, + )} +
+
+ +
+
`} From 066ab12be1606e38b87cee898e72772058fe5a8b Mon Sep 17 00:00:00 2001 From: Alvaro Saurin Date: Thu, 9 Jul 2026 21:37:44 +0200 Subject: [PATCH 074/240] Revert 59328a1a: move initial-model preference to workspace level (keep aux-model row layout + className props) --- internal/config/config.go | 38 +-------- internal/conversation/acp_callback_sink.go | 12 --- .../conversation/acp_callback_sink_test.go | 36 ++++----- internal/conversation/bgsession_callbacks.go | 81 ------------------- 4 files changed, 16 insertions(+), 151 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index fa2e9775b..fa0150e45 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -947,35 +947,6 @@ type ConversationsConfig struct { // MinLoopCompletionDelaySeconds is the global lower limit (floor) for the // on-completion loop trigger's delay. nil = use default (DefaultMinLoopCompletionDelaySeconds). MinLoopCompletionDelaySeconds *int `json:"min_loop_completion_delay_seconds,omitempty" yaml:"min_loop_completion_delay_seconds,omitempty"` - // InitialModelProfile is the name of a Model profile (Config.Models) applied - // as the baseline model of every new conversation right after the agent - // reports its available models. Empty means keep the agent's default model. - // Mutually exclusive with InitialModelTag in the UI; when both are set, - // InitialModelProfile wins. - InitialModelProfile string `json:"initial_model_profile,omitempty" yaml:"initial_model_profile,omitempty"` - // InitialModelTag selects the initial baseline model by capability tag - // (e.g. "Coding"). Resolved to the first Model profile (Config.Models, in - // definition order) carrying this tag whose Criteria matches an available - // model. Empty means keep the agent's default model. - InitialModelTag string `json:"initial_model_tag,omitempty" yaml:"initial_model_tag,omitempty"` -} - -// GetInitialModelPreference returns the initial-model preference as an ordered -// list of PromptPreferredModel entries suitable for SelectPreferredModel. -// Returns nil when neither InitialModelProfile nor InitialModelTag is set. -// InitialModelProfile takes precedence over InitialModelTag when both are set. -// Safe to call on a nil receiver. -func (c *ConversationsConfig) GetInitialModelPreference() []PromptPreferredModel { - if c == nil { - return nil - } - if c.InitialModelProfile != "" { - return []PromptPreferredModel{{ModelName: c.InitialModelProfile}} - } - if c.InitialModelTag != "" { - return []PromptPreferredModel{{ModelTag: c.InitialModelTag}} - } - return nil } // ActionButtonsConfig configures the follow-up suggestions feature. @@ -1693,8 +1664,6 @@ type rawConfig struct { MaxChildConversations *int `yaml:"max_child_conversations"` MaxLoopIterations *int `yaml:"max_loop_iterations"` MinLoopCompletionDelaySeconds *int `yaml:"min_loop_completion_delay_seconds"` - InitialModelProfile string `yaml:"initial_model_profile"` - InitialModelTag string `yaml:"initial_model_tag"` } `yaml:"conversations"` // RestrictedRunners is the top-level per-runner-type configuration RestrictedRunners map[string]*WorkspaceRunnerConfig `yaml:"restricted_runners"` @@ -2078,16 +2047,11 @@ func Parse(data []byte) (*Config, error) { cfg.Conversations.MinLoopCompletionDelaySeconds = raw.Conversations.MinLoopCompletionDelaySeconds } - // Copy initial model preference (applied as baseline for new conversations) - cfg.Conversations.InitialModelProfile = raw.Conversations.InitialModelProfile - cfg.Conversations.InitialModelTag = raw.Conversations.InitialModelTag - // If no config was actually set, nil out the conversations config if cfg.Conversations.Processing == nil && cfg.Conversations.Queue == nil && cfg.Conversations.ActionButtons == nil && cfg.Conversations.ExternalImages == nil && cfg.Conversations.DefaultFlags == nil && cfg.Conversations.MaxChildConversations == nil && - cfg.Conversations.MaxLoopIterations == nil && cfg.Conversations.MinLoopCompletionDelaySeconds == nil && - cfg.Conversations.InitialModelProfile == "" && cfg.Conversations.InitialModelTag == "" { + cfg.Conversations.MaxLoopIterations == nil && cfg.Conversations.MinLoopCompletionDelaySeconds == nil { cfg.Conversations = nil } } diff --git a/internal/conversation/acp_callback_sink.go b/internal/conversation/acp_callback_sink.go index 805e89ffe..63a8de005 100644 --- a/internal/conversation/acp_callback_sink.go +++ b/internal/conversation/acp_callback_sink.go @@ -115,12 +115,6 @@ type acpCallbackDeps interface { // cbApplyConfigConstraintsAsync kicks off the async constraint-application // goroutine for a category (matches the legacy `go bs.applyConfigConstraints(...)`). cbApplyConfigConstraintsAsync(category string) - // cbMaybeApplyInitialModelAsync kicks off an async goroutine that applies - // the global initial-model preference (Settings → Conversations) as the - // session's persistent baseline model. No-op for resumed sessions (they - // already have a persisted BaselineModel) and for sessions whose workspace - // has an ACP server constraint on the model category (which takes precedence). - cbMaybeApplyInitialModelAsync() // cbStreamingSuppressed reports whether streaming callbacks are currently // suppressed (e.g. during an in-place context flush). When true, each gated @@ -643,12 +637,6 @@ func (acpCallbackSink) setAgentModels(d acpCallbackDeps, models *SessionModelSta d.cbInitBaselineModelIfEmpty(models.CurrentModelId) d.cbApplyConfigConstraintsAsync(ConfigOptionCategoryModel) - - // For fresh conversations (no persisted baseline, no ACP server constraint on - // the model), apply the global initial-model preference from Settings → - // Conversations as the session's persistent baseline. See - // (*BackgroundSession).cbMaybeApplyInitialModelAsync. - d.cbMaybeApplyInitialModelAsync() } // recordEventWithSeqHelper is a small helper used by BackgroundSession's diff --git a/internal/conversation/acp_callback_sink_test.go b/internal/conversation/acp_callback_sink_test.go index b5d005875..a913edc66 100644 --- a/internal/conversation/acp_callback_sink_test.go +++ b/internal/conversation/acp_callback_sink_test.go @@ -42,22 +42,21 @@ type fakeCallbackDeps struct { streamingSuppressed bool // mitto-2tm: gates streaming callback short-circuit // recorders - notifiedEvents []string - recordedEvents []session.Event - recordedEventKinds []string - recordedPermissions []recordedPermission - contextUsages [][2]int - mcpRequests []string - planEntries [][]PlanEntry - uiPromptCalls []UIPromptRequest - modeCurrentValues []string - persistedConfig [][2]string - configChanged [][2]string - legacyModesSet []SessionConfigOption - storedAgentModels []*SessionModelState - modelReplacements []SessionConfigOption - asyncConstraintCats []string - initialModelInvocations int + notifiedEvents []string + recordedEvents []session.Event + recordedEventKinds []string + recordedPermissions []recordedPermission + contextUsages [][2]int + mcpRequests []string + planEntries [][]PlanEntry + uiPromptCalls []UIPromptRequest + modeCurrentValues []string + persistedConfig [][2]string + configChanged [][2]string + legacyModesSet []SessionConfigOption + storedAgentModels []*SessionModelState + modelReplacements []SessionConfigOption + asyncConstraintCats []string } type recordedPermission struct{ Title, OptionID, Outcome string } @@ -178,11 +177,6 @@ func (f *fakeCallbackDeps) cbApplyConfigConstraintsAsync(category string) { defer f.mu.Unlock() f.asyncConstraintCats = append(f.asyncConstraintCats, category) } -func (f *fakeCallbackDeps) cbMaybeApplyInitialModelAsync() { - f.mu.Lock() - defer f.mu.Unlock() - f.initialModelInvocations++ -} func (f *fakeCallbackDeps) cbStreamingSuppressed() bool { return f.streamingSuppressed diff --git a/internal/conversation/bgsession_callbacks.go b/internal/conversation/bgsession_callbacks.go index 9b6282493..46ce21074 100644 --- a/internal/conversation/bgsession_callbacks.go +++ b/internal/conversation/bgsession_callbacks.go @@ -7,7 +7,6 @@ package conversation import ( "context" "log/slog" - "time" "github.com/coder/acp-go-sdk" @@ -282,86 +281,6 @@ func (bs *BackgroundSession) cbApplyConfigConstraintsAsync(category string) { go bs.applyConfigConstraints(category) } -// initialModelApplyBudget bounds the SetSessionModel RPC issued to apply the -// global initial-model preference on fresh conversations. Kept generous so a -// cold agent still lands the switch, but capped so the goroutine does not -// linger indefinitely on a stuck ACP. -var initialModelApplyBudget = 90 * time.Second - -// cbMaybeApplyInitialModelAsync applies the global initial-model preference -// (Settings → Conversations → Initial Model) as the session's persistent -// baseline for FRESH conversations only. Skipped when: -// - no preference is configured; -// - the session was resumed (persisted metadata already carries a BaselineModel); -// - the workspace has an ACP server constraint on the model category (it wins -// and would fight with our change on every resume); -// - the preference cannot be resolved against the agent's available models. -// -// Applies via SetConfigOption so the change updates the baseline, persists to -// metadata, and emits a session_change timeline entry — identical to a manual -// UI selection. -func (bs *BackgroundSession) cbMaybeApplyInitialModelAsync() { - if bs.mittoConfig == nil { - return - } - prefs := bs.mittoConfig.Conversations.GetInitialModelPreference() - if len(prefs) == 0 { - return - } - // Skip resumed sessions: they already have a persisted baseline that reflects - // prior manual selections (or a prior application of this same preference). - if bs.store != nil && bs.persistedID != "" { - if meta, err := bs.store.GetMetadata(bs.persistedID); err == nil && meta.BaselineModel != "" { - return - } - } - // Skip when a workspace ACP server constraint already governs the model - // category — it wins (see applyConfigConstraints) and re-runs on every - // resume, so any change we make here would be immediately reverted. - if constraint := bs.cbACPServerConstraint(ConfigOptionCategoryModel); constraint != nil && constraint.Pattern != "" { - return - } - - go func() { - models := bs.agentModels - if models == nil { - return - } - resolved := SelectPreferredModel(prefs, bs.mittoConfig.EffectiveModelProfiles(), models) - if resolved == "" { - if bs.logger != nil { - bs.logger.Debug("initial model preference: no matching available model", - "session_id", bs.persistedID, - "preference", prefs) - } - return - } - if models.CurrentModelId == resolved { - // Baseline is already the desired model — still record it in the persisted - // baseline metadata so future resumes skip the constraint check above. - bs.cmPersistBaselineModel(resolved) - return - } - ctx, cancel := context.WithTimeout(bs.ctx, initialModelApplyBudget) - defer cancel() - if err := bs.configMgr.applyConfigOption(bs, ctx, ConfigOptionCategoryModel, resolved); err != nil { - if bs.logger != nil { - bs.logger.Warn("initial model preference: failed to apply", - "session_id", bs.persistedID, - "model", resolved, - "error", err) - } - return - } - if bs.logger != nil { - bs.logger.Info("initial model preference applied", - "session_id", bs.persistedID, - "model", resolved, - "preference", prefs) - } - }() -} - // cbStreamingSuppressed reports whether streaming callbacks are currently suppressed // (i.e. during an in-place context flush). Used by acpCallbackSink to short-circuit. func (bs *BackgroundSession) cbStreamingSuppressed() bool { From 78c0f5eec7ab2c3eaf0c945f88249ab9a0dc6742 Mon Sep 17 00:00:00 2001 From: Alvaro Saurin Date: Thu, 9 Jul 2026 21:43:56 +0200 Subject: [PATCH 075/240] feat(sessionlist): collapse folder context menu into Open submenu (mitto-bbi.4) --- web/static/app.js | 74 ++++++++++++++++++++++++- web/static/components/SessionList.js | 67 +++++++++++++++------- web/static/components/SettingsDialog.js | 25 ++++++--- 3 files changed, 135 insertions(+), 31 deletions(-) diff --git a/web/static/app.js b/web/static/app.js index fec08c7df..4fd567b64 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -113,7 +113,10 @@ import { Toolbar } from "./components/Toolbar.js"; import { MessageList } from "./components/MessageList.js"; import { Message } from "./components/Message.js"; import { ChatInput } from "./components/ChatInput.js"; -import { SettingsDialog } from "./components/SettingsDialog.js"; +import { + SettingsDialog, + DEFAULT_MAC_OPEN_TARGETS, +} from "./components/SettingsDialog.js"; import { WorkspacesDialog } from "./components/WorkspacesDialog.js"; import { AgentDiscoveryDialog } from "./components/AgentDiscoveryDialog.js"; import { QueueDropdown } from "./components/QueueDropdown.js"; @@ -992,6 +995,13 @@ function App() { const [terminalActionCommand, setTerminalActionCommand] = useState( "open -a Terminal ${MITTO_WORKING_DIR}", ); + // "Open In" targets (macOS only, mitto-bbi). Populated from config.ui.mac.open_in.targets; + // falls back to DEFAULT_MAC_OPEN_TARGETS when the block is absent — matches the fallback + // the backend applies at exec time via config.DefaultOpenTargets(). Passed to + // for the folder context-menu "Open ▸" submenu. + const [openInTargets, setOpenInTargets] = useState(() => + DEFAULT_MAC_OPEN_TARGETS.map((t) => ({ ...t })), + ); // Derive enabled state from non-empty command const badgeClickEnabled = @@ -1067,6 +1077,25 @@ function App() { config?.ui?.mac?.terminal_action?.command || "open -a Terminal ${MITTO_WORKING_DIR}", ); + // Load Open In targets (macOS only). Same shape and fallback as + // SettingsDialog.js — when ui.mac.open_in.targets is missing/empty we + // synthesize the shared DEFAULT_MAC_OPEN_TARGETS so the folder + // context-menu submenu still shows Finder + Terminal on fresh installs. + const macOpenInTargets = config?.ui?.mac?.open_in?.targets; + if (Array.isArray(macOpenInTargets) && macOpenInTargets.length > 0) { + setOpenInTargets( + macOpenInTargets.map((t) => ({ + id: t.id || "", + label: t.label || "", + icon: t.icon || "", + command: t.command || "", + enabled: t.enabled !== false, + builtin: t.builtin === true, + })), + ); + } else { + setOpenInTargets(DEFAULT_MAC_OPEN_TARGETS.map((t) => ({ ...t }))); + } // Load input font family setting (web UI) if (config?.ui?.web?.input_font_family) { setInputFontFamily(config.ui.web.input_font_family); @@ -1752,6 +1781,47 @@ function App() { [badgeClickEnabled, showToast], ); + // Fire a configured "Open In" target (mitto-bbi.4). Sends + // {action:"open", target_id} to /api/badge-click; backend resolves against + // EffectiveOpenTargets() and executes target.Command via sh -c. Errors surface + // as toasts using the same envelope as handleFolderOpen. + const handleOpenTarget = useCallback( + async (workspacePath, targetId) => { + if (!workspacePath || !targetId) return; + + try { + const res = await authFetch(apiUrl("/api/badge-click"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + workspace_path: workspacePath, + action: "open", + target_id: targetId, + }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + showToast({ + style: "error", + title: data.error?.message || data.error || "Failed to open target", + }); + } else { + const data = await res.json(); + if (!data.success && data.error) { + showToast({ style: "error", title: data.error }); + } + } + } catch (err) { + showToast({ + style: "error", + title: "Failed to open target: " + err.message, + }); + } + }, + [showToast], + ); + // Move a folder to an organizational group (folders.json group label). An // empty group clears the assignment. Persists via PUT /api/workspaces/{uuid}/folder-group, // then refreshes workspaces so the sidebar regroups immediately. @@ -3474,6 +3544,8 @@ function App() { onFolderOpen=${handleFolderOpen} onMoveFolderToGroup=${handleMoveFolderToGroup} onTerminalClick=${handleTerminalClick} + openInTargets=${openInTargets} + onOpenTarget=${handleOpenTarget} onBeadsOpen=${handleBeadsOpen} onBeadsCreate=${(wd) => setQuickCreate({ open: true, workingDir: wd })} diff --git a/web/static/components/SessionList.js b/web/static/components/SessionList.js index af4f85692..b01e1a558 100644 --- a/web/static/components/SessionList.js +++ b/web/static/components/SessionList.js @@ -61,6 +61,22 @@ import { getPromptIconOrDefault, } from "./Icons.js"; +// Resolve an icon component for an OpenTarget entry keyed by its icon field +// (falls back to the target id when icon is empty). Anything unrecognised falls +// back to the generic folder icon. Keep this in sync with the ids in +// SettingsDialog.DEFAULT_MAC_OPEN_TARGETS and config.DefaultOpenTargets(). +const resolveOpenIcon = (key) => { + switch (key) { + case "finder": + return FolderOpenIcon; + case "terminal": + case "iterm": + return TerminalIcon; + default: + return FolderOpenIcon; + } +}; + // Module-level cache for git changes: keyed by workingDir. // Each entry: { data, ts } where ts is Date.now() of the last fetch. const GIT_CHANGES_CACHE = {}; @@ -204,6 +220,11 @@ export function SessionList({ onFolderOpen, onMoveFolderToGroup, // Called with (workingDir, group) to reassign a folder's group onTerminalClick, + // Configurable "Open ▸" submenu targets (mitto-bbi). Each entry: + // {id,label,icon,command,enabled,builtin}. Only entries with enabled===true + // appear in the folder context-menu submenu. Callback: onOpenTarget(workingDir, id). + openInTargets = [], + onOpenTarget, onBeadsOpen, onBeadsCreate, // (workingDir) => open the new-issue side panel for a folder onFetchBeadsListPrompts, // async (workingDir) => menus:beadsList prompts[] @@ -1562,28 +1583,32 @@ export function SessionList({ }, ] : []), - ...(badgeClickEnabled && groupContextMenu.workingDir - ? [ - { - label: "Open Folder", - icon: html`<${FolderOpenIcon} className="w-4 h-4" />`, + ...(() => { + // Collapsed "Open ▸" submenu (mitto-bbi.4): one entry per enabled + // OpenTarget from ui.mac.open_in.targets. Hidden entirely when the + // filtered list is empty — matches previous behaviour of hiding + // when both legacy toggles were off. + if (!groupContextMenu.workingDir) return []; + const enabledTargets = (openInTargets || []).filter( + (t) => t && t.enabled === true, + ); + if (enabledTargets.length === 0) return []; + return [ + { + label: "Open", + icon: html`<${FolderOpenIcon} className="w-4 h-4" />`, + submenu: enabledTargets.map((t) => ({ + label: t.label || t.id, + icon: html`<${resolveOpenIcon( + t.icon || t.id, + )} className="w-4 h-4" />`, onClick: () => - onFolderOpen && - onFolderOpen(groupContextMenu.workingDir), - }, - ] - : []), - ...(terminalActionEnabled && groupContextMenu.workingDir - ? [ - { - label: "Open Terminal", - icon: html`<${TerminalIcon} className="w-4 h-4" />`, - onClick: () => - onTerminalClick && - onTerminalClick(groupContextMenu.workingDir), - }, - ] - : []), + onOpenTarget && + onOpenTarget(groupContextMenu.workingDir, t.id), + })), + }, + ]; + })(), ...(!configReadonly && groupContextMenu.workingDir ? [ { diff --git a/web/static/components/SettingsDialog.js b/web/static/components/SettingsDialog.js index 0f919cb17..3efb3a3b2 100644 --- a/web/static/components/SettingsDialog.js +++ b/web/static/components/SettingsDialog.js @@ -132,6 +132,21 @@ const THEME_LABELS = { // WorkspaceBadge is now a standalone component module (not prop-drilled from app.js) +// DEFAULT_MAC_OPEN_TARGETS mirrors backend config.DefaultOpenTargets() (darwin) +// verbatim so the UI shows the same rows the backend would synthesize when +// ui.mac.open_in.targets is absent. Consumed by SettingsDialog (Open In section) +// and by app.js (folder context-menu submenu — see SessionList.js). Keep the +// entries in sync with internal/config/config.go DefaultOpenTargets(). +export const DEFAULT_MAC_OPEN_TARGETS = [ + { id: "finder", label: "Finder", icon: "finder", command: "open ${MITTO_WORKING_DIR}", enabled: true, builtin: true }, + { id: "terminal", label: "Terminal", icon: "terminal", command: "open -a Terminal ${MITTO_WORKING_DIR}", enabled: true, builtin: true }, + { id: "iterm", label: "iTerm", icon: "iterm", command: "open -a iTerm ${MITTO_WORKING_DIR}", enabled: false, builtin: true }, + { id: "vscode", label: "Visual Studio Code", icon: "vscode", command: `open -a "Visual Studio Code" \${MITTO_WORKING_DIR}`, enabled: false, builtin: true }, + { id: "cursor", label: "Cursor", icon: "cursor", command: "open -a Cursor ${MITTO_WORKING_DIR}", enabled: false, builtin: true }, + { id: "xcode", label: "Xcode", icon: "xcode", command: "open -a Xcode ${MITTO_WORKING_DIR}", enabled: false, builtin: true }, + { id: "goland", label: "GoLand", icon: "goland", command: "open -a GoLand ${MITTO_WORKING_DIR}", enabled: false, builtin: true }, +]; + /** * FolderListEditor — reusable folder list editing component with append/replace modes. * @@ -1771,15 +1786,7 @@ export function SettingsDialog({ })), ); } else { - setOpenInTargets([ - { id: "finder", label: "Finder", icon: "finder", command: "open ${MITTO_WORKING_DIR}", enabled: true, builtin: true }, - { id: "terminal", label: "Terminal", icon: "terminal", command: "open -a Terminal ${MITTO_WORKING_DIR}", enabled: true, builtin: true }, - { id: "iterm", label: "iTerm", icon: "iterm", command: "open -a iTerm ${MITTO_WORKING_DIR}", enabled: false, builtin: true }, - { id: "vscode", label: "Visual Studio Code", icon: "vscode", command: `open -a "Visual Studio Code" \${MITTO_WORKING_DIR}`, enabled: false, builtin: true }, - { id: "cursor", label: "Cursor", icon: "cursor", command: "open -a Cursor ${MITTO_WORKING_DIR}", enabled: false, builtin: true }, - { id: "xcode", label: "Xcode", icon: "xcode", command: "open -a Xcode ${MITTO_WORKING_DIR}", enabled: false, builtin: true }, - { id: "goland", label: "GoLand", icon: "goland", command: "open -a GoLand ${MITTO_WORKING_DIR}", enabled: false, builtin: true }, - ]); + setOpenInTargets(DEFAULT_MAC_OPEN_TARGETS.map((t) => ({ ...t }))); } // Load notification permission status (macOS only) - used to show warning if denied From 25c58c048a0da4237629b3db1cebcdde224193c8 Mon Sep 17 00:00:00 2001 From: Alvaro Saurin Date: Thu, 9 Jul 2026 21:56:46 +0200 Subject: [PATCH 076/240] feat(config): per-workspace initial-model preference for new conversations Adds an Initial Model row on the Workspaces dialog (per-WorkspaceSettings) that switches every fresh conversation created in the workspace to a specific model as its persistent baseline, replacing the ACP agent's default. Selection is by Model profile name OR capability tag (mutually exclusive; profile wins server-side when both are set). Resumed sessions and auto-children are unaffected (they preserve their persisted baseline or their own per-child override). Config: - WorkspaceSettings.InitialModelProfile / InitialModelTag with GetInitialModelPreference() returning an ordered []PromptPreferredModel ready for SelectPreferredModel. - Round-trip persistence via the existing bulk /api/config POST from WorkspacesDialog; empty when neither field is set. Backend: - New BackgroundSessionConfig.InitialModelPreference carries the resolved slice into the session. SessionManager.CreateSessionWithWorkspace fills it from the effective workspace (fresh top-level sessions only). - New AcpCallbackDeps.cbMaybeApplyInitialModelAsync hook invoked from the model-config-options callback path (after applyConfigConstraints). - BackgroundSession implementation applies the preference in a background goroutine bounded by initialModelApplyBudget (90s), skipping when: no preference configured; the session is resumed and already has a persisted BaselineModel; the workspace has an ACP-server constraint on the model category; or the preference does not resolve against the agent's available models. Applied via applyConfigOption so it updates the baseline, persists metadata, and emits a session_change entry like a manual UI selection. Frontend: - WorkspacesDialog: new 'Initial Model' row above the 'Auxiliary Model Selection' row, side-by-side ModelProfileSelect / ModelTagSelect; selecting one clears the other. Values omitted from the save payload when unset. Loaded from selectedWorkspace.initial_model_profile / initial_model_tag and flushed through buildWorkspaceEditsFor. Tests: - WorkspaceSettings JSON round-trip and omitempty for the new fields. - GetInitialModelPreference: nil receiver, empty, profile-only, tag-only, and profile-wins-over-tag. --- internal/config/workspaces.go | 30 +++++ internal/config/workspaces_test.go | 103 ++++++++++++++++++ internal/conversation/acp_callback_sink.go | 13 +++ .../conversation/acp_callback_sink_test.go | 36 +++--- internal/conversation/background_session.go | 38 +++++-- internal/conversation/bgsession_callbacks.go | 82 ++++++++++++++ internal/conversation/session_manager.go | 9 ++ web/static/components/WorkspacesDialog.js | 50 +++++++++ 8 files changed, 335 insertions(+), 26 deletions(-) diff --git a/internal/config/workspaces.go b/internal/config/workspaces.go index 2149a6521..fb7b7846d 100644 --- a/internal/config/workspaces.go +++ b/internal/config/workspaces.go @@ -117,6 +117,17 @@ type WorkspaceSettings struct { // model. Mutually exclusive with AuxiliaryModelProfile in the UI; when both // are set, AuxiliaryModelProfile wins. Falls back to AuxiliaryModelSelection. AuxiliaryModelTag string `json:"auxiliary_model_tag,omitempty" yaml:"auxiliary_model_tag,omitempty"` + // InitialModelProfile is the name of a Model profile (Config.Models) applied + // as the baseline model of every new conversation created in this workspace, + // right after the agent reports its available models. Empty means keep the + // agent's default model. Mutually exclusive with InitialModelTag in the UI; + // when both are set, InitialModelProfile wins. + InitialModelProfile string `json:"initial_model_profile,omitempty" yaml:"initial_model_profile,omitempty"` + // InitialModelTag selects the initial baseline model by capability tag + // (e.g. "Coding"). Resolved to the first Model profile (Config.Models, in + // definition order) carrying this tag whose Criteria matches an available + // model. Empty means keep the agent's default model. + InitialModelTag string `json:"initial_model_tag,omitempty" yaml:"initial_model_tag,omitempty"` // IsDefault marks this workspace as the default for its working directory. // When multiple workspaces share the same folder (e.g. different ACP servers // or model variants), the one with IsDefault set is preferred when a workspace @@ -163,6 +174,25 @@ func (w *WorkspaceSettings) GetAutoApprove() *bool { return w.AutoApprove } +// GetInitialModelPreference returns the workspace's initial-model preference as +// an ordered list of PromptPreferredModel entries suitable for +// conversation.SelectPreferredModel. Returns nil when neither +// InitialModelProfile nor InitialModelTag is set. InitialModelProfile takes +// precedence over InitialModelTag when both are set. Safe to call on a nil +// receiver. +func (w *WorkspaceSettings) GetInitialModelPreference() []PromptPreferredModel { + if w == nil { + return nil + } + if w.InitialModelProfile != "" { + return []PromptPreferredModel{{ModelName: w.InitialModelProfile}} + } + if w.InitialModelTag != "" { + return []PromptPreferredModel{{ModelTag: w.InitialModelTag}} + } + return nil +} + // NormalizeDefaultWorkspaces enforces the invariant that at most one workspace // per working directory has IsDefault set. When several workspaces sharing the // same folder are marked default, the first one (in slice order) is kept and the diff --git a/internal/config/workspaces_test.go b/internal/config/workspaces_test.go index 1b723cc96..e051677b5 100644 --- a/internal/config/workspaces_test.go +++ b/internal/config/workspaces_test.go @@ -62,6 +62,109 @@ func TestWorkspaceSettings_AuxiliaryModelSelection_JSONOmitempty(t *testing.T) { } } +// ---- WorkspaceSettings InitialModel tests ---- + +func TestWorkspaceSettings_InitialModel_JSONRoundTrip(t *testing.T) { + w := WorkspaceSettings{ + ACPServer: "claude-code", + WorkingDir: "/proj", + InitialModelProfile: "Claude Opus", + InitialModelTag: "Coding", + } + data, err := json.Marshal(w) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + var got WorkspaceSettings + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + if got.InitialModelProfile != "Claude Opus" { + t.Errorf("InitialModelProfile = %q, want %q", got.InitialModelProfile, "Claude Opus") + } + if got.InitialModelTag != "Coding" { + t.Errorf("InitialModelTag = %q, want %q", got.InitialModelTag, "Coding") + } +} + +func TestWorkspaceSettings_InitialModel_JSONOmitempty(t *testing.T) { + w := WorkspaceSettings{ + ACPServer: "claude-code", + WorkingDir: "/proj", + } + data, err := json.Marshal(w) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + if _, ok := raw["initial_model_profile"]; ok { + t.Error("initial_model_profile should be omitted from JSON when empty") + } + if _, ok := raw["initial_model_tag"]; ok { + t.Error("initial_model_tag should be omitted from JSON when empty") + } +} + +func TestWorkspaceSettings_GetInitialModelPreference(t *testing.T) { + tests := []struct { + name string + ws *WorkspaceSettings + want []PromptPreferredModel + wantNil bool + }{ + { + name: "nil receiver", + ws: nil, + wantNil: true, + }, + { + name: "no preference configured", + ws: &WorkspaceSettings{ACPServer: "auggie"}, + wantNil: true, + }, + { + name: "profile only", + ws: &WorkspaceSettings{InitialModelProfile: "Claude Opus"}, + want: []PromptPreferredModel{{ModelName: "Claude Opus"}}, + }, + { + name: "tag only", + ws: &WorkspaceSettings{InitialModelTag: "Coding"}, + want: []PromptPreferredModel{{ModelTag: "Coding"}}, + }, + { + name: "profile wins over tag when both set", + ws: &WorkspaceSettings{ + InitialModelProfile: "Claude Opus", + InitialModelTag: "Cheap", + }, + want: []PromptPreferredModel{{ModelName: "Claude Opus"}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.ws.GetInitialModelPreference() + if tt.wantNil { + if got != nil { + t.Errorf("got %v, want nil", got) + } + return + } + if len(got) != len(tt.want) { + t.Fatalf("got %d entries, want %d: %v", len(got), len(tt.want), got) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("entry %d: got %+v, want %+v", i, got[i], tt.want[i]) + } + } + }) + } +} + // ---- LoadWorkspacesFromFile tests ---- func TestLoadWorkspacesFromFile_JSON(t *testing.T) { diff --git a/internal/conversation/acp_callback_sink.go b/internal/conversation/acp_callback_sink.go index 63a8de005..95e6c6372 100644 --- a/internal/conversation/acp_callback_sink.go +++ b/internal/conversation/acp_callback_sink.go @@ -115,6 +115,13 @@ type acpCallbackDeps interface { // cbApplyConfigConstraintsAsync kicks off the async constraint-application // goroutine for a category (matches the legacy `go bs.applyConfigConstraints(...)`). cbApplyConfigConstraintsAsync(category string) + // cbMaybeApplyInitialModelAsync kicks off an async goroutine that applies + // the per-workspace initial-model preference (WorkspaceSettings → Initial + // Model) as the session's persistent baseline model. No-op for resumed + // sessions (they already have a persisted BaselineModel), for sessions + // whose workspace has an ACP server constraint on the model category + // (which takes precedence), and when no preference is configured. + cbMaybeApplyInitialModelAsync() // cbStreamingSuppressed reports whether streaming callbacks are currently // suppressed (e.g. during an in-place context flush). When true, each gated @@ -637,6 +644,12 @@ func (acpCallbackSink) setAgentModels(d acpCallbackDeps, models *SessionModelSta d.cbInitBaselineModelIfEmpty(models.CurrentModelId) d.cbApplyConfigConstraintsAsync(ConfigOptionCategoryModel) + + // For fresh conversations (no persisted baseline, no ACP server constraint on + // the model), apply the per-workspace initial-model preference from + // WorkspaceSettings → Initial Model as the session's persistent baseline. + // See (*BackgroundSession).cbMaybeApplyInitialModelAsync. + d.cbMaybeApplyInitialModelAsync() } // recordEventWithSeqHelper is a small helper used by BackgroundSession's diff --git a/internal/conversation/acp_callback_sink_test.go b/internal/conversation/acp_callback_sink_test.go index a913edc66..2ab48e58d 100644 --- a/internal/conversation/acp_callback_sink_test.go +++ b/internal/conversation/acp_callback_sink_test.go @@ -42,21 +42,22 @@ type fakeCallbackDeps struct { streamingSuppressed bool // mitto-2tm: gates streaming callback short-circuit // recorders - notifiedEvents []string - recordedEvents []session.Event - recordedEventKinds []string - recordedPermissions []recordedPermission - contextUsages [][2]int - mcpRequests []string - planEntries [][]PlanEntry - uiPromptCalls []UIPromptRequest - modeCurrentValues []string - persistedConfig [][2]string - configChanged [][2]string - legacyModesSet []SessionConfigOption - storedAgentModels []*SessionModelState - modelReplacements []SessionConfigOption - asyncConstraintCats []string + notifiedEvents []string + recordedEvents []session.Event + recordedEventKinds []string + recordedPermissions []recordedPermission + contextUsages [][2]int + mcpRequests []string + planEntries [][]PlanEntry + uiPromptCalls []UIPromptRequest + modeCurrentValues []string + persistedConfig [][2]string + configChanged [][2]string + legacyModesSet []SessionConfigOption + storedAgentModels []*SessionModelState + modelReplacements []SessionConfigOption + asyncConstraintCats []string + maybeApplyInitialCall int } type recordedPermission struct{ Title, OptionID, Outcome string } @@ -177,6 +178,11 @@ func (f *fakeCallbackDeps) cbApplyConfigConstraintsAsync(category string) { defer f.mu.Unlock() f.asyncConstraintCats = append(f.asyncConstraintCats, category) } +func (f *fakeCallbackDeps) cbMaybeApplyInitialModelAsync() { + f.mu.Lock() + defer f.mu.Unlock() + f.maybeApplyInitialCall++ +} func (f *fakeCallbackDeps) cbStreamingSuppressed() bool { return f.streamingSuppressed diff --git a/internal/conversation/background_session.go b/internal/conversation/background_session.go index 158d7ccba..bd1cdde83 100644 --- a/internal/conversation/background_session.go +++ b/internal/conversation/background_session.go @@ -212,17 +212,22 @@ type BackgroundSession struct { stderrPatterns *CompiledStderrPatterns // Per-agent stderr regex patterns (mitto-k6h); nil = baseline only acpServerConstraints map[string]*config.ACPServerConstraint // Auto-selection constraints from the ACP server config mittoConfig *config.Config // Full Mitto config; used for model-tag resolution (config.ResolveModelTags) - contextFlushCommand string // Agent-native context-flush command (e.g. "/clear"); empty = disabled - procCtl acpProcessController // ACP restart policy collaborator (composition) - titleCoord titleCoordinator // Auto-title generation triggers collaborator (composition) - promptArgCache *promptArgCache // Per-conversation prompt argument value cache (composition) - queueDisp queueDispatcher // Queue tick / dispatch logic collaborator (composition) - callbackSink acpCallbackSink // WebClient callback cluster collaborator (composition) - uiPromptCtr uiPromptCenter // UI prompt + notify collaborator (composition) - followUpCoord followUpCoordinator // Follow-up suggestions + action-button collaborator (composition) - configMgr configManager // Session-config / model-baseline collaborator (composition) - handshaker sharedSessionHandshaker // Shared-process session handshake collaborator (composition) - promptDisp promptDispatcher // PromptWithMeta helper-split collaborator (composition) + // initialModelPreference is the per-workspace initial-model preference + // applied to fresh top-level sessions by cbMaybeApplyInitialModelAsync. + // Nil for resumed sessions, auto-children, and workspaces without a + // preference configured. + initialModelPreference []config.PromptPreferredModel + contextFlushCommand string // Agent-native context-flush command (e.g. "/clear"); empty = disabled + procCtl acpProcessController // ACP restart policy collaborator (composition) + titleCoord titleCoordinator // Auto-title generation triggers collaborator (composition) + promptArgCache *promptArgCache // Per-conversation prompt argument value cache (composition) + queueDisp queueDispatcher // Queue tick / dispatch logic collaborator (composition) + callbackSink acpCallbackSink // WebClient callback cluster collaborator (composition) + uiPromptCtr uiPromptCenter // UI prompt + notify collaborator (composition) + followUpCoord followUpCoordinator // Follow-up suggestions + action-button collaborator (composition) + configMgr configManager // Session-config / model-baseline collaborator (composition) + handshaker sharedSessionHandshaker // Shared-process session handshake collaborator (composition) + promptDisp promptDispatcher // PromptWithMeta helper-split collaborator (composition) // Session config options - configurable settings for the session // This supports both legacy "modes" API and newer "configOptions" API. @@ -401,6 +406,15 @@ type BackgroundSessionConfig struct { // Used by auto-children to apply a per-child initial model profile. ModelConstraintOverride *config.ACPServerConstraint + // InitialModelPreference is the per-workspace initial-model preference + // (WorkspaceSettings.InitialModelProfile / InitialModelTag) resolved as an + // ordered list ready for SelectPreferredModel. When non-empty and no + // ModelConstraintOverride is set, BackgroundSession applies it as the + // session's persistent baseline after the agent reports its available + // models. Only set for fresh top-level sessions by SessionManager; + // resumed sessions and auto-children leave this nil. + InitialModelPreference []config.PromptPreferredModel + // AvailableACPServers is the pre-computed list of ACP servers that have workspaces // configured for the session's working directory. Populated by SessionManager using // the same logic as the mitto_conversation_get_current MCP tool. @@ -638,6 +652,8 @@ func NewBackgroundSession(cfg BackgroundSessionConfig) (*BackgroundSession, erro ) // Store full config for model-tag resolution (config.ResolveModelTags). bs.mittoConfig = cfg.MittoConfig + // Per-workspace initial-model preference (applied after agent reports models). + bs.initialModelPreference = cfg.InitialModelPreference // Look up the agent-native context-flush command from config bs.contextFlushCommand = lookupContextFlushCommand(cfg.MittoConfig, cfg.ACPServer) diff --git a/internal/conversation/bgsession_callbacks.go b/internal/conversation/bgsession_callbacks.go index 46ce21074..8653c3097 100644 --- a/internal/conversation/bgsession_callbacks.go +++ b/internal/conversation/bgsession_callbacks.go @@ -7,6 +7,7 @@ package conversation import ( "context" "log/slog" + "time" "github.com/coder/acp-go-sdk" @@ -281,6 +282,87 @@ func (bs *BackgroundSession) cbApplyConfigConstraintsAsync(category string) { go bs.applyConfigConstraints(category) } +// initialModelApplyBudget bounds the SetSessionModel RPC issued to apply the +// workspace initial-model preference on fresh conversations. Kept generous so a +// cold agent still lands the switch, but capped so the goroutine does not +// linger indefinitely on a stuck ACP. +var initialModelApplyBudget = 90 * time.Second + +// cbMaybeApplyInitialModelAsync applies the per-workspace initial-model +// preference (WorkspaceSettings → Initial Model) as the session's persistent +// baseline for FRESH conversations only. Skipped when: +// - no preference is configured on the workspace; +// - the session was resumed and already has a persisted BaselineModel; +// - the workspace has an ACP server constraint on the model category (it wins +// and would fight with our change on every resume); +// - the preference cannot be resolved against the agent's available models. +// +// Applies via SetConfigOption so the change updates the baseline, persists to +// metadata, and emits a session_change timeline entry — identical to a manual +// UI selection. +func (bs *BackgroundSession) cbMaybeApplyInitialModelAsync() { + if len(bs.initialModelPreference) == 0 { + return + } + // Skip resumed sessions: they already have a persisted baseline that reflects + // prior manual selections (or a prior application of this same preference). + if bs.store != nil && bs.persistedID != "" { + if meta, err := bs.store.GetMetadata(bs.persistedID); err == nil && meta.BaselineModel != "" { + return + } + } + // Skip when a workspace ACP server constraint already governs the model + // category — it wins (see applyConfigConstraints) and re-runs on every + // resume, so any change we make here would be immediately reverted. + if constraint := bs.cbACPServerConstraint(ConfigOptionCategoryModel); constraint != nil && constraint.Pattern != "" { + return + } + + prefs := bs.initialModelPreference + go func() { + models := bs.agentModels + if models == nil { + return + } + var profiles []config.ModelProfile + if bs.mittoConfig != nil { + profiles = bs.mittoConfig.EffectiveModelProfiles() + } + resolved := SelectPreferredModel(prefs, profiles, models) + if resolved == "" { + if bs.logger != nil { + bs.logger.Debug("initial model preference: no matching available model", + "session_id", bs.persistedID, + "preference", prefs) + } + return + } + if models.CurrentModelId == resolved { + // Baseline is already the desired model — still record it in the persisted + // baseline metadata so future resumes skip the constraint check above. + bs.cmPersistBaselineModel(resolved) + return + } + ctx, cancel := context.WithTimeout(bs.ctx, initialModelApplyBudget) + defer cancel() + if err := bs.configMgr.applyConfigOption(bs, ctx, ConfigOptionCategoryModel, resolved); err != nil { + if bs.logger != nil { + bs.logger.Warn("initial model preference: failed to apply", + "session_id", bs.persistedID, + "model", resolved, + "error", err) + } + return + } + if bs.logger != nil { + bs.logger.Info("initial model preference applied", + "session_id", bs.persistedID, + "model", resolved, + "preference", prefs) + } + }() +} + // cbStreamingSuppressed reports whether streaming callbacks are currently suppressed // (i.e. during an in-place context flush). Used by acpCallbackSink to short-circuit. func (bs *BackgroundSession) cbStreamingSuppressed() bool { diff --git a/internal/conversation/session_manager.go b/internal/conversation/session_manager.go index b36b16dc5..283ecb926 100644 --- a/internal/conversation/session_manager.go +++ b/internal/conversation/session_manager.go @@ -1444,6 +1444,14 @@ func (sm *SessionManager) CreateSessionWithWorkspace(ctx context.Context, name, // Build available ACP servers list for this workspace folder (used in @mitto:variable substitution). availableServers := sm.buildAvailableACPServers(workingDir, acpServer) + // Resolve the per-workspace initial-model preference (InitialModelProfile / + // InitialModelTag) as an ordered PromptPreferredModel list. Only applied to + // fresh top-level sessions (this create path); auto-children go through + // ResumeSessionWithModelConstraint which leaves this nil. BackgroundSession + // no-ops when the session is resumed with a persisted BaselineModel or when + // the workspace has an ACP-server constraint on the model category. + initialModelPref := effectiveWs.GetInitialModelPreference() + newBsStart := time.Now() bs, err := NewBackgroundSession(BackgroundSessionConfig{ PersistedID: "", // Empty = generate fresh @@ -1466,6 +1474,7 @@ func (sm *SessionManager) CreateSessionWithWorkspace(ctx context.Context, name, APIPrefix: sm.apiPrefix, WorkspaceUUID: workspaceUUID, MittoConfig: sm.mittoConfig, // Pass config for default flags + InitialModelPreference: initialModelPref, // Per-workspace initial-model preference (applied on fresh sessions) AvailableACPServers: availableServers, // Pre-computed workspace server list GlobalMCPServer: sm.mcpServer, AuxiliaryManager: sm.auxiliaryManager, diff --git a/web/static/components/WorkspacesDialog.js b/web/static/components/WorkspacesDialog.js index 19819c694..f2cf9664a 100644 --- a/web/static/components/WorkspacesDialog.js +++ b/web/static/components/WorkspacesDialog.js @@ -243,6 +243,11 @@ export function WorkspacesDialog({ // constraint by picking "-- None --" (vs. never having touched the control). const [editAuxModelConstraintCleared, setEditAuxModelConstraintCleared] = useState(false); + // Per-workspace initial-model preference applied as the baseline model of + // every new conversation created in this workspace. Mutually exclusive: + // profile wins server-side when both are set. + const [editInitialModelProfile, setEditInitialModelProfile] = useState(""); + const [editInitialModelTag, setEditInitialModelTag] = useState(""); const [editRunner, setEditRunner] = useState("exec"); const [editRunnerConfig, setEditRunnerConfig] = useState(null); const [editAutoApprove, setEditAutoApprove] = useState(false); @@ -571,6 +576,8 @@ export function WorkspacesDialog({ setEditAuxModelProfile(selectedWorkspace.auxiliary_model_profile || ""); setEditAuxModelTag(selectedWorkspace.auxiliary_model_tag || ""); setEditAuxModelConstraintCleared(false); + setEditInitialModelProfile(selectedWorkspace.initial_model_profile || ""); + setEditInitialModelTag(selectedWorkspace.initial_model_tag || ""); setEditAcpCommandOverride(selectedWorkspace.acp_command_override || ""); setEditRunner(selectedWorkspace.restricted_runner || "exec"); setEditRunnerConfig(selectedWorkspace.restricted_runner_config || null); @@ -1286,6 +1293,8 @@ export function WorkspacesDialog({ auxiliary_model_profile: editAuxModelProfile || undefined, auxiliary_model_tag: editAuxModelTag || undefined, auxiliary_model_selection: auxModelSelection, + initial_model_profile: editInitialModelProfile || undefined, + initial_model_tag: editInitialModelTag || undefined, restricted_runner: editRunner, restricted_runner_config: editRunner !== "exec" ? editRunnerConfig : undefined, @@ -4267,6 +4276,47 @@ export function WorkspacesDialog({ Leave empty to use the default.

+
+ +

+ Apply this model as the baseline for every new + conversation created in this workspace +

+
+
+ <${ModelProfileSelect} + value=${editInitialModelProfile} + profiles=${modelProfiles} + className="w-full" + onChange=${(name) => { + setEditInitialModelProfile(name); + if (name) { + setEditInitialModelTag(""); + } + }} + /> +
+ or by tag +
+ <${ModelTagSelect} + value=${editInitialModelTag} + profiles=${modelProfiles} + className="w-full" + onChange=${(tag) => { + setEditInitialModelTag(tag); + if (tag) { + setEditInitialModelProfile(""); + } + }} + /> +
+
+
+ +
+ +

+ Apply this model as the baseline for every new conversation created + with this ACP server +

+
+
+ <${ModelProfileSelect} + value=${initialModelProfile} + profiles=${modelProfiles} + className="w-full" + onChange=${(name) => { + setInitialModelProfile(name); + const overrides = { initialModelProfile: name }; + if (name) { + setInitialModelTag(""); + overrides.initialModelTag = ""; + } + emitChange(overrides); + }} + /> +
+ or by tag +
+ <${ModelTagSelect} + value=${initialModelTag} + profiles=${modelProfiles} + className="w-full" + onChange=${(tag) => { + setInitialModelTag(tag); + const overrides = { initialModelTag: tag }; + if (tag) { + setInitialModelProfile(""); + overrides.initialModelProfile = ""; + } + emitChange(overrides); + }} + /> +
+
+ ${(() => { + // Precedence hint: list workspaces that already pin an initial + // model for this ACP server (their setting wins over this one). + const overriders = (workspaces || []).filter( + (w) => + w && + w.acp_server === server.name && + (w.initial_model_profile || w.initial_model_tag), + ); + if (overriders.length === 0) return null; + const names = overriders + .map((w) => w.name || w.working_dir || w.uuid) + .filter(Boolean); + const shown = names.slice(0, 3).join(", "); + const suffix = names.length > 3 ? ", …" : ""; + const label = names.length === 1 ? "workspace" : "workspaces"; + return html` +

+ Overridden by ${label}: ${shown}${suffix} +

+ `; + })()} +
{ // Update server in-memory (prompts are now read-only from files) setAcpServers( @@ -2991,6 +3082,8 @@ export function SettingsDialog({ constraints: constraints || undefined, // undefined to omit if empty model_profile: modelProfile || undefined, // undefined to omit if none model_tag: modelTag || undefined, // undefined to omit if none + initial_model_profile: initialModelProfile || undefined, // undefined to omit if none + initial_model_tag: initialModelTag || undefined, // undefined to omit if none context_flush_command: contextFlushCommand && contextFlushCommand.trim() ? contextFlushCommand.trim() @@ -3611,6 +3704,7 @@ export function SettingsDialog({ server=${srv} agentTypes=${agentTypes} modelProfiles=${modelProfiles} + workspaces=${workspaces} onChange=${( name, cmd, @@ -3622,6 +3716,8 @@ export function SettingsDialog({ contextFlushCommand, modelProfile, modelTag, + initialModelProfile, + initialModelTag, ) => updateServer( srv.name, @@ -3635,6 +3731,8 @@ export function SettingsDialog({ contextFlushCommand, modelProfile, modelTag, + initialModelProfile, + initialModelTag, )} /> `} diff --git a/web/static/components/WorkspacesDialog.js b/web/static/components/WorkspacesDialog.js index f2cf9664a..1bfebc8b6 100644 --- a/web/static/components/WorkspacesDialog.js +++ b/web/static/components/WorkspacesDialog.js @@ -4316,6 +4316,26 @@ export function WorkspacesDialog({ />
+ ${(() => { + // Precedence hint: when this workspace has no + // initial-model preference of its own but its ACP + // server does, surface which value will be used. + if (editInitialModelProfile || editInitialModelTag) + return null; + const srv = acpServers.find( + (s) => s.name === editAcpServer, + ); + const srvValue = + srv && + (srv.initial_model_profile || + srv.initial_model_tag); + if (!srvValue) return null; + return html` +

+ Using ACP server default: ${srvValue} +

+ `; + })()}