feat: allow spawn_agent model and reasoning effort override (#27385)

This commit is contained in:
Michael Suchacz
2026-07-22 18:43:22 +02:00
committed by GitHub
parent 8a3fb04510
commit 02fd1cc691
14 changed files with 1191 additions and 46 deletions
+2
View File
@@ -849,6 +849,8 @@ The generation goroutine supports:
Model configs may carry a `reasoning_effort` config (`{default, max}`) inside `chat_model_configs.options`. Users select a per-turn effort when sending or editing a message; the value is stored on `chat_messages.reasoning_effort` and on `chat_queued_messages.reasoning_effort` for queued messages. Queued messages carry the value through promotion, and `chats.last_reasoning_effort` tracks the most recent message that set one, mirroring `last_model_config_id`.
Subagent spawning is a second source of both values. `spawn_agent` accepts optional `model_config_id` and `reasoning_effort` args (discoverable via the `list_subagent_models` tool): an explicit model selection becomes the child chat's `last_model_config_id` and wins over personal and deployment subagent overrides and over parent inheritance, and an explicit effort is stored on the child's initial message and wins over effort carried by those overrides. Both are validated at spawn time (enabled config, enabled provider, usable credentials, effort on the global scale) and rejected with tool errors before the child chat is created; `computer_use` spawns reject both args because their model routing is specialized. Generation-time resolution and clamping below apply to the child unchanged.
During generation preparation, the effective effort is resolved as the chat's `last_reasoning_effort` if set, else the config's `default`; clamped to the config's `max` on the global scale `none < minimal < low < medium < high < xhigh < max`; and passed through to the provider. The provider verifies whether the configured value is valid for that model at runtime. If the model config has no `reasoning_effort`, any user-selected value is ignored. The resolved value is injected into the provider-native options with `chatprovider.ApplyReasoningEffort` after provider option conversion. For Anthropic, the fantasy provider converts effort into enabled budget thinking on models older than Claude 4.6, which reject adaptive thinking.
#### Compaction model selection
+25 -23
View File
@@ -3346,7 +3346,8 @@ func builtinPlanToolAllowed(name string, isRootChat bool) bool {
return true
case "write_file", "edit_files", "list_templates", "read_template",
"create_workspace", "start_workspace", "stop_workspace", "propose_plan", "spawn_agent",
"spawn_explore_agent", "wait_agent", "list_agents", "ask_user_question", "attach_file":
"spawn_explore_agent", "wait_agent", "list_agents", "list_subagent_models",
"ask_user_question", "attach_file":
return isRootChat
case "process_list", "process_signal", "message_agent", "interrupt_agent", "close_agent",
"spawn_computer_use_agent":
@@ -3414,28 +3415,29 @@ func activeToolNamesForTurn(
func allowedExploreToolNames(allTools []fantasy.AgentTool) []string {
builtinExplorePolicy := map[string]bool{
"read_file": true,
"write_file": false,
"edit_files": false,
"execute": true,
"process_output": true,
"process_list": false,
"process_signal": false,
"list_templates": false,
"read_template": false,
"create_workspace": false,
"start_workspace": false,
"stop_workspace": false,
"propose_plan": false,
"spawn_agent": false,
"wait_agent": false,
"message_agent": false,
"interrupt_agent": false,
"close_agent": false,
"list_agents": false,
"read_skill": true,
"read_skill_file": true,
"ask_user_question": false,
"read_file": true,
"write_file": false,
"edit_files": false,
"execute": true,
"process_output": true,
"process_list": false,
"process_signal": false,
"list_templates": false,
"read_template": false,
"create_workspace": false,
"start_workspace": false,
"stop_workspace": false,
"propose_plan": false,
"spawn_agent": false,
"wait_agent": false,
"message_agent": false,
"interrupt_agent": false,
"close_agent": false,
"list_agents": false,
"list_subagent_models": false,
"read_skill": true,
"read_skill_file": true,
"ask_user_question": false,
}
toolNames := make([]string, 0, len(allTools))
+194
View File
@@ -113,6 +113,8 @@ type listAgentsArgs struct {
Offset *int `json:"offset,omitempty"`
}
type listSubagentModelsArgs struct{}
func subagentModelOverrideLogLabel(
overrideContext codersdk.ChatModelOverrideContext,
) string {
@@ -543,6 +545,142 @@ func (p *Server) resolveModelConfigAndNormalizedProvider(
return database.ChatModelConfig{}, "", sql.ErrNoRows
}
func (p *Server) resolveExplicitSpawnOverrides(
ctx context.Context,
ownerID uuid.UUID,
args spawnAgentArgs,
) (*uuid.UUID, *string, error) {
var explicitModelConfigID *uuid.UUID
if raw := strings.TrimSpace(args.ModelConfigID); raw != "" {
modelConfigID, err := uuid.Parse(raw)
if err != nil {
return nil, nil, xerrors.New(
"invalid model_config_id: must be a valid UUID; use " +
listSubagentModelsToolName + " to see available models",
)
}
//nolint:gocritic // Chatd needs its scoped config and user-data access here.
chatdCtx := dbauthz.AsChatd(ctx)
modelConfig, providerName, err := p.resolveModelConfigAndNormalizedProvider(
chatdCtx,
modelConfigID,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, nil, xerrors.New(
"model_config_id not found or is disabled; use " +
listSubagentModelsToolName + " to see available models",
)
case errors.Is(err, errInvalidModelOverrideMetadata):
return nil, nil, xerrors.Errorf(
"model_config_id metadata is invalid: %w",
err,
)
default:
p.logger.Warn(ctx, "failed to resolve spawn_agent model_config_id",
slog.F("model_config_id", modelConfigID),
slog.Error(err),
)
return nil, nil, xerrors.New("internal error looking up model config")
}
}
providerKeys, err := p.resolveUserProviderAPIKeys(
chatdCtx,
ownerID,
modelConfigAIProviderID(modelConfig),
)
if err != nil {
p.logger.Warn(ctx, "failed to resolve provider API keys for spawn_agent model_config_id",
slog.F("model_config_id", modelConfigID),
slog.Error(err),
)
return nil, nil, xerrors.New("internal error looking up model config")
}
if !userCanUseProviderKeys(providerKeys, providerName) {
return nil, nil, xerrors.Errorf(
"model_config_id credentials are unavailable for provider %q",
providerName,
)
}
explicitModelConfigID = &modelConfig.ID
}
var explicitReasoningEffort *string
if raw := strings.TrimSpace(args.ReasoningEffort); raw != "" {
effort := strings.ToLower(raw)
if !chatprovider.IsValidReasoningEffort(effort) {
return nil, nil, xerrors.Errorf(
"invalid reasoning_effort: must be one of %s",
strings.Join(codersdk.ChatModelReasoningEffortValues(), ", "),
)
}
explicitReasoningEffort = &effort
}
return explicitModelConfigID, explicitReasoningEffort, nil
}
func (p *Server) listSpawnableModelConfigs(
ctx context.Context,
ownerID uuid.UUID,
) ([]map[string]any, error) {
//nolint:gocritic // Chatd needs its scoped config and user-data access here.
chatdCtx := dbauthz.AsChatd(ctx)
rows, err := p.db.GetEnabledChatModelConfigs(chatdCtx)
if err != nil {
return nil, xerrors.Errorf("get enabled chat model configs: %w", err)
}
models := make([]map[string]any, 0, len(rows))
providerKeysByID := make(map[uuid.UUID]chatprovider.ProviderAPIKeys)
for _, row := range rows {
providerName := chatprovider.NormalizeProvider(row.Provider)
if providerName == "" {
continue
}
if _, _, err := chatprovider.ResolveModelWithProviderHint(
row.ChatModelConfig.Model,
providerName,
); err != nil {
continue
}
providerID := modelConfigAIProviderID(row.ChatModelConfig)
providerKeys, ok := providerKeysByID[providerID]
if !ok {
providerKeys, err = p.resolveUserProviderAPIKeys(
chatdCtx,
ownerID,
providerID,
)
if err != nil {
return nil, xerrors.Errorf("resolve provider API keys: %w", err)
}
providerKeysByID[providerID] = providerKeys
}
if !userCanUseProviderKeys(providerKeys, providerName) {
continue
}
entry := map[string]any{
"model_config_id": row.ChatModelConfig.ID.String(),
"display_name": row.ChatModelConfig.DisplayName,
"model": row.ChatModelConfig.Model,
"provider": providerName,
"context_limit": row.ChatModelConfig.ContextLimit,
"is_default": row.ChatModelConfig.IsDefault,
}
callConfig := codersdk.ChatModelCallConfig{}
if len(row.ChatModelConfig.Options) > 0 {
if err := json.Unmarshal(row.ChatModelConfig.Options, &callConfig); err == nil {
if efforts := chatprovider.SelectableReasoningEfforts(callConfig.ReasoningEffort); len(efforts) > 0 {
entry["reasoning_efforts"] = efforts
}
}
}
models = append(models, entry)
}
return models, nil
}
func (p *Server) subagentTools(
ctx context.Context,
currentChat func() database.Chat,
@@ -583,6 +721,24 @@ func (p *Server) subagentTools(
return fantasy.NewTextErrorResponse(err.Error()), nil
}
if definition.id == subagentTypeComputerUse &&
(strings.TrimSpace(args.ModelConfigID) != "" ||
strings.TrimSpace(args.ReasoningEffort) != "") {
return fantasy.NewTextErrorResponse(
`model_config_id and reasoning_effort are not supported for type "` +
subagentTypeComputerUse + `"`,
), nil
}
explicitModelConfigID, explicitReasoningEffort, err := p.resolveExplicitSpawnOverrides(
ctx,
parent.OwnerID,
args,
)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
turnParent := currentChatSnapshot
if turnParent.ID == uuid.Nil {
turnParent = parent
@@ -594,12 +750,17 @@ func (p *Server) subagentTools(
parent,
turnParent,
currentModelConfigID,
explicitModelConfigID,
args.Prompt,
)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
if explicitReasoningEffort != nil {
options.reasoningEffortOverride = explicitReasoningEffort
}
childChat, err := p.createChildSubagentChatWithOptions(
ctx,
parent,
@@ -618,6 +779,39 @@ func (p *Server) subagentTools(
}, childChat)), nil
},
),
fantasy.NewAgentTool(
listSubagentModelsToolName,
"List the enabled model configurations available for "+
spawnAgentToolName+"'s model_config_id argument. Only models "+
"usable with the chat owner's credentials are returned. Each "+
"entry includes model_config_id, display_name, model, "+
"provider, context_limit, is_default, and reasoning_efforts "+
"(the values accepted by "+spawnAgentToolName+"'s "+
"reasoning_effort for that model).",
func(ctx context.Context, _ listSubagentModelsArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
if currentChat == nil {
return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil
}
parent, err := p.loadSubagentSpawnParentChat(ctx, currentChat)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
models, err := p.listSpawnableModelConfigs(ctx, parent.OwnerID)
if err != nil {
p.logger.Warn(ctx, "failed to list spawnable model configs",
slog.F("chat_id", parent.ID),
slog.Error(err),
)
return fantasy.NewTextErrorResponse("internal error listing model configs"), nil
}
return toolJSONResponse(map[string]any{
"models": models,
}), nil
},
),
fantasy.NewAgentTool(
"wait_agent",
"Wait for a spawned child agent to finish and return its response "+
+36 -18
View File
@@ -14,7 +14,8 @@ import (
)
const (
spawnAgentToolName = "spawn_agent"
spawnAgentToolName = "spawn_agent"
listSubagentModelsToolName = "list_subagent_models"
subagentTypeGeneral = "general"
subagentTypeExplore = "explore"
@@ -38,16 +39,18 @@ const (
)
type spawnAgentArgs struct {
Type string `json:"type"`
Prompt string `json:"prompt"`
Title string `json:"title,omitempty"`
Type string `json:"type"`
Prompt string `json:"prompt"`
Title string `json:"title,omitempty"`
ModelConfigID string `json:"model_config_id,omitempty" description:"Optional model config UUID from list_subagent_models. Runs the child on that model instead of the configured default. Not supported for type 'computer_use'."`
ReasoningEffort string `json:"reasoning_effort,omitempty" description:"Optional reasoning effort for the child: none, minimal, low, medium, high, xhigh, or max. Clamped to the selected model's supported range. Not supported for type 'computer_use'."`
}
type subagentDefinition struct {
id string
description string
unavailableReason func(context.Context, *Server, database.Chat) string
buildOptions func(context.Context, *Server, database.Chat, database.Chat, uuid.UUID, string) (childSubagentChatOptions, error)
buildOptions func(context.Context, *Server, database.Chat, database.Chat, uuid.UUID, *uuid.UUID, string) (childSubagentChatOptions, error)
}
func allSubagentDefinitions() []subagentDefinition {
@@ -55,7 +58,10 @@ func allSubagentDefinitions() []subagentDefinition {
{
id: subagentTypeGeneral,
description: "substantial delegated research, analysis, reasoning, review, planning support, and implementation",
buildOptions: func(ctx context.Context, p *Server, parent database.Chat, _ database.Chat, _ uuid.UUID, _ string) (childSubagentChatOptions, error) {
buildOptions: func(ctx context.Context, p *Server, parent database.Chat, _ database.Chat, _ uuid.UUID, explicitModelConfigID *uuid.UUID, _ string) (childSubagentChatOptions, error) {
if explicitModelConfigID != nil {
return childSubagentChatOptions{modelConfigIDOverride: explicitModelConfigID}, nil
}
modelConfigID, reasoningEffort, err := p.resolveSubagentModelConfigID(
ctx,
parent.OwnerID,
@@ -75,17 +81,24 @@ func allSubagentDefinitions() []subagentDefinition {
{
id: subagentTypeExplore,
description: "narrow repository-local read-only code discovery and code tracing",
buildOptions: func(ctx context.Context, p *Server, _ database.Chat, turnParent database.Chat, currentModelConfigID uuid.UUID, _ string) (childSubagentChatOptions, error) {
modelConfigID, reasoningEffort, err := p.resolveSubagentModelConfigID(
ctx,
turnParent.OwnerID,
codersdk.ChatModelOverrideContextExplore,
)
if err != nil {
return childSubagentChatOptions{}, err
}
if modelConfigID == uuid.Nil {
modelConfigID = currentModelConfigID
buildOptions: func(ctx context.Context, p *Server, _ database.Chat, turnParent database.Chat, currentModelConfigID uuid.UUID, explicitModelConfigID *uuid.UUID, _ string) (childSubagentChatOptions, error) {
modelConfigID := currentModelConfigID
var reasoningEffort *string
if explicitModelConfigID != nil {
modelConfigID = *explicitModelConfigID
} else {
resolvedModelConfigID, resolvedReasoningEffort, err := p.resolveSubagentModelConfigID(
ctx,
turnParent.OwnerID,
codersdk.ChatModelOverrideContextExplore,
)
if err != nil {
return childSubagentChatOptions{}, err
}
if resolvedModelConfigID != uuid.Nil {
modelConfigID = resolvedModelConfigID
}
reasoningEffort = resolvedReasoningEffort
}
inheritedMCPServerIDs, err := p.resolveExploreToolSnapshot(
ctx,
@@ -129,7 +142,7 @@ func allSubagentDefinitions() []subagentDefinition {
}
return ""
},
buildOptions: func(ctx context.Context, p *Server, currentChat database.Chat, _ database.Chat, _ uuid.UUID, prompt string) (childSubagentChatOptions, error) {
buildOptions: func(ctx context.Context, p *Server, currentChat database.Chat, _ database.Chat, _ uuid.UUID, _ *uuid.UUID, prompt string) (childSubagentChatOptions, error) {
provider, _, _, err := p.computerUseProviderAndModelFromConfig(ctx)
if err != nil {
return childSubagentChatOptions{}, err
@@ -301,6 +314,11 @@ func buildSpawnAgentDescription(
"subagents modify the same files they will conflict with each other, " +
"so ensure parallel subagent tasks are independent. The child agent " +
"receives the same workspace tools but cannot spawn its own subagents. " +
"You may optionally set model_config_id (a model config UUID from " +
listSubagentModelsToolName + ") to run the child on a specific model " +
"instead of the configured default, and reasoning_effort to pin the " +
"child's reasoning effort; both apply only to type \"" +
subagentTypeGeneral + "\" and type \"" + subagentTypeExplore + "\". " +
"After spawning, use wait_agent to retrieve the result. Agents persist " +
"after completion; reuse an agent via message_agent for follow-up work " +
"when it already has relevant context. Spawned agents are your " +
+524
View File
@@ -1387,6 +1387,530 @@ func TestCreateChildSubagentChat_OverrideWorksWhenParentHasNoModel(t *testing.T)
require.Equal(t, overrideModel.ID, childChat.LastModelConfigID)
}
func TestSpawnAgent_ExplicitModelConfigID(t *testing.T) {
t.Parallel()
for _, subagentType := range []string{subagentTypeGeneral, subagentTypeExplore} {
t.Run(subagentType, func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, parentModel := seedInternalChatDeps(t, db)
selectedModel := insertInternalChatModelConfig(
t, db, "spawn-explicit-"+uuid.NewString(), true,
)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-explicit-model",
)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentType,
Prompt: "delegate work",
ModelConfigID: selectedModel.ID.String(),
})
result := requireSpawnAgentResponse(t, resp)
require.Equal(t, subagentType, result.SubagentType)
childID, err := uuid.Parse(result.ChatID)
require.NoError(t, err)
childChat, err := db.GetChatByID(ctx, childID)
require.NoError(t, err)
require.Equal(t, selectedModel.ID, childChat.LastModelConfigID)
require.False(t, childChat.LastReasoningEffort.Valid)
parentAfter, err := db.GetChatByID(ctx, parentChat.ID)
require.NoError(t, err)
require.Equal(t, parentModel.ID, parentAfter.LastModelConfigID)
})
}
}
type personalOverrideErrorStore struct {
database.Store
}
func (*personalOverrideErrorStore) GetUserChatPersonalModelOverride(
context.Context,
database.GetUserChatPersonalModelOverrideParams,
) (string, error) {
return "", xerrors.New("unexpected personal override lookup")
}
func TestSpawnAgent_ExplicitModelSkipsConfiguredOverrides(t *testing.T) {
t.Parallel()
for _, subagentType := range []string{subagentTypeGeneral, subagentTypeExplore} {
t.Run(subagentType, func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
store := &personalOverrideErrorStore{Store: db}
server := newInternalTestServer(t, store, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, parentModel := seedInternalChatDeps(t, db)
enableInternalChatPersonalModelOverrides(t, db)
selectedModel := insertInternalChatModelConfig(
t, db, "selected-skip-overrides-"+uuid.NewString(), true,
)
parentChat := createInternalParentChat(
ctx, t, server, store, org.ID, user.ID, parentModel.ID, "parent-skip-overrides",
)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentType,
Prompt: "delegate work",
ModelConfigID: selectedModel.ID.String(),
})
childChat, err := db.GetChatByID(ctx, requireSpawnAgentChildChatID(t, resp))
require.NoError(t, err)
require.Equal(t, selectedModel.ID, childChat.LastModelConfigID)
})
}
}
func TestSpawnAgent_ExplicitModelBeatsConfiguredOverrides(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, parentModel := seedInternalChatDeps(t, db)
deploymentModel := insertInternalChatModelConfig(
t, db, "general-deployment-"+uuid.NewString(), true,
)
personalModel := insertInternalChatModelConfig(
t, db, "general-personal-"+uuid.NewString(), true,
)
selectedModel := insertInternalChatModelConfig(
t, db, "general-selected-"+uuid.NewString(), true,
)
require.NoError(t, db.UpsertChatGeneralModelOverride(
ctx, deploymentModel.ID.String()+":medium",
))
enableInternalChatPersonalModelOverrides(t, db)
upsertInternalUserChatPersonalModelOverride(
t,
db,
user.ID,
codersdk.ChatPersonalModelOverrideContextGeneral,
string(codersdk.ChatPersonalModelOverrideModeModel)+":"+personalModel.ID.String()+":low",
)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-explicit-beats-overrides",
)
baselineResp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeGeneral,
Prompt: "delegate baseline work",
})
baselineChild, err := db.GetChatByID(ctx, requireSpawnAgentChildChatID(t, baselineResp))
require.NoError(t, err)
require.Equal(t, personalModel.ID, baselineChild.LastModelConfigID)
require.Equal(
t,
database.NullChatReasoningEffort{ChatReasoningEffort: database.ChatReasoningEffortLow, Valid: true},
baselineChild.LastReasoningEffort,
)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeGeneral,
Prompt: "delegate explicit work",
ModelConfigID: selectedModel.ID.String(),
})
childChat, err := db.GetChatByID(ctx, requireSpawnAgentChildChatID(t, resp))
require.NoError(t, err)
require.Equal(t, selectedModel.ID, childChat.LastModelConfigID)
require.False(t, childChat.LastReasoningEffort.Valid)
}
func TestSpawnAgent_ExplicitReasoningEffort(t *testing.T) {
t.Parallel()
t.Run("EffortOnlyKeepsModelChain", func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, parentModel := seedInternalChatDeps(t, db)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-effort-only",
)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeGeneral,
Prompt: "delegate work",
ReasoningEffort: "high",
})
childChat, err := db.GetChatByID(ctx, requireSpawnAgentChildChatID(t, resp))
require.NoError(t, err)
require.Equal(t, parentModel.ID, childChat.LastModelConfigID)
require.Equal(
t,
database.NullChatReasoningEffort{ChatReasoningEffort: database.ChatReasoningEffortHigh, Valid: true},
childChat.LastReasoningEffort,
)
})
t.Run("CombinedModelAndEffort", func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, parentModel := seedInternalChatDeps(t, db)
selectedModel := insertInternalChatModelConfig(
t, db, "combined-"+uuid.NewString(), true,
)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-combined",
)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeGeneral,
Prompt: "delegate work",
ModelConfigID: selectedModel.ID.String(),
ReasoningEffort: "low",
})
childChat, err := db.GetChatByID(ctx, requireSpawnAgentChildChatID(t, resp))
require.NoError(t, err)
require.Equal(t, selectedModel.ID, childChat.LastModelConfigID)
require.Equal(
t,
database.NullChatReasoningEffort{ChatReasoningEffort: database.ChatReasoningEffortLow, Valid: true},
childChat.LastReasoningEffort,
)
})
t.Run("BeatsOverrideCarriedEffort", func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, parentModel := seedInternalChatDeps(t, db)
deploymentModel := insertInternalChatModelConfig(
t, db, "general-deployment-"+uuid.NewString(), true,
)
require.NoError(t, db.UpsertChatGeneralModelOverride(
ctx, deploymentModel.ID.String()+":low",
))
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-effort-beats-override",
)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeGeneral,
Prompt: "delegate work",
ReasoningEffort: "high",
})
childChat, err := db.GetChatByID(ctx, requireSpawnAgentChildChatID(t, resp))
require.NoError(t, err)
require.Equal(t, deploymentModel.ID, childChat.LastModelConfigID)
require.Equal(
t,
database.NullChatReasoningEffort{ChatReasoningEffort: database.ChatReasoningEffortHigh, Valid: true},
childChat.LastReasoningEffort,
)
})
}
func TestSpawnAgent_ModelArgsRejections(t *testing.T) {
t.Parallel()
tests := []struct {
name string
args func(ctx context.Context, t *testing.T, db database.Store, user database.User) spawnAgentArgs
wantError string
}{
{
name: "MalformedModelConfigID",
args: func(context.Context, *testing.T, database.Store, database.User) spawnAgentArgs {
return spawnAgentArgs{ModelConfigID: "not-a-uuid"}
},
wantError: "invalid model_config_id: must be a valid UUID",
},
{
name: "UnknownModelConfigID",
args: func(context.Context, *testing.T, database.Store, database.User) spawnAgentArgs {
return spawnAgentArgs{ModelConfigID: uuid.NewString()}
},
wantError: "model_config_id not found or is disabled",
},
{
name: "DisabledModelConfig",
args: func(_ context.Context, t *testing.T, db database.Store, _ database.User) spawnAgentArgs {
disabled := insertInternalChatModelConfig(
t, db, "reject-disabled-"+uuid.NewString(), false,
)
return spawnAgentArgs{ModelConfigID: disabled.ID.String()}
},
wantError: "model_config_id not found or is disabled",
},
{
name: "DisabledProvider",
args: func(_ context.Context, t *testing.T, db database.Store, user database.User) spawnAgentArgs {
dbgen.ChatProvider(t, db, database.ChatProvider{
Provider: "openai-compat",
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
}, func(p *database.InsertChatProviderParams) {
p.Enabled = false
})
config := insertInternalChatModelConfigForProvider(
t, db, "openai-compat", "gpt-4o-mini", true,
)
return spawnAgentArgs{ModelConfigID: config.ID.String()}
},
wantError: "model_config_id not found or is disabled",
},
{
name: "DeletedProvider",
args: func(_ context.Context, t *testing.T, db database.Store, user database.User) spawnAgentArgs {
provider := dbgen.ChatProvider(t, db, database.ChatProvider{
Provider: "openai-compat",
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
})
config := insertInternalChatModelConfigForProvider(
t, db, "openai-compat", "gpt-4o-mini", true,
)
require.NoError(t, db.DeleteAIProviderByID(
systemRestrictedTestContext(t), provider.ID,
))
return spawnAgentArgs{ModelConfigID: config.ID.String()}
},
wantError: "model_config_id not found or is disabled",
},
{
name: "MissingCredentials",
args: func(_ context.Context, t *testing.T, db database.Store, user database.User) spawnAgentArgs {
insertInternalChatProvider(
t, db, user.ID, "openai-compat", "", false, true, false,
)
config := insertInternalChatModelConfigForProvider(
t, db, "openai-compat", "gpt-4o-mini", true,
)
return spawnAgentArgs{ModelConfigID: config.ID.String()}
},
wantError: `credentials are unavailable for provider "openai-compat"`,
},
{
name: "InvalidReasoningEffort",
args: func(context.Context, *testing.T, database.Store, database.User) spawnAgentArgs {
return spawnAgentArgs{ReasoningEffort: "ultra"}
},
wantError: "invalid reasoning_effort: must be one of",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-rejection",
)
args := tt.args(ctx, t, db, user)
args.Type = subagentTypeGeneral
args.Prompt = "delegate work"
resp := runSpawnAgentTool(ctx, t, server, parentChat, args)
require.True(t, resp.IsError, "expected tool error but got: %s", resp.Content)
require.Contains(t, resp.Content, tt.wantError)
children, err := db.GetChildChatsByParentIDs(ctx, database.GetChildChatsByParentIDsParams{
ParentIds: []uuid.UUID{parentChat.ID},
Archived: sql.NullBool{},
})
require.NoError(t, err)
require.Empty(t, children, "no child chat may be created on rejection")
})
}
}
func TestSpawnAgent_ComputerUseRejectsModelArgs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
muter func(*spawnAgentArgs, database.ChatModelConfig)
}{
{
name: "ModelConfigID",
muter: func(args *spawnAgentArgs, model database.ChatModelConfig) {
args.ModelConfigID = model.ID.String()
},
},
{
name: "ReasoningEffort",
muter: func(args *spawnAgentArgs, _ database.ChatModelConfig) {
args.ReasoningEffort = "high"
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
insertEnabledAnthropicProvider(t, db, user.ID)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-computer-use-args",
)
args := spawnAgentArgs{
Type: subagentTypeComputerUse,
Prompt: "take a screenshot",
}
tt.muter(&args, model)
resp := runSpawnAgentTool(ctx, t, server, parentChat, args)
require.True(t, resp.IsError, "expected tool error but got: %s", resp.Content)
require.Contains(t, resp.Content, `not supported for type "computer_use"`)
children, err := db.GetChildChatsByParentIDs(ctx, database.GetChildChatsByParentIDsParams{
ParentIds: []uuid.UUID{parentChat.ID},
Archived: sql.NullBool{},
})
require.NoError(t, err)
require.Empty(t, children, "no child chat may be created on rejection")
})
}
}
func TestListSubagentModels(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, defaultModel := seedInternalChatDeps(t, db)
effortModel := insertInternalChatModelConfigWithOptions(
t,
db,
"openai",
"gpt-effort-"+uuid.NewString(),
true,
json.RawMessage(`{"reasoning_effort":{"default":"medium","max":"high"}}`),
)
disabledModel := insertInternalChatModelConfig(
t, db, "list-disabled-"+uuid.NewString(), false,
)
insertInternalChatProvider(t, db, user.ID, "anthropic", "", false, true, false)
noCredsModel := insertInternalChatModelConfigForProvider(
t, db, "anthropic", "claude-sonnet-4-5", true,
)
dbgen.ChatProvider(t, db, database.ChatProvider{
Provider: "openai-compat",
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
}, func(p *database.InsertChatProviderParams) {
p.Enabled = false
})
disabledProviderModel := insertInternalChatModelConfigForProvider(
t, db, "openai-compat", "gpt-4o-mini", true,
)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, defaultModel.ID, "parent-list-models",
)
resp := runSubagentTool(
ctx,
t,
server,
parentChat,
parentChat.LastModelConfigID,
listSubagentModelsToolName,
struct{}{},
)
require.False(t, resp.IsError, "expected success but got: %s", resp.Content)
type listedModel struct {
ModelConfigID string `json:"model_config_id"`
DisplayName string `json:"display_name"`
Model string `json:"model"`
Provider string `json:"provider"`
ContextLimit int64 `json:"context_limit"`
IsDefault bool `json:"is_default"`
ReasoningEfforts []string `json:"reasoning_efforts"`
}
var result struct {
Models []listedModel `json:"models"`
}
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
byID := make(map[string]listedModel, len(result.Models))
for _, m := range result.Models {
byID[m.ModelConfigID] = m
}
require.Contains(t, byID, defaultModel.ID.String())
require.Contains(t, byID, effortModel.ID.String())
require.NotContains(t, byID, disabledModel.ID.String())
require.NotContains(t, byID, noCredsModel.ID.String())
require.NotContains(t, byID, disabledProviderModel.ID.String())
defaultEntry := byID[defaultModel.ID.String()]
require.Equal(t, defaultModel.DisplayName, defaultEntry.DisplayName)
require.Equal(t, defaultModel.Model, defaultEntry.Model)
require.Equal(t, "openai", defaultEntry.Provider)
require.Equal(t, defaultModel.ContextLimit, defaultEntry.ContextLimit)
require.True(t, defaultEntry.IsDefault)
effortEntry := byID[effortModel.ID.String()]
require.False(t, effortEntry.IsDefault)
require.Equal(
t,
[]string{"none", "minimal", "low", "medium", "high"},
effortEntry.ReasoningEfforts,
)
}
func TestListSubagentModels_NotAvailableForChildChats(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
_, child := createParentChildChats(ctx, t, server, user, org, model)
childChat, err := db.GetChatByID(ctx, child.ID)
require.NoError(t, err)
require.True(t, childChat.ParentChatID.Valid, "child chat must have a parent")
resp := runSubagentTool(
ctx,
t,
server,
childChat,
childChat.LastModelConfigID,
listSubagentModelsToolName,
struct{}{},
)
require.True(t, resp.IsError)
require.Contains(t, resp.Content, "delegated chats cannot create child subagents")
}
func TestSpawnAgent_ExploreUsesConfiguredModelOverride(t *testing.T) {
t.Parallel()
+2 -1
View File
@@ -187,7 +187,8 @@ parallel.
| Tool | What it does |
|---------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `spawn_agent` (`type=general` or `explore`) | Delegates a task to a sub-agent with its own context window. |
| `spawn_agent` (`type=general` or `explore`) | Delegates a task to a sub-agent with its own context window. Optionally accepts `model_config_id` and `reasoning_effort` to run the child on a specific enabled model instead of the configured default. |
| `list_subagent_models` | Lists the enabled model configurations the agent can pass to `spawn_agent` as `model_config_id`. |
| `wait_agent` | Waits for a sub-agent to finish and collects its result. |
| `message_agent` | Sends a follow-up message to a running sub-agent. |
| `interrupt_agent` | Halts a sub-agent's current turn; it transitions to waiting or running if there are queued messages. |
+3 -2
View File
@@ -245,7 +245,8 @@ tasks:
| `process_list` | List all tracked processes in the workspace |
| `process_signal` | Send a signal (terminate/kill) to a tracked process |
| `attach_file` | Attach a workspace file to the chat as a durable downloadable attachment |
| `spawn_agent` (`type=general` or `explore`) | Delegate a task to a sub-agent running in parallel |
| `spawn_agent` (`type=general` or `explore`) | Delegate a task to a sub-agent running in parallel, optionally on a specific model |
| `list_subagent_models` | List the models available for `spawn_agent`'s `model_config_id` argument |
| `wait_agent` | Wait for a sub-agent to complete and collect its result |
| `message_agent` | Send a follow-up message to a running sub-agent |
| `interrupt_agent` | Halt a sub-agent's current turn; it transitions to waiting or running if there are queued messages |
@@ -261,7 +262,7 @@ the workspace.
Platform tools (`list_templates`, `read_template`, `create_workspace`,
`start_workspace`, `propose_plan`, `ask_user_question`) and orchestration tools (`spawn_agent`,
`wait_agent`, `message_agent`, `interrupt_agent`, `list_agents`)
`list_subagent_models`, `wait_agent`, `message_agent`, `interrupt_agent`, `list_agents`)
are only available to root chats. Sub-agents do not have access to these
tools and cannot create workspaces or spawn further sub-agents.
+9 -1
View File
@@ -269,12 +269,20 @@ The configurable contexts:
Resolution order, evaluated per chat or subagent:
1. Explicit `model_config_id` on the `spawn_agent` tool call (general and
explore subagents only).
1. Personal override (when the admin gate is on and a model is set).
1. Admin subagent override.
1. The chat's selected model (or the deployment default for new chats).
If a referenced model is later disabled or deleted, that layer is skipped
and resolution falls through to the next.
and resolution falls through to the next. Explicit `spawn_agent` selection
is different: an unusable `model_config_id` fails the tool call instead of
falling through. Agents discover selectable models (and their reasoning
effort ranges) with the `list_subagent_models` tool, which only returns
enabled models usable with the chat owner's credentials. Computer-use
subagents always run on the administrator-configured computer-use model and
reject explicit model selection.
> [!NOTE]
> Both override layers are experimental and may change between releases.
+1 -1
View File
@@ -1822,7 +1822,7 @@ export const chatProviderConfigs = () => ({
},
});
const chatModelConfigsKey = ["chat-model-configs"] as const;
export const chatModelConfigsKey = ["chat-model-configs"] as const;
export const chatModelConfigs = () => ({
queryKey: chatModelConfigsKey,
@@ -8,13 +8,19 @@ import {
} from "lucide-react";
import type React from "react";
import { useState } from "react";
import { useQuery } from "react-query";
import { Link, useLocation } from "react-router";
import { chatModelConfigs } from "#/api/queries/chats";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
import { safeBuildAgentChatPath } from "../../../utils/navigation";
import { Response } from "../Response";
import { useDesktopPanel } from "./DesktopPanelContext";
import { InlineDesktopPreview } from "./InlineDesktopPreview";
import { RecordingPreview } from "./RecordingPreview";
import {
resolveSpawnModelDisplay,
type SpawnModelDisplay,
} from "./spawnModelDisplay";
import type { SubagentAction, SubagentDescriptor } from "./subagentDescriptor";
import { ToolCall } from "./ToolCall";
import { isSubagentSuccessStatus, type ToolStatus } from "./utils";
@@ -65,6 +71,7 @@ function getSubagentLabel(
descriptor: SubagentDescriptor,
title: string,
isTimeout: boolean,
modelDisplay: SpawnModelDisplay,
): React.ReactNode {
if (showDesktopPreview && toolStatus === "running") {
return "Using the computer...";
@@ -87,10 +94,22 @@ function getSubagentLabel(
: toolStatus === "error"
? "error"
: "running";
const modelDetails = [
modelDisplay.modelLabel,
modelDisplay.effortLabel ? `${modelDisplay.effortLabel} thinking` : "",
]
.filter(Boolean)
.join(", ");
return (
<>
{SUBAGENT_VERBS[descriptor.action][phase]}
<span className="opacity-60">{title}</span>
{modelDetails && (
<>
{" "}
with <span className="opacity-60">{modelDetails}</span>
</>
)}
</>
);
}
@@ -179,6 +198,19 @@ export const SubagentTool: React.FC<{
const location = useLocation();
const [expanded, setExpanded] = useState(false);
const { desktopChatId, onOpenDesktop } = useDesktopPanel();
const wantsModelDisplay =
descriptor.action === "spawn" && Boolean(descriptor.modelConfigId);
const modelConfigsQuery = useQuery({
...chatModelConfigs(),
enabled: wantsModelDisplay,
});
const modelDisplay: SpawnModelDisplay = wantsModelDisplay
? resolveSpawnModelDisplay({
configs: modelConfigsQuery.data,
modelConfigId: descriptor.modelConfigId,
reasoningEffort: descriptor.reasoningEffort,
})
: {};
const hasPrompt = Boolean(prompt?.trim());
const hasMessage = Boolean(message?.trim());
const hasReport = Boolean(report?.trim());
@@ -213,6 +245,7 @@ export const SubagentTool: React.FC<{
descriptor,
title,
isTimeout,
modelDisplay,
)}
</ToolCall.Label>
<ToolCall.Chevron />
@@ -9,6 +9,8 @@ import {
within,
} from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { chatModelConfigsKey } from "#/api/queries/chats";
import { MockChatModelConfig } from "#/testHelpers/chatModels";
import { ChatWorkspaceContext } from "../../../context/ChatWorkspaceContext";
import { BlockList } from "../../ChatConversation/ConversationTimeline";
import { DesktopPanelContext } from "./DesktopPanelContext";
@@ -688,6 +690,135 @@ export const SubagentMalformedChatIdLinksToRecoverableChatId: Story = {
},
};
const mockChatModelConfig = {
...MockChatModelConfig,
id: "8b29eba2-53a9-4c9a-95bb-b0326ac0a2fe",
model: "claude-sonnet-4-6",
display_name: "Claude Sonnet 4.6",
model_config: {
reasoning_effort: { default: "medium", max: "high" },
},
};
export const SubagentSpawnWithModelAndEffort: Story = {
args: {
name: "spawn_agent",
status: "completed",
args: {
title: "Workspace diagnostics",
prompt: "Collect logs and summarize why startup failed.",
model_config_id: "8b29eba2-53a9-4c9a-95bb-b0326ac0a2fe",
reasoning_effort: "high",
},
result: {
chat_id: "child-chat-id",
title: "Workspace diagnostics",
status: "completed",
},
},
parameters: {
queries: [{ key: chatModelConfigsKey, data: [mockChatModelConfig] }],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() =>
expect(
canvas.getByRole("button", {
name: /Spawned Workspace diagnostics with Claude Sonnet 4.6, high thinking/,
}),
).toBeInTheDocument(),
);
},
};
export const SubagentSpawnWithModelDefaultEffort: Story = {
args: {
name: "spawn_agent",
status: "completed",
args: {
title: "Workspace diagnostics",
prompt: "Collect logs and summarize why startup failed.",
model_config_id: "8b29eba2-53a9-4c9a-95bb-b0326ac0a2fe",
},
result: {
chat_id: "child-chat-id",
title: "Workspace diagnostics",
status: "completed",
},
},
parameters: {
queries: [{ key: chatModelConfigsKey, data: [mockChatModelConfig] }],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() =>
expect(
canvas.getByRole("button", {
name: /Spawned Workspace diagnostics with Claude Sonnet 4.6, medium thinking/,
}),
).toBeInTheDocument(),
);
},
};
// Effort-only spawns resolve against an inherited model whose effort
// bounds are unknown client-side, so no suffix is shown.
export const SubagentSpawnWithEffortOnly: Story = {
args: {
name: "spawn_agent",
status: "completed",
args: {
title: "Workspace diagnostics",
prompt: "Collect logs and summarize why startup failed.",
reasoning_effort: "high",
},
result: {
chat_id: "child-chat-id",
title: "Workspace diagnostics",
status: "completed",
},
},
parameters: {
queries: [{ key: chatModelConfigsKey, data: [mockChatModelConfig] }],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const label = canvas.getByRole("button", {
name: /Spawned Workspace diagnostics/,
});
expect(label).toBeInTheDocument();
expect(label).not.toHaveTextContent(/with/);
},
};
export const SubagentSpawnWithUnknownModelConfig: Story = {
args: {
name: "spawn_agent",
status: "completed",
args: {
title: "Workspace diagnostics",
prompt: "Collect logs and summarize why startup failed.",
model_config_id: "00000000-0000-0000-0000-000000000000",
},
result: {
chat_id: "child-chat-id",
title: "Workspace diagnostics",
status: "completed",
},
},
parameters: {
queries: [{ key: chatModelConfigsKey, data: [mockChatModelConfig] }],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const label = canvas.getByRole("button", {
name: /Spawned Workspace diagnostics/,
});
expect(label).toBeInTheDocument();
expect(label).not.toHaveTextContent(/with/);
},
};
export const ExploreSubagentRunning: Story = {
args: {
name: "spawn_explore_agent",
@@ -0,0 +1,142 @@
import { describe, expect, it } from "vitest";
import type { ChatModelConfig } from "#/api/typesGenerated";
import { MockChatModelConfig } from "#/testHelpers/chatModels";
import { resolveSpawnModelDisplay } from "./spawnModelDisplay";
const config = (overrides: Partial<ChatModelConfig>): ChatModelConfig => ({
...MockChatModelConfig,
...overrides,
});
const sonnet = config({
id: "cfg-sonnet",
model: "claude-sonnet-4-6",
display_name: "Claude Sonnet 4.6",
model_config: {
reasoning_effort: { default: "medium", max: "high" },
},
});
const noEffortModel = config({
id: "cfg-plain",
model: "gpt-4.1",
display_name: "GPT-4.1",
});
describe("resolveSpawnModelDisplay", () => {
it("returns nothing without explicit args", () => {
expect(
resolveSpawnModelDisplay({ configs: [sonnet], modelConfigId: undefined }),
).toEqual({});
});
it("resolves model and explicit effort", () => {
expect(
resolveSpawnModelDisplay({
configs: [sonnet],
modelConfigId: "cfg-sonnet",
reasoningEffort: "low",
}),
).toEqual({ modelLabel: "Claude Sonnet 4.6", effortLabel: "low" });
});
it("falls back to the config default effort when not requested", () => {
expect(
resolveSpawnModelDisplay({
configs: [sonnet],
modelConfigId: "cfg-sonnet",
}),
).toEqual({ modelLabel: "Claude Sonnet 4.6", effortLabel: "medium" });
});
it("clamps a requested effort above the config max", () => {
expect(
resolveSpawnModelDisplay({
configs: [sonnet],
modelConfigId: "cfg-sonnet",
reasoningEffort: "max",
}),
).toEqual({ modelLabel: "Claude Sonnet 4.6", effortLabel: "high" });
});
it("omits effort for models without effort settings", () => {
expect(
resolveSpawnModelDisplay({
configs: [noEffortModel],
modelConfigId: "cfg-plain",
reasoningEffort: "high",
}),
).toEqual({ modelLabel: "GPT-4.1" });
});
it("shows nothing when the config is unknown", () => {
expect(
resolveSpawnModelDisplay({
configs: [sonnet],
modelConfigId: "deleted-config",
reasoningEffort: "xhigh",
}),
).toEqual({});
});
it("shows nothing for effort-only spawns", () => {
expect(
resolveSpawnModelDisplay({
configs: [sonnet],
reasoningEffort: "high",
}),
).toEqual({});
});
it("omits an effective effort of none", () => {
expect(
resolveSpawnModelDisplay({
configs: [sonnet],
modelConfigId: "cfg-sonnet",
reasoningEffort: "none",
}),
).toEqual({ modelLabel: "Claude Sonnet 4.6" });
expect(
resolveSpawnModelDisplay({
configs: [sonnet],
reasoningEffort: "none",
}),
).toEqual({});
});
it("ignores values outside the global scale", () => {
expect(
resolveSpawnModelDisplay({
configs: [sonnet],
modelConfigId: "cfg-sonnet",
reasoningEffort: "ultra",
}),
).toEqual({ modelLabel: "Claude Sonnet 4.6", effortLabel: "medium" });
expect(
resolveSpawnModelDisplay({ configs: [sonnet], reasoningEffort: "ultra" }),
).toEqual({});
});
it("falls back to the model id when display name is blank", () => {
const blankName = config({
id: "cfg-blank",
model: "o4-mini",
display_name: " ",
});
expect(
resolveSpawnModelDisplay({
configs: [blankName],
modelConfigId: "cfg-blank",
}),
).toEqual({ modelLabel: "o4-mini" });
});
it("returns nothing while configs are still loading", () => {
expect(
resolveSpawnModelDisplay({
configs: undefined,
modelConfigId: "cfg-sonnet",
}),
).toEqual({});
});
});
@@ -0,0 +1,77 @@
import type * as TypesGen from "#/api/typesGenerated";
import {
ChatModelReasoningEffortHigh,
ChatModelReasoningEffortLow,
ChatModelReasoningEffortMax,
ChatModelReasoningEffortMedium,
ChatModelReasoningEffortMinimal,
ChatModelReasoningEffortNone,
ChatModelReasoningEffortXHigh,
} from "#/api/typesGenerated";
// Global reasoning effort scale, ordered low to high. Mirrors
// codersdk.ChatModelReasoningEffortValues().
const effortScale: readonly string[] = [
ChatModelReasoningEffortNone,
ChatModelReasoningEffortMinimal,
ChatModelReasoningEffortLow,
ChatModelReasoningEffortMedium,
ChatModelReasoningEffortHigh,
ChatModelReasoningEffortXHigh,
ChatModelReasoningEffortMax,
];
const effortRank = (value: string | undefined): number =>
value === undefined ? -1 : effortScale.indexOf(value.trim().toLowerCase());
export type SpawnModelDisplay = {
modelLabel?: string;
effortLabel?: string;
};
export const resolveSpawnModelDisplay = ({
configs,
modelConfigId,
reasoningEffort,
}: {
configs: readonly TypesGen.ChatModelConfig[] | undefined;
modelConfigId?: string;
reasoningEffort?: string;
}): SpawnModelDisplay => {
const requestedRank = effortRank(reasoningEffort);
const requested = requestedRank >= 0 ? effortScale[requestedRank] : undefined;
const config = modelConfigId
? configs?.find((c) => c.id === modelConfigId)
: undefined;
if (!config) {
return {};
}
const modelLabel = config.display_name.trim() || config.model.trim();
const effortConfig = config.model_config?.reasoning_effort;
if (!effortConfig) {
return { modelLabel };
}
let effective = requested;
if (effective === undefined) {
const defaultRank = effortRank(effortConfig.default);
effective = defaultRank >= 0 ? effortScale[defaultRank] : undefined;
}
if (effective === undefined) {
return { modelLabel };
}
if (effortConfig.max !== undefined) {
const maxRank = effortRank(effortConfig.max);
if (maxRank < 0) {
return { modelLabel };
}
if (effortRank(effective) > maxRank) {
effective = effortScale[maxRank];
}
}
if (effective === ChatModelReasoningEffortNone) {
return { modelLabel };
}
return { modelLabel, effortLabel: effective };
};
@@ -17,6 +17,10 @@ export type SubagentDescriptor = {
title: string;
fallbackTitle: string;
supportsDesktopAffordance: boolean;
/** Set only when the spawn args explicitly selected a model. */
modelConfigId?: string;
/** Set only when the spawn args explicitly pinned a reasoning effort. */
reasoningEffort?: string;
};
const subagentCatalog: Record<
@@ -151,6 +155,12 @@ export const getSubagentDescriptor = ({
const title =
getProvidedSubagentTitle({ args: argsRecord, result: resultRecord }) ||
catalogEntry.fallbackTitle;
const modelConfigId =
action === "spawn" ? asString(argsRecord?.model_config_id).trim() : "";
const reasoningEffort =
action === "spawn"
? asString(argsRecord?.reasoning_effort).trim().toLowerCase()
: "";
return {
action,
@@ -159,5 +169,7 @@ export const getSubagentDescriptor = ({
title,
fallbackTitle: catalogEntry.fallbackTitle,
supportsDesktopAffordance: catalogEntry.supportsDesktopAffordance,
...(modelConfigId ? { modelConfigId } : {}),
...(reasoningEffort ? { reasoningEffort } : {}),
};
};