From 7991cebf6a7627100167083aac7cd62e82b10ed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Fri, 17 Jul 2026 22:09:46 +0200 Subject: [PATCH 1/2] feat(runtime): expose current agent's supported thinking levels Extract the supported-thinking-levels resolution used by applyAgentThinkingLevel into resolveAgentThinkingLevels, shared by both the setter (SetAgentThinkingLevel/CycleAgentThinkingLevel) and a new read-only LocalRuntime.CurrentAgentThinkingLevels. Expose it via App.CurrentAgentThinkingLevels using the same optional-capability pattern as CurrentAgentToolsetStatuses/ContextBreakdown, so remote runtimes and non-reasoning models degrade to nil. This is a behavior-neutral refactor: applyAgentThinkingLevel's existing tests pass unchanged. It lands ahead of the /effort argument-completion wiring (#3731) so completion candidates can never diverge from what SetAgentThinkingLevel actually accepts. --- pkg/app/app.go | 21 ++++ pkg/app/app_test.go | 31 ++++++ pkg/runtime/model_switcher.go | 81 +++++++++++++-- pkg/runtime/model_switcher_test.go | 153 +++++++++++++++++++++++++++++ 4 files changed, 277 insertions(+), 9 deletions(-) diff --git a/pkg/app/app.go b/pkg/app/app.go index 22c2de559..8968f3af2 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -296,6 +296,27 @@ func (a *App) RestartToolset(ctx context.Context, name string) error { return a.runtime.RestartToolset(ctx, name) } +// agentThinkingLevelsProvider is an optional runtime capability: resolving +// the thinking-effort levels the current agent's active model supports. +// Only the local runtime (which holds the agent and its model config) +// implements it; remote runtimes don't, so /effort argument completion +// simply offers no candidates for them. +type agentThinkingLevelsProvider interface { + CurrentAgentThinkingLevels(ctx context.Context) []effort.Level +} + +// CurrentAgentThinkingLevels returns the thinking-effort levels supported by +// the current agent's active model, or nil when the runtime cannot resolve +// this (remote runtime, unresolvable agent/model, or a model that does not +// support thinking at all). Backs the /effort argument completer (#3731). +func (a *App) CurrentAgentThinkingLevels(ctx context.Context) []effort.Level { + p, ok := a.runtime.(agentThinkingLevelsProvider) + if !ok { + return nil + } + return p.CurrentAgentThinkingLevels(ctx) +} + // CurrentAgentCommands returns the commands for the active agent func (a *App) CurrentAgentCommands(ctx context.Context) types.Commands { return a.runtime.CurrentAgentInfo(ctx).Commands diff --git a/pkg/app/app_test.go b/pkg/app/app_test.go index 5811bc579..23fa43c75 100644 --- a/pkg/app/app_test.go +++ b/pkg/app/app_test.go @@ -857,6 +857,37 @@ func (m *liveSessionsMockRuntime) CompactLiveSession(_ context.Context, sessionI return nil } +// thinkingLevelsMockRuntime is a minimal mockRuntime extension implementing +// agentThinkingLevelsProvider, exercising the pass-through half of +// App.CurrentAgentThinkingLevels (the type-assertion-miss half is covered by +// plain *mockRuntime, which does not implement the interface). +type thinkingLevelsMockRuntime struct { + mockRuntime + + levels []effort.Level +} + +func (m *thinkingLevelsMockRuntime) CurrentAgentThinkingLevels(context.Context) []effort.Level { + return m.levels +} + +func TestApp_CurrentAgentThinkingLevels_UnsupportedRuntime(t *testing.T) { + t.Parallel() + + app := New(t.Context(), &mockRuntime{}, session.New()) + assert.Nil(t, app.CurrentAgentThinkingLevels(t.Context()), + "runtimes without a thinking-levels resolution degrade to no /effort candidates") +} + +func TestApp_CurrentAgentThinkingLevels_PassesThroughRuntimeLevels(t *testing.T) { + t.Parallel() + + rt := &thinkingLevelsMockRuntime{levels: []effort.Level{effort.Low, effort.Medium, effort.High}} + app := New(t.Context(), rt, session.New()) + + assert.Equal(t, []effort.Level{effort.Low, effort.Medium, effort.High}, app.CurrentAgentThinkingLevels(t.Context())) +} + func TestApp_LiveSessions_UnsupportedRuntime(t *testing.T) { t.Parallel() diff --git a/pkg/runtime/model_switcher.go b/pkg/runtime/model_switcher.go index ef5b97edb..c9316faf6 100644 --- a/pkg/runtime/model_switcher.go +++ b/pkg/runtime/model_switcher.go @@ -238,10 +238,78 @@ func levelNames(levels []effort.Level) string { return strings.Join(names, ", ") } +// resolveThinkingLevelsForModels returns the thinking-effort levels +// supported by models[0] (the agent's primary effective model) plus its +// currently active level. It is the pure resolution step shared by +// resolveAgentThinkingLevels (the read-only getter path, which snapshots +// EffectiveModels itself) and applyAgentThinkingLevel (the setter path, +// which must reuse the exact same snapshot it later re-creates providers +// from — see resolveAgentThinkingLevels for why that sharing matters). +func (r *LocalRuntime) resolveThinkingLevelsForModels(ctx context.Context, models []provider.Provider) ([]effort.Level, effort.Level, error) { + if len(models) == 0 { + return nil, "", errors.New("agent has no model configured") + } + + baseCfg := models[0].BaseConfig().ModelConfig + if !r.modelSupportsThinking(ctx, &baseCfg) { + return nil, "", fmt.Errorf("model %q does not support thinking levels: %w", baseCfg.DisplayOrModel(), ErrUnsupported) + } + + return modelinfo.SupportedThinkingLevels(baseCfg.Provider, baseCfg.Model), currentThinkingLevel(&baseCfg), nil +} + +// resolveAgentThinkingLevels resolves agentName's current effective model +// and returns the thinking-effort levels it supports plus its currently +// active level. This is the single source of truth shared by +// applyAgentThinkingLevel (which validates a pick against it) and +// CurrentAgentThinkingLevels (which exposes it read-only for /effort +// argument completion), so a level offered by one can never be rejected by +// the other (#3731). +// +// It is read-only, so a single EffectiveModels snapshot is correct here. +// applyAgentThinkingLevel must NOT call this: it needs the same snapshot +// for both resolution and provider recreation (a second, later +// EffectiveModels() call could race a concurrent /model change or scoped +// override and validate against one model while applying to another). +func (r *LocalRuntime) resolveAgentThinkingLevels(ctx context.Context, agentName string) ([]effort.Level, effort.Level, error) { + if r.modelSwitcherCfg == nil { + return nil, "", ErrUnsupported + } + + a, err := r.team.Agent(agentName) + if err != nil { + return nil, "", fmt.Errorf("agent not found: %w", err) + } + + return r.resolveThinkingLevelsForModels(ctx, a.EffectiveModels()) +} + +// CurrentAgentThinkingLevels returns the thinking-effort levels the current +// agent's active model supports, or nil when the runtime has no model +// switcher configured, the agent/model can't be resolved, or the model does +// not support thinking at all. Backs the /effort argument completer +// (#3731): candidates must come from this resolution, not the static effort +// vocabulary, because SetAgentThinkingLevel hard-rejects any level outside +// it. +func (r *LocalRuntime) CurrentAgentThinkingLevels(ctx context.Context) []effort.Level { + supported, _, err := r.resolveAgentThinkingLevels(ctx, r.currentAgentName()) + if err != nil { + return nil + } + return supported +} + // applyAgentThinkingLevel resolves the agent's current model, asks pick to // choose the target level among the model's supported ones, re-creates the // effective provider(s) with that level, and installs them as a runtime // override. +// +// It snapshots EffectiveModels exactly once and reuses that same snapshot +// for both capability resolution and provider recreation. Taking a second, +// later snapshot would let a concurrent /model change or scoped override +// land in between: the level would be validated against one model but +// applied to another, and — if the second snapshot came back empty — the +// override could be silently cleared while still reporting success. func (r *LocalRuntime) applyAgentThinkingLevel(ctx context.Context, agentName string, pick func(supported []effort.Level, current effort.Level) (effort.Level, error)) (effort.Level, error) { if r.modelSwitcherCfg == nil { return "", ErrUnsupported @@ -253,17 +321,12 @@ func (r *LocalRuntime) applyAgentThinkingLevel(ctx context.Context, agentName st } models := a.EffectiveModels() - if len(models) == 0 { - return "", errors.New("agent has no model configured") - } - - baseCfg := models[0].BaseConfig().ModelConfig - if !r.modelSupportsThinking(ctx, &baseCfg) { - return "", fmt.Errorf("model %q does not support thinking levels: %w", baseCfg.DisplayOrModel(), ErrUnsupported) + supported, current, err := r.resolveThinkingLevelsForModels(ctx, models) + if err != nil { + return "", err } - supported := modelinfo.SupportedThinkingLevels(baseCfg.Provider, baseCfg.Model) - next, err := pick(supported, currentThinkingLevel(&baseCfg)) + next, err := pick(supported, current) if err != nil { return "", err } diff --git a/pkg/runtime/model_switcher_test.go b/pkg/runtime/model_switcher_test.go index 80be6fccb..10e4e8752 100644 --- a/pkg/runtime/model_switcher_test.go +++ b/pkg/runtime/model_switcher_test.go @@ -300,6 +300,159 @@ func TestSetAgentThinkingLevel(t *testing.T) { }) } +// TestSetAgentThinkingLevel_ConcurrentOverrideBetweenResolutionAndApply is +// the regression test for the single-snapshot fix: applyAgentThinkingLevel +// must resolve capabilities and re-create providers from the SAME +// EffectiveModels snapshot. A second, later snapshot would let a +// concurrent /model change or scoped override land between resolution and +// application, validating the effort against one model but applying it to +// another (see #3731 follow-up). +func TestSetAgentThinkingLevel_ConcurrentOverrideBetweenResolutionAndApply(t *testing.T) { + t.Parallel() + + t.Run("override swapped mid-flight does not leak into the applied provider", func(t *testing.T) { + t.Parallel() + + // gpt-5 tops out at high; opus 4.7 accepts max. If applyAgentThinkingLevel + // re-read EffectiveModels after pick ran, it would recreate the override + // from opus instead of gpt-5, silently applying "high" to a model that was + // never validated against it. + modelA := newConfigProvider(latest.ModelConfig{Provider: "openai", Model: "gpt-5"}) + modelB := newConfigProvider(latest.ModelConfig{Provider: "anthropic", Model: "claude-opus-4-7"}) + + root := agent.New("root", "test", agent.WithModel(modelA)) + r := &LocalRuntime{ + team: team.New(team.WithAgents(root)), + modelSwitcherCfg: &ModelSwitcherConfig{ + ProviderRegistry: testProviderRegistry(), + EnvProvider: environment.NewMapEnvProvider(map[string]string{ + "OPENAI_API_KEY": "sk-test", + "ANTHROPIC_API_KEY": "sk-test", + }), + }, + } + + // pick runs after the snapshot is resolved but before providers are + // re-created; a concurrent /model change landing here is exactly the + // race the bug exposed. + level, err := r.applyAgentThinkingLevel(t.Context(), "root", func(supported []effort.Level, _ effort.Level) (effort.Level, error) { + require.NotContains(t, supported, effort.Max, "resolution must see model A (gpt-5), which tops out at high") + root.SetModelOverride(modelB) + return effort.High, nil + }) + require.NoError(t, err) + assert.Equal(t, effort.High, level) + + override := root.Model(t.Context()) + require.NotNil(t, override) + assert.Equal(t, "openai", override.BaseConfig().ModelConfig.Provider, + "the applied provider must be re-created from the model validated against (A), not the one swapped in mid-flight (B)") + budget := override.BaseConfig().ModelConfig.ThinkingBudget + require.NotNil(t, budget) + assert.Equal(t, "high", budget.Effort) + }) + + t.Run("override cleared mid-flight does not install an empty override", func(t *testing.T) { + t.Parallel() + + // No configured models: the agent's only model comes from the runtime + // override, so clearing it mid-flight reproduces the empty second-snapshot + // case the bug allowed through. + modelA := newConfigProvider(latest.ModelConfig{Provider: "openai", Model: "gpt-5"}) + root := agent.New("root", "test") + root.SetModelOverride(modelA) + + r := &LocalRuntime{ + team: team.New(team.WithAgents(root)), + modelSwitcherCfg: &ModelSwitcherConfig{ + ProviderRegistry: testProviderRegistry(), + EnvProvider: environment.NewMapEnvProvider(map[string]string{"OPENAI_API_KEY": "sk-test"}), + }, + } + + level, err := r.applyAgentThinkingLevel(t.Context(), "root", func(_ []effort.Level, _ effort.Level) (effort.Level, error) { + // Simulate a concurrent override reset (e.g. /model default) landing + // between resolution and provider recreation. + root.SetModelOverride() + return effort.High, nil + }) + require.NoError(t, err) + assert.Equal(t, effort.High, level) + + override := root.Model(t.Context()) + require.NotNil(t, override, "must not silently install an empty override while reporting success") + assert.Equal(t, "openai", override.BaseConfig().ModelConfig.Provider) + }) +} + +func TestCurrentAgentThinkingLevels(t *testing.T) { + t.Parallel() + + newThinkingRuntimeWithRouter := func(cfg latest.ModelConfig, env map[string]string) *LocalRuntime { + root := agent.New("root", "test", agent.WithModel(newConfigProvider(cfg))) + tm := team.New(team.WithAgents(root)) + return &LocalRuntime{ + team: tm, + agents: newAgentRouter(tm, "root"), + modelSwitcherCfg: &ModelSwitcherConfig{ + ProviderRegistry: testProviderRegistry(), + EnvProvider: environment.NewMapEnvProvider(env), + }, + } + } + + t.Run("reasoning model returns its supported levels", func(t *testing.T) { + t.Parallel() + r := newThinkingRuntimeWithRouter( + latest.ModelConfig{Provider: "openai", Model: "gpt-5"}, + map[string]string{"OPENAI_API_KEY": "sk-test"}, + ) + + levels := r.CurrentAgentThinkingLevels(t.Context()) + assert.NotEmpty(t, levels) + assert.Contains(t, levels, effort.High) + assert.NotContains(t, levels, effort.Max, "gpt-5 tops out below max") + }) + + t.Run("non-reasoning model is nil", func(t *testing.T) { + t.Parallel() + r := newThinkingRuntimeWithRouter( + latest.ModelConfig{Provider: "openai", Model: "gpt-4o"}, + map[string]string{"OPENAI_API_KEY": "sk-test"}, + ) + + assert.Nil(t, r.CurrentAgentThinkingLevels(t.Context())) + }) + + t.Run("nil modelSwitcherCfg is nil", func(t *testing.T) { + t.Parallel() + root := agent.New("root", "test") + tm := team.New(team.WithAgents(root)) + r := &LocalRuntime{team: tm, agents: newAgentRouter(tm, "root")} + + assert.Nil(t, r.CurrentAgentThinkingLevels(t.Context())) + }) + + t.Run("matches the levels SetAgentThinkingLevel validates against", func(t *testing.T) { + t.Parallel() + r := newThinkingRuntimeWithRouter( + latest.ModelConfig{Provider: "anthropic", Model: "claude-opus-4-7"}, + map[string]string{"ANTHROPIC_API_KEY": "sk-test"}, + ) + + levels := r.CurrentAgentThinkingLevels(t.Context()) + require.Contains(t, levels, effort.Max, "opus 4.7 top tier") + + // Every level CurrentAgentThinkingLevels reports must be one + // SetAgentThinkingLevel actually accepts -- the single-source-of-truth + // guarantee #3731 depends on. + for _, level := range levels { + _, err := r.SetAgentThinkingLevel(t.Context(), "root", level) + assert.NoError(t, err, "level %q reported as supported but rejected", level) + } + }) +} + // TestSetAgentThinkingLevel_NoneSurvivesOnGPT56 is the regression test for // the gpt-5.6+ "none" gating: cycling an agent's model to None on a model // with a real API-level none effort must produce a provider whose config From f40621ac91a7d9ee0840ce1cf41b125a0371bd3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Fri, 17 Jul 2026 22:15:39 +0200 Subject: [PATCH 2/2] feat(tui): add argument auto-completion for /effort Pressing Tab after "/effort " opens a completion popup listing the current agent's supported reasoning-effort levels, reusing the argument-completion hook shipped for /toolset-restart. Candidates come from LocalRuntime.CurrentAgentThinkingLevels (added in the previous commit), not the static effort vocabulary, so a level offered here is always one SetAgentThinkingLevel actually accepts. Unlike /toolset-restart, unsupported levels are never shown (not even Disabled): the /effort picker dialog already only lists supported levels, and completion should match it exactly. Remote runtimes and non-reasoning models resolve to no supported levels, so the popup simply doesn't open (Tab falls through to the usual focus switch). --- docs/features/tui/index.md | 2 +- docs/guides/thinking/index.md | 1 + pkg/tui/commands/commands.go | 42 +++++++++++++ pkg/tui/commands/commands_test.go | 99 +++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 1 deletion(-) diff --git a/docs/features/tui/index.md b/docs/features/tui/index.md index ea549eabc..493310c5a 100644 --- a/docs/features/tui/index.md +++ b/docs/features/tui/index.md @@ -78,7 +78,7 @@ Type `/` during a session to see available commands, or press Ctrl+`, or `/effort` alone to pick from the supported levels; reasoning models only) | +| `/effort` | Set the current model's reasoning-effort level (`/effort `, or `/effort` alone to pick from the supported levels; reasoning models only). Press Tab after `/effort` and a space to complete a level the current model supports | | `/settings` | Manage appearance, behavior, and notification preferences | | `/yolo` | Toggle automatic tool call approval | | `/title` | Set or regenerate session title | diff --git a/docs/guides/thinking/index.md b/docs/guides/thinking/index.md index 079be3b9a..ef7018594 100644 --- a/docs/guides/thinking/index.md +++ b/docs/guides/thinking/index.md @@ -334,6 +334,7 @@ While running in the TUI, press **Shift+Tab** to cycle the thinking effort level - This applies as a session override — it is **not** saved to the config file. The next session starts from the level defined in your YAML. - For models that don't support reasoning, and for remote runtimes, Shift+Tab is a no-op and an informational message is displayed. - `/effort` only accepts levels the current model supports; requesting an unsupported level shows the model's supported list. Like Shift+Tab, it is unavailable for non-reasoning models and remote runtimes. +- Press Tab after `/effort` and a space to complete a level from the current model's supported range; it lists the same levels the picker shows (and, like the picker, offers no candidates for non-reasoning models or remote runtimes). ## Sharing Thinking Config Across Models diff --git a/pkg/tui/commands/commands.go b/pkg/tui/commands/commands.go index 02f10d28c..c9ab6aa86 100644 --- a/pkg/tui/commands/commands.go +++ b/pkg/tui/commands/commands.go @@ -11,6 +11,7 @@ import ( "github.com/docker/docker-agent/pkg/app" "github.com/docker/docker-agent/pkg/config/types" + "github.com/docker/docker-agent/pkg/effort" "github.com/docker/docker-agent/pkg/feedback" "github.com/docker/docker-agent/pkg/tools" mcptools "github.com/docker/docker-agent/pkg/tools/mcp" @@ -654,6 +655,46 @@ func attachToolsetRestartCompletion(items []Item, source toolsetStatusSource) { } } +// effortLevelsSource is the minimal surface effortCandidates needs. *app.App +// satisfies it; tests can supply a stub instead of constructing a full +// application. +type effortLevelsSource interface { + CurrentAgentThinkingLevels(ctx context.Context) []effort.Level +} + +// effortCandidates returns one ArgumentCandidate per thinking-effort level +// the current agent's active model supports, in the source's canonical +// order. Unlike toolsetRestartCandidates, unsupported levels are never +// listed (let alone Disabled): SetAgentThinkingLevel hard-rejects any level +// outside this set, so offering it would complete-then-fail. A source with +// no resolvable levels (remote runtime, non-reasoning model) yields no +// candidates, and the popup simply doesn't open. +func effortCandidates(ctx context.Context, source effortLevelsSource) []ArgumentCandidate { + levels := source.CurrentAgentThinkingLevels(ctx) + candidates := make([]ArgumentCandidate, 0, len(levels)) + for _, level := range levels { + candidates = append(candidates, ArgumentCandidate{Label: string(level)}) + } + return candidates +} + +// attachEffortCompletion wires the effort-level argument completer onto the +// /effort item. Attached post-hoc (rather than inline in +// builtInSessionCommands) so that function stays free of any +// effort-levels-source dependency. ctx is the long-lived TUI context, closed +// over by the completer so it can re-resolve the model on every call. +func attachEffortCompletion(items []Item, ctx context.Context, source effortLevelsSource) { + for i := range items { + if items[i].ID != "session.effort" { + continue + } + items[i].CompleteArgument = func() []ArgumentCandidate { + return effortCandidates(ctx, source) + } + return + } +} + // BuildCommandCategories builds the list of command categories for the command palette func BuildCommandCategories(ctx context.Context, application *app.App) []Category { // Get session commands and filter based on model capabilities @@ -662,6 +703,7 @@ func BuildCommandCategories(ctx context.Context, application *app.App) []Categor sessionCommands = removeByIDs(sessionCommands, snapshotCommandIDs) } attachToolsetRestartCompletion(sessionCommands, application) + attachEffortCompletion(sessionCommands, ctx, application) categories := []Category{ { diff --git a/pkg/tui/commands/commands_test.go b/pkg/tui/commands/commands_test.go index 25722ce12..78260e3d3 100644 --- a/pkg/tui/commands/commands_test.go +++ b/pkg/tui/commands/commands_test.go @@ -1,11 +1,13 @@ package commands import ( + "context" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/docker/docker-agent/pkg/effort" "github.com/docker/docker-agent/pkg/tools" "github.com/docker/docker-agent/pkg/tools/lifecycle" "github.com/docker/docker-agent/pkg/tui/messages" @@ -398,3 +400,100 @@ func TestAttachToolsetRestartCompletion_QueriesFreshOnEachCall(t *testing.T) { assert.Equal(t, "filesystem", second[0].Label) assert.Equal(t, "github", second[1].Label) } + +// stubEffortLevelsSource is a minimal effortLevelsSource for exercising +// effortCandidates without a real *app.App. +type stubEffortLevelsSource struct { + levels []effort.Level +} + +func (s *stubEffortLevelsSource) CurrentAgentThinkingLevels(context.Context) []effort.Level { + return s.levels +} + +func TestEffortCandidates_ReturnsSupportedLevelsInOrder(t *testing.T) { + t.Parallel() + + source := &stubEffortLevelsSource{levels: []effort.Level{effort.None, effort.Low, effort.Medium, effort.High}} + + candidates := effortCandidates(t.Context(), source) + require.Len(t, candidates, 4) + assert.Equal(t, "none", candidates[0].Label) + assert.Equal(t, "low", candidates[1].Label) + assert.Equal(t, "medium", candidates[2].Label) + assert.Equal(t, "high", candidates[3].Label) + + for _, c := range candidates { + assert.False(t, c.Disabled, "unsupported levels are never listed, so no candidate is ever Disabled") + } +} + +func TestEffortCandidates_NoSupportedLevelsIsEmpty(t *testing.T) { + t.Parallel() + + // Mirrors a remote runtime or a non-reasoning model, where + // CurrentAgentThinkingLevels resolves to nil. + assert.Empty(t, effortCandidates(t.Context(), &stubEffortLevelsSource{})) +} + +func TestAttachEffortCompletion(t *testing.T) { + t.Parallel() + + items := builtInSessionCommands() + source := &stubEffortLevelsSource{levels: []effort.Level{effort.Low, effort.High}} + attachEffortCompletion(items, t.Context(), source) + + var effortItem, other *Item + for i := range items { + switch items[i].ID { + case "session.effort": + effortItem = &items[i] + case "session.exit": + other = &items[i] + } + } + require.NotNil(t, effortItem) + require.NotNil(t, effortItem.CompleteArgument, "the effort item must get a completer") + candidates := effortItem.CompleteArgument() + require.Len(t, candidates, 2) + assert.Equal(t, "low", candidates[0].Label) + assert.Equal(t, "high", candidates[1].Label) + + require.NotNil(t, other) + assert.Nil(t, other.CompleteArgument, "commands without a provider keep a nil CompleteArgument") +} + +// TestAttachEffortCompletion_QueriesFreshOnEachCall is a regression test for +// bug 2 (PR #3728, mirrored for /effort): the attached CompleteArgument +// closure must query the effort-levels source live on every call, not +// capture a snapshot at attach time. The model (and so its supported levels) +// can change between attach time and Tab via /model. +func TestAttachEffortCompletion_QueriesFreshOnEachCall(t *testing.T) { + t.Parallel() + + items := builtInSessionCommands() + source := &stubEffortLevelsSource{levels: []effort.Level{effort.Low, effort.High}} + attachEffortCompletion(items, t.Context(), source) + + var effortItem *Item + for i := range items { + if items[i].ID == "session.effort" { + effortItem = &items[i] + } + } + require.NotNil(t, effortItem) + + first := effortItem.CompleteArgument() + require.Len(t, first, 2) + assert.Equal(t, "low", first[0].Label) + + // The model switches to one with a different supported range after the + // completer was attached (e.g. via /model). A second call must reflect + // that change rather than replaying the first snapshot. + source.levels = []effort.Level{effort.None, effort.Minimal, effort.Low, effort.Medium, effort.High, effort.XHigh} + + second := effortItem.CompleteArgument() + require.Len(t, second, 6, "must reflect the current model's supported levels, not the one captured at attach time") + assert.Equal(t, "none", second[0].Label) + assert.Equal(t, "xhigh", second[5].Label) +}