Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/features/tui/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Type `/` during a session to see available commands, or press <kbd>Ctrl</kbd>+<k
| `/export` | Export the session as HTML |
| `/sessions` | Browse and load past sessions |
| `/model` | Change the model for the current agent |
| `/effort` | Set the current model's reasoning-effort level (`/effort <none\|minimal\|low\|medium\|high\|xhigh\|max>`, or `/effort` alone to pick from the supported levels; reasoning models only) |
| `/effort` | Set the current model's reasoning-effort level (`/effort <none\|minimal\|low\|medium\|high\|xhigh\|max>`, or `/effort` alone to pick from the supported levels; reasoning models only). Press <kbd>Tab</kbd> 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 |
Expand Down
1 change: 1 addition & 0 deletions docs/guides/thinking/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kbd>Tab</kbd> 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

Expand Down
21 changes: 21 additions & 0 deletions pkg/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions pkg/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
81 changes: 72 additions & 9 deletions pkg/runtime/model_switcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
153 changes: 153 additions & 0 deletions pkg/runtime/model_switcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading