Files
zero/internal/tui/profile_command_test.go
b04fe96e9e fix(config): lock config read-modify-write across processes (#960)
* fix(config): lock config read-modify-write across processes

Every config mutator loads the whole document, edits independent fields, and
publishes a complete replacement by rename. The rename is atomic, so a reader
never sees partial JSON — but two processes that loaded the same revision each
write a full document, and the second rename silently discards the first one's
acknowledged update. The result is valid JSON with one update missing and no
error anywhere.

lockConfigFile takes a cross-process advisory lock through lockutil, using the
retry-with-deadline idiom cron, hooks and oauth already share (10s timeout, 20ms
retry). Callers acquire BEFORE their first read, so the lock spans load,
mutation, validation and publication and the read inside it is authoritative —
holding it only around the write would still let both processes start from the
same stale revision.

The lock file is a sibling (config.json.lock), never the config itself: an
advisory lock is held against an inode, and publishing by rename installs a new
one, so locking the config directly would leave each process holding a
different inode.

Covered: all 14 mutators in writer.go, plus ClearProviderKeyStored and
MigratePlaintextProviderKeys in credentials.go. The migration matters most — it
rewrites the config on every startup, so it is the likeliest writer to collide
with an interactive mutation in another Zero. The SetProviderDescription test
seam locks too, so it cannot stand in as the one unsynchronized writer.

Two shapes needed care:

- The lock is not reentrant. EnsureCatalogProvider scans for an existing
  profile and then upserts, so UpsertProvider is split into a locking wrapper
  and upsertProviderLocked; one lock now spans the scan and the upsert, which
  also closes the window where two processes could both create the same
  catalog profile.
- SetPet edits raw bytes to preserve unknown members and formatting rather than
  round-tripping the struct. It takes the same lock, so it neither clobbers nor
  is clobbered by the struct writers.

Regression tests, each verified to FAIL with the lock disabled:

- TestConcurrentMutationsDoNotLoseUpdates — theme, pet, recaps, favorites and a
  provider mutated at once; all five are independent fields, so a lost update
  shows up as a zero value in exactly one of them.
- TestConcurrentProviderUpsertsAllSurvive — 16 distinct providers added
  concurrently, all must be present.
- TestConcurrentSameFieldMutationsSerialize — 24 writers contending on one
  field; every call succeeds and the document stays readable.
- TestCrossProcessMutationExcludesAndPreserves — the coordinated two-process
  case. Goroutines share this process's descriptors, so only a second OS
  process shows the lock is kernel-held. The child announces itself, then the
  parent asserts its write cannot land while the lock is held elsewhere, does
  its own mutation, releases, and requires both updates to survive.

Fixes #832

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01U389qmQUhoZB3YtXkFmiTq

* fix(cli): take the config lock for MCP config edits

The previous commit locked internal/config's mutators, but that is not every
writer of the user config document. internal/cli's MCP editor reads the SAME
file, edits it, and republishes it with the identical temp-file+rename shape,
at three sites (add/update, remove, disable/enable). Locking only one package
left `zero mcp add` free to clobber a concurrent provider or preference write —
and to be clobbered by one — with the file still valid JSON afterwards, which
is the same silent lost update issue #832 reports.

config.LockFile exports the existing helper so the lock is one authority across
packages rather than a private detail of internal/config. A second, adjacent
implementation would drift from the first exactly the way this writer already
drifted from the mutators.

TestRunMCPAddParticipatesInConfigLock is the regression. Racing the two writers
and waiting to observe a lost update is NOT reliable — the losing interleaving
is narrow, and a straightforward concurrent version of this test passed five
consecutive runs against the unlocked code, so it would have shipped as
reassurance that proved nothing. It instead asserts the deterministic property:
while the config lock is held elsewhere, the MCP writer's update cannot land,
and it completes once the lock is released. That fails immediately against the
unlocked code and passes with it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01U389qmQUhoZB3YtXkFmiTq

* fix(config): report config unlock failures instead of discarding them

AGENTS.md requires that a mutator never report success when unlock failed, and
the release error was being dropped on the floor. A failed Release can leave the
lock held for the rest of the process, so returning (cfg, nil) after one claims
a state the next mutation cannot reproduce — it will block for the full lock
timeout and then fail.

lockConfigFile now returns lock.Release directly, and every caller joins it into
its own result with the idiom credstore already uses:

	defer func() { err = errors.Join(err, unlock()) }()

Joined, not chosen between: a release failure annotates the result rather than
masking the mutation error that explains what actually went wrong. That covers
all 16 mutators in writer.go, both in credentials.go, and the test seam. The
three MCP sites return an exit code rather than an error, so they convert a
release failure into a crash exit and a stderr message, without overwriting a
failure the command had already reported.

A release failure cannot be provoked through the public API — lockutil.Release
is idempotent and reports nil once released — so the mutators call through a
lockConfigFileFn seam that a test can substitute. My first attempt at this test
could only ever t.Skip, which asserts nothing; the seam is what turns it into a
real assertion.

- TestMutationReportsUnlockFailure: SetTheme surfaces the release error AND the
  mutation is still published, since a release failure annotates the result
  rather than undoing the write.
- TestMutationErrorSurvivesUnlockFailure: an unknown-provider error and the
  release failure are both present in the joined error.

Both verified to fail when the defer discards the release error.

Note for review: cron, hooks, oauth and swarm all still discard their
lockutil.Release error, so this makes internal/config stricter than its
siblings. Worth deciding whether the guideline should be applied to them too —
out of scope here.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01U389qmQUhoZB3YtXkFmiTq

* fix(cli): complete MCP config lock handling

Amp-Thread-ID: https://ampcode.com/threads/T-01a043da-1703-70c5-9d8d-904cd8fd964b
Co-authored-by: Pierre Bruno <[email protected]>

* test(cli): synchronize MCP config lock assertion

Amp-Thread-ID: https://ampcode.com/threads/T-01a0448f-5860-721c-8a47-5119fc57f685
Co-authored-by: Amp <[email protected]>

* fix(tui): persist provider model selection atomically

Amp-Thread-ID: https://ampcode.com/threads/T-01a04c92-2d1d-7508-91bc-416341b7e8b0
Co-authored-by: Amp <[email protected]>

* fix(tui): surface unsaved model selections on every switch path

choosePicker replaces handleModelCommand's status text with its own
applied-notice, so the "· not saved (…)" qualifier that text carried was
dropped and the selection reported an unqualified success. The user only
found out at the next launch, when the old persisted model came back.
dispatchCommand drops the same text on a committed switch, so typed
/model had the identical hole — the cross-provider path already returned
the outcome as a value precisely because display copy is not a contract.

handleModelCommand now returns that outcome too, and one
modelAppliedNoticeFor decides how a committed switch is announced for
both entry points, so neither can regain the gap independently.

Recent-model history is written under the same lock, so a selection that
already lost the lock would pay a SECOND full timeout for one keystroke
and add a redundant save error. It now falls back to the in-session
updateRecentModels for exactly that case, keyed on a new
config.ErrLockUnavailable rather than on any persistence failure: a
missing config file also fails the lookup, and that write still has its
own reason to be attempted.

Regressions cover both entry points. The picker test holds the config
lock — the case the review asked for — and bounds elapsed time, which is
what proves one keystroke costs one wait; it fails at 20s against the
previous code. The typed test uses an unwritable config path so it
proves the same plumbing without a second ten-second timeout.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01REzorhNj3F1DGPXyn5Uq7j

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Co-authored-by: Amp <[email protected]>
2026-09-12 08:44:43 +08:00

433 lines
18 KiB
Go

package tui
import (
"context"
"os"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// profileSwitchModel builds a session on a model whose catalog entry supports
// the fast profile's "low" effort, with a second saved provider whose custom
// model has no catalog entry (so no effort ring) — the two directions the
// model-switch reconciliation must handle.
func profileSwitchModel(t *testing.T) model {
t.Helper()
return newModel(context.Background(), Options{
ProviderName: "anthropic",
ModelName: "claude-sonnet-4.5",
Provider: &fakeProvider{},
ProviderProfile: config.ProviderProfile{Name: "anthropic", CatalogID: "anthropic", Model: "claude-sonnet-4.5", APIKey: "k"},
SavedProviders: []config.ProviderProfile{
{Name: "anthropic", CatalogID: "anthropic", Model: "claude-sonnet-4.5", APIKey: "k"},
{Name: "ollama", CatalogID: "ollama", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "http://localhost:11434/v1", Model: "kimi-k2.7-code:cloud"},
},
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return &fakeProvider{}, nil
},
})
}
// The profile's effort fill is per-model and must be re-derived on a model
// switch: dropped when the destination does not support the level, refilled
// when a later destination does, with the escalation's effort restore tracking
// whether the profile currently governs the effort.
func TestProfileEffortReconciledOnModelSwitch(t *testing.T) {
m := profileSwitchModel(t)
m, _ = m.handleProfileCommand("fast")
if m.reasoningEffort != "low" || m.agentOptions.Profile == nil || !m.agentOptions.Profile.Escalate.RestoreDefaultEffort {
t.Fatalf("fast on a low-supporting model must fill low and arm the restore, got effort %q", m.reasoningEffort)
}
// Supported -> unsupported: the profile-applied level must not survive
// onto a model with no effort ring.
m, text, ok, _, _ := m.switchProviderModel("ollama", "kimi-k2.7-code:cloud")
if !ok {
t.Fatalf("switch to ollama failed: %q", text)
}
if m.reasoningEffort != "" || m.execProfileAppliedEffort != "" {
t.Fatalf("effort = %q applied = %q, want both cleared on an unsupported destination", m.reasoningEffort, m.execProfileAppliedEffort)
}
if m.agentOptions.Profile.Escalate.RestoreDefaultEffort {
t.Fatal("the profile no longer governs the effort, so the restore must disarm")
}
if m.execProfileName != "fast" || m.agentOptions.MaxTurns != 30 {
t.Fatalf("profile must stay active across the switch: name %q turns %d", m.execProfileName, m.agentOptions.MaxTurns)
}
// Unsupported -> supported: switching (back) to a supporting model must
// behave like selecting the profile there.
m, text, ok, _, _ = m.switchProviderModel("anthropic", "claude-sonnet-4.5")
if !ok {
t.Fatalf("switch back to anthropic failed: %q", text)
}
if m.reasoningEffort != "low" || m.execProfileAppliedEffort != "low" {
t.Fatalf("effort = %q applied = %q, want the profile's low refilled", m.reasoningEffort, m.execProfileAppliedEffort)
}
if !m.agentOptions.Profile.Escalate.RestoreDefaultEffort {
t.Fatal("the profile governs the effort again, so the restore must re-arm")
}
}
// The direct /model path (reconcileEffortForModelSwitch, driven by the
// target's authoritative ring) drops a KNOWN-unsupported effort — the TUI run
// path forwards the effort unfiltered, so it must not survive. When that drop
// voids a choice made under an active profile, the profile's effort
// bookkeeping resets and the profile resumes governing: a later switch to a
// supporting ring fills the profile's level again.
func TestProfileEffortVoidedByModelSwitchDrop(t *testing.T) {
m := profileSwitchModel(t)
m, _ = m.handleProfileCommand("fast")
m, _ = m.handleEffortCommand("high")
if m.reasoningEffort != "high" || !m.execProfileEffortTouched {
t.Fatalf("setup: effort %q touched %v, want an explicit high", m.reasoningEffort, m.execProfileEffortTouched)
}
// Destination with an AUTHORITATIVE empty ring (a catalog model without
// effort controls): "high" is known-unsupported, the drop fires, and the
// voided choice resets the profile's bookkeeping.
m, reset := m.reconcileEffortForModelSwitch(nil, true)
if !reset {
t.Fatal("a dropped preference with nothing refilled must report the reset")
}
if m.reasoningEffort != "" {
t.Fatalf("effort = %q, a known-unsupported level must not survive", m.reasoningEffort)
}
if m.execProfileEffortTouched || m.execProfileAppliedEffort != "" {
t.Fatalf("voided choice must reset the bookkeeping: touched %v applied %q", m.execProfileEffortTouched, m.execProfileAppliedEffort)
}
if m.agentOptions.Profile.Escalate.RestoreDefaultEffort {
t.Fatal("no profile-governed effort on this model, so the restore must be disarmed")
}
if m.execProfileName != "fast" || m.agentOptions.MaxTurns != 30 {
t.Fatalf("profile must stay active: name %q turns %d", m.execProfileName, m.agentOptions.MaxTurns)
}
// With the void cleared, a destination that supports the profile's level
// behaves like selecting the profile there: the fill returns.
ring := []modelregistry.ReasoningEffort{"low", "medium", "high"}
m, reset = m.reconcileEffortForModelSwitch(ring, true)
if reset {
t.Fatal("a refill is not a reset to auto")
}
if m.reasoningEffort != "low" || m.execProfileAppliedEffort != "low" {
t.Fatalf("effort = %q applied = %q, want the profile's low refilled", m.reasoningEffort, m.execProfileAppliedEffort)
}
if !m.agentOptions.Profile.Escalate.RestoreDefaultEffort {
t.Fatal("the profile governs the effort again, so the restore must re-arm")
}
}
// A touched effort the destination DOES support survives the direct /model
// path untouched — the drop only fires for known-unsupported levels.
func TestProfileEffortTouchedSurvivesSupportedModelSwitch(t *testing.T) {
m := profileSwitchModel(t)
m, _ = m.handleProfileCommand("fast")
m, _ = m.handleEffortCommand("high")
m, reset := m.reconcileEffortForModelSwitch([]modelregistry.ReasoningEffort{"low", "medium", "high"}, true)
if reset {
t.Fatal("nothing was dropped, so no reset")
}
if m.reasoningEffort != "high" || !m.execProfileEffortTouched {
t.Fatalf("effort = %q touched %v, an explicit choice the destination supports must survive", m.reasoningEffort, m.execProfileEffortTouched)
}
if m.agentOptions.Profile.Escalate.RestoreDefaultEffort {
t.Fatal("an explicit choice keeps the restore disarmed")
}
}
// An UNKNOWN ring (live-discovered/custom target with no catalog entry) is
// not evidence of unsupported: an explicit preference survives, exactly as it
// does on the cross-provider picker path, while the profile's own fill still
// retreats to models where support is known.
func TestProfileEffortUnknownRingPreservesExplicitChoice(t *testing.T) {
m := profileSwitchModel(t)
m, _ = m.handleProfileCommand("fast")
m, _ = m.handleEffortCommand("high")
m, reset := m.reconcileEffortForModelSwitch(nil, false)
if reset {
t.Fatal("an unknown ring must not report a reset")
}
if m.reasoningEffort != "high" || !m.execProfileEffortTouched {
t.Fatalf("effort = %q touched %v, an explicit choice must survive an unknown ring", m.reasoningEffort, m.execProfileEffortTouched)
}
// Untouched profile fill on an unknown ring: the fill retreats (the
// profile only governs where support is known), with clean bookkeeping.
m2 := profileSwitchModel(t)
m2, _ = m2.handleProfileCommand("fast")
m2, reset = m2.reconcileEffortForModelSwitch(nil, false)
if reset {
t.Fatal("a profile-fill retreat is not an unsupported-preference reset")
}
if m2.reasoningEffort != "" || m2.execProfileAppliedEffort != "" {
t.Fatalf("effort = %q applied = %q, the profile fill must retreat on an unknown ring", m2.reasoningEffort, m2.execProfileAppliedEffort)
}
if m2.agentOptions.Profile.Escalate.RestoreDefaultEffort {
t.Fatal("no profile-governed effort, so the restore must be disarmed")
}
}
// An explicitly touched effort is the user's choice: the reconciliation must
// leave it alone entirely across model switches.
func TestProfileEffortTouchedSurvivesModelSwitch(t *testing.T) {
m := profileSwitchModel(t)
m, _ = m.handleProfileCommand("fast")
m, _ = m.handleEffortCommand("high")
m, text, ok, _, _ := m.switchProviderModel("ollama", "kimi-k2.7-code:cloud")
if !ok {
t.Fatalf("switch to ollama failed: %q", text)
}
if m.reasoningEffort != "high" {
t.Fatalf("effort = %q, an explicit choice must survive the switch untouched", m.reasoningEffort)
}
if m.agentOptions.Profile.Escalate.RestoreDefaultEffort {
t.Fatal("an explicit choice keeps the restore disarmed")
}
}
func TestProfileCommandStatus(t *testing.T) {
_, out := model{}.handleProfileCommand("")
if !strings.Contains(out, "balanced (default)") {
t.Fatalf("bare /profile must report the balanced default, got %q", out)
}
_, out = model{}.handleProfileCommand("status")
if !strings.Contains(out, "balanced (default)") {
t.Fatalf("/profile status must report the balanced default, got %q", out)
}
}
func TestProfileCommandUnknownValue(t *testing.T) {
got, out := model{}.handleProfileCommand("turbo")
if !strings.Contains(out, "Usage:") {
t.Fatalf("unknown profile must print usage, got %q", out)
}
if got.execProfileName != "" || got.agentOptions.Profile != nil {
t.Fatal("unknown profile must not change any state")
}
}
// Fast applies to the next run: the turn budget drops, the escalation policy
// is armed with the DISPLACED budget as its restore target, and effort stays
// untouched when the session has no model-supported effort ring (model{}).
func TestProfileCommandSetsFastPosture(t *testing.T) {
m := model{}
m.agentOptions.MaxTurns = 80
got, out := m.handleProfileCommand("fast")
if got.agentOptions.MaxTurns != 30 {
t.Fatalf("MaxTurns = %d, want the fast profile's 30", got.agentOptions.MaxTurns)
}
if got.execProfileName != "fast" {
t.Fatalf("execProfileName = %q, want fast", got.execProfileName)
}
policy := got.agentOptions.Profile
if policy == nil || policy.Escalate == nil {
t.Fatal("fast must arm an escalation policy on agentOptions")
}
if policy.Escalate.MaxTurns != 80 {
t.Fatalf("Escalate.MaxTurns = %d, want the displaced 80", policy.Escalate.MaxTurns)
}
if got.reasoningEffort != "" {
t.Fatalf("effort = %q, must stay auto when the model exposes no effort ring", got.reasoningEffort)
}
if !strings.Contains(out, "fast") || !strings.Contains(out, "headless-only") {
t.Fatalf("status must name the profile and the TUI signal asymmetry, got %q", out)
}
}
func TestProfileCommandThoroughArmsSelfCorrect(t *testing.T) {
m := model{}
m.agentOptions.MaxTurns = 80
got, _ := m.handleProfileCommand("thorough")
if got.agentOptions.MaxTurns != 160 {
t.Fatalf("MaxTurns = %d, want thorough's 160", got.agentOptions.MaxTurns)
}
if !got.selfCorrectTests {
t.Fatal("thorough must arm full self-correction")
}
if got.agentOptions.Profile != nil {
t.Fatal("thorough has no escalation triggers, so the loop policy must stay nil")
}
}
func TestProfileCommandBalancedRestoresDisplaced(t *testing.T) {
m := model{}
m.agentOptions.MaxTurns = 80
m, _ = m.handleProfileCommand("fast")
got, _ := m.handleProfileCommand("balanced")
if got.agentOptions.MaxTurns != 80 {
t.Fatalf("MaxTurns = %d, want the displaced 80 restored", got.agentOptions.MaxTurns)
}
if got.execProfileName != "" || got.agentOptions.Profile != nil {
t.Fatal("balanced must clear the profile state and loop policy")
}
}
// Profiles must not stack: switching fast -> thorough restores fast's
// displacement first, so thorough displaces the ORIGINAL budget and a later
// balanced lands back on it.
func TestProfileCommandSwitchDoesNotStack(t *testing.T) {
m := model{}
m.agentOptions.MaxTurns = 80
m, _ = m.handleProfileCommand("fast")
m, _ = m.handleProfileCommand("thorough")
if m.agentOptions.MaxTurns != 160 {
t.Fatalf("MaxTurns = %d, want thorough's 160", m.agentOptions.MaxTurns)
}
if m.execProfileDisplacedMaxTurns != 80 {
t.Fatalf("displaced = %d, want the original 80 (not fast's 30)", m.execProfileDisplacedMaxTurns)
}
m, _ = m.handleProfileCommand("balanced")
if m.agentOptions.MaxTurns != 80 {
t.Fatalf("MaxTurns = %d, want the original 80 restored", m.agentOptions.MaxTurns)
}
}
// An explicit /turns while a profile is active is a pinned budget: it must
// disarm the escalation's turn target (mirroring headless exec, where an
// explicit --max-turns leaves nothing displaced) while the other triggers
// stay armed, and it must survive a later revert.
func TestProfileCommandTurnsPinDisarmsEscalationTarget(t *testing.T) {
m := model{}
m.agentOptions.MaxTurns = 80
m, _ = m.handleProfileCommand("fast")
m, _ = m.handleTurnsCommand("20")
if m.agentOptions.MaxTurns != 20 {
t.Fatalf("MaxTurns = %d, want the pinned 20", m.agentOptions.MaxTurns)
}
policy := m.agentOptions.Profile
if policy == nil || policy.Escalate == nil {
t.Fatal("the profile's other escalation triggers must stay armed")
}
if policy.Escalate.MaxTurns != 0 {
t.Fatalf("Escalate.MaxTurns = %d, want 0 (a pinned budget must never be raised by escalation)", policy.Escalate.MaxTurns)
}
if policy.Escalate.OnToolFailureStreak == 0 {
t.Fatal("disarming the turn target must not disarm the other triggers")
}
m, _ = m.handleProfileCommand("balanced")
if m.agentOptions.MaxTurns != 20 {
t.Fatalf("MaxTurns = %d, the pinned 20 must survive the revert", m.agentOptions.MaxTurns)
}
}
// A manual override that COINCIDES with the profile's own value must still
// survive the revert: touched beats value equality.
func TestProfileCommandRevertLeavesCoincidingOverride(t *testing.T) {
m := model{}
m.agentOptions.MaxTurns = 80
m, _ = m.handleProfileCommand("fast")
m, _ = m.handleTurnsCommand("30") // explicit, happens to equal fast's 30
m, _ = m.handleProfileCommand("balanced")
if m.agentOptions.MaxTurns != 30 {
t.Fatalf("MaxTurns = %d, the explicit /turns 30 must survive even though it equals fast's value", m.agentOptions.MaxTurns)
}
// Same for self-correct: /selfcorrect off then on under thorough is an
// explicit opt-in, not the profile's arming.
m2 := model{}
m2.agentOptions.MaxTurns = 80
m2, _ = m2.handleProfileCommand("thorough")
m2, _ = m2.handleSelfCorrectCommand("off")
m2, _ = m2.handleSelfCorrectCommand("on")
m2, _ = m2.handleProfileCommand("balanced")
if !m2.selfCorrectTests {
t.Fatal("an explicit /selfcorrect on must survive the revert even though thorough also armed it")
}
}
// Reverting a profile whose displaced budget was zero (nothing was set before
// it) must clear ZERO_MAX_TURNS: SetMaxTurnsEnv ignores zero, so without an
// explicit unset, spawned sub-agents would keep the removed profile's budget.
func TestProfileCommandRevertFromZeroClearsMaxTurnsEnv(t *testing.T) {
t.Setenv(config.MaxTurnsEnv, "")
m := model{} // MaxTurns 0: the profile displaces nothing
m, _ = m.handleProfileCommand("fast")
if got := os.Getenv(config.MaxTurnsEnv); got != "30" {
t.Fatalf("env after apply = %q, want the profile's 30", got)
}
m, _ = m.handleProfileCommand("balanced")
if m.agentOptions.MaxTurns != 0 {
t.Fatalf("MaxTurns = %d, want the displaced 0 restored", m.agentOptions.MaxTurns)
}
if got, present := os.LookupEnv(config.MaxTurnsEnv); present {
t.Fatalf("env after revert = %q (present), want the key removed", got)
}
}
// An explicit effort choice after /profile must disarm the escalation's
// effort restore (the effort analog of the /turns pin): a mid-run escalation
// must never clear an effort the user pinned by hand. Needs a catalog model
// with an effort ring so the profile's fill actually applies.
func TestProfileCommandExplicitEffortDisarmsRestore(t *testing.T) {
m := model{modelName: "claude-sonnet-4.5"}
m.agentOptions.MaxTurns = 80
m, _ = m.handleProfileCommand("fast")
if m.reasoningEffort != "low" {
t.Fatalf("profile did not fill low effort for the test model: got %q", m.reasoningEffort)
}
policy := m.agentOptions.Profile
if policy == nil || policy.Escalate == nil || !policy.Escalate.RestoreDefaultEffort {
t.Fatal("profile-filled effort must arm the escalation effort restore")
}
m, _ = m.handleEffortCommand("high")
if m.reasoningEffort != "high" {
t.Fatalf("effort = %q, want the explicit high", m.reasoningEffort)
}
policy = m.agentOptions.Profile
if policy == nil || policy.Escalate == nil {
t.Fatal("the escalation policy must stay armed")
}
if policy.Escalate.RestoreDefaultEffort {
t.Fatal("an explicit /effort must disarm the effort restore")
}
if policy.Escalate.MaxTurns != 80 {
t.Fatalf("Escalate.MaxTurns = %d, disarming effort must not touch the turn target", policy.Escalate.MaxTurns)
}
// And the explicit choice survives a later revert (touched bit).
m, _ = m.handleProfileCommand("balanced")
if m.reasoningEffort != "high" {
t.Fatalf("effort = %q, the explicit high must survive the revert", m.reasoningEffort)
}
}
// A manual override made after selecting a profile survives the revert: the
// profile only restores knobs that still hold the value it applied.
func TestProfileCommandRevertLeavesManualOverride(t *testing.T) {
m := model{}
m.agentOptions.MaxTurns = 80
m, _ = m.handleProfileCommand("fast")
m, _ = m.handleTurnsCommand("100")
got, _ := m.handleProfileCommand("balanced")
if got.agentOptions.MaxTurns != 100 {
t.Fatalf("MaxTurns = %d, the user's explicit /turns 100 must survive the revert", got.agentOptions.MaxTurns)
}
// An explicit /selfcorrect choice armed BEFORE thorough must survive too.
m2 := model{selfCorrectTests: true}
m2.agentOptions.MaxTurns = 80
m2, _ = m2.handleProfileCommand("thorough")
m2, _ = m2.handleProfileCommand("balanced")
if !m2.selfCorrectTests {
t.Fatal("an explicit self-correct opt-in must survive profile revert")
}
}