feat: no silent model fallback — explicit failure, gone models, per-profile fallback (#2385)

Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Clément Fiere
2026-08-09 22:44:25 +02:00
committed by GitHub
parent 0b60af298e
commit e4bc3be8b1
94 changed files with 5867 additions and 636 deletions
+2 -2
View File
@@ -178,9 +178,9 @@ func emitBulk(e *emitter, count int) {
// delayRange returns min/max delay in milliseconds based on model name.
func delayRange(model string) (int, int) {
switch model {
case "mock-fast":
case modelFast:
return 10, 50
case "mock-slow":
case modelSlow:
return 500, 3000
default:
return 100, 500
+16 -2
View File
@@ -17,6 +17,15 @@ import (
acp "github.com/coder/acp-go-sdk"
)
// Advertised model ids. mock-slow is a delay tier (see delayRange) that E2E
// fixtures select for slow responses; it must be advertised for the
// no-silent-model-fallback strict policy to accept it.
const (
modelFast = "mock-fast"
modelSmart = "mock-smart"
modelSlow = "mock-slow"
)
// logOutput is the writer for log messages (stderr). Tests can override this.
var logOutput io.Writer = os.Stderr
@@ -199,8 +208,13 @@ func mockSessionConfigOptionsForModel(model string) []acp.SessionConfigOption {
Id: "model",
Name: "Model",
Options: acp.SessionConfigSelectOptions{Ungrouped: &acp.SessionConfigSelectOptionsUngrouped{
{Value: "mock-fast", Name: "Mock Fast", Description: ptr("Fast mock model for testing")},
{Value: "mock-smart", Name: "Mock Smart", Description: ptr("Smart mock model for testing")},
{Value: modelFast, Name: "Mock Fast", Description: ptr("Fast mock model for testing")},
{Value: modelSmart, Name: "Mock Smart", Description: ptr("Smart mock model for testing")},
// E2E fixtures use "mock-slow" for the slow-response delay tier.
// It must be advertised so the no-silent-model-fallback strict
// policy (which fails session start when the profile model is
// absent from the advertised list) does not reject it.
{Value: modelSlow, Name: "Mock Slow", Description: ptr("Slow mock model for testing")},
}},
Type: "select",
}},
@@ -189,3 +189,57 @@ func TestSetSessionConfigOptionRejectsClosedSession(t *testing.T) {
t.Fatal("SetSessionConfigOption() recreated closed session config")
}
}
// TestMockSessionConfigOptionsForModelAdvertisesSlowModel verifies the mock
// agent advertises "mock-slow" in the model selector. E2E fixtures use the
// slow tier for delay-timing assertions, and the no-silent-model-fallback
// strict policy fails session start when the profile model is absent from the
// advertised list — so dropping the advertisement would break both.
func TestMockSessionConfigOptionsForModelAdvertisesSlowModel(t *testing.T) {
for _, model := range []string{modelFast, modelSmart, modelSlow} {
options := mockSessionConfigOptionsForModel(model)
values := make(map[string]string, len(options))
for _, option := range options {
if option.Select != nil {
values[string(option.Select.Id)] = string(option.Select.CurrentValue)
}
}
if values["model"] != model {
t.Errorf("mockSessionConfigOptionsForModel(%q) current model = %q, want %q", model, values["model"], model)
}
}
modelOption := findModelOption(t)
advertised := make([]string, 0, len(*modelOption.Select.Options.Ungrouped))
for _, option := range *modelOption.Select.Options.Ungrouped {
advertised = append(advertised, string(option.Value))
}
if !containsString(advertised, modelSlow) {
t.Fatalf("advertised models = %v, missing mock-slow (E2E + strict policy depend on it)", advertised)
}
for _, want := range []string{modelFast, modelSmart, modelSlow} {
if !containsString(advertised, want) {
t.Errorf("advertised models = %v, missing %s", advertised, want)
}
}
}
func findModelOption(t *testing.T) acp.SessionConfigOption {
t.Helper()
for _, option := range mockSessionConfigOptionsForModel(modelFast) {
if option.Select != nil && string(option.Select.Id) == "model" {
return option
}
}
t.Fatal("no model config option advertised")
return acp.SessionConfigOption{}
}
func containsString(values []string, want string) bool {
for _, v := range values {
if v == want {
return true
}
}
return false
}
+2 -2
View File
@@ -66,9 +66,9 @@ func processTUIPrompt(prompt, model string) {
// tuiDelay returns the thinking delay in milliseconds based on the model.
func tuiDelay(model string) int {
switch model {
case "mock-fast":
case modelFast:
return 100
case "mock-slow":
case modelSlow:
return 2000
default:
return 500
@@ -192,6 +192,11 @@ type AgentStreamEventData struct {
// runtime/workflow overrides are applied.
OriginalConfigCandidate []streams.ConfigOption `json:"original_config_candidate,omitempty"`
// Session model fallback (from "session_model_fallback" event): the
// fallback model the session started on because the configured start
// model was unavailable.
FallbackModel string `json:"fallback_model,omitempty"`
// Session info (from "session_info" event)
SessionTitle string `json:"session_title,omitempty"`
SessionUpdatedAt string `json:"session_updated_at,omitempty"`
@@ -537,6 +542,24 @@ type SessionModelsEventPayload struct {
Timestamp string `json:"timestamp"`
}
// SessionModelFallbackEventPayload is the payload for session fallback-model
// events: the session started on the profile's fallback model because the
// configured start model was unavailable.
type SessionModelFallbackEventPayload struct {
TaskID string `json:"task_id"`
SessionID string `json:"session_id"`
AgentID string `json:"agent_id"`
FallbackModel string `json:"fallback_model"`
Timestamp string `json:"timestamp"`
}
// GetSessionID returns the session ID for this event (used by event routing).
// Without it the gateway's extractSessionID cannot route the notification to
// the session's subscribers.
func (p SessionModelFallbackEventPayload) GetSessionID() string {
return p.SessionID
}
// SessionModelsSnapshot is the persisted provider-derived state needed to
// hydrate the task model selector before live session events reconnect.
type SessionModelsSnapshot struct {
@@ -201,6 +201,7 @@ func (p *EventPublisher) PublishAgentStreamEvent(execution *AgentExecution, even
SupportsPromptQueueing: event.SupportsPromptQueueing,
AuthMethods: event.AuthMethods,
CurrentModelID: event.CurrentModelID,
FallbackModel: event.FallbackModel,
SessionModels: event.SessionModels,
ConfigOptions: event.ConfigOptions,
ConfigBaselineCandidate: event.ConfigBaselineCandidate,
@@ -446,28 +446,43 @@ func (m *Manager) reapplySessionModeAfterReset(ctx context.Context, execution *A
}
// reapplySessionModelAfterReset re-applies the effective model to a freshly
// initialized ACP session so a context reset does not replace the task's model
// with the provider default advertised by the new session.
// initialized ACP session under the no-silent-model-fallback policy: a model
// that is gone in the fresh session's advertised list fails the reset
// explicitly (returning an error) unless the profile opted into a fallback
// model or the legacy auto-fallback toggle. Previously the re-apply was
// best-effort, silently leaving the fresh session on the provider default.
func (m *Manager) reapplySessionModelAfterReset(
ctx context.Context,
execution *AgentExecution,
newSessionID, modelID string,
) {
) error {
if execution.agentctl == nil || modelID == "" {
return
return nil
}
if err := execution.agentctl.SetModel(ctx, modelID); err != nil {
policy := m.resolveStartModelPolicy(ctx, execution.AgentProfileID)
policy.Model = modelID
appliedModel, usingFallback, err := applyStartModelPolicy(
ctx, m.logger, execution.agentctl, execution.GetModelState(), policy,
)
if err != nil {
m.logger.Warn("failed to re-apply session model after context reset",
zap.String("execution_id", execution.ID),
zap.String("model", modelID),
zap.Error(err))
return
return err
}
m.logger.Info("re-applied session model after context reset",
zap.String("execution_id", execution.ID),
zap.String("session_id", execution.SessionID),
zap.String("new_acp_session_id", newSessionID),
zap.String("model", modelID))
if appliedModel != "" {
m.logger.Info("re-applied session model after context reset",
zap.String("execution_id", execution.ID),
zap.String("session_id", execution.SessionID),
zap.String("new_acp_session_id", newSessionID),
zap.String("model", appliedModel),
zap.Bool("using_fallback", usingFallback))
if usingFallback && m.sessionManager != nil {
m.sessionManager.publishModelFallbackEvent(execution, newSessionID, appliedModel)
}
}
return nil
}
func (m *Manager) effectiveSessionModelForReset(ctx context.Context, execution *AgentExecution) string {
@@ -539,6 +554,13 @@ func (m *Manager) ResetAgentContext(ctx context.Context, executionID string) err
m.resetStreamingStateWithHistory(exec)
// The cached model state describes the OLD ACP session. The fresh
// session has not advertised its models yet, so evaluating the start
// model against the stale list could reject a model the new session
// accepts (or vice versa). Clear it: reapplySessionModelAfterReset
// then relies on the fresh session's own SetModel result.
exec.SetModelState(nil)
// Drain any stale prompt completion signal
select {
case <-exec.promptDoneCh:
@@ -547,8 +569,19 @@ func (m *Manager) ResetAgentContext(ctx context.Context, executionID string) err
})
// Restore the task's effective model and the user's session permission mode
// onto the fresh ACP session.
m.reapplySessionModelAfterReset(ctx, execution, newSessionID, effectiveModel)
// onto the fresh ACP session. A strict-mode model that is gone in the new
// session fails the reset explicitly instead of silently dropping to the
// provider default.
if err := m.reapplySessionModelAfterReset(ctx, execution, newSessionID, effectiveModel); err != nil {
// The reset already committed the execution as Ready; a model
// re-application failure must not leave it there, or later prompts
// would run on the fresh session with provider-default model state.
_ = m.executionStore.WithLock(executionID, func(exec *AgentExecution) {
exec.Status = v1.AgentStatusFailed
exec.ErrorMessage = err.Error()
})
return err
}
m.reapplySessionModeAfterReset(ctx, execution, newSessionID, prevMode)
m.logger.Info("agent context reset via session (no process restart)",
@@ -158,6 +158,44 @@ func (m *Manager) resolveProfileSessionConfig(ctx context.Context, profileID str
return info.Model, info.Mode, info.ConfigOptions
}
// resolveProfileSessionConfigAndPolicy resolves the ACP session config and
// the no-silent-model-fallback policy from a single ResolveProfile call.
// Session start needs both, so a shared lookup avoids two DB reads of the
// same profile per session.
func (m *Manager) resolveProfileSessionConfigAndPolicy(ctx context.Context, profileID string) (string, string, map[string]string, StartModelPolicy) {
if profileID == "" || m.profileResolver == nil {
return "", "", nil, StartModelPolicy{}
}
info, err := m.profileResolver.ResolveProfile(ctx, profileID)
if err != nil || info == nil {
return "", "", nil, StartModelPolicy{}
}
return info.Model, info.Mode, info.ConfigOptions, StartModelPolicy{
Model: info.Model,
FallbackModel: info.FallbackModel,
AutoFallback: info.AutoFallback,
}
}
// resolveStartModelPolicy resolves the profile's no-silent-model-fallback
// policy (start model + optional fallback + legacy toggle). Returns a zero
// policy when the profile cannot be resolved, which the policy helper treats
// as "no start model configured".
func (m *Manager) resolveStartModelPolicy(ctx context.Context, profileID string) StartModelPolicy {
if profileID == "" || m.profileResolver == nil {
return StartModelPolicy{}
}
info, err := m.profileResolver.ResolveProfile(ctx, profileID)
if err != nil || info == nil {
return StartModelPolicy{}
}
return StartModelPolicy{
Model: info.Model,
FallbackModel: info.FallbackModel,
AutoFallback: info.AutoFallback,
}
}
// initializeACPSession delegates to SessionManager for full ACP session initialization and prompting.
// We pass MarkBootReady (not MarkReady) for the no-prompt branches: dispatchInitialPrompt only
// invokes the callback when there's no taskDescription/attachments to send, which is a *boot*
@@ -168,12 +206,18 @@ func (m *Manager) resolveProfileSessionConfig(ctx context.Context, profileID str
// profile defaults. This preserves user-selected model and reasoning-effort
// values across process recovery.
func (m *Manager) initializeACPSession(ctx context.Context, execution *AgentExecution, agentConfig agents.Agent, taskDescription string, attachments []MessageAttachment, mcpServers []agentctltypes.McpServer) error {
profileModel, profileMode, profileConfigOptions := m.resolveProfileSessionConfig(ctx, execution.AgentProfileID)
profileModel, profileMode, profileConfigOptions, policy := m.resolveProfileSessionConfigAndPolicy(ctx, execution.AgentProfileID)
runtimeModel, runtimeMode, runtimeConfigOptions := m.sessionRuntimeOverrides(ctx, execution)
// The effective runtime model (user-selected, persisted session state)
// takes precedence over the profile's start model for the session; the
// policy still carries the profile's fallback settings so a gone
// effective model is handled the same way (InitializeAndPromptWithLayers
// resolves the effective model and applies the policy).
return m.sessionManager.InitializeAndPromptWithLayers(
ctx, execution, agentConfig, taskDescription, attachments, mcpServers, m.MarkBootReady,
profileModel, profileMode, profileConfigOptions,
runtimeModel, runtimeMode, runtimeConfigOptions,
policy,
)
}
@@ -76,6 +76,8 @@ func (r *StoreProfileResolver) ResolveProfile(ctx context.Context, profileID str
AgentName: agent.Name,
Model: model,
Mode: profile.Mode,
FallbackModel: profile.FallbackModel,
AutoFallback: profile.AutoFallback,
ConfigOptions: profile.ConfigOptions,
AutoApprove: profile.AutoApprove,
DangerouslySkipPermissions: profile.DangerouslySkipPermissions,
@@ -324,18 +324,20 @@ func (sm *SessionManager) InitializeAndPrompt(
attachments []MessageAttachment,
mcpServers []agentctltypes.McpServer,
markReady func(executionID string) error,
profileModel string,
startModelPolicy StartModelPolicy,
profileMode string,
profileConfigOptions map[string]string,
) error {
// Preserve the legacy call contract for direct callers: its supplied
// configuration is the final runtime layer. The lifecycle manager uses the
// layered entry point below so profile settings can be snapshotted first.
// configuration rides the profile layer (no separate runtime override).
// The lifecycle manager uses the layered entry point so profile settings
// can be snapshotted first.
return sm.InitializeAndPromptWithLayers(
ctx, execution, agentConfig, taskDescription, attachments, mcpServers,
markReady,
startModelPolicy.Model, profileMode, profileConfigOptions,
"", "", nil,
profileModel, profileMode, profileConfigOptions,
startModelPolicy,
)
}
@@ -356,6 +358,7 @@ func (sm *SessionManager) InitializeAndPromptWithLayers(
runtimeModel string,
runtimeMode string,
runtimeConfigOptions map[string]string,
startModelPolicy StartModelPolicy,
) error {
ctx, result, err := sm.initializeACPConnection(ctx, execution, agentConfig, mcpServers)
if err != nil {
@@ -363,10 +366,39 @@ func (sm *SessionManager) InitializeAndPromptWithLayers(
}
execution.ACPSessionID = result.SessionID
execution.sessionInitialized = true
providerDefaultConfig := execution.GetModelState()
// No-silent-model-fallback: decide the effective model up front under the
// profile's policy (a gone model fails the launch explicitly unless a
// fallback model or the legacy auto-fallback toggle is configured), then
// hand the decided model to the layers so it is applied exactly once.
// Previously SetModel failures were best-effort — the session continued
// on the provider default, which is the implicit switch this feature
// eliminates. The gate is the effective model (which includes the
// persisted runtime override), not the profile's start model: a profile
// with no model and a session with a runtime model must still enforce
// strict failure / the configured fallback.
effectiveModel := sm.applyStartModelPolicyToEffectiveModel(
ctx, execution, result.SessionID, profileModel, runtimeModel, startModelPolicy,
)
if effectiveModel.err != nil {
return effectiveModel.err
}
// The layers apply the policy-decided model; the runtime override is
// neutralized because the decision already accounted for it. When the
// policy ran it already applied the model via SetModel — the layers must
// not apply it again (a second SetModel failure would be only logged,
// and the original-config snapshot could disagree with reality).
profileModel = effectiveModel.model
runtimeModel = ""
// Only mark the launch initialized once the start-model policy has
// succeeded — a strict unavailable model fails here, and a failed launch
// must not look initialized.
execution.sessionInitialized = true
finalConfigID, profileModelApplied, profileModeApplied, profileConfigOptionsApplied := sm.applyProfileSessionLayers(
ctx, execution, result.SessionID, profileModel, profileMode, profileConfigOptions,
effectiveModel.handled, effectiveModel.appliedModel,
)
// Capture the effective profile state before runtime overrides or workflow
@@ -392,6 +424,58 @@ func (sm *SessionManager) InitializeAndPromptWithLayers(
return nil
}
// applyStartModelPolicyToEffectiveModel resolves the effective model (profile
// start model, overridden by the persisted runtime model) and applies the
// profile's no-silent-model-fallback policy to it: strict profiles fail a gone
// model explicitly, fallback-model profiles switch to the configured fallback
// (publishing the fallback event), and auto-fallback keeps legacy behavior.
// Returns the decided model (fallback when one was applied), the model
// actually applied by the policy, whether the policy handled model selection,
// or the policy error. The caller neutralizes the runtime override afterwards
// because the decision already accounted for it.
func (sm *SessionManager) applyStartModelPolicyToEffectiveModel(
ctx context.Context,
execution *AgentExecution,
acpSessionID string,
profileModel, runtimeModel string,
startModelPolicy StartModelPolicy,
) (effective struct {
model string
appliedModel string
handled bool
err error
}) {
effective.model = profileModel
if runtimeModel != "" {
effective.model = runtimeModel
}
if effective.model == "" || execution.agentctl == nil {
return effective
}
// The policy owns this model decision even when it deliberately continues
// without an applied model (auto-fallback or method-not-found). Later
// profile layers must not retry an outcome that was already handled.
effective.handled = true
startModelPolicy.Model = effective.model
appliedModel, usingFallback, policyErr := applyStartModelPolicy(
ctx, sm.logger, execution.agentctl,
execution.GetModelState(), startModelPolicy,
)
if policyErr != nil {
sm.logger.Error("start model unavailable, failing session start",
zap.String("execution_id", execution.ID),
zap.Error(policyErr))
effective.err = policyErr
return effective
}
effective.appliedModel = appliedModel
if usingFallback {
sm.publishModelFallbackEvent(execution, acpSessionID, appliedModel)
effective.model = appliedModel
}
return effective
}
func (sm *SessionManager) initializeACPConnection(
ctx context.Context,
execution *AgentExecution,
@@ -452,6 +536,8 @@ func (sm *SessionManager) applyProfileSessionLayers(
profileModel string,
profileMode string,
profileConfigOptions map[string]string,
modelPolicyHandled bool,
policyAppliedModel string,
) (string, string, string, map[string]string) {
if execution.agentctl == nil {
return "", "", "", nil
@@ -461,7 +547,21 @@ func (sm *SessionManager) applyProfileSessionLayers(
profileModeApplied := ""
profileConfigOptionsApplied := make(map[string]string)
if profileModel != "" {
if err := execution.agentctl.SetModel(ctx, profileModel); err != nil {
if modelPolicyHandled {
if policyAppliedModel == "" {
sm.logger.Debug("start-model policy handled model without applying one",
zap.String("execution_id", execution.ID), zap.String("model", profileModel))
} else {
// The start-model policy applied this model via SetModel before
// the layers ran — record it as applied without repeating the
// call, so a duplicate SetModel cannot fail silently behind the
// policy's back.
finalConfigID = modelConfigIDFromState(execution.GetModelState())
profileModelApplied = policyAppliedModel
sm.logger.Info("profile model applied by start-model policy",
zap.String("execution_id", execution.ID), zap.String("model", policyAppliedModel))
}
} else if err := execution.agentctl.SetModel(ctx, profileModel); err != nil {
sm.logger.Warn("failed to set profile model via ACP",
zap.String("execution_id", execution.ID), zap.String("model", profileModel), zap.Error(err))
} else {
@@ -582,6 +682,24 @@ func (sm *SessionManager) publishSettledConfigOptions(
})
}
// publishModelFallbackEvent broadcasts an explicit "using fallback model"
// signal so the UI can surface why the session is not on the configured
// start model. The event carries the fallback model id in Data.
func (sm *SessionManager) publishModelFallbackEvent(
execution *AgentExecution,
acpSessionID string,
fallbackModel string,
) {
if sm.eventPublisher == nil || fallbackModel == "" {
return
}
sm.eventPublisher.PublishAgentStreamEvent(execution, agentctl.AgentEvent{
Type: streams.EventTypeSessionModelFallback,
SessionID: acpSessionID,
FallbackModel: fallbackModel,
})
}
func (sm *SessionManager) publishWorkflowSessionConfigFailures(
execution *AgentExecution,
acpSessionID string,
@@ -239,6 +239,223 @@ func createTestClient(t *testing.T, serverURL string) *agentctl.Client {
// --- Tests ---
// TestInitializeAndPromptWithLayers_StrictPolicyFailsGoneModel verifies the
// start-model policy is applied to the EFFECTIVE model (profile start model or
// persisted runtime override) and a strict profile fails the launch explicitly
// when that model is unavailable. This is the regression guard for the
// no-silent model-fallback gate: an unavailable model must never silently
// continue on the provider default, and a failed launch must not look
// initialized.
func TestInitializeAndPromptWithLayers_StrictPolicyFailsGoneModel(t *testing.T) {
for _, tc := range []struct {
name string
profileModel string
runtimeModel string
}{
{name: "profile start model gone", profileModel: "claude-gone", runtimeModel: ""},
{name: "runtime override gone", profileModel: "", runtimeModel: "claude-gone"},
} {
t.Run(tc.name, func(t *testing.T) {
mock := newMockAgentServer(t)
defer mock.Close()
log := newSessionTestLogger()
stopCh := newTestStopCh(t)
sm := NewSessionManager(log, stopCh)
streamMgr := NewStreamManager(log, StreamCallbacks{
OnAgentEvent: func(execution *AgentExecution, event agentctl.AgentEvent) {},
}, nil, stopCh)
cleanupStreamManager(t, stopCh, streamMgr)
sm.SetDependencies(NewEventPublisher(&MockEventBusWithTracking{}, log), streamMgr, nil, nil)
client := createTestClient(t, mock.server.URL)
defer client.Close()
execution := &AgentExecution{
ID: "exec-1",
TaskID: "task-1",
SessionID: "session-1",
WorkspacePath: "/workspace",
agentctl: client,
promptDoneCh: make(chan PromptCompletionSignal, 1),
}
// Advertise only gpt-5; the policy model (claude-gone) is missing.
execution.SetModelState(modelState("gpt-5"))
agentConfig := &testAgent{
id: "test-agent",
enabled: true,
runtimeConfig: &agents.RuntimeConfig{
Cmd: agents.NewCommand("test-agent"),
Protocol: agent.ProtocolACP,
SessionConfig: agents.SessionConfig{},
ResourceLimits: agents.ResourceLimits{MemoryMB: 512, CPUCores: 0.5, Timeout: time.Hour},
},
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
err := sm.InitializeAndPromptWithLayers(
ctx, execution, agentConfig, "", nil, nil,
func(executionID string) error { return nil },
tc.profileModel, "plan", nil,
tc.runtimeModel, "", nil,
StartModelPolicy{},
)
if err == nil {
t.Fatal("expected InitializeAndPromptWithLayers to fail for an unavailable model")
}
if !strings.Contains(err.Error(), "claude-gone") {
t.Errorf("error should name the unavailable model, got %q", err.Error())
}
if execution.sessionInitialized {
t.Error("a failed launch must not be marked initialized")
}
})
}
}
// TestInitializeAndPrompt_AppliesStartModelPolicyExactlyOnce verifies the
// start-model policy's SetModel is not repeated by the profile layers: the
// policy applies the model, then the layers must only record it as applied
// (a second SetModel would be a redundant ACP round-trip whose failure would
// be silently logged while the session still starts).
func TestInitializeAndPrompt_AppliesStartModelPolicyExactlyOnce(t *testing.T) {
mock := newMockAgentServer(t)
defer mock.Close()
log := newSessionTestLogger()
stopCh := newTestStopCh(t)
sm := NewSessionManager(log, stopCh)
streamMgr := NewStreamManager(log, StreamCallbacks{
OnAgentEvent: func(execution *AgentExecution, event agentctl.AgentEvent) {},
}, nil, stopCh)
cleanupStreamManager(t, stopCh, streamMgr)
sm.SetDependencies(NewEventPublisher(&MockEventBusWithTracking{}, log), streamMgr, nil, nil)
client := createTestClient(t, mock.server.URL)
defer client.Close()
execution := &AgentExecution{
ID: "exec-1",
TaskID: "task-1",
SessionID: "session-1",
WorkspacePath: "/workspace",
agentctl: client,
promptDoneCh: make(chan PromptCompletionSignal, 1),
}
execution.SetModelState(modelState("gpt-5"))
agentConfig := &testAgent{
id: "test-agent",
enabled: true,
runtimeConfig: &agents.RuntimeConfig{
Cmd: agents.NewCommand("test-agent"),
Protocol: agent.ProtocolACP,
SessionConfig: agents.SessionConfig{},
ResourceLimits: agents.ResourceLimits{MemoryMB: 512, CPUCores: 0.5, Timeout: time.Hour},
},
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
err := sm.InitializeAndPrompt(ctx, execution, agentConfig, "", nil, nil,
func(executionID string) error { return nil },
StartModelPolicy{Model: "gpt-5"}, "plan", nil)
if err != nil {
t.Fatalf("InitializeAndPrompt failed: %v", err)
}
setModelCalls := 0
for _, action := range mock.getActionLog() {
if action == "agent.session.set_model" {
setModelCalls++
}
}
if setModelCalls != 1 {
t.Fatalf("set_model calls = %d, want exactly 1 (the policy applies the model once)", setModelCalls)
}
}
// TestInitializeAndPrompt_AutoFallbackFailureDoesNotRetryModel verifies that
// the legacy best-effort policy owns its failed SetModel attempt. The profile
// layers must not send the same request a second time after the policy has
// deliberately continued on the provider default.
func TestInitializeAndPrompt_AutoFallbackFailureDoesNotRetryModel(t *testing.T) {
mock := newMockAgentServer(t)
defer mock.Close()
mock.handler = func(msg ws.Message) *ws.Message {
if msg.Action == "agent.session.set_model" {
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "model unavailable", nil)
return resp
}
return mock.defaultHandler(msg)
}
log := newSessionTestLogger()
stopCh := newTestStopCh(t)
sm := NewSessionManager(log, stopCh)
streamMgr := NewStreamManager(log, StreamCallbacks{
OnAgentEvent: func(execution *AgentExecution, event agentctl.AgentEvent) {},
}, nil, stopCh)
cleanupStreamManager(t, stopCh, streamMgr)
eventBus := &MockEventBusWithTracking{}
sm.SetDependencies(NewEventPublisher(eventBus, log), streamMgr, nil, nil)
client := createTestClient(t, mock.server.URL)
defer client.Close()
execution := &AgentExecution{
ID: "exec-1",
TaskID: "task-1",
SessionID: "session-1",
WorkspacePath: "/workspace",
agentctl: client,
promptDoneCh: make(chan PromptCompletionSignal, 1),
}
execution.SetModelState(&CachedModelState{
CurrentModelID: "provider-default",
Models: []streams.SessionModelInfo{{ModelID: "gpt-5"}},
})
agentConfig := &testAgent{
id: "test-agent",
enabled: true,
runtimeConfig: &agents.RuntimeConfig{
Cmd: agents.NewCommand("test-agent"),
Protocol: agent.ProtocolACP,
SessionConfig: agents.SessionConfig{},
ResourceLimits: agents.ResourceLimits{MemoryMB: 512, CPUCores: 0.5, Timeout: time.Hour},
},
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
err := sm.InitializeAndPrompt(ctx, execution, agentConfig, "", nil, nil,
func(executionID string) error { return nil },
StartModelPolicy{Model: "gpt-5", AutoFallback: true}, "plan", nil)
if err != nil {
t.Fatalf("InitializeAndPrompt failed: %v", err)
}
setModelCalls := 0
for _, action := range mock.getActionLog() {
if action == "agent.session.set_model" {
setModelCalls++
}
}
if setModelCalls != 1 {
t.Fatalf("set_model calls = %d, want exactly 1 policy attempt", setModelCalls)
}
var original *AgentStreamEventPayload
for _, event := range eventBus.getStreamEvents() {
if event.Data != nil && originalConfigEventData(event.Data.Data) {
original = &event
break
}
}
if original == nil {
t.Fatal("expected original configuration snapshot")
}
if original.Data.CurrentModelID != "provider-default" {
t.Fatalf("original model = %q, want provider-default after failed policy attempt", original.Data.CurrentModelID)
}
}
func TestInitializeAndPrompt_StreamBeforeInitialize(t *testing.T) {
// This test verifies the critical ordering: stream connects BEFORE initialize is called.
mock := newMockAgentServer(t)
@@ -285,7 +502,7 @@ func TestInitializeAndPrompt_StreamBeforeInitialize(t *testing.T) {
err := sm.InitializeAndPrompt(ctx, execution, agentConfig, "", nil, nil, func(executionID string) error {
return nil
}, "", "", nil)
}, StartModelPolicy{}, "", nil)
if err != nil {
t.Fatalf("InitializeAndPrompt failed: %v", err)
}
@@ -366,7 +583,7 @@ func TestInitializeAndPrompt_AppliesProfileConfigOptions(t *testing.T) {
defer cancel()
err := sm.InitializeAndPrompt(ctx, execution, agentConfig, "", nil, nil, func(executionID string) error {
return nil
}, "sonnet", "plan", map[string]string{
}, StartModelPolicy{Model: "sonnet"}, "plan", map[string]string{
"effort": "high",
"model": "ignored",
"mode": "ignored",
@@ -811,7 +1028,7 @@ func TestInitializeAndPrompt_StreamTimeout(t *testing.T) {
err = sm.InitializeAndPrompt(ctx, execution, agentConfig, "", nil, nil, func(executionID string) error {
return nil
}, "", "", nil)
}, StartModelPolicy{}, "", nil)
// Should fail because stream couldn't connect and Initialize fails
if err == nil {
@@ -866,7 +1083,7 @@ func TestInitializeAndPrompt_WithTaskDescription(t *testing.T) {
err := sm.InitializeAndPrompt(ctx, execution, agentConfig, "Build a feature", nil, nil, func(executionID string) error {
return nil
}, "", "", nil)
}, StartModelPolicy{}, "", nil)
if err != nil {
t.Fatalf("InitializeAndPrompt failed: %v", err)
}
@@ -934,7 +1151,7 @@ func TestInitializeAndPrompt_NoStreamManager(t *testing.T) {
// But it should NOT panic due to nil streamManager.
err := sm.InitializeAndPrompt(ctx, execution, agentConfig, "", nil, nil, func(executionID string) error {
return nil
}, "", "", nil)
}, StartModelPolicy{}, "", nil)
// Expect error because Initialize call over WS will fail (stream not connected)
if err == nil {
@@ -0,0 +1,146 @@
package lifecycle
import (
"context"
"errors"
"fmt"
"github.com/kandev/kandev/internal/agent/runtime/routingerr"
"github.com/kandev/kandev/internal/agentctl/sessionmodel"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
// StartModelPolicy carries the profile's model-selection settings for the
// no-silent-model-fallback policy applied at session start (and when a
// context reset re-applies the effective model to a fresh ACP session).
type StartModelPolicy struct {
// Model is the configured start model (ACP model ID). Empty means "use
// the agent's default" — nothing is applied.
Model string
// FallbackModel is the optional single model to switch to when Model is
// unavailable. Ignored when AutoFallback is enabled.
FallbackModel string
// AutoFallback restores the legacy best-effort behavior: SetModel
// failures log and the session continues on the provider default.
AutoFallback bool
}
// modelApplier abstracts the agentctl surface applyStartModelPolicy needs so
// tests can script SetModel without a live agentctl server.
type modelApplier interface {
SetModel(ctx context.Context, modelID string) error
}
// advertisedModelIDs returns the session's currently advertised model IDs.
// An empty slice means the agent has not advertised a model list (unknown).
func advertisedModelIDs(state *CachedModelState) []string {
if state == nil {
return nil
}
ids := make([]string, 0, len(state.Models))
for _, m := range state.Models {
ids = append(ids, m.ModelID)
}
return ids
}
// containsModel reports whether ids contains id.
func containsModel(ids []string, id string) bool {
for _, existing := range ids {
if existing == id {
return true
}
}
return false
}
// applyFallbackModel applies the policy's fallback model after the start
// model proved unavailable (gone from the advertised list or rejected by
// SetModel). Shared by both failure branches so the log line, error
// wrapping, and return shape stay in sync. reason ("is unavailable" /
// "failed") distinguishes the failure context in the error text.
func applyFallbackModel(
ctx context.Context,
log *logger.Logger,
applier modelApplier,
policy StartModelPolicy,
reason string,
) (string, bool, error) {
if err := applier.SetModel(ctx, policy.FallbackModel); err != nil {
return "", false, fmt.Errorf(
"start model %q %s and fallback model %q failed to apply: %w",
policy.Model, reason, policy.FallbackModel, err)
}
log.Info("start model unavailable, using fallback model",
zap.String("start_model", policy.Model),
zap.String("fallback_model", policy.FallbackModel))
return policy.FallbackModel, true, nil
}
// applyStartModelPolicy enforces the no-silent-model-fallback rules when
// applying the profile's start model to a session:
//
// - AutoFallback: legacy best-effort (attempt SetModel; on failure warn
// and continue on the provider default).
// - Start model gone (absent from the advertised list, when known):
// apply FallbackModel when set (returning usingFallback=true), else
// fail with the actionable model-unavailable message.
// - Start model present / list unknown: apply it; a method-not-found
// error means the agent has no model selection — continue silently;
// any other error fails explicitly.
//
// Returns the model actually applied ("" when none was applied / the agent
// default remains) and whether the fallback model was used.
func applyStartModelPolicy(
ctx context.Context,
log *logger.Logger,
applier modelApplier,
state *CachedModelState,
policy StartModelPolicy,
) (applied string, usingFallback bool, err error) {
if policy.Model == "" {
return "", false, nil
}
if policy.AutoFallback {
if err := applier.SetModel(ctx, policy.Model); err != nil {
log.Warn("failed to set profile model via ACP (auto-fallback)",
zap.String("model", policy.Model), zap.Error(err))
return "", false, nil
}
return policy.Model, false, nil
}
advertised := advertisedModelIDs(state)
if len(advertised) > 0 && !containsModel(advertised, policy.Model) {
if policy.FallbackModel != "" {
return applyFallbackModel(ctx, log, applier, policy, "is unavailable")
}
return "", false, fmt.Errorf("%s", routingerr.ModelUnavailableMessage(policy.Model))
}
if err := applier.SetModel(ctx, policy.Model); err != nil {
if sessionmodel.IsMethodNotFound(err) {
// The agent declares no model-selection support — nothing to
// enforce; the session continues on the agent default.
log.Debug("agent does not support model selection, continuing",
zap.String("model", policy.Model), zap.Error(err))
return "", false, nil
}
// SetModel failed even though the model appeared available (the
// advertised list may not have arrived yet at session start, or the
// agent rejected the model itself). The explicit fallback exists
// exactly for this: switch to it instead of failing — but never
// fall through to the provider default. A transport or cancellation
// failure is NOT evidence the model is unavailable — switching
// models would mask a broken connection, so those fail explicitly
// like any other non-availability error.
if policy.FallbackModel != "" &&
!isTransportDeadErr(err) && !errors.Is(err, context.Canceled) {
return applyFallbackModel(ctx, log, applier, policy, "failed")
}
return "", false, fmt.Errorf("failed to set start model %q: %w", policy.Model, err)
}
return policy.Model, false, nil
}
@@ -0,0 +1,276 @@
package lifecycle
import (
"context"
"errors"
"strings"
"testing"
acp "github.com/coder/acp-go-sdk"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/common/logger"
)
// fakeModelApplier records SetModel calls and returns scripted errors, one
// per call; the last error repeats for any further calls.
type fakeModelApplier struct {
calls []string
errs []error
}
// SetModel records the applied model so tests can assert what the policy sent.
func (f *fakeModelApplier) SetModel(_ context.Context, modelID string) error {
f.calls = append(f.calls, modelID)
if len(f.errs) == 0 {
return nil
}
err := f.errs[0]
if len(f.errs) > 1 {
f.errs = f.errs[1:]
}
return err
}
// newPolicyTestLogger returns a discard logger for policy tests.
func newPolicyTestLogger() *logger.Logger {
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
return log
}
// modelState builds a cached model state advertising the given model ids.
func modelState(models ...string) *CachedModelState {
infos := make([]streams.SessionModelInfo, 0, len(models))
for _, id := range models {
infos = append(infos, streams.SessionModelInfo{ModelID: id})
}
return &CachedModelState{Models: infos}
}
// methodNotFoundErr simulates the agent declaring no model-selection support.
func methodNotFoundErr() error {
return &acp.RequestError{Code: -32601, Message: "method not found"}
}
// TestApplyStartModelPolicy_StrictGoneModelFails verifies a gone start model fails the session with the actionable message.
func TestApplyStartModelPolicy_StrictGoneModelFails(t *testing.T) {
applier := &fakeModelApplier{}
_, usingFallback, err := applyStartModelPolicy(
context.Background(), newPolicyTestLogger(), applier,
modelState("gpt-5"), StartModelPolicy{Model: "claude-gone"},
)
if err == nil {
t.Fatal("expected error for gone start model in strict mode")
}
if !strings.Contains(err.Error(), "claude-gone") {
t.Errorf("error should name the gone model, got %q", err.Error())
}
if usingFallback {
t.Error("usingFallback should be false")
}
if len(applier.calls) != 0 {
t.Errorf("SetModel should not be called for a gone model in strict mode, got %v", applier.calls)
}
}
// TestApplyStartModelPolicy_StrictGoneModelNoSession verifies no model is applied when the strict policy rejects a gone start model.
func TestApplyStartModelPolicy_StrictGoneModelNoSession(t *testing.T) {
// Empty advertised list = "unknown" — fall back to SetModel result.
applier := &fakeModelApplier{errs: []error{errors.New("model rejected")}}
_, _, err := applyStartModelPolicy(
context.Background(), newPolicyTestLogger(), applier,
&CachedModelState{}, StartModelPolicy{Model: "claude-sonnet"},
)
if err == nil {
t.Fatal("expected error when SetModel fails and list is unknown")
}
}
// TestApplyStartModelPolicy_StrictGoneModelUnsupportedContinues verifies a method-not-found SetModel error continues silently.
func TestApplyStartModelPolicy_StrictGoneModelUnsupportedContinues(t *testing.T) {
applier := &fakeModelApplier{errs: []error{methodNotFoundErr()}}
applied, usingFallback, err := applyStartModelPolicy(
context.Background(), newPolicyTestLogger(), applier,
&CachedModelState{}, StartModelPolicy{Model: "claude-sonnet"},
)
if err != nil {
t.Fatalf("method-not-found should continue silently, got %v", err)
}
if applied != "" || usingFallback {
t.Errorf("expected no applied model, got applied=%q fallback=%v", applied, usingFallback)
}
}
// TestApplyStartModelPolicy_StrictModelPresentApplies verifies an advertised start model is applied.
func TestApplyStartModelPolicy_StrictModelPresentApplies(t *testing.T) {
applier := &fakeModelApplier{}
applied, usingFallback, err := applyStartModelPolicy(
context.Background(), newPolicyTestLogger(), applier,
modelState("claude-sonnet"), StartModelPolicy{Model: "claude-sonnet"},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if applied != "claude-sonnet" || usingFallback {
t.Errorf("expected applied=claude-sonnet fallback=false, got applied=%q fallback=%v", applied, usingFallback)
}
if len(applier.calls) != 1 || applier.calls[0] != "claude-sonnet" {
t.Errorf("SetModel calls = %v, want [claude-sonnet]", applier.calls)
}
}
// TestApplyStartModelPolicy_FallbackModelUsedWhenGone verifies the configured fallback replaces a gone start model.
func TestApplyStartModelPolicy_FallbackModelUsedWhenGone(t *testing.T) {
applier := &fakeModelApplier{}
applied, usingFallback, err := applyStartModelPolicy(
context.Background(), newPolicyTestLogger(), applier,
modelState("gpt-5"), StartModelPolicy{Model: "claude-gone", FallbackModel: "gpt-5"},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if applied != "gpt-5" || !usingFallback {
t.Errorf("expected applied=gpt-5 fallback=true, got applied=%q fallback=%v", applied, usingFallback)
}
if len(applier.calls) != 1 || applier.calls[0] != "gpt-5" {
t.Errorf("SetModel calls = %v, want [gpt-5]", applier.calls)
}
}
// TestApplyStartModelPolicy_FallbackWhenSetModelRejectsGoneModel verifies the fallback applies when SetModel rejects the start model even without an advertised list.
func TestApplyStartModelPolicy_FallbackWhenSetModelRejectsGoneModel(t *testing.T) {
// Regression: at session start the advertised model list may not have
// arrived yet (empty = "unknown"). The adapter still rejects the gone
// start model — the fallback must be applied then too, not only when
// the list is known.
applier := &fakeModelApplier{errs: []error{
errors.New(`model "claude-gone" is not in the agent's 2 available models`),
nil,
}}
applied, usingFallback, err := applyStartModelPolicy(
context.Background(), newPolicyTestLogger(), applier,
&CachedModelState{}, StartModelPolicy{Model: "claude-gone", FallbackModel: "gpt-5"},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if applied != "gpt-5" || !usingFallback {
t.Errorf("expected applied=gpt-5 fallback=true, got applied=%q fallback=%v", applied, usingFallback)
}
if len(applier.calls) != 2 || applier.calls[0] != "claude-gone" || applier.calls[1] != "gpt-5" {
t.Errorf("SetModel calls = %v, want [claude-gone gpt-5]", applier.calls)
}
}
// TestApplyStartModelPolicy_FallbackModelFailureFails verifies a failed fallback SetModel fails the session explicitly.
func TestApplyStartModelPolicy_FallbackModelFailureFails(t *testing.T) {
applier := &fakeModelApplier{errs: []error{errors.New("rejected")}}
_, _, err := applyStartModelPolicy(
context.Background(), newPolicyTestLogger(), applier,
modelState("gpt-5"), StartModelPolicy{Model: "claude-gone", FallbackModel: "gpt-5"},
)
if err == nil {
t.Fatal("expected error when the fallback model itself fails to apply")
}
if !strings.Contains(err.Error(), "gpt-5") {
t.Errorf("error should name the fallback model, got %q", err.Error())
}
}
// TestApplyStartModelPolicy_AutoFallbackLegacyBestEffort verifies auto-fallback logs and continues on SetModel failure.
func TestApplyStartModelPolicy_AutoFallbackLegacyBestEffort(t *testing.T) {
// Gone model + auto-fallback: legacy behavior — try, warn, continue.
applier := &fakeModelApplier{errs: []error{errors.New("not in available models")}}
applied, usingFallback, err := applyStartModelPolicy(
context.Background(), newPolicyTestLogger(), applier,
modelState("gpt-5"), StartModelPolicy{Model: "claude-gone", AutoFallback: true},
)
if err != nil {
t.Fatalf("auto-fallback should never fail: %v", err)
}
if applied != "" || usingFallback {
t.Errorf("expected applied=\"\" fallback=false, got applied=%q fallback=%v", applied, usingFallback)
}
if len(applier.calls) != 1 {
t.Errorf("legacy path should still attempt SetModel, calls=%v", applier.calls)
}
}
// TestApplyStartModelPolicy_AutoFallbackPresentApplies verifies auto-fallback still applies an available start model.
func TestApplyStartModelPolicy_AutoFallbackPresentApplies(t *testing.T) {
applier := &fakeModelApplier{}
applied, usingFallback, err := applyStartModelPolicy(
context.Background(), newPolicyTestLogger(), applier,
modelState("claude-sonnet"), StartModelPolicy{Model: "claude-sonnet", AutoFallback: true},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if applied != "claude-sonnet" || usingFallback {
t.Errorf("expected applied=claude-sonnet fallback=false, got applied=%q fallback=%v", applied, usingFallback)
}
}
// TestApplyStartModelPolicy_EmptyModelNoOp verifies an empty start model is a no-op.
func TestApplyStartModelPolicy_EmptyModelNoOp(t *testing.T) {
applier := &fakeModelApplier{}
applied, usingFallback, err := applyStartModelPolicy(
context.Background(), newPolicyTestLogger(), applier,
modelState("gpt-5"), StartModelPolicy{},
)
if err != nil || applied != "" || usingFallback {
t.Errorf("empty model should no-op, got applied=%q fallback=%v err=%v", applied, usingFallback, err)
}
if len(applier.calls) != 0 {
t.Errorf("SetModel should not be called for empty model, got %v", applier.calls)
}
}
// TestApplyStartModelPolicy_TransportErrorDoesNotFallback verifies a
// transport/protocol SetModel failure fails explicitly instead of switching
// to the fallback: a broken connection is not evidence the model is
// unavailable, so applying the fallback would mask the real fault.
func TestApplyStartModelPolicy_TransportErrorDoesNotFallback(t *testing.T) {
applier := &fakeModelApplier{errs: []error{
errors.New(`websocket: connection closed: peer disconnected`),
}}
_, _, err := applyStartModelPolicy(
context.Background(), newPolicyTestLogger(), applier,
modelState("claude-gone"), StartModelPolicy{Model: "claude-gone", FallbackModel: "gpt-5"},
)
if err == nil {
t.Fatal("expected explicit failure for a transport error")
}
if len(applier.calls) != 1 || applier.calls[0] != "claude-gone" {
t.Errorf("SetModel calls = %v, want only [claude-gone] (no fallback)", applier.calls)
}
}
// TestApplyStartModelPolicy_CancelErrorDoesNotFallback verifies a cancelled
// SetModel context does not switch models either.
func TestApplyStartModelPolicy_CancelErrorDoesNotFallback(t *testing.T) {
applier := &fakeModelApplier{errs: []error{context.Canceled}}
_, _, err := applyStartModelPolicy(
context.Background(), newPolicyTestLogger(), applier,
modelState("claude-gone"), StartModelPolicy{Model: "claude-gone", FallbackModel: "gpt-5"},
)
if err == nil {
t.Fatal("expected explicit failure for a cancelled SetModel")
}
if len(applier.calls) != 1 || applier.calls[0] != "claude-gone" {
t.Errorf("SetModel calls = %v, want only [claude-gone] (no fallback)", applier.calls)
}
}
@@ -793,13 +793,19 @@ type CredentialsManager interface {
// AgentProfileInfo contains resolved profile information
type AgentProfileInfo struct {
ProfileID string
ProfileName string
AgentID string
AgentName string // e.g., "auggie", "claude", "codex"
Model string // applied through ACP model selection at session start
Mode string // applied via ACP session/set_mode at session start (empty = use agent default)
ConfigOptions map[string]string
ProfileID string
ProfileName string
AgentID string
AgentName string // e.g., "auggie", "claude", "codex"
Model string // applied through ACP model selection at session start
Mode string // applied via ACP session/set_mode at session start (empty = use agent default)
ConfigOptions map[string]string
// FallbackModel is the optional single ACP model ID to switch to when
// Model is unavailable. Ignored when AutoFallback is enabled.
FallbackModel string
// AutoFallback opts the profile into the legacy automatic-fallback
// behavior (session-start best-effort).
AutoFallback bool
AllowIndexing bool // Deprecated: legacy, kept so existing call sites compile; launch path reads CLIFlags.
CLIPassthrough bool
NativeSessionResume bool // Agent supports ACP session/load for resume
@@ -44,6 +44,29 @@ const (
// new session recovers.
const RemediationStartFreshSession = "start_fresh_session"
// ModelUnavailableMessage renders the actionable user-facing message for a
// configured model that is no longer available. Shared by the session-start
// policy and the office post-start failure path so chat and run detail both
// ask the user to change the model instead of silently falling back.
func ModelUnavailableMessage(modelID string) string {
return fmt.Sprintf("Model unavailable: the configured model %q is no longer available. Change the model in the agent profile or configure a fallback.", modelID)
}
// IsAvailabilityCode reports whether a classified code means the provider or
// model became unavailable (auth expired, model dropped, credentials or
// subscription missing) — the failure class the no-silent-model-fallback
// feature treats as "ask the user to change the model". Transient conditions
// (rate limiting, quota) are deliberately excluded: they carry their own
// AutoRetryable handling and must not trigger "change the model" guidance.
func IsAvailabilityCode(code Code) bool {
switch code {
case CodeModelUnavailable, CodeAuthRequired, CodeMissingCredentials,
CodeSubscriptionRequired, CodeProviderUnavailable:
return true
}
return false
}
// Confidence reflects how strongly the classifier trusts the matched signal.
type Confidence string
@@ -72,9 +72,11 @@ type CreateAgentRequest struct {
}
type CreateAgentProfileRequest struct {
Name string
Model string
Mode string
Name string
Model string
FallbackModel string
AutoFallback bool
Mode string
// CLIFlags is the explicit list to persist. When nil the list is seeded
// from the agent's curated PermissionSettings() catalogue (all disabled
// by default) so a fresh profile opens with the agent's suggestions.
@@ -210,6 +212,8 @@ func (c *Controller) createAgentProfiles(ctx context.Context, agentID, displayNa
Name: profileReq.Name,
AgentDisplayName: displayName,
Model: profileReq.Model,
FallbackModel: strings.TrimSpace(profileReq.FallbackModel),
AutoFallback: profileReq.AutoFallback,
Mode: profileReq.Mode,
CLIFlags: cliFlags,
EnvVars: envVarsFromDTO(profileReq.EnvVars),
@@ -475,9 +475,13 @@ func resolveDefaultModel(agentConfig agents.Agent, _ string) (string, bool, erro
return "passthrough", true, nil
}
// Mock agent is not probed (not an InferenceAgent) but needs a concrete
// model for E2E tests that exercise the ModelSelector UI.
// model for E2E tests that exercise the ModelSelector UI. It must be one
// of the models the mock agent actually advertises (mock-fast /
// mock-smart): the no-silent-model-fallback policy fails session start
// explicitly when the profile model is absent from the advertised list,
// and "mock-default" is not advertised.
if agentConfig.ID() == "mock-agent" {
return "mock-default", false, nil
return "mock-fast", false, nil
}
return "", false, nil
}
@@ -134,3 +134,22 @@ func TestConfigOptionDTOsPreserveDescriptions(t *testing.T) {
t.Errorf("value description = %#v, want provider description", got)
}
}
// TestResolveDefaultModel_MockAgentSeedsAdvertisedModel pins the mock agent's
// seeded default profile model to one the mock agent actually advertises
// (mock-fast). The no-silent-model-fallback policy fails session start
// explicitly when the profile model is absent from the advertised list, so a
// sentinel like "mock-default" would break every default-profile session in
// the E2E environment.
func TestResolveDefaultModel_MockAgentSeedsAdvertisedModel(t *testing.T) {
model, isPassthroughOnly, err := resolveDefaultModel(agents.NewMockAgent(), "")
if err != nil {
t.Fatalf("resolveDefaultModel: %v", err)
}
if isPassthroughOnly {
t.Fatal("mock agent must not be seeded as passthrough-only")
}
if model != "mock-fast" {
t.Fatalf("mock default profile model = %q, want %q (an advertised model)", model, "mock-fast")
}
}
@@ -21,6 +21,8 @@ type CreateProfileRequest struct {
AgentID string
Name string
Model string
FallbackModel string
AutoFallback bool
Mode string
ConfigOptions map[string]string
AllowIndexing bool
@@ -73,6 +75,8 @@ func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest
Name: req.Name,
AgentDisplayName: displayName,
Model: req.Model,
FallbackModel: strings.TrimSpace(req.FallbackModel),
AutoFallback: req.AutoFallback,
Mode: req.Mode,
ConfigOptions: profileconfig.SanitizeConfigOptions(req.ConfigOptions),
AllowIndexing: req.AllowIndexing,
@@ -134,6 +138,8 @@ type UpdateProfileRequest struct {
ID string
Name *string
Model *string
FallbackModel *string
AutoFallback *bool
Mode *string
ConfigOptions *map[string]string
AllowIndexing *bool
@@ -153,7 +159,8 @@ type UpdateProfileRequest struct {
}
func enabledOnlyUpdate(req UpdateProfileRequest) bool {
return req.Enabled != nil && req.Name == nil && req.Model == nil && req.Mode == nil &&
return req.Enabled != nil && req.Name == nil && req.Model == nil &&
req.FallbackModel == nil && req.AutoFallback == nil && req.Mode == nil &&
req.ConfigOptions == nil && req.AllowIndexing == nil && req.AutoApprove == nil &&
req.CLIPassthrough == nil && req.CLIFlags == nil && req.EnvVars == nil &&
req.CommandPrefix == nil
@@ -175,6 +182,12 @@ func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest
}
}
}
if req.FallbackModel != nil {
profile.FallbackModel = strings.TrimSpace(*req.FallbackModel)
}
if req.AutoFallback != nil {
profile.AutoFallback = *req.AutoFallback
}
if req.Mode != nil {
profile.Mode = *req.Mode
}
@@ -573,6 +586,8 @@ func toProfileDTO(profile *models.AgentProfile) dto.AgentProfileDTO {
Name: profile.Name,
AgentDisplayName: profile.AgentDisplayName,
Model: profile.Model,
FallbackModel: profile.FallbackModel,
AutoFallback: profile.AutoFallback,
Mode: profile.Mode,
ConfigOptions: profileconfig.SanitizeConfigOptions(profile.ConfigOptions),
AllowIndexing: profile.AllowIndexing,
@@ -446,3 +446,60 @@ func TestUpdateProfile_SetsEnabled(t *testing.T) {
t.Fatalf("expected stored profile to be re-enabled, got %+v", st.updated)
}
}
// TestUpdateProfile_MixedEnabledAndFallbackPersistsBoth verifies a request
// carrying enabled plus the fallback fields takes the full-update path. The
// enabled-only shortcut must never silently drop FallbackModel/AutoFallback
// supplied in the same PATCH.
func TestUpdateProfile_MixedEnabledAndFallbackPersistsBoth(t *testing.T) {
ctrl := newTestController(map[string]agents.Agent{"test-agent": &testAgent{
id: "test-agent",
name: "test-agent",
displayName: "Test Agent",
enabled: true,
}})
st := newFakeStore()
agent := &models.Agent{ID: "agent-1", Name: "test-agent"}
st.agents[agent.ID] = agent
st.byName[agent.Name] = agent
ctrl.repo = st
profile, err := ctrl.CreateProfile(context.Background(), CreateProfileRequest{
AgentID: "agent-1",
Name: "Mixed",
Model: "gpt-5",
})
if err != nil {
t.Fatalf("CreateProfile: %v", err)
}
enabled := false
fallback := "gpt-5-mini"
autoFallback := true
updated, err := ctrl.UpdateProfile(context.Background(), UpdateProfileRequest{
ID: profile.ID,
Enabled: &enabled,
FallbackModel: &fallback,
AutoFallback: &autoFallback,
})
if err != nil {
t.Fatalf("UpdateProfile: %v", err)
}
if updated.FallbackModel != fallback {
t.Fatalf("response fallback_model = %q, want %q (must not take the enabled-only path)", updated.FallbackModel, fallback)
}
if updated.Enabled {
t.Fatal("response enabled = true, want false")
}
if !updated.AutoFallback {
t.Fatal("response auto_fallback = false, want true")
}
stored, err := st.GetAgentProfile(context.Background(), profile.ID)
if err != nil {
t.Fatalf("GetAgentProfile: %v", err)
}
if stored.FallbackModel != fallback || !stored.AutoFallback {
t.Fatalf("stored fallback fields lost: fallback_model=%q auto_fallback=%v",
stored.FallbackModel, stored.AutoFallback)
}
}
@@ -388,18 +388,26 @@ func (r *ProfileReconciler) healProfile(
changed = true
}
// No-silent-model-fallback: a configured model that is no longer
// advertised (e.g. provider auth expired) is KEPT, never overwritten
// with the probe default. The UI surfaces it as unavailable so the user
// decides — implicitly switching models on boot is exactly what this
// feature forbids. Only the empty-model case ("use the agent's
// default") is seeded from the probe.
if p.Model != "" && !modelExists(p.Model, caps.Models) {
r.log.Info("profile model no longer available, auto-healing",
r.log.Info("profile model no longer available, keeping (no silent fallback)",
zap.String("profile_id", p.ID),
zap.String("old_model", p.Model),
zap.String("new_model", caps.CurrentModelID))
p.Model = caps.CurrentModelID
changed = true
zap.String("model", p.Model))
}
if p.Model == "" && caps.CurrentModelID != "" {
p.Model = caps.CurrentModelID
changed = true
}
if p.FallbackModel != "" && !modelExists(p.FallbackModel, caps.Models) {
r.log.Info("profile fallback model no longer available, keeping (no silent fallback)",
zap.String("profile_id", p.ID),
zap.String("fallback_model", p.FallbackModel))
}
if p.Mode != "" && !modeExists(p.Mode, caps.Modes) {
r.log.Info("profile mode no longer available, clearing",
@@ -310,16 +310,20 @@ func TestProfileReconciler_DoesNotReseedAfterUserDelete(t *testing.T) {
}
}
func TestProfileReconciler_HealsStaleModel(t *testing.T) {
// TestProfileReconciler_KeepsGoneModel verifies the reconciler keeps a gone start model instead of healing it.
func TestProfileReconciler_KeepsGoneModel(t *testing.T) {
st := newFakeStore()
// Seed an existing DB agent and profile with a stale model.
// Seed an existing DB agent and profile whose model is no longer
// advertised (e.g. provider auth expired). The reconciler must NOT
// silently replace it — no implicit model fallback.
dbAgent := &models.Agent{Name: "claude-acp"}
_ = st.CreateAgent(context.Background(), dbAgent)
existing := &models.AgentProfile{
AgentID: dbAgent.ID,
Name: "Claude",
Model: "claude-gone",
Mode: "",
Mode: "default",
}
_ = st.CreateAgentProfile(context.Background(), existing)
@@ -340,15 +344,93 @@ func TestProfileReconciler_HealsStaleModel(t *testing.T) {
if err := r.Run(context.Background()); err != nil {
t.Fatalf("Run: %v", err)
}
// No update: the gone model is kept, not overwritten.
if len(st.updated) != 0 {
t.Fatalf("expected no profile update, got %d: %+v", len(st.updated), st.updated)
}
live, err := st.GetAgentProfile(context.Background(), existing.ID)
if err != nil {
t.Fatalf("get profile: %v", err)
}
if live.Model != "claude-gone" {
t.Errorf("model = %q, want claude-gone (kept, not healed)", live.Model)
}
}
// TestProfileReconciler_SeedsEmptyModel verifies the reconciler still seeds an empty model from the probe.
func TestProfileReconciler_SeedsEmptyModel(t *testing.T) {
st := newFakeStore()
dbAgent := &models.Agent{Name: "claude-acp"}
_ = st.CreateAgent(context.Background(), dbAgent)
existing := &models.AgentProfile{
AgentID: dbAgent.ID,
Name: "Claude",
Model: "",
}
_ = st.CreateAgentProfile(context.Background(), existing)
ag := &mockInferenceAgent{id: "claude-acp", displayName: "Claude", enabled: true}
caps := &fakeCapReader{
caps: map[string]hostutility.AgentCapabilities{
"claude-acp": {
AgentType: "claude-acp",
Status: hostutility.StatusOK,
Models: []hostutility.Model{{ID: "claude-sonnet", Name: "Sonnet"}},
CurrentModelID: "claude-sonnet",
},
},
}
r := newReconciler(t, st, caps, ag)
if err := r.Run(context.Background()); err != nil {
t.Fatalf("Run: %v", err)
}
if len(st.updated) != 1 {
t.Fatalf("expected 1 updated profile, got %d", len(st.updated))
}
updated := st.updated[0]
if updated.Model != "claude-new" {
t.Errorf("healed model = %q, want claude-new", updated.Model)
if updated := st.updated[0]; updated.Model != "claude-sonnet" {
t.Errorf("seeded model = %q, want claude-sonnet", updated.Model)
}
if updated.Mode != "default" {
t.Errorf("backfilled mode = %q, want default", updated.Mode)
}
// TestProfileReconciler_KeepsGoneFallbackModel verifies a gone fallback model is kept, not healed.
func TestProfileReconciler_KeepsGoneFallbackModel(t *testing.T) {
st := newFakeStore()
dbAgent := &models.Agent{Name: "omp-acp"}
_ = st.CreateAgent(context.Background(), dbAgent)
existing := &models.AgentProfile{
AgentID: dbAgent.ID,
Name: "Hybrid",
Model: "claude-sonnet",
FallbackModel: "gpt-gone",
}
_ = st.CreateAgentProfile(context.Background(), existing)
ag := &mockInferenceAgent{id: "omp-acp", displayName: "OMP", enabled: true}
caps := &fakeCapReader{
caps: map[string]hostutility.AgentCapabilities{
"omp-acp": {
AgentType: "omp-acp",
Status: hostutility.StatusOK,
Models: []hostutility.Model{{ID: "claude-sonnet", Name: "Sonnet"}},
CurrentModelID: "claude-sonnet",
},
},
}
r := newReconciler(t, st, caps, ag)
if err := r.Run(context.Background()); err != nil {
t.Fatalf("Run: %v", err)
}
if len(st.updated) != 0 {
t.Fatalf("expected no profile update, got %d: %+v", len(st.updated), st.updated)
}
live, err := st.GetAgentProfile(context.Background(), existing.ID)
if err != nil {
t.Fatalf("get profile: %v", err)
}
if live.FallbackModel != "gpt-gone" {
t.Errorf("fallback_model = %q, want gpt-gone (kept, not healed)", live.FallbackModel)
}
}
+19 -12
View File
@@ -7,18 +7,25 @@ import (
)
type AgentProfileDTO struct {
ID string `json:"id"`
AgentID string `json:"agent_id"`
Name string `json:"name"`
AgentDisplayName string `json:"agent_display_name"`
Model string `json:"model"`
Mode string `json:"mode,omitempty"`
ConfigOptions map[string]string `json:"config_options,omitempty"`
AllowIndexing bool `json:"allow_indexing"` // Deprecated: use CLIFlags. Retained for legacy clients.
AutoApprove bool `json:"auto_approve"`
CLIFlags []CLIFlagDTO `json:"cli_flags"`
EnvVars []ProfileEnvVarDTO `json:"env_vars,omitempty"`
CLIPassthrough bool `json:"cli_passthrough"`
ID string `json:"id"`
AgentID string `json:"agent_id"`
Name string `json:"name"`
AgentDisplayName string `json:"agent_display_name"`
Model string `json:"model"`
Mode string `json:"mode,omitempty"`
// FallbackModel is the optional single ACP model ID the runtime switches
// to when the start model becomes unavailable. Ignored when
// auto_fallback is true.
FallbackModel string `json:"fallback_model,omitempty"`
// AutoFallback opts the profile into the legacy automatic-fallback
// behavior (session-start best-effort, office re-dispatch).
AutoFallback bool `json:"auto_fallback"`
ConfigOptions map[string]string `json:"config_options,omitempty"`
AllowIndexing bool `json:"allow_indexing"` // Deprecated: use CLIFlags. Retained for legacy clients.
AutoApprove bool `json:"auto_approve"`
CLIFlags []CLIFlagDTO `json:"cli_flags"`
EnvVars []ProfileEnvVarDTO `json:"env_vars,omitempty"`
CLIPassthrough bool `json:"cli_passthrough"`
// Enabled gates the profile from new-work selection. When false the
// profile is hidden from task/session creation pickers but still serves
// existing sessions and remains editable in settings.
@@ -308,6 +308,8 @@ type createAgentRequest struct {
type createAgentProfileRequest struct {
Name string `json:"name"`
Model string `json:"model"`
FallbackModel string `json:"fallback_model,omitempty"`
AutoFallback bool `json:"auto_fallback"`
Mode string `json:"mode,omitempty"`
CLIFlags []dto.CLIFlagDTO `json:"cli_flags,omitempty"`
EnvVars []dto.ProfileEnvVarDTO `json:"env_vars,omitempty"`
@@ -333,6 +335,8 @@ func (h *Handlers) httpCreateAgent(c *gin.Context) {
profiles = append(profiles, controller.CreateAgentProfileRequest{
Name: profile.Name,
Model: profile.Model,
FallbackModel: profile.FallbackModel,
AutoFallback: profile.AutoFallback,
Mode: profile.Mode,
CLIFlags: profile.CLIFlags,
EnvVars: profile.EnvVars,
@@ -494,6 +498,8 @@ func (h *Handlers) httpUpdateProfileMcpConfig(c *gin.Context) {
type createProfileRequest struct {
Name string `json:"name"`
Model string `json:"model"`
FallbackModel string `json:"fallback_model,omitempty"`
AutoFallback bool `json:"auto_fallback"`
Mode string `json:"mode,omitempty"`
ConfigOptions map[string]string `json:"config_options,omitempty"`
AllowIndexing bool `json:"allow_indexing"`
@@ -518,6 +524,8 @@ func (h *Handlers) httpCreateProfile(c *gin.Context) {
AgentID: c.Param("id"),
Name: body.Name,
Model: body.Model,
FallbackModel: body.FallbackModel,
AutoFallback: body.AutoFallback,
Mode: body.Mode,
ConfigOptions: body.ConfigOptions,
AllowIndexing: body.AllowIndexing,
@@ -548,6 +556,8 @@ func (h *Handlers) httpCreateProfile(c *gin.Context) {
type updateProfileRequest struct {
Name *string `json:"name,omitempty"`
Model *string `json:"model,omitempty"`
FallbackModel *string `json:"fallback_model,omitempty"`
AutoFallback *bool `json:"auto_fallback,omitempty"`
Mode *string `json:"mode,omitempty"`
ConfigOptions *map[string]string `json:"config_options,omitempty"`
AllowIndexing *bool `json:"allow_indexing,omitempty"`
@@ -573,6 +583,8 @@ func (h *Handlers) httpUpdateProfile(c *gin.Context) {
ID: c.Param("id"),
Name: body.Name,
Model: body.Model,
FallbackModel: body.FallbackModel,
AutoFallback: body.AutoFallback,
Mode: body.Mode,
ConfigOptions: body.ConfigOptions,
AllowIndexing: body.AllowIndexing,
@@ -45,6 +45,17 @@ type AgentProfile struct {
// Validated against the host utility capability cache by the reconciler.
Model string `json:"model" db:"model"`
// FallbackModel is the optional ACP model ID the runtime switches to when
// the start model (Model) becomes unavailable. Empty means "no explicit
// fallback". Ignored at runtime when AutoFallback is enabled.
FallbackModel string `json:"fallback_model,omitempty" db:"fallback_model"`
// AutoFallback opts the profile into the legacy automatic-fallback
// behavior: session-start model application is best-effort and office
// post-start failures re-dispatch to the next provider candidate. When
// enabled, FallbackModel is ignored and the UI hides its field.
AutoFallback bool `json:"auto_fallback" db:"auto_fallback"`
// Mode is the optional ACP session mode applied via session/set_mode at
// session start. Empty when the agent does not advertise modes.
// The DB column is nullable; the settings repo handles the empty-string ↔ NULL
@@ -163,6 +163,12 @@ func (r *sqliteRepository) initSchema() error {
// columns, so an ADD COLUMN that ran before it would be lost.
r.migrate.Apply("agent_profiles.command_prefix", `ALTER TABLE agent_profiles ADD COLUMN command_prefix TEXT NOT NULL DEFAULT ''`)
// No-silent-model-fallback: added after the table-recreation migration
// above for the same reason as command_prefix — a legacy DB that
// recreates agent_profiles would otherwise lose columns added before it.
r.migrate.Apply("agent_profiles.fallback_model", `ALTER TABLE agent_profiles ADD COLUMN fallback_model TEXT NOT NULL DEFAULT ''`)
r.migrate.Apply("agent_profiles.auto_fallback", `ALTER TABLE agent_profiles ADD COLUMN auto_fallback INTEGER NOT NULL DEFAULT 0`)
return nil
}
@@ -511,7 +517,7 @@ func (r *sqliteRepository) CreateAgentProfile(ctx context.Context, profile *mode
max_concurrent_sessions, cooldown_sec, skip_idle_runs,
consecutive_failures, failure_threshold,
executor_preference, budget_monthly_cents, settings, permissions,
command_prefix
command_prefix, fallback_model, auto_fallback
) VALUES (
?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?,
@@ -522,7 +528,7 @@ func (r *sqliteRepository) CreateAgentProfile(ctx context.Context, profile *mode
?, ?, ?,
?, ?,
?, ?, ?, ?,
?
?, ?, ?
)
`),
profile.ID, profile.AgentID, profile.Name, profile.AgentDisplayName, profile.Model,
@@ -537,6 +543,8 @@ func (r *sqliteRepository) CreateAgentProfile(ctx context.Context, profile *mode
profile.ConsecutiveFailures, enrich.failureThreshold,
enrich.executorPreference, profile.BudgetMonthlyCents, enrich.settings, enrich.permissions,
profile.CommandPrefix,
profile.FallbackModel,
dialect.BoolToInt(profile.AutoFallback),
)
return err
}
@@ -768,7 +776,7 @@ func (r *sqliteRepository) UpdateAgentProfile(ctx context.Context, profile *mode
consecutive_failures = ?, failure_threshold = ?,
executor_preference = ?,
budget_monthly_cents = ?, settings = ?, permissions = ?,
command_prefix = ?
command_prefix = ?, fallback_model = ?, auto_fallback = ?
WHERE id = ? AND deleted_at IS NULL
`), profile.AgentID, profile.Name, profile.AgentDisplayName, profile.Model,
nullableString(profile.Mode), nullableString(profile.MigratedFrom),
@@ -783,6 +791,8 @@ func (r *sqliteRepository) UpdateAgentProfile(ctx context.Context, profile *mode
enrich.executorPreference,
profile.BudgetMonthlyCents, enrich.settings, enrich.permissions,
profile.CommandPrefix,
profile.FallbackModel,
dialect.BoolToInt(profile.AutoFallback),
profile.ID)
if err != nil {
return err
@@ -852,7 +862,8 @@ const agentProfileSelectColumns = `
COALESCE(failure_threshold, 3), COALESCE(executor_preference, ''),
COALESCE(budget_monthly_cents, 0),
COALESCE(settings, '{}'), COALESCE(permissions, '{}'),
COALESCE(command_prefix, '')
COALESCE(command_prefix, ''),
COALESCE(fallback_model, ''), COALESCE(auto_fallback, 0)
FROM agent_profiles`
func (r *sqliteRepository) GetAgentProfile(ctx context.Context, id string) (*models.AgentProfile, error) {
@@ -1025,6 +1036,7 @@ func scanAgentProfile(scanner interface {
var role, status string
var skipIdleRuns int
var failureThreshold int
var autoFallback int
if err := scanner.Scan(
&profile.ID,
&profile.AgentID,
@@ -1065,6 +1077,8 @@ func scanAgentProfile(scanner interface {
&profile.Settings,
&profile.Permissions,
&profile.CommandPrefix,
&profile.FallbackModel,
&autoFallback,
); err != nil {
return nil, err
}
@@ -1081,6 +1095,7 @@ func scanAgentProfile(scanner interface {
profile.Enabled = enabled == 1
profile.UserModified = userModified == 1
profile.SkipIdleRuns = skipIdleRuns == 1
profile.AutoFallback = autoFallback == 1
profile.Role = models.AgentRole(role)
profile.Status = models.AgentStatus(status)
profile.ConfigOptions = configOptionsFromSettings(profile.Settings)
@@ -0,0 +1,141 @@
package store
import (
"context"
"testing"
"github.com/kandev/kandev/internal/agent/settings/models"
)
// TestFallbackModel_RoundTrip verifies fallback_model and auto_fallback
// survive insert → read → update → read.
func TestFallbackModel_RoundTrip(t *testing.T) {
repo := newFreshRepo(t)
ctx := context.Background()
if err := repo.CreateAgent(ctx, &models.Agent{Name: "omp-acp"}); err != nil {
t.Fatalf("create agent: %v", err)
}
agent, err := repo.GetAgentByName(ctx, "omp-acp")
if err != nil {
t.Fatalf("get agent: %v", err)
}
profile := &models.AgentProfile{
AgentID: agent.ID,
Name: "hybrid",
AgentDisplayName: "OMP",
Model: "claude-sonnet-4-5",
FallbackModel: "gpt-5",
AutoFallback: false,
}
if err := repo.CreateAgentProfile(ctx, profile); err != nil {
t.Fatalf("create profile: %v", err)
}
got, err := repo.GetAgentProfile(ctx, profile.ID)
if err != nil {
t.Fatalf("get profile: %v", err)
}
if got.FallbackModel != "gpt-5" {
t.Errorf("fallback_model mismatch: got %q, want %q", got.FallbackModel, "gpt-5")
}
if got.AutoFallback {
t.Errorf("auto_fallback mismatch: got true, want false")
}
// Update: flip the toggle on and change the fallback.
got.AutoFallback = true
got.FallbackModel = "gpt-5.2"
if err := repo.UpdateAgentProfile(ctx, got); err != nil {
t.Fatalf("update profile: %v", err)
}
got2, err := repo.GetAgentProfile(ctx, profile.ID)
if err != nil {
t.Fatalf("re-get profile: %v", err)
}
if got2.FallbackModel != "gpt-5.2" {
t.Errorf("fallback_model after update mismatch: got %q", got2.FallbackModel)
}
if !got2.AutoFallback {
t.Errorf("auto_fallback after update mismatch: got false, want true")
}
}
// TestFallbackModel_DefaultsStrict verifies a profile created without the new
// fields reads back strict mode (empty fallback, toggle off) — the safe
// default for existing rows.
func TestFallbackModel_DefaultsStrict(t *testing.T) {
repo := newFreshRepo(t)
ctx := context.Background()
if err := repo.CreateAgent(ctx, &models.Agent{Name: "claude-acp"}); err != nil {
t.Fatalf("create agent: %v", err)
}
agent, err := repo.GetAgentByName(ctx, "claude-acp")
if err != nil {
t.Fatalf("get agent: %v", err)
}
profile := &models.AgentProfile{
AgentID: agent.ID,
Name: "default",
AgentDisplayName: "Claude",
}
if err := repo.CreateAgentProfile(ctx, profile); err != nil {
t.Fatalf("create profile: %v", err)
}
got, err := repo.GetAgentProfile(ctx, profile.ID)
if err != nil {
t.Fatalf("get profile: %v", err)
}
if got.FallbackModel != "" {
t.Errorf("expected empty fallback_model, got %q", got.FallbackModel)
}
if got.AutoFallback {
t.Errorf("expected auto_fallback off, got true")
}
}
// TestFallbackModel_SchemaReplay verifies the fallback_model / auto_fallback
// migration survives a same-DB replay: opening a database that already has
// both columns (an upgraded install) and running initSchema again must be a
// no-op, not an error, and the written data must survive. Per
// apps/backend/AGENTS.md, startup schema changes require fresh-DB plus
// same-DB replay tests.
func TestFallbackModel_SchemaReplay(t *testing.T) {
repo := newFreshRepo(t)
ctx := context.Background()
if err := repo.CreateAgent(ctx, &models.Agent{Name: "omp-acp"}); err != nil {
t.Fatalf("create agent: %v", err)
}
agent, err := repo.GetAgentByName(ctx, "omp-acp")
if err != nil {
t.Fatalf("get agent: %v", err)
}
profile := &models.AgentProfile{
AgentID: agent.ID,
Name: "hybrid",
AgentDisplayName: "OMP",
Model: "claude-sonnet-4-5",
FallbackModel: "deepseek/deepseek-v4-flash",
AutoFallback: true,
}
if err := repo.CreateAgentProfile(ctx, profile); err != nil {
t.Fatalf("create profile: %v", err)
}
// Simulate a restart on an upgraded DB: the columns already exist, so the
// migrate.Apply duplicate-column errors must be swallowed.
if err := repo.initSchema(); err != nil {
t.Fatalf("initSchema replay on upgraded DB: %v", err)
}
got, err := repo.GetAgentProfile(ctx, profile.ID)
if err != nil {
t.Fatalf("get profile after replay: %v", err)
}
if got.FallbackModel != "deepseek/deepseek-v4-flash" {
t.Errorf("fallback_model after replay mismatch: got %q", got.FallbackModel)
}
if !got.AutoFallback {
t.Errorf("auto_fallback after replay mismatch: got false, want true")
}
}
@@ -65,6 +65,11 @@ const (
// EventTypeSessionModels indicates available models from ACP session/new.
EventTypeSessionModels = "session_models"
// EventTypeSessionModelFallback indicates the session started on the
// profile's fallback model because the configured start model was
// unavailable. Data carries {"fallback_model": <model id>}.
EventTypeSessionModelFallback = "session_model_fallback"
// EventTypeSessionInfo indicates ACP session metadata such as title changed.
EventTypeSessionInfo = "session_info"
@@ -288,6 +293,11 @@ type AgentEvent struct {
// CurrentModelID is the active model identifier.
CurrentModelID string `json:"current_model_id,omitempty"`
// FallbackModel carries the fallback model a session started on when
// the configured start model was unavailable (session_model_fallback
// events).
FallbackModel string `json:"fallback_model,omitempty"`
// SessionModels lists models available in the ACP session.
SessionModels []SessionModelInfo `json:"session_models,omitempty"`
+17 -4
View File
@@ -260,10 +260,11 @@ const (
// Event types for ACP capabilities and models
const (
AgentCapabilitiesUpdated = "agent_capabilities.updated" // Agent capabilities received
SessionModelsUpdated = "session_models.updated" // Session models received
SessionInfoUpdated = "session_info.updated" // ACP session info received
SessionMCPStatusUpdated = "session_mcp_status.updated" // MCP attachment evidence changed
AgentCapabilitiesUpdated = "agent_capabilities.updated" // Agent capabilities received
SessionModelsUpdated = "session_models.updated" // Session models received
SessionModelFallbackUpdated = "session_model_fallback.updated" // Session started on the profile's fallback model
SessionInfoUpdated = "session_info.updated" // ACP session info received
SessionMCPStatusUpdated = "session_mcp_status.updated" // MCP attachment evidence changed
)
// Event types for session todos (ACP plan entries)
@@ -466,6 +467,18 @@ func BuildSessionModelsWildcardSubject() string {
return SessionModelsUpdated + ".*"
}
// BuildSessionModelFallbackSubject creates a session-specific fallback-model
// subject.
func BuildSessionModelFallbackSubject(sessionID string) string {
return SessionModelFallbackUpdated + "." + sessionID
}
// BuildSessionModelFallbackWildcardSubject creates a wildcard subscription
// for all session fallback-model events.
func BuildSessionModelFallbackWildcardSubject() string {
return SessionModelFallbackUpdated + ".*"
}
// BuildSessionMCPStatusSubject creates a session-specific MCP status subject.
func BuildSessionMCPStatusSubject(sessionID string) string {
return SessionMCPStatusUpdated + "." + sessionID
@@ -51,6 +51,7 @@ func RegisterSessionStreamNotifications(ctx context.Context, eventBus bus.EventB
b.subscribe(eventBus, events.BuildSessionModeWildcardSubject(), ws.ActionSessionModeChanged)
b.subscribe(eventBus, events.BuildAgentCapabilitiesWildcardSubject(), ws.ActionSessionAgentCapabilities)
b.subscribe(eventBus, events.BuildSessionModelsWildcardSubject(), ws.ActionSessionModelsUpdated)
b.subscribe(eventBus, events.BuildSessionModelFallbackWildcardSubject(), ws.ActionSessionModelFallback)
b.subscribe(eventBus, events.BuildSessionMCPStatusWildcardSubject(), ws.ActionSessionMCPStatusUpdated)
b.subscribe(eventBus, events.BuildSessionInfoWildcardSubject(), ws.ActionSessionInfoUpdated)
b.subscribe(eventBus, events.BuildSessionTodosWildcardSubject(), ws.ActionSessionTodosUpdated)
@@ -1,8 +1,16 @@
package websocket
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/auth/authn"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
ws "github.com/kandev/kandev/pkg/websocket"
)
func TestSessionStreamBroadcaster_FileChangeBatching(t *testing.T) {
@@ -151,3 +159,80 @@ func TestExtractSessionID(t *testing.T) {
})
}
}
// TestSessionStreamBroadcaster_SubscribesFallbackSubject pins the subject
// mapping: RegisterSessionStreamNotifications must subscribe the
// session_model_fallback wildcard so orchestrator publishes reach the hub.
func TestSessionStreamBroadcaster_SubscribesFallbackSubject(t *testing.T) {
log := testLogger()
eventBus := &subscriptionRecordingEventBus{MemoryEventBus: bus.NewMemoryEventBus(log)}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := NewHub(nil, log)
b := RegisterSessionStreamNotifications(ctx, eventBus, hub, log)
_ = b
for _, subject := range eventBus.subjects {
if subject == events.BuildSessionModelFallbackWildcardSubject() {
return
}
}
t.Fatalf("session stream broadcaster must subscribe the fallback wildcard subject %q",
events.BuildSessionModelFallbackWildcardSubject())
}
// TestSessionStreamBroadcaster_BroadcastsFallbackNotification verifies a
// published session_model_fallback event is routed to the session's
// subscribers as the session.model_fallback action — the payload's
// GetSessionID must drive extractSessionID (a broken routing key drops the
// notification silently).
func TestSessionStreamBroadcaster_BroadcastsFallbackNotification(t *testing.T) {
hub := newAccessTestHub(t)
eventBus := bus.NewMemoryEventBus(testLogger())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
_ = RegisterSessionStreamNotifications(ctx, eventBus, hub, testLogger())
client := registerAccessClient(t, hub, "client-fallback", authn.Identity{UserID: "user-1", Role: authn.RoleMember})
raw, err := json.Marshal(map[string]interface{}{"session_id": "session-fallback-1"})
if err != nil {
t.Fatalf("marshal subscribe payload: %v", err)
}
client.handleSessionSubscribe(&ws.Message{
ID: "1", Type: ws.MessageTypeRequest, Action: ws.ActionSessionSubscribe, Payload: raw,
})
sessionID := "session-fallback-1"
_ = eventBus.Publish(ctx, events.BuildSessionModelFallbackSubject(sessionID), bus.NewEvent(
events.SessionModelFallbackUpdated,
"test",
lifecycle.SessionModelFallbackEventPayload{
TaskID: "task-1",
SessionID: sessionID,
AgentID: "a1",
FallbackModel: "gpt-5",
Timestamp: time.Now().UTC().Format(time.RFC3339),
},
))
// Wait deterministically on the client's send channels (the hub writes
// the broadcast there synchronously) instead of polling on a clock.
timeout := time.After(2 * time.Second)
for {
select {
case raw := <-client.send:
var msg ws.Message
if err := json.Unmarshal(raw, &msg); err == nil && msg.Action == ws.ActionSessionModelFallback {
return
}
case raw := <-client.controlSend:
var msg ws.Message
if err := json.Unmarshal(raw, &msg); err == nil && msg.Action == ws.ActionSessionModelFallback {
return
}
case <-timeout:
t.Fatalf("client did not receive %s", ws.ActionSessionModelFallback)
}
}
}
@@ -0,0 +1,113 @@
package scheduler_test
import (
"context"
"testing"
"time"
settingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
officemodels "github.com/kandev/kandev/internal/office/models"
officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite"
"github.com/kandev/kandev/internal/office/routing"
"github.com/kandev/kandev/internal/office/scheduler"
)
// seedRoutedRun sets up a run that was launched via routing: a resolved
// route (provider claude-acp, model claude-sonnet) and an in-flight attempt
// row at seq 1 so HandlePostStartFailure can find the candidate. The
// returned run struct carries the routing columns in memory, mirroring how
// the scheduler receives a freshly-loaded run row.
func seedRoutedRun(t *testing.T, repo *officesqlite.Repository) *officemodels.Run {
t.Helper()
seedRoutingConfig(t, repo, []routing.ProviderID{"claude-acp"})
run := seedRun(t, repo, `{"task_id":"t-1"}`)
seq, err := repo.IncrementRouteAttemptSeq(context.Background(), run.ID)
if err != nil {
t.Fatalf("increment seq: %v", err)
}
if err := repo.SetRunResolvedRoute(context.Background(), run.ID, "claude-profile", "claude-acp", "claude-sonnet"); err != nil {
t.Fatalf("set resolved route: %v", err)
}
if err := repo.AppendRouteAttempt(context.Background(), &officemodels.RouteAttempt{
RunID: run.ID,
Seq: 1,
ExecutionProfileID: "claude-profile",
ProviderID: "claude-acp",
Model: "claude-sonnet",
Tier: "balanced",
Outcome: scheduler.RouteAttemptOutcomeLaunched,
StartedAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("append attempt: %v", err)
}
run.CurrentRouteAttemptSeq = seq
run.ResolvedExecutionProfileID = new("claude-profile")
run.ResolvedProviderID = new("claude-acp")
run.ResolvedModel = new("claude-sonnet")
run.RequestedTier = new("balanced")
return run
}
// agentWithFallback builds a test agent profile with the given fallback settings.
func agentWithFallback(autoFallback bool, fallbackModel string) *settingsmodels.AgentProfile {
agent := makeAgent()
agent.AutoFallback = autoFallback
agent.FallbackModel = fallbackModel
return agent
}
const postStartFailureMessage = "missing API key: not authenticated"
// TestHandlePostStartFailure_AvailabilityFailureRequeuesViaWorkspaceRoute
// pins the office fallback owner: post-start availability failures requeue
// to the next candidate via the configured workspace route
// (routingerr.Decide(ContextOffice)), regardless of the agent profile's
// fallback settings. The session-start policy owns the model-fallback
// decision; the office router must not consult the execution profile
// (run.AgentProfileID vs execution.AgentProfileID can disagree).
func TestHandlePostStartFailure_AvailabilityFailureRequeuesViaWorkspaceRoute(t *testing.T) {
repo := newTestRepoSched(t)
starter := newFakeTaskStarter()
ss := buildScheduler(t, repo, starter)
run := seedRoutedRun(t, repo)
// A strict profile (no auto-fallback, no fallback model) must NOT gate
// office post-start fallback: the run still requeues to the next
// candidate, exactly as the provider-neutral ADR defines for office.
handled, err := ss.HandlePostStartFailure(
context.Background(), run, agentWithFallback(false, ""), postStartFailureMessage, nil)
if err != nil {
t.Fatalf("HandlePostStartFailure: %v", err)
}
if !handled {
t.Fatal("office availability failure must be handled (requeue via workspace route)")
}
got, err := repo.GetRun(context.Background(), run.ID)
if err != nil {
t.Fatalf("get run: %v", err)
}
if got.Status != "queued" {
t.Errorf("run must be requeued for the next candidate, got status %q", got.Status)
}
}
// TestHandlePostStartFailure_NonFallbackFailureEscalates verifies failures
// the classifier marks as not fallback-allowed (ambiguous runtime errors)
// keep the pre-existing escalation path: handled=false so the caller runs
// the legacy failure handling.
func TestHandlePostStartFailure_NonFallbackFailureEscalates(t *testing.T) {
repo := newTestRepoSched(t)
starter := newFakeTaskStarter()
ss := buildScheduler(t, repo, starter)
run := seedRoutedRun(t, repo)
handled, err := ss.HandlePostStartFailure(
context.Background(), run, agentWithFallback(true, ""), "unexpected glitch", nil)
if err != nil {
t.Fatalf("HandlePostStartFailure: %v", err)
}
if handled {
t.Fatal("ambiguous runtime failure must not be handled by the routing path (escalate)")
}
}
@@ -10,6 +10,7 @@ import (
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/runtime/routingerr"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
@@ -463,7 +464,31 @@ func (s *Service) handleAgentFailed(ctx context.Context, event *bus.Event) error
// retry-by-classifier path lives behind HandleRunFailure for
// rate-limit-retry callers; we deliberately do NOT call into it
// here. See docs/specs/office-agent-error-handling.
return s.HandleAgentFailure(ctx, run, data.ErrorMessage)
return s.HandleAgentFailure(ctx, run, enrichModelFailureMessage(run, data.ErrorMessage))
}
// enrichModelFailureMessage prepends the actionable "change the model" copy
// when a run fails with a provider/model availability error in strict mode
// (no auto-fallback toggle, no fallback model — the routing dispatcher
// escalates instead of re-dispatching). The raw agent error is preserved
// after the hint so the run detail and chat still show the original cause.
func enrichModelFailureMessage(run *models.Run, message string) string {
if run == nil || run.ResolvedModel == nil || *run.ResolvedModel == "" || message == "" {
return message
}
provider := ""
if run.ResolvedProviderID != nil {
provider = *run.ResolvedProviderID
}
classified := routingerr.Classify(routingerr.Input{
Phase: routingerr.PhaseStreaming,
ProviderID: provider,
Stderr: message,
})
if !routingerr.IsAvailabilityCode(classified.Code) {
return message
}
return routingerr.ModelUnavailableMessage(*run.ResolvedModel) + "\n\n" + message
}
// tryPostStartFallback delegates to the routing dispatcher when one is
@@ -125,6 +125,9 @@ func (s *Service) handleAgentStreamEvent(ctx context.Context, payload *lifecycle
case "session_models":
s.handleSessionModelsEvent(ctx, payload)
case "session_model_fallback":
s.handleSessionModelFallbackEvent(ctx, payload)
case streams.EventTypeMCPAttachment:
s.handleSessionMCPAttachmentEvent(ctx, payload)
@@ -2507,6 +2510,37 @@ func (s *Service) handleSessionModelsEvent(ctx context.Context, payload *lifecyc
_ = s.eventBus.Publish(ctx, subject, bus.NewEvent(events.SessionModelsUpdated, "orchestrator", eventPayload))
}
// handleSessionModelFallbackEvent broadcasts session_model_fallback events to
// the WebSocket so the UI can surface why the session is not on the
// configured start model (the profile's fallback was applied because the
// start model is unavailable).
func (s *Service) handleSessionModelFallbackEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) {
sessionID := payload.SessionID
// The fallback event fires during session init, before the execution's
// task-session id is always linked. Resolve it from the task when the
// payload carries no session id so the note is not dropped.
if sessionID == "" && payload.TaskID != "" && s.repo != nil {
if sess, err := s.repo.GetActiveTaskSessionByTaskID(ctx, payload.TaskID); err == nil && sess != nil {
sessionID = sess.ID
}
}
if sessionID == "" || s.eventBus == nil || payload.Data == nil {
return
}
eventPayload := lifecycle.SessionModelFallbackEventPayload{
TaskID: payload.TaskID,
SessionID: sessionID,
AgentID: payload.AgentID,
FallbackModel: payload.Data.FallbackModel,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
s.logger.Info("publishing session_model_fallback event to WS",
zap.String("session_id", sessionID),
zap.String("fallback_model", eventPayload.FallbackModel))
subject := events.BuildSessionModelFallbackSubject(sessionID)
_ = s.eventBus.Publish(ctx, subject, bus.NewEvent(events.SessionModelFallbackUpdated, "orchestrator", eventPayload))
}
func workflowSessionConfigFailures(raw any) []string {
data, ok := raw.(map[string]any)
if !ok || data == nil {
@@ -2979,6 +2979,84 @@ func TestHandleSessionModelsEventCapturesOriginalEffectiveConfigurationOnce(t *t
require.Equal(t, map[string]string{"reasoning_effort": "high"}, original.ConfigOptions)
}
// TestHandleSessionModelFallbackEventPublishesToWS verifies the fallback event publishes the WS notification.
func TestHandleSessionModelFallbackEventPublishesToWS(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
eb := &recordingEventBus{}
svc := &Service{logger: testLogger(), repo: repo, eventBus: eb}
svc.handleSessionModelFallbackEvent(ctx, &lifecycle.AgentStreamEventPayload{
TaskID: "t1",
SessionID: "s1",
AgentID: "a1",
Data: &lifecycle.AgentStreamEventData{
FallbackModel: "deepseek/deepseek-v4-flash",
},
})
require.Len(t, eb.events, 1)
require.Equal(t, events.BuildSessionModelFallbackSubject("s1"), eb.events[0].subject)
payload := eb.events[0].event.Data.(lifecycle.SessionModelFallbackEventPayload)
require.Equal(t, "s1", payload.SessionID)
require.Equal(t, "deepseek/deepseek-v4-flash", payload.FallbackModel)
}
// TestHandleAgentStreamEventRoutesSessionModelFallback pins the dispatch
// mapping: a "session_model_fallback" stream event must reach
// handleSessionModelFallbackEvent and publish the WS notification.
func TestHandleAgentStreamEventRoutesSessionModelFallback(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
eb := &recordingEventBus{}
svc := &Service{logger: testLogger(), repo: repo, eventBus: eb}
svc.handleAgentStreamEvent(ctx, &lifecycle.AgentStreamEventPayload{
TaskID: "t1",
SessionID: "s1",
ExecutionID: "exec-1",
AgentID: "a1",
Data: &lifecycle.AgentStreamEventData{
Type: "session_model_fallback",
FallbackModel: "gpt-5",
},
})
require.Len(t, eb.events, 1)
require.Equal(t, events.BuildSessionModelFallbackSubject("s1"), eb.events[0].subject)
payload := eb.events[0].event.Data.(lifecycle.SessionModelFallbackEventPayload)
require.Equal(t, "gpt-5", payload.FallbackModel)
}
// TestHandleSessionModelFallbackEventResolvesSessionFromTask covers the
// delayed-session path: the fallback fires during session init, before the
// execution's task-session id is linked, so the handler must resolve the
// session from the task id instead of dropping the note.
func TestHandleSessionModelFallbackEventResolvesSessionFromTask(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
eb := &recordingEventBus{}
svc := &Service{logger: testLogger(), repo: repo, eventBus: eb}
svc.handleSessionModelFallbackEvent(ctx, &lifecycle.AgentStreamEventPayload{
TaskID: "t1",
AgentID: "a1",
Data: &lifecycle.AgentStreamEventData{
FallbackModel: "gpt-5",
},
})
require.Len(t, eb.events, 1)
require.Equal(t, events.BuildSessionModelFallbackSubject("s1"), eb.events[0].subject)
payload := eb.events[0].event.Data.(lifecycle.SessionModelFallbackEventPayload)
require.Equal(t, "s1", payload.SessionID)
require.Equal(t, "gpt-5", payload.FallbackModel)
}
func TestHandleSessionModelsEventStoresBaselineCandidateAndPublishesLiveState(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
+1
View File
@@ -221,6 +221,7 @@ const (
ActionSessionModeChanged = "session.mode_changed"
ActionSessionAgentCapabilities = "session.agent_capabilities"
ActionSessionModelsUpdated = "session.models_updated"
ActionSessionModelFallback = "session.model_fallback"
ActionSessionMCPStatusUpdated = "session.mcp_status_updated"
ActionSessionInfoUpdated = "session.info_updated"
ActionSessionSetMode = "session.set_mode"
+4
View File
@@ -95,6 +95,8 @@ export async function createAgentProfileAction(
payload: {
name: string;
model: string;
fallback_model?: string;
auto_fallback?: boolean;
mode?: string;
config_options?: Record<string, string>;
cli_passthrough: boolean;
@@ -118,6 +120,8 @@ export async function updateAgentProfileAction(
payload: {
name?: string;
model?: string;
fallback_model?: string;
auto_fallback?: boolean;
mode?: string;
config_options?: Record<string, string>;
allow_indexing?: boolean;
@@ -57,7 +57,8 @@ export function useSelectableProfileOptions(agentProfiles: AgentProfileOption[])
!!getCapabilityWarning(
sortedProfiles[i]?.capability_status,
sortedProfiles[i]?.capability_error,
),
) ||
!!opt.disabled,
})),
[baseOptions, sortedProfiles],
);
+21 -19
View File
@@ -255,28 +255,30 @@ export function SetupWizard({
);
}
return (
<div className="fixed inset-0 z-50 bg-background flex items-center justify-center">
<div className="relative w-full max-w-2xl mx-auto px-6">
<CloseButton href={closeHref} />
<StepIndicator current={step} total={SETUP_WIZARD_STEP_COUNT} />
<div className="mt-8">
<WizardStepContent
<div className="fixed inset-0 z-50 bg-background overflow-y-auto">
<div className="flex min-h-full items-center justify-center px-6 py-8">
<div className="relative w-full max-w-2xl mx-auto">
<CloseButton href={closeHref} />
<StepIndicator current={step} total={SETUP_WIZARD_STEP_COUNT} />
<div className="mt-8">
<WizardStepContent
step={step}
data={data}
agentProfiles={profileOptions}
patch={patch}
onAgentProfilesChange={setProfileOptions}
/>
</div>
<WizardFooter
step={step}
data={data}
agentProfiles={profileOptions}
patch={patch}
onAgentProfilesChange={setProfileOptions}
canAdvance={canAdvance}
submitting={submitting}
onBack={() => setStep((s) => s - 1)}
onNext={() => setStep((s) => s + 1)}
onSkip={skipInitialTask}
onSubmit={handleSubmit}
/>
</div>
<WizardFooter
step={step}
canAdvance={canAdvance}
submitting={submitting}
onBack={() => setStep((s) => s - 1)}
onNext={() => setStep((s) => s + 1)}
onSkip={skipInitialTask}
onSubmit={handleSubmit}
/>
</div>
</div>
);
@@ -105,6 +105,13 @@ describe("toAgentProfilePatch", () => {
});
});
it("maps fallback_model and auto_fallback to camelCase fields", () => {
expect(toAgentProfilePatch({ fallback_model: "gpt-5", auto_fallback: true })).toEqual({
fallbackModel: "gpt-5",
autoFallback: true,
});
});
it("omits undefined keys so partial patches do not clobber unrelated fields", () => {
expect(toAgentProfilePatch({ cli_passthrough: false })).toEqual({ cliPassthrough: false });
expect(toAgentProfilePatch({})).toEqual({});
@@ -149,6 +156,15 @@ describe("isProfileDirty", () => {
expect(isProfileDirty(draft, saved)).toBe(true);
});
it("returns true when fallbackModel or autoFallback changes", () => {
expect(isProfileDirty(draftFrom(baseProfile, { fallbackModel: "gpt-5" }), baseProfile)).toBe(
true,
);
expect(isProfileDirty(draftFrom(baseProfile, { autoFallback: true }), baseProfile)).toBe(true);
const saved: AgentProfile = { ...baseProfile, fallbackModel: "gpt-5", autoFallback: true };
expect(isProfileDirty(draftFrom(saved), saved)).toBe(false);
});
it("returns true when there is no saved profile", () => {
expect(isProfileDirty(draftFrom(baseProfile))).toBe(true);
});
@@ -439,6 +455,74 @@ describe("command prefix save payloads", () => {
});
});
describe("fallback model save payloads", () => {
it("includes fallback_model and auto_fallback when saving a dirty existing profile", async () => {
const savedAgent = agentWithProfiles([baseProfile]);
const draftProfile = draftFrom(baseProfile, {
fallbackModel: "gpt-5",
autoFallback: true,
});
const draftAgent = agentWithProfiles([draftProfile]);
const { callbacks } = createTestCallbacks(draftAgent);
vi.mocked(updateAgentProfileAction).mockResolvedValue({
...baseProfile,
fallbackModel: "gpt-5",
autoFallback: true,
});
await saveExistingAgent(draftAgent, savedAgent, false, callbacks);
expect(updateAgentProfileAction).toHaveBeenCalledWith(
baseProfile.id,
expect.objectContaining({ fallback_model: "gpt-5", auto_fallback: true }),
);
});
it("includes the fallback fields when creating a new agent's profile", async () => {
const draftProfile = draftFrom(baseProfile, {
id: DRAFT_PROFILE_ID,
fallbackModel: "gpt-5",
autoFallback: true,
});
const draftAgent = agentWithProfiles([draftProfile]);
const { callbacks } = createTestCallbacks(draftAgent);
vi.mocked(createAgentAction).mockResolvedValue(
agentWithProfiles([{ ...draftProfile, id: PERSISTED_PROFILE_ID }]),
);
await saveNewAgent(draftAgent, callbacks);
expect(createAgentAction).toHaveBeenCalledWith(
expect.objectContaining({
profiles: [expect.objectContaining({ fallback_model: "gpt-5", auto_fallback: true })],
}),
);
});
it("includes the fallback fields when adding a new profile to an existing agent", async () => {
const savedAgent = agentWithProfiles([baseProfile]);
const newProfile = draftFrom(baseProfile, {
id: toAgentProfileId("draft-new-profile"),
name: "New profile",
fallbackModel: "gpt-5",
autoFallback: true,
});
const draftAgent = agentWithProfiles([baseProfile, newProfile]);
const { callbacks } = createTestCallbacks(draftAgent);
vi.mocked(createAgentProfileAction).mockResolvedValue({
...newProfile,
id: PERSISTED_PROFILE_ID,
});
await saveExistingAgent(draftAgent, savedAgent, false, callbacks);
expect(createAgentProfileAction).toHaveBeenCalledWith(
savedAgent.id,
expect.objectContaining({ fallback_model: "gpt-5", auto_fallback: true }),
);
});
});
function agentWithProfiles(profiles: DraftProfile[]): DraftAgent {
return {
id: "agent-1",
@@ -35,6 +35,8 @@ export function toAgentProfilePatch(patch: Partial<ProfileFormData>): Partial<Ag
const next: Partial<AgentProfile> = {};
if (patch.name !== undefined) next.name = patch.name;
if (patch.model !== undefined) next.model = patch.model;
if (patch.fallback_model !== undefined) next.fallbackModel = patch.fallback_model;
if (patch.auto_fallback !== undefined) next.autoFallback = patch.auto_fallback;
if (patch.mode !== undefined) next.mode = patch.mode;
if (patch.config_options !== undefined) next.configOptions = patch.config_options;
if (patch.allow_indexing !== undefined) next.allowIndexing = patch.allow_indexing;
@@ -210,6 +212,8 @@ export async function saveNewAgent(draftAgent: DraftAgent, callbacks: SaveAgentC
profiles: draftAgent.profiles.map((profile) => ({
name: profile.name,
model: profile.model,
fallback_model: profile.fallbackModel ?? "",
auto_fallback: profile.autoFallback ?? false,
mode: profile.mode,
config_options: profile.configOptions ?? {},
...permissionsToProfilePatch(profile),
@@ -286,6 +290,8 @@ async function savePersistedProfile(
return updateAgentProfileAction(profile.id, {
name: profile.name,
model: profile.model,
fallback_model: profile.fallbackModel ?? "",
auto_fallback: profile.autoFallback ?? false,
mode: profile.mode,
config_options: profile.configOptions ?? {},
...permissionsToProfilePatch(profile),
@@ -318,6 +324,8 @@ async function saveExistingProfiles(
const createdProfile = await createAgentProfileAction(savedAgent.id, {
name: profile.name,
model: profile.model,
fallback_model: profile.fallbackModel ?? "",
auto_fallback: profile.autoFallback ?? false,
mode: profile.mode,
config_options: profile.configOptions ?? {},
...permissionsToProfilePatch(profile),
@@ -505,6 +513,8 @@ export function isProfileDirty(draft: DraftProfile, saved?: AgentProfile): boole
draft.name !== saved.name ||
draft.model !== saved.model ||
(draft.mode ?? "") !== (saved.mode ?? "") ||
(draft.fallbackModel ?? "") !== (saved.fallbackModel ?? "") ||
(draft.autoFallback ?? false) !== (saved.autoFallback ?? false) ||
!areConfigOptionsEqual(draft.configOptions, saved.configOptions) ||
arePermissionsDirty(draft, saved) ||
isProfileCliConfigDirty(draft, saved)
@@ -145,6 +145,56 @@ describe("CliProfileEditor", () => {
});
});
describe("CliProfileEditor fallback settings", () => {
it("keeps inline fallback settings collapsed and disables the explicit choice in automatic mode", () => {
const profile = {
id: toAgentProfileId("p-fallback"),
name: "fallback",
agentId: "claude",
agentDisplayName: "Claude",
model: MODEL_ID,
fallbackModel: "claude-opus-4",
autoFallback: true,
mode: "",
allowIndexing: false,
autoApprove: false,
cliFlags: [],
cliPassthrough: false,
createdAt: CREATED_AT,
updatedAt: CREATED_AT,
};
render(
<StateProvider
initialState={{
settingsAgents: { items: [] },
availableAgents: {
items: [baseAvailableAgent],
tools: [],
loaded: true,
loading: false,
},
}}
>
<CliProfileEditor mode="edit" profile={profile} onSaved={vi.fn()} />
</StateProvider>,
);
const trigger = screen.getByTestId("profile-fallback-settings-trigger");
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(screen.getByTestId("profile-fallback-settings-summary").textContent).toContain(
"Automatic fallback enabled",
);
fireEvent.click(trigger);
const fallbackSwitch = screen.getByRole("switch", { name: "Agent fallback" });
expect((fallbackSwitch as HTMLButtonElement).disabled).toBe(true);
const modelComboboxes = screen.getAllByTestId("profile-model-combobox-trigger");
expect((modelComboboxes.at(-1) as HTMLButtonElement).disabled).toBe(true);
});
});
describe("CliProfileEditor recommended flags", () => {
it("can hide passthrough while showing recommended CLI flags", () => {
render(
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Label } from "@kandev/ui/label";
import { Input } from "@kandev/ui/input";
import { Switch } from "@kandev/ui/switch";
@@ -10,6 +11,7 @@ import { useAppStore } from "@/components/state-provider";
import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents";
import { ModelCombobox } from "@/components/settings/model-combobox";
import { ModeCombobox } from "@/components/settings/mode-combobox";
import { ModelFallbackFields } from "@/components/agent/cli-profile-fallback-fields";
import { CLIFlagsField } from "@/components/settings/cli-flags-field";
import {
createAgentAction,
@@ -22,7 +24,6 @@ import {
buildDefaultPermissions,
PERMISSION_APPLY_AGENTCTL_AUTO_APPROVE,
} from "@/lib/agent-permissions";
import { useTranslation } from "react-i18next";
export type CliProfileEditorMode = "create" | "edit";
@@ -51,6 +52,8 @@ type FormState = {
agentName: string;
profileName: string;
model: string;
fallbackModel: string;
autoFallback: boolean;
mode: string;
cliFlags: CLIFlag[];
cliPassthrough: boolean;
@@ -67,6 +70,8 @@ function fromExistingProfile(profile: AgentProfile): FormState {
agentName: profile.agentId ?? "",
profileName: profile.name,
model: profile.model ?? "",
fallbackModel: profile.fallbackModel ?? "",
autoFallback: profile.autoFallback ?? false,
mode: profile.mode ?? "",
cliFlags: profile.cliFlags ?? [],
cliPassthrough: profile.cliPassthrough ?? false,
@@ -86,6 +91,8 @@ function fromDefaultAgent(
agentName: defaultAgent?.name ?? "",
profileName: defaultName,
model: cfg?.default_model ?? "",
fallbackModel: "",
autoFallback: false,
mode: cfg?.current_mode_id ?? "",
cliFlags: seedDefaultCLIFlags(permissionSettings),
cliPassthrough: false,
@@ -180,6 +187,10 @@ export function CliProfileEditor({
patch({
agentName: name,
model: installed.find((a) => a.name === name)?.model_config.default_model ?? "",
// A fallback model belongs to the previous client's model
// vocabulary; carrying it over would save a false gone-model
// configuration for the new client.
fallbackModel: "",
mode: installed.find((a) => a.name === name)?.model_config.current_mode_id ?? "",
cliFlags: seedDefaultCLIFlags(
installed.find((a) => a.name === name)?.permission_settings ?? {},
@@ -194,13 +205,7 @@ export function CliProfileEditor({
onChange={(profileName) => patch({ profileName })}
/>
<ModelModeFields
modelConfig={modelConfig ?? null}
model={form.model}
mode={form.mode}
onModelChange={(v) => patch({ model: v })}
onModeChange={(v) => patch({ mode: v })}
/>
<ModelModeFieldsBinding form={form} patch={patch} modelConfig={modelConfig ?? null} />
<AdvancedToggles
open={advancedOpen}
@@ -307,47 +312,108 @@ function CliClientPicker({
);
}
// ModelModeFieldsBinding forwards the editor's form state to ModelModeFields.
// Extracted so CliProfileEditor stays under the linter's line budget.
function ModelModeFieldsBinding({
form,
patch,
modelConfig,
}: {
form: FormState;
patch: (p: Partial<FormState>) => void;
modelConfig: NonNullable<AvailableAgent["model_config"]> | null;
}) {
return (
<ModelModeFields
modelConfig={modelConfig}
model={form.model}
fallbackModel={form.fallbackModel}
autoFallback={form.autoFallback}
mode={form.mode}
onModelChange={(v) => patch({ model: v })}
onFallbackModelChange={(v) => patch({ fallbackModel: v })}
onAutoFallbackChange={(v) => patch({ autoFallback: v })}
onModeChange={(v) => patch({ mode: v })}
/>
);
}
type ModelModeFieldsProps = {
modelConfig: NonNullable<AvailableAgent["model_config"]> | null;
model: string;
fallbackModel: string;
autoFallback: boolean;
mode: string;
onModelChange: (v: string) => void;
onFallbackModelChange: (v: string) => void;
onAutoFallbackChange: (v: boolean) => void;
onModeChange: (v: string) => void;
};
function ModelModeFields({
modelConfig,
model,
fallbackModel,
autoFallback,
mode,
onModelChange,
onFallbackModelChange,
onAutoFallbackChange,
onModeChange,
}: ModelModeFieldsProps) {
const { t } = useTranslation();
if (!modelConfig) {
return <p className="text-xs text-muted-foreground">{t("common:pickACliClientToLoad")}</p>;
}
const availableModels = modelConfig.available_models ?? [];
const startModelGone = Boolean(model && !availableModels.some((m) => m.id === model));
const fallbackModelGone = Boolean(
fallbackModel && !availableModels.some((m) => m.id === fallbackModel),
);
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label>{t("common:model")}</Label>
<ModelCombobox
value={model}
onChange={onModelChange}
models={modelConfig.available_models ?? []}
currentModelId={modelConfig.current_model_id}
/>
</div>
{(modelConfig.available_modes ?? []).length > 0 && (
<div className="grid grid-cols-1 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label>{t("common:mode")}</Label>
<ModeCombobox
value={mode}
onChange={onModeChange}
modes={modelConfig.available_modes ?? []}
currentModeId={modelConfig.current_mode_id}
<Label>{t("common:model")}</Label>
<ModelCombobox
value={model}
onChange={onModelChange}
models={
startModelGone
? [
...availableModels,
{
id: model,
name: `${model} (${t("settings:startModelUnavailable")})`,
disabled: true,
},
]
: availableModels
}
currentModelId={modelConfig.current_model_id}
/>
</div>
)}
{(modelConfig.available_modes ?? []).length > 0 && (
<div>
<Label>{t("common:mode")}</Label>
<ModeCombobox
value={mode}
onChange={onModeChange}
modes={modelConfig.available_modes ?? []}
currentModeId={modelConfig.current_mode_id}
/>
</div>
)}
</div>
<ModelFallbackFields
availableModels={availableModels}
fallbackModel={fallbackModel}
fallbackModelGone={fallbackModelGone}
autoFallback={autoFallback}
currentModelId={modelConfig.current_model_id}
onFallbackModelChange={onFallbackModelChange}
onAutoFallbackChange={onAutoFallbackChange}
/>
</div>
);
}
@@ -500,6 +566,8 @@ async function saveExistingProfile(id: string, form: FormState): Promise<AgentPr
return updateAgentProfileAction(id, {
name: form.profileName.trim(),
model: form.model,
fallback_model: form.fallbackModel ?? "",
auto_fallback: form.autoFallback,
mode: form.mode || undefined,
allow_indexing: form.allowIndexing,
auto_approve: form.autoApprove,
@@ -513,6 +581,8 @@ async function saveNewProfile(form: FormState, settingsAgents: Agent[]): Promise
const profilePayload = {
name: form.profileName.trim(),
model: form.model,
fallback_model: form.fallbackModel ?? "",
auto_fallback: form.autoFallback,
mode: form.mode || undefined,
allow_indexing: form.allowIndexing,
auto_approve: form.autoApprove,
@@ -0,0 +1,107 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Label } from "@kandev/ui/label";
import { Switch } from "@kandev/ui/switch";
import { ModelCombobox } from "@/components/settings/model-combobox";
import {
FallbackOptionHelp,
ModelFallbackSettingsShell,
} from "@/components/settings/model-fallback-settings-shell";
// ModelFallbackFields renders the no-silent-model-fallback controls for the
// inline CLI profile editor inside the shared fallback-settings disclosure.
export function ModelFallbackFields({
availableModels,
fallbackModel,
fallbackModelGone,
autoFallback,
currentModelId,
onFallbackModelChange,
onAutoFallbackChange,
}: {
availableModels: { id: string; name: string }[];
fallbackModel: string;
fallbackModelGone: boolean;
autoFallback: boolean;
currentModelId: string | undefined;
onFallbackModelChange: (v: string) => void;
onAutoFallbackChange: (v: boolean) => void;
}) {
const { t } = useTranslation();
// Checking the attached switch with no value yet must keep the selector
// visible until a model is picked, so a transient local flag extends the
// value-derived enabled state.
const [optedIn, setOptedIn] = useState(false);
const fallbackEnabled = Boolean(fallbackModel) || optedIn;
return (
<ModelFallbackSettingsShell
autoFallback={autoFallback}
fallbackModel={fallbackModel}
automaticOption={
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1">
<Label>{t("settings:autoFallback")}</Label>
<FallbackOptionHelp kind="automatic" />
</div>
<Switch
checked={autoFallback}
onCheckedChange={onAutoFallbackChange}
aria-label={t("settings:autoFallback")}
/>
</div>
<p className="text-xs text-muted-foreground">{t("settings:autoFallbackHelper")}</p>
</div>
}
explicitOption={
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1">
<Label className={fallbackModelGone ? "text-destructive" : undefined}>
{t("settings:agentFallback")}
</Label>
<FallbackOptionHelp kind="explicit" />
</div>
<Switch
checked={fallbackEnabled}
disabled={autoFallback}
onCheckedChange={(checked) => {
setOptedIn(checked);
if (!checked) onFallbackModelChange("");
}}
aria-label={t("settings:agentFallback")}
/>
</div>
{fallbackEnabled && (
<ModelCombobox
value={fallbackModel}
onChange={onFallbackModelChange}
models={
fallbackModelGone
? [
...availableModels,
{
id: fallbackModel,
name: `${fallbackModel} (${t("settings:startModelUnavailable")})`,
disabled: true,
},
]
: availableModels
}
currentModelId={currentModelId}
placeholder={t("settings:agentFallbackPlaceholder")}
disabled={autoFallback}
/>
)}
<p className="text-xs text-muted-foreground">
{autoFallback
? t("settings:agentFallbackDisabledHelper")
: t("settings:agentFallbackHelper")}
</p>
</div>
}
/>
);
}
@@ -268,3 +268,111 @@ describe("ModelConfigSelector provider descriptions", () => {
expect(screen.queryByRole("tooltip")).toBeNull();
});
});
describe("ModelConfigSelector fallback suffix", () => {
it("appends the suffix to the trigger label when provided", () => {
render(
<TooltipProvider>
<ModelConfigSelector
modelOptions={[{ id: "gpt-5", name: "GPT-5" }]}
currentModel="gpt-5"
onModelChange={() => {}}
currentModelSuffix=" (fallback)"
triggerSummary="changed"
/>
</TooltipProvider>,
);
expect(screen.getByRole("button", { name: modelSettingsButtonName }).textContent).toContain(
"GPT-5 (fallback)",
);
});
it("renders a config-option model with the suffix on the trigger", () => {
render(
<TooltipProvider>
<ModelConfigSelector
modelOptions={[{ id: "gpt-5", name: "GPT-5" }]}
currentModel="gpt-5"
onModelChange={() => {}}
currentModelSuffix=" (fallback)"
triggerSummary="changed"
configOptions={[
{
type: "select",
id: "model",
name: "Model",
currentValue: "gpt-5",
category: "model",
options: [{ value: "gpt-5", name: "GPT-5" }],
},
]}
/>
</TooltipProvider>,
);
expect(screen.getByRole("button", { name: modelSettingsButtonName }).textContent).toContain(
"GPT-5 (fallback)",
);
});
});
describe("ModelConfigSelector disabled (gone) models", () => {
it("renders a disabled model greyed out with its reason and blocks selection", () => {
const onModelChange = vi.fn();
render(
<TooltipProvider>
<ModelConfigSelector
modelOptions={[
{ id: "claude-sonnet", name: "Claude Sonnet" },
{
id: "claude-gone",
name: "Claude Gone",
disabled: true,
disabledReason: "no longer available",
},
]}
currentModel="claude-gone"
onModelChange={onModelChange}
/>
</TooltipProvider>,
);
fireEvent.click(screen.getByRole("button", { name: modelSettingsButtonName }));
const disabledItem = screen.getByRole("option", { name: /claude gone/i });
expect(disabledItem.className).toContain("opacity-40");
expect(disabledItem.getAttribute("aria-disabled")).toBe("true");
fireEvent.click(disabledItem);
expect(onModelChange).not.toHaveBeenCalled();
});
it("keeps the disabled (gone) model as the current value display", () => {
const onModelChange = vi.fn();
render(
<TooltipProvider>
<ModelConfigSelector
modelOptions={[
{
id: "claude-gone",
name: "Claude Gone",
disabled: true,
disabledReason: "no longer available",
},
{ id: "gpt-5", name: "GPT-5" },
]}
currentModel="claude-gone"
onModelChange={onModelChange}
/>
</TooltipProvider>,
);
// The trigger still shows the configured (gone) model name so the user
// sees what was configured.
expect(screen.getByRole("button", { name: modelSettingsButtonName }).textContent).toContain(
"Claude Gone",
);
});
});
+53 -9
View File
@@ -24,6 +24,10 @@ export type ModelSelectorOption = {
name: string;
description?: string;
usageMultiplier?: string;
/** When true the model is unavailable ("gone") — rendered greyed out and
* not selectable, with `disabledReason` shown in a tooltip. */
disabled?: boolean;
disabledReason?: string;
};
export type DynamicConfigOption = {
@@ -43,6 +47,8 @@ export type SelectConfigOption = DynamicConfigOption & {
type TriggerLabelOptions = {
summary: "changed";
configBaseline?: Record<string, string>;
/** Appended to the trigger's model label (e.g. "(fallback)"). */
currentModelSuffix?: string;
};
type TriggerDetail = {
@@ -114,9 +120,9 @@ export function triggerLabel(
options?: TriggerLabelOptions,
): string {
const modelConfig = configOptions.find(isModelConfigOption);
const modelValue = modelConfig
? currentOptionName(modelConfig)
: displayModelName(modelOptions, currentModel);
const modelValue =
(modelConfig ? currentOptionName(modelConfig) : displayModelName(modelOptions, currentModel)) +
(options?.currentModelSuffix ?? "");
const baseline = options?.configBaseline;
const extras = configOptions
.filter((option) => !isModelConfigOption(option))
@@ -183,12 +189,13 @@ function ModelRow({
selected: boolean;
onSelect: (value: string) => void;
}) {
return (
const item = (
<CommandItem
value={model.id}
keywords={[model.name, model.description ?? "", model.id]}
onSelect={() => onSelect(model.id)}
className="relative pr-7"
onSelect={() => !model.disabled && onSelect(model.id)}
disabled={model.disabled}
className={cn("relative pr-7", model.disabled && "opacity-40 cursor-not-allowed")}
>
<div className="flex min-w-0 flex-1 items-center">
<div className="min-w-0 flex-1">
@@ -208,6 +215,28 @@ function ModelRow({
/>
</CommandItem>
);
// cmdk's CommandItem swallows pointer events with no native tooltip slot;
// wrap disabled items in a Tooltip trigger so the gone-reason shows. The
// CommandItem is disabled (unfocusable), so the wrapper itself must be
// focusable and carry an accessible name for keyboard users to reach the
// reason.
if (model.disabled && model.disabledReason) {
return (
<Tooltip>
<TooltipTrigger asChild>
<div
tabIndex={0}
aria-label={`${model.name}: ${model.disabledReason}`}
className="rounded outline-none focus-visible:ring-2 focus-visible:ring-ring/35"
>
{item}
</div>
</TooltipTrigger>
<TooltipContent side="right">{model.disabledReason}</TooltipContent>
</Tooltip>
);
}
return item;
}
function ConfigOptionTrigger({
@@ -423,11 +452,16 @@ export type ModelConfigSelectorProps = {
triggerClassName?: string;
triggerSummary?: "all" | "changed";
configBaseline?: Record<string, string>;
/** Optional suffix appended to the trigger's model label (e.g. "(fallback)"). */
currentModelSuffix?: string;
/** Optional title tooltip on the trigger (e.g. explains a live-only note). */
triggerTitle?: string;
};
type ModelConfigSelectorTriggerProps = Pick<
ModelConfigSelectorProps,
"ariaLabel" | "disabled" | "placeholder" | "triggerClassName" | "variant"
"ariaLabel" | "disabled" | "placeholder" | "triggerClassName" | "triggerTitle" | "variant"
> & {
label: string;
details?: TriggerDetail[];
@@ -440,6 +474,7 @@ function ModelConfigSelectorTrigger({
label,
placeholder,
triggerClassName,
triggerTitle,
variant,
}: ModelConfigSelectorTriggerProps) {
const compact = variant === "compact";
@@ -455,6 +490,7 @@ function ModelConfigSelectorTrigger({
className={cn(baseClassName, triggerClassName)}
aria-label={ariaLabel}
disabled={disabled}
title={triggerTitle}
>
<span className="truncate">{label || placeholder}</span>
<IconChevronDown className="h-3.5 w-3.5 shrink-0 opacity-70" />
@@ -482,9 +518,10 @@ function ModelConfigSelectorTrigger({
function triggerLabelOptions(
triggerSummary: "all" | "changed",
configBaseline: Record<string, string> | undefined,
currentModelSuffix?: string,
): TriggerLabelOptions | undefined {
if (triggerSummary !== "changed") return undefined;
return { summary: "changed", configBaseline };
return { summary: "changed", configBaseline, currentModelSuffix };
}
export const ModelConfigSelector = memo(function ModelConfigSelector({
@@ -501,7 +538,9 @@ export const ModelConfigSelector = memo(function ModelConfigSelector({
triggerClassName: customTriggerClassName,
triggerSummary = "all",
configBaseline,
currentModelSuffix,
}: ModelConfigSelectorProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [activeConfigId, setActiveConfigId] = useState<string | null>(null);
const selectConfigOptions = usableConfigOptions(configOptions);
@@ -514,12 +553,16 @@ export const ModelConfigSelector = memo(function ModelConfigSelector({
currentModel,
modelConfig,
configOptions,
triggerLabelOptions(triggerSummary, configBaseline),
triggerLabelOptions(triggerSummary, configBaseline, currentModelSuffix),
);
const details =
triggerSummary === "changed"
? triggerDetails(modelOptions, currentModel, modelConfig, extraConfigOptions)
: undefined;
// The (fallback) marker is a live WS signal, not replayed on reload. When
// it is active, explain on the trigger that the note is transient so users
// are not surprised when the marker disappears after a refresh.
const triggerTitle = currentModelSuffix ? t("settings:fallbackNoteLive") : undefined;
const hasExtraConfigOptions = extraConfigOptions.length > 0;
const onModelSelect = (value: string) => {
@@ -546,6 +589,7 @@ export const ModelConfigSelector = memo(function ModelConfigSelector({
label={label}
placeholder={placeholder}
triggerClassName={customTriggerClassName}
triggerTitle={triggerTitle}
variant={variant}
/>
<PopoverContent
@@ -16,6 +16,8 @@ export function isProfileDirty(
const changed = [
draft.name !== savedProfile.name,
draft.model !== savedProfile.model,
(draft.fallbackModel ?? "") !== (savedProfile.fallbackModel ?? ""),
(draft.autoFallback ?? false) !== (savedProfile.autoFallback ?? false),
(draft.mode ?? "") !== (savedProfile.mode ?? ""),
!areConfigOptionsEqual(draft.configOptions, savedProfile.configOptions),
arePermissionsDirty(draft, savedProfile, permissionSettings),
@@ -100,6 +100,8 @@ export function useProfileSave({
{
name: draft.name,
model: draft.model,
fallback_model: draft.fallbackModel ?? "",
auto_fallback: draft.autoFallback ?? false,
mode: draft.mode,
config_options: draft.configOptions ?? {},
...permissionsToProfilePatch(draft),
@@ -191,6 +191,8 @@ function ProfileSettingsCard({
profile={{
name: draft.name,
model: draft.model,
fallback_model: draft.fallbackModel ?? "",
auto_fallback: draft.autoFallback ?? false,
mode: draft.mode ?? "",
config_options: draft.configOptions ?? {},
auto_approve: permissionValues.auto_approve,
@@ -202,6 +204,8 @@ function ProfileSettingsCard({
baselineProfile={{
name: savedProfile.name,
model: savedProfile.model,
fallback_model: savedProfile.fallbackModel ?? "",
auto_fallback: savedProfile.autoFallback ?? false,
mode: savedProfile.mode ?? "",
config_options: savedProfile.configOptions ?? {},
auto_approve: savedPermissionValues.auto_approve,
@@ -3,6 +3,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { IconSelector } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { Button } from "@kandev/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import {
@@ -18,12 +19,17 @@ import type { ModelEntry } from "@/lib/types/http";
type ModelComboboxProps = {
value: string;
onChange: (value: string) => void;
models: ModelEntry[];
models: ModelComboboxModel[];
currentModelId?: string;
placeholder?: string;
disabled?: boolean;
};
// ModelComboboxModel adds the optional unavailable ("gone") marker used by
// profile editors to keep a configured-but-unadvertised model visible while
// preventing its selection.
type ModelComboboxModel = ModelEntry & { disabled?: boolean };
function copilotUsage(model: ModelEntry): string | undefined {
const raw = model.meta?.copilotUsage;
return typeof raw === "string" ? raw : undefined;
@@ -119,11 +125,16 @@ export function ModelCombobox({
key={model.id}
value={`${model.id} ${model.name}`}
onSelect={() => {
if (model.disabled) return;
onChange(model.id);
setOpen(false);
}}
disabled={model.disabled}
data-checked={effectiveValue === model.id}
className="cursor-pointer"
className={cn(
"cursor-pointer",
model.disabled && "opacity-40 cursor-not-allowed",
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 truncate">
@@ -0,0 +1,55 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { ModelFallbackSettingsShell } from "./model-fallback-settings-shell";
afterEach(cleanup);
function renderShell({ autoFallback = false, fallbackModel = "", isDirty = false } = {}) {
return render(
<ModelFallbackSettingsShell
autoFallback={autoFallback}
fallbackModel={fallbackModel}
isDirty={isDirty}
automaticOption={<span>Automatic option</span>}
explicitOption={<span>Explicit option</span>}
/>,
);
}
describe("ModelFallbackSettingsShell", () => {
it("starts collapsed with a state summary", () => {
renderShell({ fallbackModel: "gpt-5", isDirty: true });
expect(
screen.getByTestId("profile-fallback-settings-trigger").getAttribute("aria-expanded"),
).toBe("false");
expect(screen.getByTestId("profile-fallback-settings-summary").textContent).toContain(
"Explicit fallback: gpt-5",
);
expect(
screen
.getByTestId("profile-fallback-settings")
.firstElementChild?.getAttribute("data-settings-dirty"),
).toBe("true");
expect(screen.queryByTestId("profile-fallback-settings-grid")).toBeNull();
});
it("reveals both fallback options in the responsive grid", () => {
renderShell({ autoFallback: true });
fireEvent.click(screen.getByTestId("profile-fallback-settings-trigger"));
expect(
screen.getByTestId("profile-fallback-settings-trigger").getAttribute("aria-expanded"),
).toBe("true");
expect(screen.getByTestId("profile-fallback-settings-grid").className).toContain(
"md:grid-cols-2",
);
expect(screen.getByTestId("profile-auto-fallback-option").textContent).toContain(
"Automatic option",
);
expect(screen.getByTestId("profile-explicit-fallback-option").textContent).toContain(
"Explicit option",
);
});
});
@@ -0,0 +1,172 @@
"use client";
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { IconChevronRight, IconInfoCircle } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@kandev/ui/drawer";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
import { cn } from "@/lib/utils";
export type FallbackOptionKind = "automatic" | "explicit";
function helpCopy(kind: FallbackOptionKind, t: (key: string) => string) {
if (kind === "automatic") {
return {
label: t("settings:autoFallbackInfoLabel"),
title: t("settings:autoFallbackHelpTitle"),
body: t("settings:autoFallbackHelp"),
};
}
return {
label: t("settings:agentFallbackInfoLabel"),
title: t("settings:agentFallbackHelpTitle"),
body: t("settings:agentFallbackHelp"),
};
}
export function FallbackOptionHelp({ kind }: { kind: FallbackOptionKind }) {
const { t } = useTranslation();
const usesTouchDrawer = useTouchDrawer();
const [open, setOpen] = useState(false);
const copy = helpCopy(kind, t);
const button = (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={copy.label}
aria-haspopup={usesTouchDrawer ? "dialog" : undefined}
aria-expanded={usesTouchDrawer ? open : undefined}
data-testid={`profile-${kind}-fallback-help`}
className={cn(
"shrink-0 cursor-pointer text-muted-foreground hover:text-foreground",
usesTouchDrawer ? "h-11 w-11" : "h-7 w-7",
)}
>
<IconInfoCircle className="size-4" aria-hidden="true" />
</Button>
);
const trigger = usesTouchDrawer ? (
<DrawerTrigger asChild>{button}</DrawerTrigger>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="bottom"
align="start"
sideOffset={8}
className="max-w-[min(24rem,calc(100vw-2rem))]"
>
<div className="space-y-1">
<p className="font-medium">{copy.title}</p>
<p>{copy.body}</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
return (
<Drawer open={open} onOpenChange={setOpen}>
{trigger}
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{copy.title}</DrawerTitle>
<DrawerDescription>{copy.body}</DrawerDescription>
</DrawerHeader>
</DrawerContent>
</Drawer>
);
}
export function ModelFallbackSettingsShell({
autoFallback,
fallbackModel,
isDirty,
automaticOption,
explicitOption,
}: {
autoFallback: boolean;
fallbackModel: string;
isDirty?: boolean;
automaticOption: ReactNode;
explicitOption: ReactNode;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
let summary = t("settings:fallbackSettingsSummaryStrict");
if (fallbackModel) {
summary = t("settings:fallbackSettingsSummaryExplicit", { model: fallbackModel });
}
if (autoFallback) {
summary = t("settings:fallbackSettingsSummaryAutomatic");
}
return (
<Collapsible
open={open}
onOpenChange={setOpen}
className="space-y-2"
data-testid="profile-fallback-settings"
>
<div
className="flex min-h-11 min-w-0 items-center gap-2"
data-settings-dirty={isDirty}
data-settings-dirty-level="container"
>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex min-h-11 min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md px-1 text-left hover:bg-muted/40"
data-testid="profile-fallback-settings-trigger"
>
<IconChevronRight
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
open && "rotate-90",
)}
aria-hidden="true"
/>
<span className="truncate font-medium">{t("settings:fallbackSettings")}</span>
</button>
</CollapsibleTrigger>
<span
className="min-w-0 truncate text-xs text-muted-foreground"
data-testid="profile-fallback-settings-summary"
>
{summary}
</span>
</div>
<CollapsibleContent>
<div
className="grid min-w-0 grid-cols-1 gap-3 md:grid-cols-2"
data-testid="profile-fallback-settings-grid"
>
<div
className="min-w-0 rounded-md border border-border/70 bg-muted/10 p-3"
data-testid="profile-auto-fallback-option"
>
{automaticOption}
</div>
<div
className="min-w-0 rounded-md border border-border/70 bg-muted/10 p-3"
data-testid="profile-explicit-fallback-option"
>
{explicitOption}
</div>
</div>
</CollapsibleContent>
</Collapsible>
);
}
@@ -10,7 +10,7 @@ import {
DialogTrigger,
} from "@kandev/ui/dialog";
import type { CommandEntry, ModeEntry } from "@/lib/types/http";
import type { ProfileFormData } from "./profile-form-fields";
import type { ProfileFormData } from "./profile-model-fields";
// An example slash command the user types verbatim — an identifier, not copy.
const EXAMPLE_COMMAND = "/init";
@@ -77,6 +77,22 @@ export function profileModelIsDirty(profile: ProfileFormData, baseline?: Profile
);
}
export function profileFallbackModelIsDirty(
profile: ProfileFormData,
baseline?: ProfileFormData,
): boolean {
return Boolean(baseline && (profile.fallback_model ?? "") !== (baseline.fallback_model ?? ""));
}
export function profileAutoFallbackIsDirty(
profile: ProfileFormData,
baseline?: ProfileFormData,
): boolean {
return Boolean(
baseline && (profile.auto_fallback ?? false) !== (baseline.auto_fallback ?? false),
);
}
export function profileModeIsDirty(profile: ProfileFormData, baseline?: ProfileFormData): boolean {
return Boolean(baseline && profile.mode !== baseline.mode);
}
@@ -108,6 +108,10 @@ function renderStatefulForm(
);
}
function expandFallbackSettings() {
fireEvent.click(screen.getByTestId("profile-fallback-settings-trigger"));
}
describe("ProfileFormFields command prefix visibility", () => {
it("shows the command prefix field for an ACP (non-passthrough) profile", () => {
renderForm(formData({ cli_passthrough: false }));
@@ -122,6 +126,62 @@ describe("ProfileFormFields command prefix visibility", () => {
});
});
describe("ProfileFormFields no-silent-model-fallback rows", () => {
it("renders the start model trigger red when the configured model is gone", () => {
renderForm(formData({ model: "claude-gone" }));
const trigger = screen.getByRole("button", { name: profileStartModelSettingsLabel });
expect(trigger.className).toContain("text-destructive");
expect(trigger.textContent).toContain("claude-gone");
});
it("shows the agent fallback row when auto-fallback is off", () => {
renderForm(formData({ auto_fallback: false }));
expandFallbackSettings();
expect(screen.queryByTestId("profile-fallback-model-field")).not.toBeNull();
});
it("keeps the agent fallback row visible but disabled when auto-fallback is on", () => {
renderForm(formData({ auto_fallback: true }));
expandFallbackSettings();
expect(screen.queryByTestId("profile-fallback-model-field")).not.toBeNull();
expect(screen.getByRole("switch", { name: "Agent fallback" })).toHaveProperty("disabled", true);
expect(screen.queryByTestId("profile-auto-fallback-field")).not.toBeNull();
});
it("marks a gone fallback model red in its picker", () => {
renderForm(formData({ fallback_model: "gpt-gone" }));
expandFallbackSettings();
const trigger = screen.getByRole("button", { name: "Agent fallback model settings" });
expect(trigger.className).toContain("text-destructive");
expect(trigger.textContent).toContain("gpt-gone");
});
it("hides the fallback selector until its attached switch is on", () => {
renderForm(formData({ auto_fallback: false }));
expandFallbackSettings();
// The optional fallback row renders its label + switch, but the model
// selector only appears once the user opts in via the attached switch.
expect(screen.queryByTestId("profile-fallback-model-field")).not.toBeNull();
expect(screen.queryByRole("button", { name: "Agent fallback model settings" })).toBeNull();
});
it("shows the fallback selector when a fallback model is configured", () => {
renderForm(formData({ fallback_model: "mock-fast" }));
expandFallbackSettings();
expect(screen.getByRole("button", { name: "Agent fallback model settings" })).not.toBeNull();
});
it("clears the fallback model when its attached switch is turned off", () => {
const onChange = vi.fn();
renderForm(formData({ fallback_model: "mock-fast" }), modelConfig, onChange);
expandFallbackSettings();
const fallbackToggle = screen.getByRole("switch", { name: "Agent fallback" });
fallbackToggle.click();
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ fallback_model: "" }));
});
});
describe("ProfileFormFields model options", () => {
it("loads model-specific options in the profile model selector", async () => {
const dynamicModelConfig: ModelConfig = {
@@ -9,13 +9,6 @@ import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Skeleton } from "@kandev/ui/skeleton";
import { Switch } from "@kandev/ui/switch";
import { ModeCombobox } from "@/components/settings/mode-combobox";
import {
configOptionToModelOptions,
isModelConfigOption,
ModelConfigSelector,
type SelectConfigOption,
} from "@/components/model-config-selector";
import { useProfileModelCapabilities } from "@/hooks/domains/settings/use-profile-model-capabilities";
import {
PERMISSION_APPLY_AGENTCTL_AUTO_APPROVE,
@@ -37,6 +30,11 @@ import {
profileModelIsDirty,
} from "@/components/settings/profile-capability-helpers";
import { modelConfigOptions } from "@/components/settings/profile-model-config";
import {
ModelFallbackSection,
ModelPicker,
ModePicker,
} from "@/components/settings/profile-model-fields";
import type {
CLIFlag,
CommandEntry,
@@ -51,6 +49,10 @@ import type {
export type ProfileFormData = {
name: string;
model: string;
/** Optional single fallback model applied when `model` is unavailable. */
fallback_model?: string;
/** Legacy automatic-fallback opt-in; hides the fallback_model field. */
auto_fallback?: boolean;
mode: string;
config_options?: Record<string, string>;
cli_passthrough: boolean;
@@ -252,74 +254,6 @@ function PermissionToggles({
);
}
function ModelPicker({
profile,
models,
currentModelId,
configOptions,
onChange,
}: {
profile: ProfileFormData;
models: ModelEntry[];
currentModelId: string | undefined;
configOptions: SelectConfigOption[];
onChange: (patch: Partial<ProfileFormData>) => void;
}) {
const { t } = useTranslation();
const modelConfig = configOptions.find(isModelConfigOption);
const modelOptions = modelConfig
? configOptionToModelOptions(modelConfig)
: models.map((model) => ({
id: model.id,
name: model.name,
description: model.description || (model.id !== model.name ? model.id : undefined),
usageMultiplier:
typeof model.meta?.copilotUsage === "string" ? model.meta.copilotUsage : undefined,
}));
const currentModel = profile.model || modelConfig?.currentValue || currentModelId || null;
const selectedConfigOptions = configOptions.map((option) => ({
...option,
currentValue: isModelConfigOption(option)
? profile.model || option.currentValue
: profile.config_options?.[option.id] || option.currentValue,
}));
return (
<ModelConfigSelector
modelOptions={modelOptions}
currentModel={currentModel}
configOptions={selectedConfigOptions}
onModelChange={(value) => onChange({ model: value })}
onConfigChange={(configId, value) =>
onChange({ config_options: { ...(profile.config_options ?? {}), [configId]: value } })
}
placeholder={t("agents:selectAModel")}
ariaLabel={t("agents:profileStartModelSettings")}
/>
);
}
function ModePicker({
profile,
modes,
currentModeId,
onChange,
}: {
profile: ProfileFormData;
modes: ModeEntry[];
currentModeId: string | undefined;
onChange: (patch: Partial<ProfileFormData>) => void;
}) {
return (
<ModeCombobox
value={profile.mode}
onChange={(value) => onChange({ mode: value })}
modes={modes}
currentModeId={currentModeId}
/>
);
}
type CapabilitiesRowProps = {
profile: ProfileFormData;
models: ModelEntry[];
@@ -422,6 +356,8 @@ function CapabilitiesRowContent({
currentModelId={currentModelId}
configOptions={configOptions}
onChange={onChange}
ariaLabel={t("settings:startModelAria")}
goneModelLabel={t("settings:startModelUnavailable")}
/>
<ModelConfigResolutionStatus
status={configStatus}
@@ -451,6 +387,15 @@ function CapabilitiesRowContent({
{activeMode?.description && (
<p className="text-xs text-muted-foreground">{activeMode.description}</p>
)}
<ModelFallbackSection
profile={profile}
models={models}
configOptions={configOptions}
baselineProfile={baselineProfile}
labelCls={labelCls}
gapCls={gapCls}
onChange={onChange}
/>
{commands.length > 0 && <CommandsButton commands={commands} />}
<CapabilityStatusMessage status={status} />
</div>
@@ -0,0 +1,308 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Label } from "@kandev/ui/label";
import { Switch } from "@kandev/ui/switch";
import { ModeCombobox } from "@/components/settings/mode-combobox";
import {
configOptionToModelOptions,
isModelConfigOption,
ModelConfigSelector,
type ModelSelectorOption,
type SelectConfigOption,
usableConfigOptions,
} from "@/components/model-config-selector";
import {
profileAutoFallbackIsDirty,
profileFallbackModelIsDirty,
} from "@/components/settings/profile-capability-helpers";
import {
FallbackOptionHelp,
ModelFallbackSettingsShell,
} from "@/components/settings/model-fallback-settings-shell";
import type { ModelConfig, ModeEntry, ModelEntry } from "@/lib/types/http";
import type { PermissionKey } from "@/lib/agent-permissions";
import type { CLIFlag } from "@/lib/types/http";
export type ProfileFormData = {
name: string;
model: string;
/** Optional single fallback model applied when `model` is unavailable. */
fallback_model?: string;
/** Legacy automatic-fallback opt-in; hides the fallback_model field. */
auto_fallback?: boolean;
mode: string;
config_options?: Record<string, string>;
cli_passthrough: boolean;
cli_flags: CLIFlag[];
command_prefix?: string;
} & Record<PermissionKey, boolean>;
export function ModelPicker({
profile,
models,
currentModelId,
configOptions,
onChange,
ariaLabel,
placeholder,
goneModelLabel,
disabled,
}: {
profile: ProfileFormData;
models: ModelEntry[];
currentModelId: string | undefined;
configOptions: SelectConfigOption[];
onChange: (patch: Partial<ProfileFormData>) => void;
ariaLabel: string;
placeholder?: string;
goneModelLabel: string;
disabled?: boolean;
}) {
const { t } = useTranslation();
const modelConfig = configOptions.find(isModelConfigOption);
const modelOptions: ModelSelectorOption[] = modelConfig
? configOptionToModelOptions(modelConfig)
: models.map((model) => ({
id: model.id,
name: model.name,
description: model.description || (model.id !== model.name ? model.id : undefined),
usageMultiplier:
typeof model.meta?.copilotUsage === "string" ? model.meta.copilotUsage : undefined,
}));
const currentModel = profile.model || modelConfig?.currentValue || currentModelId || null;
// A configured model that is no longer advertised ("gone") stays visible
// in the list, greyed out and unselectable, so the user sees what was
// configured instead of it silently vanishing.
const modelIsGone = Boolean(profile.model && !modelOptions.some((m) => m.id === profile.model));
if (modelIsGone) {
modelOptions.unshift({
id: profile.model!,
name: profile.model!,
disabled: true,
disabledReason: goneModelLabel,
});
}
const selectedConfigOptions = configOptions.map((option) => ({
...option,
currentValue: isModelConfigOption(option)
? profile.model || option.currentValue
: profile.config_options?.[option.id] || option.currentValue,
}));
return (
<ModelConfigSelector
modelOptions={modelOptions}
currentModel={currentModel}
configOptions={selectedConfigOptions}
onModelChange={(value) => onChange({ model: value })}
onConfigChange={(configId, value) =>
onChange({ config_options: { ...(profile.config_options ?? {}), [configId]: value } })
}
placeholder={placeholder ?? t("settings:selectAModel")}
ariaLabel={ariaLabel}
disabled={disabled}
triggerClassName={modelIsGone ? "text-destructive" : undefined}
/>
);
}
export function ModePicker({
profile,
modes,
currentModeId,
onChange,
}: {
profile: ProfileFormData;
modes: ModeEntry[];
currentModeId: string | undefined;
onChange: (patch: Partial<ProfileFormData>) => void;
}) {
return (
<ModeCombobox
value={profile.mode}
onChange={(value) => onChange({ mode: value })}
modes={modes}
currentModeId={currentModeId}
/>
);
}
export function modelConfigOptions(modelConfig: ModelConfig): SelectConfigOption[] {
return usableConfigOptions(
modelConfig.config_options?.map((option) => ({
type: option.type,
id: option.id,
name: option.name,
currentValue: option.current_value,
category: option.category,
options: option.options,
})),
);
}
// ModelFallbackSection renders the no-silent-model-fallback controls inside a
// collapsed disclosure. The explicit fallback remains visible while automatic
// fallback is enabled, but its controls are disabled and its saved value is
// preserved until automatic fallback is turned off again.
// FallbackModelPicker owns the optional fallback row: its attached switch
// tracks whether the user opted in. Checking with no value yet must keep the
// selector visible (empty) until a model is picked, so a transient local flag
// extends the value-derived state.
function FallbackModelPicker({
profile,
models,
configOptions,
baselineProfile,
labelCls,
gapCls,
disabled,
onChange,
}: {
profile: ProfileFormData;
models: ModelEntry[];
configOptions: SelectConfigOption[];
baselineProfile?: ProfileFormData;
labelCls?: string;
gapCls: string;
disabled?: boolean;
onChange: (patch: Partial<ProfileFormData>) => void;
}) {
const { t } = useTranslation();
const [optedIn, setOptedIn] = useState(false);
const fallbackEnabled = Boolean(profile.fallback_model) || optedIn;
// The fallback picker must not inherit the START model's current value or
// config options: an enabled-but-empty fallback shows its placeholder, and
// selecting a model through the config-option path still lands in
// fallback_model. Rebuild the options with the fallback's own current value.
const fallbackConfigOptions = configOptions.map((option) => ({
...option,
currentValue: isModelConfigOption(option)
? (profile.fallback_model ?? "")
: option.currentValue,
}));
const fallbackModelOptionId = configOptions.find(isModelConfigOption)?.id;
return (
<div
data-testid="profile-fallback-model-field"
className={gapCls}
data-settings-dirty={profileFallbackModelIsDirty(profile, baselineProfile)}
data-settings-dirty-level="container"
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1">
<Label className={labelCls}>{t("settings:agentFallback")}</Label>
<FallbackOptionHelp kind="explicit" />
</div>
<Switch
checked={fallbackEnabled}
disabled={disabled}
onCheckedChange={(checked) => {
setOptedIn(checked);
if (!checked) onChange({ fallback_model: "" });
}}
aria-label={t("settings:agentFallback")}
/>
</div>
{fallbackEnabled && (
<div className="min-w-0">
<ModelPicker
profile={{ ...profile, model: profile.fallback_model ?? "" }}
models={models}
currentModelId={profile.fallback_model || undefined}
configOptions={fallbackConfigOptions}
onChange={(patch) => {
if (patch.model !== undefined) {
onChange({ fallback_model: patch.model });
return;
}
if (patch.config_options && fallbackModelOptionId) {
const selected = patch.config_options[fallbackModelOptionId];
if (selected !== undefined) onChange({ fallback_model: selected });
}
}}
ariaLabel={t("settings:agentFallbackAria")}
placeholder={t("settings:agentFallbackPlaceholder")}
goneModelLabel={t("settings:fallbackModelUnavailable")}
disabled={disabled}
/>
</div>
)}
<p className="text-xs text-muted-foreground">
{disabled ? t("settings:agentFallbackDisabledHelper") : t("settings:agentFallbackHelper")}
</p>
</div>
);
}
// Auto-fallback (legacy behavior) is mutually exclusive with a per-profile
// fallback model: the toggle switches between the two. Selecting a fallback
// model is only meaningful when auto-fallback is off, because auto-fallback
// already allows the provider to switch to any advertised model — the
// configured fallback would be redundant. The strict mode (both off) is the
// only automatic model switch allowed when auto-fallback is off.
export function ModelFallbackSection({
profile,
models,
configOptions,
baselineProfile,
labelCls,
gapCls,
onChange,
}: {
profile: ProfileFormData;
models: ModelEntry[];
configOptions: SelectConfigOption[];
baselineProfile?: ProfileFormData;
labelCls?: string;
gapCls: string;
onChange: (patch: Partial<ProfileFormData>) => void;
}) {
const { t } = useTranslation();
const autoFallback = profile.auto_fallback ?? false;
const isDirty =
profileAutoFallbackIsDirty(profile, baselineProfile) ||
profileFallbackModelIsDirty(profile, baselineProfile);
return (
<ModelFallbackSettingsShell
autoFallback={autoFallback}
fallbackModel={profile.fallback_model ?? ""}
isDirty={isDirty}
automaticOption={
<div
data-testid="profile-auto-fallback-field"
className={`min-w-0 ${gapCls}`}
data-settings-dirty={profileAutoFallbackIsDirty(profile, baselineProfile)}
data-settings-dirty-level="container"
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1">
<Label className={labelCls}>{t("settings:autoFallback")}</Label>
<FallbackOptionHelp kind="automatic" />
</div>
<Switch
checked={autoFallback}
onCheckedChange={(checked) => onChange({ auto_fallback: checked })}
aria-label={t("settings:autoFallback")}
/>
</div>
<p className="text-xs text-muted-foreground">{t("settings:autoFallbackHelper")}</p>
</div>
}
explicitOption={
<FallbackModelPicker
profile={profile}
models={models}
configOptions={configOptions}
baselineProfile={baselineProfile}
labelCls={labelCls}
gapCls={gapCls}
disabled={autoFallback}
onChange={onChange}
/>
}
/>
);
}
@@ -1,38 +1,102 @@
import { describe, it, expect } from "vitest";
import { renderHook } from "@testing-library/react";
import { cleanup, render, renderHook, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentProfileOption } from "@/lib/state/slices";
import type { AvailableAgent } from "@/lib/types/http-agents";
import type { Executor } from "@/lib/types/http";
import type { AgentProfileOption } from "@/lib/state/slices/settings/types";
import { computeExecutorHint, useAgentProfileOptions } from "./task-create-dialog-options";
function profileOption(id: string, enabled?: boolean): AgentProfileOption {
// Minimal store shape consumed by useAvailableAgents.
type MockStore = {
availableAgents: {
items: AvailableAgent[];
loading: boolean;
loaded: boolean;
tools: [];
};
};
let mockStore: MockStore = {
availableAgents: { items: [], loading: false, loaded: true, tools: [] },
};
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (s: MockStore) => unknown) => selector(mockStore),
}));
function setAvailableAgents(items: AvailableAgent[]) {
mockStore = { availableAgents: { items, loading: false, loaded: true, tools: [] } };
}
const AGENT_WITH_GPT: AvailableAgent = {
name: "omp-acp",
available: true,
model_config: {
default_model: "gpt-5",
available_models: [{ id: "gpt-5", name: "GPT-5" }],
current_model_id: "gpt-5",
available_modes: [],
supports_dynamic_models: false,
status: "ok",
},
} as unknown as AvailableAgent;
const GONE_MODEL = "claude-gone";
const DATA_DISABLED = "data-disabled";
function profileOption(overrides: Partial<AgentProfileOption>): AgentProfileOption {
return {
id,
label: `Agent • ${id}`,
agent_id: "a1",
agent_name: "agent",
id: "profile-1",
label: "OMP • hybrid",
agent_id: "agent-1",
agent_name: "omp-acp",
cli_passthrough: false,
enabled,
...overrides,
};
}
function exec(id: string, type: Executor["type"]): Executor {
return { id, type, name: id } as Executor;
function OptionsProbe({ profiles }: { profiles: AgentProfileOption[] }) {
const options = useAgentProfileOptions(profiles);
return (
<div>
{options.map((option, index) => (
<div
key={option.value}
data-testid={`option-${index}`}
data-disabled={option.disabled ? "true" : undefined}
data-reason={option.disabledReason}
>
{option.renderLabel()}
</div>
))}
</div>
);
}
const WORKTREE_SINGLE = "A git worktree will be created from the base branch.";
const WORKTREE_MULTI =
"A git worktree will be created for each repository in a parent folder. The agent runs in that parent folder so it can see every worktree side by side.";
const DOCKER =
"A Docker container will be created from the selected base branch and checked out on a task branch.";
const LOCAL = "The agent will run directly on the repository.";
function renderOptions(profiles: AgentProfileOption[]) {
render(<OptionsProbe profiles={profiles} />);
return screen.getByTestId("option-0");
}
beforeEach(() => {
vi.clearAllMocks();
setAvailableAgents([AGENT_WITH_GPT]);
});
afterEach(cleanup);
describe("computeExecutorHint", () => {
const executors = [
exec("wt", "worktree"),
exec("loc", "local"),
exec("docker", "local_docker"),
exec("remote-docker", "remote_docker"),
{ id: "wt", type: "worktree" } as Executor,
{ id: "loc", type: "local" } as Executor,
{ id: "docker", type: "local_docker" } as Executor,
{ id: "remote-docker", type: "remote_docker" } as Executor,
];
const WORKTREE_SINGLE = "A git worktree will be created from the base branch.";
const WORKTREE_MULTI =
"A git worktree will be created for each repository in a parent folder. The agent runs in that parent folder so it can see every worktree side by side.";
const DOCKER =
"A Docker container will be created from the selected base branch and checked out on a task branch.";
const LOCAL = "The agent will run directly on the repository.";
it("returns the multi-repo worktree hint when more than one repo is selected", () => {
expect(computeExecutorHint(executors, "wt", 2)).toBe(WORKTREE_MULTI);
@@ -57,18 +121,18 @@ describe("computeExecutorHint", () => {
});
it("returns null for an unrecognised executor type", () => {
const odd = [exec("x", "remote" as Executor["type"])];
const odd = [{ id: "x", type: "remote" as Executor["type"] } as Executor];
expect(computeExecutorHint(odd, "x", 1)).toBeNull();
});
});
describe("useAgentProfileOptions", () => {
describe("useAgentProfileOptions enabled filter", () => {
it("omits disabled profiles from the selectable options", () => {
const { result } = renderHook(() =>
useAgentProfileOptions([
profileOption("p-enabled"),
profileOption("p-disabled", false),
profileOption("p-legacy"),
profileOption({ id: "p-enabled", label: "Agent • p-enabled" }),
profileOption({ id: "p-disabled", label: "Agent • p-disabled", enabled: false }),
profileOption({ id: "p-legacy", label: "Agent • p-legacy" }),
]),
);
const ids = result.current.map((o) => o.value);
@@ -76,7 +140,56 @@ describe("useAgentProfileOptions", () => {
});
it("keeps profiles whose enabled flag is absent (legacy options)", () => {
const { result } = renderHook(() => useAgentProfileOptions([profileOption("p-legacy")]));
const { result } = renderHook(() =>
useAgentProfileOptions([profileOption({ id: "p-legacy", label: "Agent • p-legacy" })]),
);
expect(result.current.map((o) => o.value)).toEqual(["p-legacy"]);
});
});
describe("useAgentProfileOptions no-silent-model-fallback gating", () => {
it("blocks a profile whose start model is gone (strict mode)", () => {
const option = renderOptions([profileOption({ model: GONE_MODEL })]);
expect(option.getAttribute(DATA_DISABLED)).toBe("true");
expect(option.getAttribute("data-reason")).toContain(GONE_MODEL);
});
it("keeps a profile with an available start model selectable", () => {
const option = renderOptions([profileOption({ model: "gpt-5" })]);
expect(option.getAttribute(DATA_DISABLED)).toBeNull();
});
it("keeps a profile with an empty (agent default) model selectable", () => {
const option = renderOptions([profileOption({ model: "" })]);
expect(option.getAttribute(DATA_DISABLED)).toBeNull();
});
it("keeps a gone-model profile with a fallback selectable and warns", () => {
const option = renderOptions([profileOption({ model: GONE_MODEL, fallback_model: "gpt-5" })]);
expect(option.getAttribute(DATA_DISABLED)).toBeNull();
expect(option.getAttribute("data-reason")).toBeNull();
// The amber warning icon carries the fallback note.
expect(screen.getByTitle(/gpt-5/)).not.toBeNull();
});
it("blocks a profile whose fallback model is also gone (both-gone)", () => {
const option = renderOptions([
profileOption({ model: GONE_MODEL, fallback_model: "other-gone" }),
]);
expect(option.getAttribute(DATA_DISABLED)).toBe("true");
expect(option.getAttribute("data-reason")).toContain(GONE_MODEL);
});
it("keeps a gone-model profile with auto-fallback selectable without a warning", () => {
const option = renderOptions([profileOption({ model: GONE_MODEL, auto_fallback: true })]);
expect(option.getAttribute(DATA_DISABLED)).toBeNull();
expect(screen.queryByTitle(/claude-gone/)).toBeNull();
});
it("does not gate when the agent model list is unknown (probe not landed)", () => {
setAvailableAgents([]);
const option = renderOptions([profileOption({ model: GONE_MODEL })]);
expect(option.getAttribute(DATA_DISABLED)).toBeNull();
});
});
@@ -1,7 +1,9 @@
"use client";
import { useMemo } from "react";
import { IconGitBranch, IconTerminal2 } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
import { IconAlertTriangle, IconGitBranch, IconTerminal2 } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow";
import type {
@@ -11,15 +13,15 @@ import type {
Executor,
ExecutorProfile,
} from "@/lib/types/http";
import type { AvailableAgent } from "@/lib/types/http-agents";
import type { AgentProfileOption } from "@/lib/state/slices";
import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents";
import { isSelectableAgentProfile } from "@/lib/state/slices/settings/types";
import { formatUserHomePath, truncateRepoPath } from "@/lib/utils";
import { getExecutorIcon } from "@/lib/executor-icons";
import { AgentLogo } from "@/components/agent-logo";
import { getCapabilityWarning } from "@/lib/capability-warning";
import { buildBranchKeywords } from "./task-create-dialog-pill";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
type OptionItem = {
value: string;
@@ -120,8 +122,16 @@ export function useBranchOptions(branchOptionsRaw: Branch[]) {
}, [branchOptionsRaw]);
}
// advertisedModelIDs returns the currently advertised model IDs for an agent
// from the host-utility probe cache (empty when the probe has not landed).
function advertisedModelIDs(availableAgents: AvailableAgent[], agentName: string): string[] {
const agent = availableAgents.find((a) => a.name === agentName);
return agent?.model_config?.available_models?.map((m) => m.id) ?? [];
}
export function useAgentProfileOptions(agentProfiles: AgentProfileOption[]): OptionItem[] {
const { t } = useTranslation();
const { items: availableAgents } = useAvailableAgents();
return useMemo(() => {
// Disabled profiles stay in the store (existing sessions keep their
// labels) but are never offered as a choice for new work.
@@ -132,36 +142,86 @@ export function useAgentProfileOptions(agentProfiles: AgentProfileOption[]): Opt
const profileLabel = parts[1] ?? "";
const isPassthrough = profile.cli_passthrough === true;
const warning = getCapabilityWarning(profile.capability_status, profile.capability_error);
// No-silent-model-fallback: a profile whose start model is no longer
// advertised ("gone") is blocked from selection unless it opted into
// an explicit fallback model (shown as a warning) or the legacy
// auto-fallback toggle.
//
// The advertised list is the host-utility probe cache. While it has
// not loaded yet (empty list) gone-ness is unknown and deliberately
// NOT assumed: nothing is blocked and no warning is shown. The backend
// rejects the launch if the model is genuinely unavailable, so this
// startup window is cosmetic. A fallback model that is itself gone
// does not make the profile selectable — it would promise a switch
// SetModel cannot apply, so the profile is blocked like strict mode.
const advertised = advertisedModelIDs(availableAgents, profile.agent_name);
const startModelGone = Boolean(
profile.model && advertised.length > 0 && !advertised.includes(profile.model),
);
const fallbackGone = Boolean(
profile.fallback_model &&
advertised.length > 0 &&
!advertised.includes(profile.fallback_model),
);
const autoFallbackOn = profile.auto_fallback === true;
const fallbackUsable = Boolean(profile.fallback_model) && !fallbackGone;
const blocked = startModelGone && !autoFallbackOn && !fallbackUsable;
const fallbackWarning = startModelGone && !autoFallbackOn && fallbackUsable;
const disabledReason = blocked
? t("settings:profileStartModelUnavailable", { model: profile.model })
: undefined;
const fallbackNote = fallbackWarning
? t("settings:profileFallbackWillBeUsed", {
model: profile.model,
fallback: profile.fallback_model,
})
: undefined;
return {
value: profile.id,
label: profile.label,
disabled: blocked || undefined,
disabledReason,
renderLabel: () => (
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
<span className="flex shrink-0 items-center gap-1.5">
<AgentLogo agentName={profile.agent_name} className="shrink-0" />
<span>{agentLabel}</span>
{warning && (
<warning.Icon className={`size-3.5 ${warning.color}`} title={warning.title} />
)}
</span>
<span className="flex shrink-0 items-center gap-1.5">
{isPassthrough && (
<IconTerminal2
className="size-3.5 text-muted-foreground"
title={t("common:cliModeYourPromptWillBe")}
/>
)}
{profileLabel ? (
<ScrollOnOverflow className="rounded-full border border-border px-2 py-0.5 text-xs">
{profileLabel}
</ScrollOnOverflow>
) : null}
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex shrink-0 items-center justify-between gap-2">
<span className="flex shrink-0 items-center gap-1.5">
<AgentLogo agentName={profile.agent_name} className="shrink-0" />
<span>{agentLabel}</span>
{warning && (
<warning.Icon className={`size-3.5 ${warning.color}`} title={warning.title} />
)}
{fallbackNote && (
<IconAlertTriangle
className="size-3.5 shrink-0 text-amber-500"
title={fallbackNote}
/>
)}
</span>
<span className="flex shrink-0 items-center gap-1.5">
{isPassthrough && (
<IconTerminal2
className="size-3.5 text-muted-foreground"
title={t("common:cliModeYourPromptWillBe")}
/>
)}
{profileLabel ? (
<ScrollOnOverflow className="rounded-full border border-border px-2 py-0.5 text-xs">
{profileLabel}
</ScrollOnOverflow>
) : null}
</span>
</span>
{fallbackNote && (
<span className="flex items-center gap-1.5 pl-1 text-xs text-amber-600">
<IconAlertTriangle className="size-3 shrink-0" aria-hidden />
{fallbackNote}
</span>
)}
</span>
),
};
});
}, [agentProfiles]);
}, [agentProfiles, availableAgents, t]);
}
export function useExecutorOptions(executors: Executor[]): OptionItem[] {
@@ -4,7 +4,13 @@ import {
type ModelSelectorOption,
usableConfigOptions,
} from "@/components/model-config-selector";
import { hasCompleteDynamicConfig, requiredConfigKeys } from "@/components/task/model-selector";
import {
annotateFallbackOptions,
buildModelOptions,
hasCompleteDynamicConfig,
requiredConfigKeys,
} from "@/components/task/model-selector";
import { t } from "@/lib/i18n";
import type { Agent, TaskSession } from "@/lib/types/http";
import type {
ConfigOptionEntry,
@@ -279,3 +285,62 @@ describe("hasCompleteDynamicConfig", () => {
expect(hasCompleteDynamicConfig(makeSession(), undefined, noAgents)).toBe(true);
});
});
describe("buildModelOptions (gone current model)", () => {
it("keeps a configured-but-unadvertised model visible and disabled with a translated reason", () => {
const options = buildModelOptions([{ id: "gpt-5", name: "GPT-5" }], "claude-gone");
expect(options).toHaveLength(2);
const gone = options[0];
expect(gone.id).toBe("claude-gone");
expect(gone.disabled).toBe(true);
expect(gone.disabledReason).toBe(t("settings:startModelUnavailable"));
});
it("does not duplicate the current model when it is still advertised", () => {
const options = buildModelOptions([{ id: "gpt-5", name: "GPT-5" }], "gpt-5");
expect(options).toHaveLength(1);
expect(options[0].disabled).toBeUndefined();
});
});
describe("annotateFallbackOptions", () => {
const fallbackSuffix = t("settings:modelFallbackSuffix");
const modelId = "gpt-5";
const modelName = "GPT-5";
const fallbackId = "gpt-5-mini";
const fallbackName = "GPT-5-Mini";
const base = [
{ id: modelId, name: modelName },
{ id: fallbackId, name: fallbackName },
];
it("returns the same array when no fallback is active (clears stale markers)", () => {
expect(annotateFallbackOptions(base, undefined)).toBe(base);
expect(annotateFallbackOptions(base, "")).toBe(base);
});
it("annotates only the fallback option with the localized suffix", () => {
const annotated = annotateFallbackOptions(base, fallbackId);
expect(annotated[0]).toBe(base[0]);
expect(annotated[1]).not.toBe(base[1]);
expect(annotated[1].name).toBe(`${fallbackName} ${fallbackSuffix}`);
expect(annotated[1].id).toBe(fallbackId);
});
it("preserves immutability of source options", () => {
const annotated = annotateFallbackOptions(base, modelId);
expect(base[0].name).toBe(modelName);
expect(annotated[0].name).toBe(`${modelName} ${fallbackSuffix}`);
// The source array and its objects are untouched.
expect(base).toEqual([
{ id: modelId, name: modelName },
{ id: fallbackId, name: fallbackName },
]);
});
it("does not double-append the suffix (idempotent)", () => {
const once = annotateFallbackOptions(base, modelId);
const twice = annotateFallbackOptions(once, modelId);
expect(twice[0].name).toBe(`${modelName} ${fallbackSuffix}`);
});
});
+101 -23
View File
@@ -1,6 +1,8 @@
"use client";
import { memo, useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
import {
configOptionToModelOptions,
@@ -21,14 +23,13 @@ import type {
ConfigOptionEntry,
SessionModelEntry,
} from "@/lib/state/slices/session-runtime/types";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
type SessionModelsEntry = {
currentModelId: string;
models: SessionModelEntry[];
configOptions: ConfigOptionEntry[];
configBaseline?: Record<string, string>;
/** Set when the session started on the profile's fallback model. */
fallbackModel?: string;
};
type ModelSelectorProps = {
@@ -137,13 +138,39 @@ function sessionModelsToOptions(models: SessionModelEntry[]): ModelSelectorOptio
}));
}
function buildModelOptions(
// annotateFallbackOptions marks the fallback model so the user sees the
// session is not on the configured start model. It returns NEW option objects
// (never mutating the input or shared store state) and reuses the same
// translated suffix as the trigger label so the option and trigger cannot
// disagree. Returns the same array when no fallback note is active.
export function annotateFallbackOptions(
options: ModelSelectorOption[],
fallbackModel: string | undefined,
): ModelSelectorOption[] {
if (!fallbackModel) return options;
const suffix = t("settings:modelFallbackSuffix");
return options.map((option) =>
option.id === fallbackModel && !option.name.endsWith(suffix)
? { ...option, name: `${option.name} ${suffix}` }
: option,
);
}
export function buildModelOptions(
availableModels: ModelSelectorOption[],
currentModel: string | null,
): ModelSelectorOption[] {
const options = [...availableModels];
if (currentModel && !options.some((m) => m.id === currentModel)) {
options.unshift({ id: currentModel, name: currentModel });
// The configured/active model is no longer advertised ("gone"). Keep it
// visible but greyed out so the user is asked to pick a replacement —
// never silently drop it.
options.unshift({
id: currentModel,
name: currentModel,
disabled: true,
disabledReason: t("settings:startModelUnavailable"),
});
}
return options;
}
@@ -227,8 +254,15 @@ function useModelChangeHandlers(
const updateLocalConfig = useCallback(
(sid: string, configId: string, value: string) => {
if (!sessionModelsData) return;
// A manual model change ends the fallback story — drop the note so the
// picker stops labelling the model as a fallback.
const fallbackModel =
configId === sessionModelsData.configOptions.find(isModelConfigOption)?.id
? undefined
: sessionModelsData.fallbackModel;
setSessionModels(sid, {
...sessionModelsData,
fallbackModel,
currentModelId: nextCurrentModelId(sessionModelsData, configId, value),
configOptions: updateConfigOptionValue(sessionModelsData.configOptions, configId, value),
});
@@ -274,6 +308,12 @@ function useModelChangeHandlers(
setSessionConfigOption(sid, modelConfig.id, modelId).catch(fail);
return;
}
// Non-config path: the next models_updated event would preserve the
// fallback note (see session-models handler) — clear it locally now
// that the user picked a model explicitly.
if (sessionModelsData) {
setSessionModels(sid, { ...sessionModelsData, fallbackModel: undefined });
}
setSessionModel(sid, modelId).catch(fail);
},
[
@@ -300,6 +340,43 @@ function useModelChangeHandlers(
return { handleModelChange, handleConfigChange };
}
// resolveModelSelectorInputs derives the model list, config options and
// current model from store state, independent of the hook lifecycle.
function resolveModelSelectorInputs({
session,
sessionModelsData,
activeModel,
settingsAgents,
availableAgents,
profileModel,
}: {
session: TaskSession | null;
sessionModelsData: SessionModelsEntry | undefined;
activeModel: string | null;
settingsAgents: Agent[];
availableAgents: AvailableAgent[];
profileModel: string | null;
}) {
const usingAcpModels = !!sessionModelsData?.models?.length;
const configOptions = usableConfigOptions(sessionModelsData?.configOptions);
const modelConfig = configOptions.find(isModelConfigOption);
const availableModels = resolveAvailableModels({
modelConfig,
usingAcpModels,
sessionModels: sessionModelsData?.models ?? [],
settingsAgents,
profileId: session?.agent_profile_id,
availableAgents,
});
const currentModel = resolveCurrentModel(
activeModel,
sessionModelsData?.currentModelId || null,
resolveSnapshotModel(session?.agent_profile_snapshot),
profileModel,
);
return { configOptions, currentModel, availableModels };
}
/** Resolves available models, config options and current model from store state. */
function useModelSelectorState(sessionId: string | null) {
useSettingsData(true);
@@ -317,32 +394,27 @@ function useModelSelectorState(sessionId: string | null) {
activeModels,
selectedSessionModels,
);
const snapshotModel = resolveSnapshotModel(session?.agent_profile_snapshot);
const profileModel = useMemo(
() => resolveProfileModel(session?.agent_profile_id, settingsAgents as Agent[]),
[session?.agent_profile_id, settingsAgents],
);
const usingAcpModels = !!sessionModelsData?.models?.length;
const configOptions = usableConfigOptions(sessionModelsData?.configOptions);
const modelConfig = configOptions.find(isModelConfigOption);
const availableModels = resolveAvailableModels({
modelConfig,
usingAcpModels,
sessionModels: sessionModelsData?.models ?? [],
settingsAgents: settingsAgents as Agent[],
profileId: session?.agent_profile_id,
availableAgents,
});
const acpCurrentModel = sessionModelsData?.currentModelId || null;
const currentModel = resolveCurrentModel(
const { configOptions, currentModel, availableModels } = resolveModelSelectorInputs({
session,
sessionModelsData,
activeModel,
acpCurrentModel,
snapshotModel,
settingsAgents: settingsAgents as Agent[],
availableAgents,
profileModel,
});
const modelOptions = useMemo(
() =>
annotateFallbackOptions(
buildModelOptions(availableModels, currentModel),
sessionModelsData?.fallbackModel,
),
[availableModels, currentModel, sessionModelsData?.fallbackModel],
);
const modelOptions = buildModelOptions(availableModels, currentModel);
const { handleModelChange, handleConfigChange } = useModelChangeHandlers(
configOptions,
@@ -354,6 +426,7 @@ function useModelSelectorState(sessionId: string | null) {
modelOptions,
configOptions,
configBaseline: sessionModelsData?.configBaseline,
fallbackModel: sessionModelsData?.fallbackModel ?? null,
configHydrated: hasCompleteDynamicConfig(session, sessionModelsData, settingsAgents as Agent[]),
requiredKeys: requiredConfigKeys(session, settingsAgents as Agent[]),
rawConfigOptionIds: (sessionModelsData?.configOptions ?? []).map((o) => o.id),
@@ -372,6 +445,7 @@ export const ModelSelector = memo(function ModelSelector({
modelOptions,
configOptions,
configBaseline,
fallbackModel,
configHydrated,
requiredKeys,
rawConfigOptionIds,
@@ -379,6 +453,9 @@ export const ModelSelector = memo(function ModelSelector({
handleConfigChange,
} = useModelSelectorState(sessionId);
const modelConfig = configOptions.find(isModelConfigOption);
// Explicit "using fallback" signal: annotate the trigger so the user sees
// the session is not on the configured start model.
const currentModelSuffix = fallbackModel ? ` ${t("settings:modelFallbackSuffix")}` : undefined;
const onModelChange = useCallback(
(value: string) => {
@@ -425,6 +502,7 @@ export const ModelSelector = memo(function ModelSelector({
triggerClassName={triggerClassName}
triggerSummary="changed"
configBaseline={configBaseline}
currentModelSuffix={currentModelSuffix}
/>
);
});
+4
View File
@@ -554,6 +554,8 @@ export class ApiClient {
name: string,
opts: {
model: string;
fallback_model?: string;
auto_fallback?: boolean;
mode?: string;
config_options?: Record<string, string>;
cli_passthrough?: boolean;
@@ -565,6 +567,8 @@ export class ApiClient {
const response = await this.request<unknown>("POST", `/api/v1/agents/${agentId}/profiles`, {
name,
model: opts.model,
fallback_model: opts.fallback_model,
auto_fallback: opts.auto_fallback,
mode: opts.mode,
config_options: opts.config_options,
cli_passthrough: opts.cli_passthrough ?? false,
@@ -0,0 +1,120 @@
import { test, expect } from "../../fixtures/test-base";
import { useRegularMode } from "../../helpers/regular-mode";
import { KanbanPage } from "../../pages/kanban-page";
// Mobile companion to no-silent-model-fallback.spec.ts: the task-create
// picker's fallback explanation must be discoverable on touch — visible
// secondary text, not a hover-only tooltip (icon title).
const GONE_MODEL = "claude-gone";
useRegularMode();
test.describe("No silent model fallback on mobile", () => {
test("profile fallback settings stack and open help in a touch drawer", async ({
testPage,
apiClient,
}) => {
test.setTimeout(60_000);
const { agents } = await apiClient.listAgents();
const agent = agents[0];
if (!agent) throw new Error("no agents available");
const profile = await apiClient.createAgentProfile(agent.id, "Mobile Fallback Settings", {
model: "mock-fast",
fallback_model: "mock-smart",
});
try {
await testPage.goto(`/settings/agents/${agent.name}/profiles/${profile.id}`);
const fallbackTrigger = testPage.getByTestId("profile-fallback-settings-trigger");
await expect(fallbackTrigger).toBeVisible({ timeout: 15_000 });
await expect(fallbackTrigger).toHaveAttribute("aria-expanded", "false");
await expect(testPage.getByTestId("profile-fallback-settings-summary")).toContainText(
"Explicit fallback: mock-smart",
);
await fallbackTrigger.tap();
const autoOption = testPage.getByTestId("profile-auto-fallback-option");
const explicitOption = testPage.getByTestId("profile-explicit-fallback-option");
await expect(autoOption).toBeVisible();
await expect(explicitOption).toBeVisible();
const autoBox = await autoOption.boundingBox();
const explicitBox = await explicitOption.boundingBox();
expect(autoBox).not.toBeNull();
expect(explicitBox).not.toBeNull();
if (!autoBox || !explicitBox) throw new Error("fallback option cards are not laid out");
expect(explicitBox.y).toBeGreaterThan(autoBox.y + 8);
const helpButton = testPage.getByTestId("profile-automatic-fallback-help");
const helpBox = await helpButton.boundingBox();
expect(helpBox).not.toBeNull();
if (!helpBox) throw new Error("automatic fallback help button is not laid out");
expect(helpBox.width).toBeGreaterThanOrEqual(44);
expect(helpBox.height).toBeGreaterThanOrEqual(44);
// The disclosure finishes a height transition when its children mount;
// keep the interaction touch-native while bypassing that transient
// stability window.
await helpButton.tap({ force: true });
const helpDrawer = testPage.getByRole("dialog");
await expect(helpDrawer).toContainText("Automatic fallback");
await expect(helpDrawer).toContainText(/remaining advertised models/);
await testPage.keyboard.press("Escape");
await expect(helpDrawer).toBeHidden();
const overflow = await testPage.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth);
} finally {
await apiClient.deleteAgentProfile(profile.id, true);
}
});
test("task-create picker shows the fallback note as visible text on touch", async ({
testPage,
apiClient,
}) => {
test.setTimeout(90_000);
const { agents } = await apiClient.listAgents();
const agent = agents[0];
if (!agent) throw new Error("no agents available");
const fallbackProfile = await apiClient.createAgentProfile(agent.id, "Gone Fallback Mobile", {
model: GONE_MODEL,
fallback_model: "mock-fast",
});
try {
// Mobile kanban opens the create-task dialog from the floating action
// button, not the desktop sidebar button.
const kanban = new KanbanPage(testPage);
await kanban.goto();
const fab = testPage.getByTestId("mobile-fab");
await expect(fab).toBeVisible({ timeout: 15_000 });
await fab.tap();
const dialog = testPage.getByTestId("create-task-dialog");
await expect(dialog).toBeVisible();
await testPage.getByTestId("task-title-input").fill("Gone model picker mobile test");
await testPage.getByTestId("task-description-input").fill("verify visible fallback note");
const selector = dialog.getByTestId("agent-profile-selector");
await selector.tap();
const fallbackOption = testPage.getByRole("option", { name: /Gone Fallback Mobile/ });
await expect(fallbackOption).toBeVisible({ timeout: 15_000 });
await expect(fallbackOption).not.toHaveAttribute("aria-disabled", "true");
// The fallback explanation must be readable without hover: the
// picker renders it as visible secondary text inside the option.
await expect(fallbackOption).toContainText(/mock-fast/);
await expect(fallbackOption).toContainText(/no longer available/);
} finally {
await apiClient.deleteAgentProfile(fallbackProfile.id, true);
}
});
});
@@ -0,0 +1,176 @@
import { test, expect } from "../../fixtures/test-base";
import { useRegularMode } from "../../helpers/regular-mode";
import { KanbanPage } from "../../pages/kanban-page";
// The mock agent advertises `mock-fast` / `mock-smart`; any other model id
// is "gone" (configured but no longer advertised) — the scenario this
// feature makes explicit instead of silently falling back.
const GONE_MODEL = "claude-gone";
useRegularMode();
test.describe("No silent model fallback", () => {
test("profile editor explains and persists the fallback settings disclosure", async ({
testPage,
apiClient,
}) => {
test.setTimeout(60_000);
const { agents } = await apiClient.listAgents();
const agent = agents[0];
if (!agent) throw new Error("no agents available");
const profile = await apiClient.createAgentProfile(agent.id, "Gone Start Model", {
model: GONE_MODEL,
});
try {
await testPage.goto(`/settings/agents/${agent.name}/profiles/${profile.id}`);
// The start-model trigger keeps the gone model visible and red.
const trigger = testPage.getByRole("button", { name: "Profile start model settings" });
await expect(trigger).toBeVisible({ timeout: 15_000 });
await expect(trigger).toContainText(GONE_MODEL);
await expect(trigger).toHaveClass(/text-destructive/);
const fallbackSettings = testPage.getByTestId("profile-fallback-settings");
const fallbackTrigger = testPage.getByTestId("profile-fallback-settings-trigger");
await expect(fallbackTrigger).toBeVisible({ timeout: 10_000 });
await expect(fallbackTrigger).toHaveAttribute("aria-expanded", "false");
await expect(testPage.getByTestId("profile-fallback-settings-summary")).toContainText(
"No fallback configured",
);
// The fallback options are intentionally hidden until the user opens
// the disclosure.
await expect(testPage.getByTestId("profile-fallback-model-field")).toBeHidden();
await fallbackTrigger.click();
await expect(fallbackSettings).toHaveAttribute("data-state", "open");
const fallbackField = testPage.getByTestId("profile-fallback-model-field");
await expect(fallbackField).toBeVisible({ timeout: 10_000 });
// Desktop keeps the two options on one row.
const autoOption = testPage.getByTestId("profile-auto-fallback-option");
const explicitOption = testPage.getByTestId("profile-explicit-fallback-option");
const autoBox = await autoOption.boundingBox();
const explicitBox = await explicitOption.boundingBox();
expect(autoBox).not.toBeNull();
expect(explicitBox).not.toBeNull();
if (!autoBox || !explicitBox) throw new Error("fallback option cards are not laid out");
expect(Math.abs(autoBox.y - explicitBox.y)).toBeLessThan(8);
expect(explicitBox.x).toBeGreaterThan(autoBox.x);
// Fine-pointer users can focus or hover the info buttons for the
// detailed explanation.
const automaticHelp = testPage.getByTestId("profile-automatic-fallback-help");
await automaticHelp.focus();
await expect(testPage.getByRole("tooltip")).toContainText(/remaining advertised models/);
await testPage.keyboard.press("Escape");
// The fallback model is optional: without a value its selector stays
// hidden behind the attached switch, and opting in reveals it.
const fallbackSelector = testPage.getByRole("button", {
name: "Agent fallback model settings",
});
await expect(fallbackSelector).toBeHidden({ timeout: 10_000 });
await fallbackField.getByRole("switch", { name: "Agent fallback" }).click();
await expect(fallbackSelector).toBeVisible({ timeout: 10_000 });
await fallbackField.getByRole("switch", { name: "Agent fallback" }).click();
await expect(fallbackSelector).toBeHidden({ timeout: 10_000 });
// Enabling automatic fallback keeps the explicit value visible but
// disables its controls so it cannot be edited accidentally.
await fallbackField.getByRole("switch", { name: "Agent fallback" }).click();
await fallbackSelector.click();
await testPage.getByRole("option", { name: /Mock Fast/ }).click();
await expect(fallbackSelector).toContainText("Mock Fast");
const toggle = testPage.getByTestId("profile-auto-fallback-field").getByRole("switch");
await toggle.click();
await expect(fallbackField).toBeVisible({ timeout: 10_000 });
await expect(fallbackField.getByRole("switch", { name: "Agent fallback" })).toBeDisabled();
await expect(
fallbackField.getByRole("button", { name: "Agent fallback model settings" }),
).toBeDisabled();
await expect(testPage.getByTestId("profile-fallback-settings-summary")).toContainText(
"Automatic fallback enabled",
);
// The gone model survives a save (no auto-heal on save).
const saveButton = testPage.getByRole("button", { name: /^Save( changes)?$/i }).first();
await expect(saveButton).toBeEnabled({ timeout: 10_000 });
await saveButton.click();
await expect(testPage.getByText(/unsaved changes/i)).toBeHidden({ timeout: 15_000 });
const savedProfile = await apiClient.getAgentProfile(profile.id);
expect(savedProfile.fallbackModel).toBe("mock-fast");
expect(savedProfile.autoFallback).toBe(true);
await testPage.reload();
await expect(testPage.getByTestId("profile-fallback-settings-trigger")).toHaveAttribute(
"aria-expanded",
"false",
);
await expect(testPage.getByTestId("profile-fallback-settings-summary")).toContainText(
"Automatic fallback enabled",
);
await testPage.getByTestId("profile-fallback-settings-trigger").click();
await expect(
testPage
.getByTestId("profile-fallback-model-field")
.getByRole("button", { name: "Agent fallback model settings" }),
).toBeDisabled();
} finally {
await apiClient.deleteAgentProfile(profile.id, true);
}
});
test("task-create picker blocks a strict-gone profile and warns for a fallback profile", async ({
testPage,
apiClient,
}) => {
test.setTimeout(90_000);
const { agents } = await apiClient.listAgents();
const agent = agents[0];
if (!agent) throw new Error("no agents available");
const strictProfile = await apiClient.createAgentProfile(agent.id, "Gone Strict", {
model: GONE_MODEL,
});
const fallbackProfile = await apiClient.createAgentProfile(agent.id, "Gone Fallback", {
model: GONE_MODEL,
fallback_model: "mock-fast",
});
try {
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.createTaskButton.first().click();
const dialog = testPage.getByTestId("create-task-dialog");
await expect(dialog).toBeVisible();
await testPage.getByTestId("task-title-input").fill("Gone model picker test");
await testPage.getByTestId("task-description-input").fill("verify gating");
// Open the agent profile dropdown.
const selector = dialog.getByTestId("agent-profile-selector");
await selector.click();
// Strict-gone profile: greyed out and unselectable.
const strictOption = testPage.getByRole("option", { name: /Gone Strict/ });
await expect(strictOption).toBeVisible({ timeout: 15_000 });
await expect(strictOption).toHaveAttribute("aria-disabled", "true");
// Fallback profile: selectable and carries the "fallback will be used"
// warning as visible secondary text (touch-discoverable, not just a
// hover tooltip) plus the alert icon.
const fallbackOption = testPage.getByRole("option", { name: /Gone Fallback/ });
await expect(fallbackOption).toBeVisible({ timeout: 15_000 });
await expect(fallbackOption).not.toHaveAttribute("aria-disabled", "true");
await expect(fallbackOption.getByRole("img", { name: /mock-fast/ })).toBeVisible();
await expect(fallbackOption).toContainText(/no longer available/);
} finally {
await apiClient.deleteAgentProfile(strictProfile.id, true);
await apiClient.deleteAgentProfile(fallbackProfile.id, true);
}
});
});
@@ -7,45 +7,50 @@ const SAMPLE_ID = "p1";
const SAMPLE_PREFIX = "greywall --";
const WORKSPACE_ID = "workspace-1";
const snakeCaseWirePayload = {
id: SAMPLE_ID,
agent_id: "claude",
name: "default",
agent_display_name: "Claude Code",
model: "claude-sonnet-4-5",
mode: "acp",
allow_indexing: true,
auto_approve: false,
cli_flags: [{ flag: "--verbose", description: "v", enabled: true }],
env_vars: [sampleEnvVar],
cli_passthrough: false,
enabled: false,
workspace_id: WORKSPACE_ID,
user_modified: true,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T00:00:00Z",
};
const expectedCamelCaseProfile = {
id: SAMPLE_ID,
name: "default",
agentId: "claude",
agentDisplayName: "Claude Code",
model: "claude-sonnet-4-5",
fallbackModel: "",
autoFallback: false,
mode: "acp",
allowIndexing: true,
autoApprove: false,
cliFlags: [{ flag: "--verbose", description: "v", enabled: true }],
envVars: [sampleEnvVar],
cliPassthrough: false,
enabled: false,
workspaceId: WORKSPACE_ID,
userModified: true,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-02T00:00:00Z",
};
describe("normalizeAgentProfile", () => {
it("converts snake_case wire payload to canonical camelCase", () => {
const wire = {
id: SAMPLE_ID,
agent_id: "claude",
name: "default",
agent_display_name: "Claude Code",
model: "claude-sonnet-4-5",
mode: "acp",
allow_indexing: true,
auto_approve: false,
cli_flags: [{ flag: "--verbose", description: "v", enabled: true }],
env_vars: [sampleEnvVar],
cli_passthrough: false,
enabled: false,
workspace_id: WORKSPACE_ID,
user_modified: true,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T00:00:00Z",
};
const result = normalizeAgentProfile(wire);
expect(result).toEqual({
id: SAMPLE_ID,
name: "default",
agentId: "claude",
agentDisplayName: "Claude Code",
model: "claude-sonnet-4-5",
mode: "acp",
allowIndexing: true,
autoApprove: false,
cliFlags: [{ flag: "--verbose", description: "v", enabled: true }],
envVars: [sampleEnvVar],
cliPassthrough: false,
enabled: false,
workspaceId: WORKSPACE_ID,
userModified: true,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-02T00:00:00Z",
});
const result = normalizeAgentProfile(snakeCaseWirePayload);
expect(result).toEqual(expectedCamelCaseProfile);
});
it("falls back to safe defaults for missing fields", () => {
@@ -114,6 +119,23 @@ describe("normalizeAgentProfile", () => {
});
expect(objectResult.commandPrefix).toBeUndefined();
});
it("maps fallback_model/auto_fallback from the wire shape", () => {
const result = normalizeAgentProfile({
id: SAMPLE_ID,
name: "default",
fallback_model: "gpt-5",
auto_fallback: true,
});
expect(result.fallbackModel).toBe("gpt-5");
expect(result.autoFallback).toBe(true);
});
it("defaults fallbackModel to empty and autoFallback to false when absent", () => {
const result = normalizeAgentProfile({ id: SAMPLE_ID, name: "default" });
expect(result.fallbackModel).toBe("");
expect(result.autoFallback).toBe(false);
});
});
describe("toAgentProfilePayload", () => {
@@ -151,6 +173,21 @@ describe("toAgentProfilePayload", () => {
expect(payload).toEqual({ id: SAMPLE_ID, name: "default", command_prefix: SAMPLE_PREFIX });
});
it("maps fallbackModel/autoFallback to fallback_model/auto_fallback", () => {
const payload = toAgentProfilePayload({
id: toAgentProfileId(SAMPLE_ID),
name: "default",
fallbackModel: "gpt-5",
autoFallback: true,
});
expect(payload).toEqual({
id: SAMPLE_ID,
name: "default",
fallback_model: "gpt-5",
auto_fallback: true,
});
});
it("maps enabled and omits it when undefined", () => {
const disabled = toAgentProfilePayload({ id: toAgentProfileId(SAMPLE_ID), enabled: false });
expect(disabled.enabled).toBe(false);
@@ -60,6 +60,8 @@ export function normalizeAgentProfile(raw: unknown): AgentProfile {
agentId: pickString(profile, "agentId", "agent_id"),
agentDisplayName: pickString(profile, "agentDisplayName", "agent_display_name"),
model: pickString(profile, "model", "model"),
fallbackModel: pickString(profile, "fallbackModel", "fallback_model"),
autoFallback: pickBool(profile, "autoFallback", "auto_fallback"),
mode: (profile.mode as string | undefined) ?? undefined,
configOptions: pickConfigOptions(profile),
allowIndexing: pickBool(profile, "allowIndexing", "allow_indexing"),
@@ -103,6 +105,8 @@ export function toAgentProfilePayload(
setPayloadField(payload, "agent_id", profile.agentId);
setPayloadField(payload, "agent_display_name", profile.agentDisplayName);
setPayloadField(payload, "model", profile.model);
setPayloadField(payload, "fallback_model", profile.fallbackModel);
setPayloadField(payload, "auto_fallback", profile.autoFallback);
setPayloadField(payload, "mode", profile.mode);
setPayloadField(payload, "config_options", profile.configOptions);
setPayloadField(payload, "allow_indexing", profile.allowIndexing);
@@ -251,6 +251,8 @@ export type SessionModelsState = {
models: SessionModelEntry[];
configOptions: ConfigOptionEntry[];
configBaseline?: Record<string, string>;
/** Set when the session started on the profile's fallback model. */
fallbackModel?: string;
}
>;
};
@@ -468,6 +470,9 @@ export type SessionRuntimeSliceActions = {
models: SessionModelEntry[];
configOptions: ConfigOptionEntry[];
configBaseline?: Record<string, string>;
/** Set when the session started on the profile's fallback model
* because the configured start model was unavailable. */
fallbackModel?: string;
},
) => void;
setSessionMCPStatus: (sessionId: string, history: MCPAttachmentHistory) => void;
@@ -53,6 +53,12 @@ export type AgentProfileOption = {
agent_id: string;
agent_name: string;
cli_passthrough: boolean;
/** Configured start model (ACP model ID). Empty = agent default. */
model?: string;
/** Optional explicit fallback model; ignored when auto_fallback is on. */
fallback_model?: string;
/** Legacy automatic-fallback opt-in. */
auto_fallback?: boolean;
workspace_id?: string;
/**
* False hides the profile from task/session creation pickers. Existing
@@ -78,6 +84,9 @@ export function toAgentProfileOption(
agent: Pick<Agent, "id" | "name" | "capability_status" | "capability_error">,
profile: Pick<AgentProfile, "id" | "agentDisplayName" | "name" | "workspaceId"> & {
cliPassthrough?: boolean;
model?: string;
fallbackModel?: string;
autoFallback?: boolean;
enabled?: boolean;
},
): AgentProfileOption {
@@ -87,6 +96,9 @@ export function toAgentProfileOption(
agent_id: agent.id,
agent_name: agent.name,
cli_passthrough: profile.cliPassthrough ?? false,
model: profile.model ?? undefined,
fallback_model: profile.fallbackModel ?? undefined,
auto_fallback: profile.autoFallback ?? undefined,
workspace_id: profile.workspaceId,
enabled: profile.enabled ?? true,
capability_status: agent.capability_status,
+2
View File
@@ -476,6 +476,8 @@ export type AppState = KanbanSlice & {
models: SessionModelEntry[];
configOptions: ConfigOptionEntry[];
configBaseline?: Record<string, string>;
/** Set when the session started on the profile's fallback model. */
fallbackModel?: string;
},
) => void;
// Prompt usage actions
+13
View File
@@ -63,6 +63,17 @@ export type AgentProfile = {
// --- CLI subprocess config ---
/** Model ID applied through ACP session model selection at session start. */
model: string;
/**
* Optional single ACP model ID the runtime switches to when the start
* model becomes unavailable. Ignored at runtime when autoFallback is on.
*/
fallbackModel?: string;
/**
* Explicit opt-in to the legacy automatic-fallback behavior (session-start
* best-effort, office re-dispatch to the next provider candidate). When
* on, the fallbackModel field is hidden/ignored.
*/
autoFallback?: boolean;
/** Optional ACP session mode applied via `session/set_mode`. */
mode?: string;
/** Dynamic ACP session config options applied via `session/set_config_option`. */
@@ -151,6 +162,8 @@ export type AgentProfilePayload = {
name: string;
agent_display_name: string;
model: string;
fallback_model?: string;
auto_fallback?: boolean;
mode?: string;
config_options?: Record<string, string>;
allow_indexing: boolean;
+26 -252
View File
@@ -1,4 +1,3 @@
import type { QueuedMessage } from "@/lib/state/slices/session/types";
import type { TaskPlanEventPayload, TaskPlanRevisionEventPayload } from "./task-plan-events";
import type { CaptureRequest } from "@/lib/logger/capture";
@@ -9,6 +8,8 @@ export type BackendMessageType = keyof BackendMessageMap;
export type { BackendMessage } from "./backend-message";
import type { BackendMessage } from "./backend-message";
import type { OfficeBackendMessageMap } from "./office-events";
import type { SessionBackendMessageMap } from "./session-events";
export type { SessionBackendMessageMap } from "./session-events";
export type { OfficeEventType, OfficeEventPayload } from "./office-events";
import type { RunEventAppendedPayload } from "./run-events";
export type { RunEventAppendedPayload } from "./run-events";
@@ -36,15 +37,6 @@ import type { TaskStatusSummary } from "@/lib/types/task-status-summary";
import type { TaskMRAutomationOptions } from "@/lib/types/gitlab";
import type { SystemMetricsSnapshot } from "./system";
import type { AgentRuntimeAvailability } from "./agent-runtime";
import type { FileChangeNotificationPayload } from "./workspace-files";
import type {
AgentCapabilitiesPayload,
SessionInfoPayload,
SessionModelsPayload,
SessionMCPStatusPayload,
SessionPromptUsagePayload,
SessionTodosPayload,
} from "./session-runtime-payloads";
import type {
ExecutorPayload,
ExecutorProfilePayload,
@@ -246,86 +238,11 @@ export type WorkflowStepEventPayload = {
step: StepPayload;
};
export type MessageAddedPayload = {
task_id: string;
message_id: string;
session_id: string;
turn_id?: string;
author_type: "user" | "agent";
author_id?: string;
content: string;
raw_content?: string;
type?: string;
metadata?: Record<string, unknown>;
requests_input?: boolean;
created_at: string;
updated_at?: string;
};
export type TaskSessionStateChangedPayload = {
task_id: string;
session_id: string;
old_state?: string;
new_state?: string;
/** Authoritative row timestamp — used to drop out-of-order subscribe snapshots. */
updated_at?: string;
/**
* Agent profile id — drives the per-agent live-session selectors on the
* sidebar. Empty for sessions launched without a profile.
*/
agent_profile_id?: string;
agent_profile_snapshot?: Record<string, unknown>;
metadata?: Record<string, unknown>;
session_metadata?: Record<string, unknown>;
is_passthrough?: boolean;
error_message?: string;
/** User-supplied session tab label; present (possibly "") on rename broadcasts. */
name?: string;
/** When true, the frontend should not show an error toast for this state change. */
suppress_toast?: boolean;
// Workflow-related fields (sent during workflow transitions)
review_status?: string;
// Task environment (for session→environment mapping)
task_environment_id?: string;
// Fine-grained busy substate (see ADR-0049), carried on coarse transitions;
// live flips arrive on session.activity_changed.
foreground_activity?: ForegroundActivity | null;
active_subagent_count?: number;
/** Backend-owned cancellation projection carried by state snapshots. */
cancellation_pending?: boolean;
/** Process-local cancellation transition generation carried by state snapshots. */
cancellation_revision?: number;
/** True when a send right now would steer the running turn; see http.ts. */
supports_steering?: boolean;
};
/**
* Payload for `session.activity_changed` — the fine-grained busy signal
* (see ADR-0049). Fires when foreground ownership or detached background
* liveness changes, including after the foreground turn settles.
*/
export type TaskSessionActivityChangedPayload = {
task_id: string;
session_id: string;
foreground_activity: ForegroundActivity | null;
active_subagent_count: number;
/** True when a send right now would steer the running turn; see http.ts. */
supports_steering?: boolean;
};
export type TaskSessionCancellationChangedPayload = {
session_id: string;
cancellation_pending: boolean;
cancellation_revision: number;
};
export type TaskSessionNotificationPayload = {
task_id: string;
session_id: string;
occurrence_id: string;
title: string;
body: string;
};
export type OfficeInboxItemNotificationPayload = {
task_id?: string;
@@ -334,24 +251,6 @@ export type OfficeInboxItemNotificationPayload = {
body: string;
};
export type TaskSessionAgentctlPayload = {
task_id: string;
session_id: string;
task_environment_id?: string;
agent_execution_id?: string;
error_message?: string;
worktree_id?: string;
worktree_path?: string;
worktree_branch?: string;
/** Effective task workspace root when the agentctl payload carries it. */
workspace_path?: string;
/** Task root that contains every per-repo worktree as a sibling subdir.
* Set only when the event signals a sibling worktree addition (multi-branch
* add_branch flow) — the frontend repoints the file browser to it instead of
* staying on the original primary worktree. */
task_workspace_path?: string;
};
export type FileInfo = {
path: string;
status: "modified" | "added" | "deleted" | "untracked" | "renamed";
@@ -363,27 +262,6 @@ export type FileInfo = {
diff_skip_reason?: "too_large" | "binary" | "truncated" | "budget_exceeded";
};
export type ProcessOutputPayload = {
session_id: string;
process_id: string;
kind: string;
stream: "stdout" | "stderr";
data: string;
timestamp?: string;
};
export type ProcessStatusPayload = {
session_id: string;
process_id: string;
kind: string;
script_name?: string;
status: string;
command?: string;
working_dir?: string;
exit_code?: number | null;
timestamp?: string;
};
// Executor and environment payload types (extracted to reduce file size)
export {
type ExecutorPayload,
@@ -424,53 +302,6 @@ export type UserSettingsUpdatedPayload = Omit<
workspace_id: string;
repository_ids: string[];
};
export type ShellOutputPayload = {
task_id: string;
session_id: string;
type: "output" | "exit";
data?: string;
code?: number;
};
export type TurnEventPayload = {
id: string;
session_id: string;
task_id: string;
started_at: string;
completed_at?: string;
metadata?: Record<string, unknown>;
/** Whether the completed turn produced any agent output. Only set on turn.completed. */
had_output?: boolean;
created_at: string;
updated_at: string;
};
export type AvailableCommandPayload = {
name: string;
description?: string;
input_hint?: string;
};
export type AvailableCommandsPayload = {
task_id: string;
session_id: string;
agent_id: string;
available_commands: AvailableCommandPayload[];
timestamp: string;
};
export type SessionModeChangedPayload = {
task_id: string;
session_id: string;
agent_id: string;
current_mode_id: string;
available_modes?: {
id: string;
name: string;
description?: string;
}[];
timestamp?: string;
};
// Session runtime payload types (extracted to reduce file size)
export {
@@ -486,21 +317,14 @@ export {
export type { TaskPlanEventPayload, TaskPlanRevisionEventPayload } from "./task-plan-events";
export type QueueStatusChangedPayload = {
session_id: string;
entries?: QueuedMessage[] | null;
count?: number;
max?: number;
merge_enabled?: boolean;
};
export type TaskStatusSummaryUpdatedPayload = {
task_id: string;
workspace_id: string;
status_summary: TaskStatusSummary;
};
export type BackendMessageMap = OfficeBackendMessageMap &
export type BackendMessageMap = SessionBackendMessageMap &
OfficeBackendMessageMap &
import("@/lib/types/http").WalkthroughBackendMessageMap &
import("@/lib/types/review").ReviewBackendMessageMap & {
"kanban.update": BackendMessage<"kanban.update", KanbanUpdatePayload>;
@@ -554,68 +378,9 @@ export type BackendMessageMap = OfficeBackendMessageMap &
"workflow.step.created": BackendMessage<"workflow.step.created", WorkflowStepEventPayload>;
"workflow.step.updated": BackendMessage<"workflow.step.updated", WorkflowStepEventPayload>;
"workflow.step.deleted": BackendMessage<"workflow.step.deleted", WorkflowStepEventPayload>;
"session.message.added": BackendMessage<"session.message.added", MessageAddedPayload>;
"session.message.updated": BackendMessage<"session.message.updated", MessageAddedPayload>;
"session.message.deleted": BackendMessage<"session.message.deleted", MessageAddedPayload>;
"session.state_changed": BackendMessage<
"session.state_changed",
TaskSessionStateChangedPayload
>;
"session.turn_finished": BackendMessage<
"session.turn_finished",
TaskSessionNotificationPayload
>;
"session.activity_changed": BackendMessage<
"session.activity_changed",
TaskSessionActivityChangedPayload
>;
"session.cancellation_changed": BackendMessage<
"session.cancellation_changed",
TaskSessionCancellationChangedPayload
>;
"session.clarification_requested": BackendMessage<
"session.clarification_requested",
TaskSessionNotificationPayload
>;
"office.inbox_item": BackendMessage<"office.inbox_item", OfficeInboxItemNotificationPayload>;
"session.agentctl_starting": BackendMessage<
"session.agentctl_starting",
TaskSessionAgentctlPayload
>;
"session.agentctl_ready": BackendMessage<"session.agentctl_ready", TaskSessionAgentctlPayload>;
"session.agentctl_error": BackendMessage<"session.agentctl_error", TaskSessionAgentctlPayload>;
"session.workspace_sources.updated": BackendMessage<
"session.workspace_sources.updated",
{
task_id: string;
session_id: string;
workspace_path: string;
adopted_session_ids?: string[];
}
>;
"session.turn.started": BackendMessage<"session.turn.started", TurnEventPayload>;
"session.turn.completed": BackendMessage<"session.turn.completed", TurnEventPayload>;
"session.available_commands": BackendMessage<
"session.available_commands",
AvailableCommandsPayload
>;
"session.mode_changed": BackendMessage<"session.mode_changed", SessionModeChangedPayload>;
"session.agent_capabilities": BackendMessage<
"session.agent_capabilities",
AgentCapabilitiesPayload
>;
"session.models_updated": BackendMessage<"session.models_updated", SessionModelsPayload>;
"session.mcp_status_updated": BackendMessage<
"session.mcp_status_updated",
SessionMCPStatusPayload
>;
"session.info_updated": BackendMessage<"session.info_updated", SessionInfoPayload>;
"session.todos_updated": BackendMessage<"session.todos_updated", SessionTodosPayload>;
"session.prompt_usage": BackendMessage<"session.prompt_usage", SessionPromptUsagePayload>;
"session.poll_mode_changed": BackendMessage<
"session.poll_mode_changed",
{ session_id: string; poll_mode: string }
>;
"executor.created": BackendMessage<"executor.created", ExecutorPayload>;
"executor.updated": BackendMessage<"executor.updated", ExecutorPayload>;
"executor.deleted": BackendMessage<"executor.deleted", ExecutorPayload>;
@@ -637,20 +402,11 @@ export type BackendMessageMap = OfficeBackendMessageMap &
"agent.profile.created": BackendMessage<"agent.profile.created", AgentProfileChangedPayload>;
"agent.profile.updated": BackendMessage<"agent.profile.updated", AgentProfileChangedPayload>;
"user.settings.updated": BackendMessage<"user.settings.updated", UserSettingsUpdatedPayload>;
"session.workspace.file.changes": BackendMessage<
"session.workspace.file.changes",
FileChangeNotificationPayload
>;
"session.shell.output": BackendMessage<"session.shell.output", ShellOutputPayload>;
"session.process.output": BackendMessage<"session.process.output", ProcessOutputPayload>;
"session.process.status": BackendMessage<"session.process.status", ProcessStatusPayload>;
"secrets.created": BackendMessage<"secrets.created", SecretListItem>;
"secrets.updated": BackendMessage<"secrets.updated", SecretListItem>;
"secrets.deleted": BackendMessage<"secrets.deleted", { id: string }>;
"message.queue.status_changed": BackendMessage<
"message.queue.status_changed",
QueueStatusChangedPayload
>;
"github.task_pr.updated": BackendMessage<"github.task_pr.updated", TaskPR>;
"github.task_pr.deleted": BackendMessage<"github.task_pr.deleted", TaskPRDeletedEvent>;
"github.task_ci_options.updated": BackendMessage<
@@ -671,3 +427,21 @@ export type BackendMessageMap = OfficeBackendMessageMap &
// Workspace file types (extracted to reduce file size)
export * from "./workspace-files";
// Session event payload types (extracted to keep backend.ts under the line budget)
export type {
MessageAddedPayload,
TaskSessionStateChangedPayload,
TaskSessionActivityChangedPayload,
TaskSessionCancellationChangedPayload,
TaskSessionNotificationPayload,
TaskSessionAgentctlPayload,
TurnEventPayload,
AvailableCommandPayload,
AvailableCommandsPayload,
SessionModeChangedPayload,
ShellOutputPayload,
ProcessOutputPayload,
ProcessStatusPayload,
QueueStatusChangedPayload,
} from "./session-events";
+262
View File
@@ -0,0 +1,262 @@
import type { BackendMessage } from "./backend-message";
import type { QueuedMessage } from "@/lib/state/slices/session/types";
import type { FileChangeNotificationPayload } from "./workspace-files";
import type { ForegroundActivity } from "./activity";
import type {
AgentCapabilitiesPayload,
SessionInfoPayload,
SessionModelsPayload,
SessionMCPStatusPayload,
SessionPromptUsagePayload,
SessionTodosPayload,
} from "./session-runtime-payloads";
export type MessageAddedPayload = {
task_id: string;
message_id: string;
session_id: string;
turn_id?: string;
author_type: "user" | "agent";
author_id?: string;
content: string;
raw_content?: string;
type?: string;
metadata?: Record<string, unknown>;
requests_input?: boolean;
created_at: string;
updated_at?: string;
};
export type TaskSessionStateChangedPayload = {
task_id: string;
session_id: string;
old_state?: string;
new_state?: string;
/** Authoritative row timestamp — used to drop out-of-order subscribe snapshots. */
updated_at?: string;
/**
* Agent profile id — drives the per-agent live-session selectors on the
* sidebar. Empty for sessions launched without a profile.
*/
agent_profile_id?: string;
agent_profile_snapshot?: Record<string, unknown>;
metadata?: Record<string, unknown>;
session_metadata?: Record<string, unknown>;
is_passthrough?: boolean;
error_message?: string;
/** User-supplied session tab label; present (possibly "") on rename broadcasts. */
name?: string;
/** When true, the frontend should not show an error toast for this state change. */
suppress_toast?: boolean;
// Workflow-related fields (sent during workflow transitions)
review_status?: string;
// Task environment (for session→environment mapping)
task_environment_id?: string;
// Fine-grained busy substate (see ADR-0049), carried on coarse transitions;
// live flips arrive on session.activity_changed.
foreground_activity?: ForegroundActivity | null;
active_subagent_count?: number;
/** Backend-owned cancellation projection carried by state snapshots. */
cancellation_pending?: boolean;
/** Process-local cancellation transition generation carried by state snapshots. */
cancellation_revision?: number;
/** True when a send right now would steer the running turn; see http.ts. */
supports_steering?: boolean;
};
export type TaskSessionActivityChangedPayload = {
task_id: string;
session_id: string;
foreground_activity: ForegroundActivity | null;
active_subagent_count: number;
/** True when a send right now would steer the running turn; see http.ts. */
supports_steering?: boolean;
};
export type TaskSessionCancellationChangedPayload = {
session_id: string;
cancellation_pending: boolean;
cancellation_revision: number;
};
export type TaskSessionNotificationPayload = {
task_id: string;
session_id: string;
occurrence_id: string;
title: string;
body: string;
};
export type TaskSessionAgentctlPayload = {
task_id: string;
session_id: string;
task_environment_id?: string;
agent_execution_id?: string;
error_message?: string;
worktree_id?: string;
worktree_path?: string;
worktree_branch?: string;
/** Effective task workspace root when the agentctl payload carries it. */
workspace_path?: string;
/** Task root that contains every per-repo worktree as a sibling subdir.
* Set only when the event signals a sibling worktree addition (multi-branch
* add_branch flow) — the frontend repoints the file browser to it instead of
* staying on the original primary worktree. */
task_workspace_path?: string;
};
export type TurnEventPayload = {
id: string;
session_id: string;
task_id: string;
started_at: string;
completed_at?: string;
metadata?: Record<string, unknown>;
/** Whether the completed turn produced any agent output. Only set on turn.completed. */
had_output?: boolean;
created_at: string;
updated_at: string;
};
export type AvailableCommandsPayload = {
task_id: string;
session_id: string;
agent_id: string;
available_commands: AvailableCommandPayload[];
timestamp: string;
};
export type SessionModeChangedPayload = {
task_id: string;
session_id: string;
agent_id: string;
current_mode_id: string;
available_modes?: {
id: string;
name: string;
description?: string;
}[];
timestamp?: string;
};
export type ShellOutputPayload = {
task_id: string;
session_id: string;
type: "output" | "exit";
data?: string;
code?: number;
};
export type ProcessOutputPayload = {
session_id: string;
process_id: string;
kind: string;
stream: "stdout" | "stderr";
data: string;
timestamp?: string;
};
export type ProcessStatusPayload = {
session_id: string;
process_id: string;
kind: string;
script_name?: string;
status: string;
command?: string;
working_dir?: string;
exit_code?: number | null;
timestamp?: string;
};
export type QueueStatusChangedPayload = {
session_id: string;
entries?: QueuedMessage[] | null;
count?: number;
max?: number;
merge_enabled?: boolean;
};
export type AvailableCommandPayload = {
name: string;
description?: string;
input_hint?: string;
};
export type SessionBackendMessageMap = {
"session.message.added": BackendMessage<"session.message.added", MessageAddedPayload>;
"session.message.updated": BackendMessage<"session.message.updated", MessageAddedPayload>;
"session.message.deleted": BackendMessage<"session.message.deleted", MessageAddedPayload>;
"session.state_changed": BackendMessage<"session.state_changed", TaskSessionStateChangedPayload>;
"session.turn_finished": BackendMessage<"session.turn_finished", TaskSessionNotificationPayload>;
"session.activity_changed": BackendMessage<
"session.activity_changed",
TaskSessionActivityChangedPayload
>;
"session.cancellation_changed": BackendMessage<
"session.cancellation_changed",
TaskSessionCancellationChangedPayload
>;
"session.clarification_requested": BackendMessage<
"session.clarification_requested",
TaskSessionNotificationPayload
>;
"session.agentctl_starting": BackendMessage<
"session.agentctl_starting",
TaskSessionAgentctlPayload
>;
"session.agentctl_ready": BackendMessage<"session.agentctl_ready", TaskSessionAgentctlPayload>;
"session.agentctl_error": BackendMessage<"session.agentctl_error", TaskSessionAgentctlPayload>;
"session.workspace_sources.updated": BackendMessage<
"session.workspace_sources.updated",
{
task_id: string;
session_id: string;
workspace_path: string;
adopted_session_ids?: string[];
}
>;
"session.turn.started": BackendMessage<"session.turn.started", TurnEventPayload>;
"session.turn.completed": BackendMessage<"session.turn.completed", TurnEventPayload>;
"session.available_commands": BackendMessage<
"session.available_commands",
AvailableCommandsPayload
>;
"session.mode_changed": BackendMessage<"session.mode_changed", SessionModeChangedPayload>;
"session.agent_capabilities": BackendMessage<
"session.agent_capabilities",
AgentCapabilitiesPayload
>;
"session.models_updated": BackendMessage<"session.models_updated", SessionModelsPayload>;
"session.model_fallback": BackendMessage<
"session.model_fallback",
{
task_id: string;
session_id: string;
agent_id: string;
fallback_model: string;
timestamp: string;
}
>;
"session.mcp_status_updated": BackendMessage<
"session.mcp_status_updated",
SessionMCPStatusPayload
>;
"session.info_updated": BackendMessage<"session.info_updated", SessionInfoPayload>;
"session.todos_updated": BackendMessage<"session.todos_updated", SessionTodosPayload>;
"session.prompt_usage": BackendMessage<"session.prompt_usage", SessionPromptUsagePayload>;
"session.poll_mode_changed": BackendMessage<
"session.poll_mode_changed",
{ session_id: string; poll_mode: string }
>;
"session.workspace.file.changes": BackendMessage<
"session.workspace.file.changes",
FileChangeNotificationPayload
>;
"session.shell.output": BackendMessage<"session.shell.output", ShellOutputPayload>;
"session.process.output": BackendMessage<"session.process.output", ProcessOutputPayload>;
"session.process.status": BackendMessage<"session.process.status", ProcessStatusPayload>;
"message.queue.status_changed": BackendMessage<
"message.queue.status_changed",
QueueStatusChangedPayload
>;
};
@@ -7,6 +7,7 @@ import type { SessionModelsPayload } from "@/lib/types/session-runtime-payloads"
import { registerSessionModelsHandlers } from "./session-models";
const providerModelId = "gpt-5.6-sol";
const GONE_MODEL = "claude-sonnet";
const providerModelName = "GPT-5.6 Sol";
const reasoningOptionId = "reasoning_effort";
const reasoningOptionName = "Reasoning Effort";
@@ -544,3 +545,117 @@ describe("session.models_updated live updates", () => {
).toBe("low");
});
});
describe("session.models_updated fallback note preservation", () => {
it("keeps the fallback model note across a models_updated event", () => {
const store = makeStore({
sessionModels: {
bySessionId: {
"session-1": {
currentModelId: "gpt-5",
models: [{ modelId: "gpt-5", name: "GPT-5" }],
configOptions: [],
fallbackModel: "gpt-5",
},
},
},
});
const handlers = registerSessionModelsHandlers(store);
const payload = makePayload("gpt-5", {
models: [{ model_id: "gpt-5", name: "GPT-5" }],
});
handlers["session.models_updated"]!(makeMessage(payload));
expect(store.getState().sessionModels.bySessionId["session-1"]?.fallbackModel).toBe("gpt-5");
});
});
describe("session.models_updated stale active model", () => {
it("keeps the active model when it disappears from the ACP list (no silent clear)", () => {
const store = makeStore({
activeModel: {
bySessionId: {
"session-1": GONE_MODEL,
},
},
});
const handlers = registerSessionModelsHandlers(store);
// The ACP list drops claude-sonnet (auth expired); only gpt-5 remains.
const payload = makePayload("gpt-5", {
models: [{ model_id: "gpt-5", name: "GPT-5" }],
});
handlers["session.models_updated"]!(makeMessage(payload));
// The gone active model must NOT be silently cleared — the picker needs
// it to stay visible (greyed) so the user is asked to change it.
expect(store.getState().setActiveModel).not.toHaveBeenCalled();
});
it("keeps the active model when it is still in the ACP list", () => {
const store = makeStore({
activeModel: {
bySessionId: {
"session-1": GONE_MODEL,
},
},
});
const handlers = registerSessionModelsHandlers(store);
const payload = makePayload(GONE_MODEL, {
models: [{ model_id: GONE_MODEL, name: "Claude Sonnet" }],
});
handlers["session.models_updated"]!(makeMessage(payload));
expect(store.getState().setActiveModel).not.toHaveBeenCalled();
});
});
describe("session.model_fallback convergence", () => {
const fallbackMessage = (payload: { session_id?: string; fallback_model?: string }) =>
({
id: "message-fallback",
type: "notification",
action: "session.model_fallback",
payload,
}) as BackendMessageMap["session.model_fallback"];
it("converges the current model to the fallback model", () => {
const store = makeStore();
registerSessionModelsHandlers(store)["session.model_fallback"]!(
fallbackMessage({ session_id: "session-1", fallback_model: providerModelId }),
);
expect(store.getState().sessionModels.bySessionId["session-1"]).toMatchObject({
currentModelId: providerModelId,
fallbackModel: providerModelId,
});
});
it("keeps the fallback as the current model across the next models_updated", () => {
// The persisted runtime config still names the gone start model; the
// models_updated payload reports the fallback. The entry must converge
// on the fallback as the live model, not re-select the gone model.
const store = makeStore({
taskSessions: {
items: {
"session-1": makeTaskSession({
runtime_config: { model: GONE_MODEL, config_options: { model: GONE_MODEL } },
}),
},
},
});
const handlers = registerSessionModelsHandlers(store);
handlers["session.model_fallback"]!(
fallbackMessage({ session_id: "session-1", fallback_model: providerModelId }),
);
handlers["session.models_updated"]!(
makeMessage(
makePayload(providerModelId, {
models: [{ model_id: providerModelId, name: providerModelName }],
}),
),
);
expect(store.getState().sessionModels.bySessionId["session-1"]).toMatchObject({
currentModelId: providerModelId,
fallbackModel: providerModelId,
});
});
});
+98 -40
View File
@@ -2,6 +2,10 @@ import type { StoreApi } from "zustand";
import type { AppState } from "@/lib/state/store";
import type { WsHandlers } from "@/lib/ws/handlers/types";
import type { SessionModelsPayload } from "@/lib/types/backend";
import type {
ConfigOptionEntry,
SessionModelsState,
} from "@/lib/state/slices/session-runtime/types";
import { createDebugLogger, isDebug } from "@/lib/debug/log";
const debug = createDebugLogger("model-selector:ws");
@@ -153,6 +157,28 @@ function debugModelsUpdate(
});
}
// resolveConfigOptions keeps the previously settled config options when the
// incoming update is a preserved echo, otherwise rebuilds from the payload.
function resolveConfigOptions(
preserve: boolean,
existing: ConfigOptionEntry[] | undefined,
payload: SessionModelsPayload,
pendingRuntime: PersistedRuntimeConfig,
): ConfigOptionEntry[] {
if (preserve && existing) {
return existing;
}
return (payload.config_options ?? []).map((o) => ({
type: o.type,
id: o.id,
name: o.name,
description: o.description,
currentValue: pendingRuntime.configOptions?.[o.id] ?? o.current_value,
category: o.category,
options: o.options,
}));
}
function clearStaleContextWindow(state: AppState, sessionId: string, currentModelId: string) {
const previousModelId = state.sessionModels.bySessionId[sessionId]?.currentModelId ?? "";
if (previousModelId && currentModelId && previousModelId !== currentModelId) {
@@ -160,22 +186,72 @@ function clearStaleContextWindow(state: AppState, sessionId: string, currentMode
}
}
function clearStaleActiveModel(
// resolveModelsUpdatedState computes the convergence target for a
// models_updated event: which model becomes current, whether the update is
// an empty relaunch echo, and whether the previously settled config options
// must be preserved. The fallback note makes the payload's reported model
// win over the persisted runtime model (which can still name the gone start
// model) so the picker converges on the fallback as the live model instead
// of re-selecting the unavailable configured model.
function resolveModelsUpdatedState(
state: AppState,
sessionId: string,
acpModels: SessionModelsPayload["models"],
) {
if (!acpModels?.length) {
return;
}
const currentActive = state.activeModel.bySessionId[sessionId];
if (currentActive && !acpModels.some((m) => m.model_id === currentActive)) {
state.setActiveModel(sessionId, "");
}
payload: SessionModelsPayload,
pendingRuntime: PersistedRuntimeConfig,
): {
currentModelId: string;
isEmpty: boolean;
populated: boolean;
preserveConfigOptions: boolean;
existingEntry: SessionModelsState["bySessionId"][string] | undefined;
existingFallback: string | undefined;
} {
const payloadCurrentModelId = resolveCurrentModelId(payload);
const existingEntry = state.sessionModels.bySessionId[sessionId];
const existingFallback = existingEntry?.fallbackModel;
return {
currentModelId:
existingFallback && payloadCurrentModelId
? payloadCurrentModelId
: pendingRuntime.model || payloadCurrentModelId,
isEmpty: isEmptyModelsUpdate(
payloadCurrentModelId,
payload.models ?? [],
payload.config_options,
),
populated: hasPopulatedModels(state, sessionId),
preserveConfigOptions: shouldPreserveExistingConfigOptions(state, sessionId, payload),
existingEntry,
existingFallback,
};
}
export function registerSessionModelsHandlers(store: StoreApi<AppState>): WsHandlers {
return {
"session.model_fallback": (message) => {
const payload = message.payload as
| { session_id?: string; fallback_model?: string }
| undefined;
if (!payload?.session_id || !payload.fallback_model) {
return;
}
const state = store.getState();
const sessionId = payload.session_id;
const existing = state.sessionModels.bySessionId[sessionId];
// Merge the explicit "using fallback model" signal into the session's
// model entry so the picker can show why the start model was replaced.
// The fallback model also becomes the current model: the persisted
// runtime model may still name the unavailable start model, and the
// picker must not keep showing that as the live model.
store.getState().setSessionModels(sessionId, {
currentModelId: payload.fallback_model,
models: existing?.models ?? [],
configOptions: existing?.configOptions ?? [],
configBaseline: existing?.configBaseline,
fallbackModel: payload.fallback_model,
});
},
"session.models_updated": (message) => {
const payload = message.payload as SessionModelsPayload | undefined;
if (!payload?.session_id) {
@@ -189,39 +265,23 @@ export function registerSessionModelsHandlers(store: StoreApi<AppState>): WsHand
const matchesPersisted = payloadMatchesPersistedRuntime(payload, persisted);
if (matchesPersisted) hydrated.add(sessionId);
const pendingRuntime = matchesPersisted ? {} : persisted;
const payloadCurrentModelId = resolveCurrentModelId(payload);
const currentModelId = pendingRuntime.model || payloadCurrentModelId;
// Classify emptiness from the incoming payload alone; persisted metadata
// must not make a genuinely empty relaunch event look populated.
const isEmpty = isEmptyModelsUpdate(payloadCurrentModelId, acpModels, payload.config_options);
const populated = hasPopulatedModels(state, sessionId);
const preserveConfigOptions = shouldPreserveExistingConfigOptions(state, sessionId, payload);
debugModelsUpdate(state, sessionId, payload, {
currentModelId,
isEmpty,
populated,
preserveConfigOptions,
});
if (isEmpty && populated) {
const resolved = resolveModelsUpdatedState(state, sessionId, payload, pendingRuntime);
debugModelsUpdate(state, sessionId, payload, resolved);
if (resolved.isEmpty && resolved.populated) {
return;
}
clearStaleContextWindow(state, sessionId, currentModelId);
clearStaleContextWindow(state, sessionId, resolved.currentModelId);
const existingEntry = state.sessionModels.bySessionId[sessionId];
const configOptions = preserveConfigOptions
? existingEntry.configOptions
: (payload.config_options ?? []).map((o) => ({
type: o.type,
id: o.id,
name: o.name,
description: o.description,
currentValue: pendingRuntime.configOptions?.[o.id] ?? o.current_value,
category: o.category,
options: o.options,
}));
const configOptions = resolveConfigOptions(
resolved.preserveConfigOptions,
resolved.existingEntry?.configOptions,
payload,
pendingRuntime,
);
state.setSessionModels(sessionId, {
currentModelId,
currentModelId: resolved.currentModelId,
fallbackModel: resolved.existingFallback,
models: acpModels.map((m) => ({
modelId: m.model_id,
name: m.name,
@@ -235,8 +295,6 @@ export function registerSessionModelsHandlers(store: StoreApi<AppState>): WsHand
persisted.baseline ??
state.sessionModels.bySessionId[sessionId]?.configBaseline,
});
clearStaleActiveModel(state, sessionId, acpModels);
},
};
}
+29 -3
View File
@@ -17,6 +17,14 @@
"addTheTabToTheSelected": "Add the {{panel}} tab to the selected split.",
"affectedKandevMcpTool": "Affected Kandev MCP tool",
"agent": "Agent",
"agentFallback": "Agent fallback",
"agentFallbackAria": "Agent fallback model settings",
"agentFallbackDisabledHelper": "Automatic fallback is enabled. Turn it off to choose one specific fallback model.",
"agentFallbackHelper": "If the start model becomes unavailable, automatically switch to this model.",
"agentFallbackHelp": "If the start model is unavailable, Kandev tries only this model. If it is unavailable too, the session fails. Use this when you want a predictable fallback.",
"agentFallbackHelpTitle": "Agent fallback",
"agentFallbackInfoLabel": "Explain agent fallback",
"agentFallbackPlaceholder": "Select a fallback model...",
"agentGeneratedTaskTitles": "Agent-generated task titles",
"agentGeneratedTaskTitlesDescription": "When enabled, new task and subtask forms hide the manual title field and require a prompt. Kandev shows a provisional title using the prompt's first six words immediately, then asks the agent to replace it with a concise title targeting about three words. If the agent cannot rename the task, the provisional title remains available for you to edit. Existing and edited tasks keep their normal title field.",
"agentGeneratedTaskTitlesDisabledHint": "Disabled by default; save this setting before opening a creation dialog.",
@@ -31,8 +39,13 @@
"autoApprove": "Auto-approve",
"autoInstallIfNotFound": "Auto-install if not found",
"autoStart": "Auto-start",
"autoStartLanguageServer": "Auto-start {{language}} language server",
"autoStartLanguageServersWhenOpening": "Auto-start language servers when opening files to get diagnostics, hover info, and go-to-definition. You can also toggle each server on/off per file. Language servers run in desktop file editors; the mobile file viewer does not start them.",
"autoFallback": "Fallback automatically to next model",
"autoFallbackHelper": "When the start model is unavailable, let the agent pick the next available model instead of failing. Disables the explicit fallback model.",
"autoFallbackHelp": "If the start model is unavailable, Kandev lets the agent choose from its remaining advertised models. The selected model can change as availability changes.",
"autoFallbackHelpTitle": "Automatic fallback",
"autoFallbackInfoLabel": "Explain automatic fallback",
"autoStartLanguageServer": "Auto-start {{language}} language server",
"builtIn": "Built-in",
"builtInBrowserPanel": "Built-in browser panel",
"cancel": "Cancel",
@@ -258,7 +271,6 @@
"externalMcpToolListAgents": "List configured agents and their profiles.",
"externalMcpToolListExecutorProfiles": "List profiles for an executor.",
"externalMcpToolListExecutors": "List all executors.",
"externalMcpToolListTaskSessions": "List every agent session on a task, newest first, so another tool can address one by ID.",
"externalMcpToolListTasks": "List tasks in a workflow.",
"externalMcpToolListWorkflowSteps": "List all steps in a workflow.",
"externalMcpToolListWorkflows": "List workflows in a workspace.",
@@ -285,6 +297,13 @@
"fontSize": "Font Size",
"frequencySeconds": "Frequency (seconds)",
"general": "General",
"fallbackModelUnavailable": "Fallback model no longer available — select a different one",
"fallbackSettings": "Fallback settings",
"fallbackSettingsSummaryAutomatic": "Automatic fallback enabled",
"fallbackSettingsSummaryExplicit": "Explicit fallback: {{model}}",
"fallbackSettingsSummaryStrict": "No fallback configured",
"fallbackNoteLive": "This session started on a fallback model because the configured model was unavailable. The (fallback) marker reflects the live session and is not shown after reload.",
"modelFallbackSuffix": "(fallback)",
"health": "Health",
"home": "Home",
"hostedUrl": "Hosted URL",
@@ -378,6 +397,7 @@
"mergeWithTarget": "Merge with {{target}}",
"messageSubmitKey": "Message Submit Key",
"metrics": "Metrics",
"mode": "Mode",
"model": "Model",
"moveTabLeft": "Move tab left",
"moveTabRight": "Move tab right",
@@ -453,6 +473,8 @@
"previewSound": "Preview sound",
"profileForTasksCreatedByAgents": "Profile for Tasks Created by Agents",
"programming": "Programming",
"profileFallbackWillBeUsed": "Start model {{model}} is no longer available — fallback {{fallback}} will be used.",
"profileStartModelUnavailable": "Start model {{model}} is no longer available — change it in the agent profile.",
"promptAdd": "Add prompt",
"promptContentPlaceholder": "Prompt content",
"promptCreateFailed": "Couldn't create prompt",
@@ -501,6 +523,7 @@
"security": "Security",
"selectADefaultEditor": "Select a default editor",
"selectAShell": "Select a shell",
"selectAModel": "Select a model...",
"sendTestNotification": "Send test notification",
"sendTestNotificationFor": "Send test notification for {{name}}",
"serverConfiguration": "Server Configuration",
@@ -573,6 +596,8 @@
"taskActions": "Task Actions",
"taskOverview": "Task overview",
"taskWorkbenchLayoutProfilesAndDefaults": "Task workbench layout profiles and defaults",
"startModelUnavailable": "No longer available — select a different model",
"startModelAria": "Profile start model settings",
"terminal": "Terminal",
"terminalFont": "Terminal Font",
"terminalFontSize": "Terminal Font Size",
@@ -707,5 +732,6 @@
"voiceWhisperTiny": "Tiny",
"voiceWhisperTinyHint": "Fastest, lower accuracy",
"vsCodeRemoteSsh": "VS Code Remote SSH",
"whenEnabledInstallYourProjectS": "When enabled, install your project's dependencies (e.g. <2>{{command}}</2> via repository setup scripts) to avoid missing type errors."
"whenEnabledInstallYourProjectS": "When enabled, install your project's dependencies (e.g. <2>{{command}}</2> via repository setup scripts) to avoid missing type errors.",
"externalMcpToolListTaskSessions": "List every agent session on a task, newest first, so another tool can address one by ID."
}
+1 -1
View File
@@ -99,7 +99,7 @@
"failedToFetchCapabilities": "Ƒàĩĺēď ţō ƒēţćĥ ćàƥàƀĩĺĩţĩēś",
"modelOptionsAuthenticationRequired": "Śĩĝń ĩń ţō ţĥĩś àĝēńţ ţō ĺōàď ḿōďēĺ ōƥţĩōńś.",
"modelOptionsResolutionFailed": "Ḿōďēĺ ōƥţĩōńś ćōũĺď ńōţ ƀē ĺōàďēď.",
"resolvingModelOptions": "Ľōàďĩńĝ ḿōďēĺ ōƥţĩōńś…",
"resolvingModelOptions": "Ĺōàďĩńĝ ḿōďēĺ ōƥţĩōńś…",
"retryModelOptions": "Ŕēţŕŷ",
"failedToLoadCommandPreview": "Ƒàĩĺēď ţō ĺōàď ćōḿḿàńď ƥŕēvĩēŵ",
"failedToLoadMcpConfig": "Ƒàĩĺēď ţō ĺōàď ḾĆƤ ćōńƒĩĝ",
+14 -14
View File
@@ -64,6 +64,19 @@
"providerQuotaReset": "Ćàƥàćĩţŷ ŕēśēţś {{resetAt}}.",
"providerQuotaResetUnknown": "Ţŕŷ àĝàĩń ŵĥēń ţĥē ƥŕōvĩďēŕ ḿàķēś ćàƥàćĩţŷ àvàĩĺàƀĺē.",
"providerQuotaTitle": "{{provider}} ũśàĝē ĺĩḿĩţ ŕēàćĥēď",
"transientRetryReasonModelCapacity": "Ḿōďēĺ àţ ćàƥàćĩţŷ",
"transientRetryReasonNetworkUnavailable": "Ńēţŵōŕķ ũńàvàĩĺàƀĺē",
"transientRetryReasonProviderOverloaded": "Ƥŕōvĩďēŕ ōvēŕĺōàďēď",
"transientRetryReasonProviderUnavailable": "Ƥŕōvĩďēŕ ţēḿƥōŕàŕĩĺŷ ũńàvàĩĺàƀĺē",
"transientRetryReasonRateLimited": "Ŕàţē ĺĩḿĩţēď",
"transientRetryReasonGeneric": "Ƥŕōvĩďēŕ ēŕŕōŕ",
"transientRetryIn": "Ŕēţŕŷĩńĝ ĩń {{countdown}}",
"transientRetryNow": "Ŕēţŕŷĩńĝ ńōŵ…",
"transientRetryCountdownLabel": "Ŕēţŕŷ ćōũńţďōŵń",
"transientRetryAttempt": "Àţţēḿƥţ {{attempt}} ōƒ {{maxAttempts}}",
"transientRetryProviderModel": "{{provider}} · {{model}}",
"transientRetryProvider": "Ƥŕōvĩďēŕ: {{provider}}",
"transientRetryModel": "Ḿōďēĺ: {{model}}",
"queueCapacity_one": "{{count}} ōƒ {{max}}",
"queueCapacity_other": "{{count}} ōƒ {{max}}",
"queueCapacityFull_one": "{{count}} ōƒ {{max}} · ƒũĺĺ",
@@ -122,18 +135,5 @@
"composerSteerPlaceholder": "Śēńď ńōŵ — ďēĺĩvēŕēď ţō ţĥē ŕũńńĩńĝ ţũŕń",
"unknownError": "Ũńķńōŵń ēŕŕōŕ",
"useWorkspaceDefault": "Ũśē ŵōŕķśƥàćē ďēƒàũĺţ...",
"useWorkspaceDefaultOption": "Ũśē ŵōŕķśƥàćē ďēƒàũĺţ",
"transientRetryReasonModelCapacity": "Ḿōďēĺ àţ ćàƥàćĩţŷ",
"transientRetryReasonNetworkUnavailable": "Ńēţŵōŕķ ũńàvàĩĺàƀĺē",
"transientRetryReasonProviderOverloaded": "Ƥŕōvĩďēŕ ōvēŕĺōàďēď",
"transientRetryReasonProviderUnavailable": "Ƥŕōvĩďēŕ ţēḿƥōŕàŕĩĺŷ ũńàvàĩĺàƀĺē",
"transientRetryReasonRateLimited": "Ŕàţē ĺĩḿĩţēď",
"transientRetryReasonGeneric": "Ƥŕōvĩďēŕ ēŕŕōŕ",
"transientRetryIn": "Ŕēţŕŷĩńĝ ĩń {{countdown}}",
"transientRetryNow": "Ŕēţŕŷĩńĝ ńōŵ…",
"transientRetryCountdownLabel": "Ŕēţŕŷ ćōũńţďōŵń",
"transientRetryAttempt": "Àţţēḿƥţ {{attempt}} ōƒ {{maxAttempts}}",
"transientRetryProviderModel": "{{provider}} · {{model}}",
"transientRetryProvider": "Ƥŕōvĩďēŕ: {{provider}}",
"transientRetryModel": "Ḿōďēĺ: {{model}}"
"useWorkspaceDefaultOption": "Ũśē ŵōŕķśƥàćē ďēƒàũĺţ"
}
+29 -3
View File
@@ -17,6 +17,14 @@
"addTheTabToTheSelected": "Àďď ţĥē {{panel}} ţàƀ ţō ţĥē śēĺēćţēď śƥĺĩţ.",
"affectedKandevMcpTool": "Àƒƒēćţēď Ķàńďēv ḾĆƤ ţōōĺ",
"agent": "Àĝēńţ",
"agentFallback": "Àĝēńţ ƒàĺĺƀàćķ",
"agentFallbackAria": "Àĝēńţ ƒàĺĺƀàćķ ḿōďēĺ śēţţĩńĝś",
"agentFallbackDisabledHelper": "Àũţōḿàţĩć ƒàĺĺƀàćķ ĩś ēńàƀĺēď. Ţũŕń ĩţ ōƒƒ ţō ćĥōōśē ōńē śƥēćĩƒĩć ƒàĺĺƀàćķ ḿōďēĺ.",
"agentFallbackHelper": "Ĩƒ ţĥē śţàŕţ ḿōďēĺ ƀēćōḿēś ũńàvàĩĺàƀĺē, àũţōḿàţĩćàĺĺŷ śŵĩţćĥ ţō ţĥĩś ḿōďēĺ.",
"agentFallbackHelp": "Ĩƒ ţĥē śţàŕţ ḿōďēĺ ĩś ũńàvàĩĺàƀĺē, Ķàńďēv ţŕĩēś ōńĺŷ ţĥĩś ḿōďēĺ. Ĩƒ ĩţ ĩś ũńàvàĩĺàƀĺē ţōō, ţĥē śēśśĩōń ƒàĩĺś. Ũśē ţĥĩś ŵĥēń ŷōũ ŵàńţ à ƥŕēďĩćţàƀĺē ƒàĺĺƀàćķ.",
"agentFallbackHelpTitle": "Àĝēńţ ƒàĺĺƀàćķ",
"agentFallbackInfoLabel": "Ēxƥĺàĩń àĝēńţ ƒàĺĺƀàćķ",
"agentFallbackPlaceholder": "Śēĺēćţ à ƒàĺĺƀàćķ ḿōďēĺ...",
"agentGeneratedTaskTitles": "Àĝēńţ-ĝēńēŕàţēď ţàśķ ţĩţĺēś",
"agentGeneratedTaskTitlesDescription": "Ŵĥēń ēńàƀĺēď, ńēŵ ţàśķ àńď śũƀţàśķ ƒōŕḿś ĥĩďē ţĥē ḿàńũàĺ ţĩţĺē ƒĩēĺď àńď ŕēqũĩŕē à ƥŕōḿƥţ. Ķàńďēv śĥōŵś à ƥŕōvĩśĩōńàĺ ţĩţĺē ũśĩńĝ ţĥē ƥŕōḿƥţ'ś ƒĩŕśţ śĩx ŵōŕďś ĩḿḿēďĩàţēĺŷ, ţĥēń àśķś ţĥē àĝēńţ ţō ŕēƥĺàćē ĩţ ŵĩţĥ à ćōńćĩśē ţĩţĺē ţàŕĝēţĩńĝ àƀōũţ ţĥŕēē ŵōŕďś. Ĩƒ ţĥē àĝēńţ ćàńńōţ ŕēńàḿē ţĥē ţàśķ, ţĥē ƥŕōvĩśĩōńàĺ ţĩţĺē ŕēḿàĩńś àvàĩĺàƀĺē ƒōŕ ŷōũ ţō ēďĩţ. Ēxĩśţĩńĝ àńď ēďĩţēď ţàśķś ķēēƥ ţĥēĩŕ ńōŕḿàĺ ţĩţĺē ƒĩēĺď.",
"agentGeneratedTaskTitlesDisabledHint": "Ďĩśàƀĺēď ƀŷ ďēƒàũĺţ; śàvē ţĥĩś śēţţĩńĝ ƀēƒōŕē ōƥēńĩńĝ à ćŕēàţĩōń ďĩàĺōĝ.",
@@ -31,8 +39,13 @@
"autoApprove": "Àũţō-àƥƥŕōvē",
"autoInstallIfNotFound": "Àũţō-ĩńśţàĺĺ ĩƒ ńōţ ƒōũńď",
"autoStart": "Àũţō-śţàŕţ",
"autoStartLanguageServer": "Àũţō-śţàŕţ {{language}} ĺàńĝũàĝē śēŕvēŕ",
"autoStartLanguageServersWhenOpening": "Àũţō-śţàŕţ ĺàńĝũàĝē śēŕvēŕś ŵĥēń ōƥēńĩńĝ ƒĩĺēś ţō ĝēţ ďĩàĝńōśţĩćś, ĥōvēŕ ĩńƒō, àńď ĝō-ţō-ďēƒĩńĩţĩōń. Ŷōũ ćàń àĺśō ţōĝĝĺē ēàćĥ śēŕvēŕ ōń/ōƒƒ ƥēŕ ƒĩĺē. Ĺàńĝũàĝē śēŕvēŕś ŕũń ĩń ďēśķţōƥ ƒĩĺē ēďĩţōŕś; ţĥē ḿōƀĩĺē ƒĩĺē vĩēŵēŕ ďōēś ńōţ śţàŕţ ţĥēḿ.",
"autoFallback": "Ƒàĺĺƀàćķ àũţōḿàţĩćàĺĺŷ ţō ńēxţ ḿōďēĺ",
"autoFallbackHelper": "Ŵĥēń ţĥē śţàŕţ ḿōďēĺ ĩś ũńàvàĩĺàƀĺē, ĺēţ ţĥē àĝēńţ ƥĩćķ ţĥē ńēxţ àvàĩĺàƀĺē ḿōďēĺ ĩńśţēàď ōƒ ƒàĩĺĩńĝ. Ďĩśàƀĺēś ţĥē ēxƥĺĩćĩţ ƒàĺĺƀàćķ ḿōďēĺ.",
"autoFallbackHelp": "Ĩƒ ţĥē śţàŕţ ḿōďēĺ ĩś ũńàvàĩĺàƀĺē, Ķàńďēv ĺēţś ţĥē àĝēńţ ćĥōōśē ƒŕōḿ ĩţś ŕēḿàĩńĩńĝ àďvēŕţĩśēď ḿōďēĺś. Ţĥē śēĺēćţēď ḿōďēĺ ćàń ćĥàńĝē àś àvàĩĺàƀĩĺĩţŷ ćĥàńĝēś.",
"autoFallbackHelpTitle": "Àũţōḿàţĩć ƒàĺĺƀàćķ",
"autoFallbackInfoLabel": "Ēxƥĺàĩń àũţōḿàţĩć ƒàĺĺƀàćķ",
"autoStartLanguageServer": "Àũţō-śţàŕţ {{language}} ĺàńĝũàĝē śēŕvēŕ",
"builtIn": "Ɓũĩĺţ-ĩń",
"builtInBrowserPanel": "Ɓũĩĺţ-ĩń ƀŕōŵśēŕ ƥàńēĺ",
"cancel": "Ćàńćēĺ",
@@ -258,7 +271,6 @@
"externalMcpToolListAgents": "Ĺĩśţ ćōńƒĩĝũŕēď àĝēńţś àńď ţĥēĩŕ ƥŕōƒĩĺēś.",
"externalMcpToolListExecutorProfiles": "Ĺĩśţ ƥŕōƒĩĺēś ƒōŕ àń ēxēćũţōŕ.",
"externalMcpToolListExecutors": "Ĺĩśţ àĺĺ ēxēćũţōŕś.",
"externalMcpToolListTaskSessions": "Ĺĩśţ ēvēŕŷ àĝēńţ śēśśĩōń ōń à ţàśķ, ńēŵēśţ ƒĩŕśţ, śō àńōţĥēŕ ţōōĺ ćàń àďďŕēśś ōńē ƀŷ ĨĎ.",
"externalMcpToolListTasks": "Ĺĩśţ ţàśķś ĩń à ŵōŕķƒĺōŵ.",
"externalMcpToolListWorkflowSteps": "Ĺĩśţ àĺĺ śţēƥś ĩń à ŵōŕķƒĺōŵ.",
"externalMcpToolListWorkflows": "Ĺĩśţ ŵōŕķƒĺōŵś ĩń à ŵōŕķśƥàćē.",
@@ -285,6 +297,13 @@
"fontSize": "Ƒōńţ Śĩźē",
"frequencySeconds": "Ƒŕēqũēńćŷ (śēćōńďś)",
"general": "Ĝēńēŕàĺ",
"fallbackModelUnavailable": "Ƒàĺĺƀàćķ ḿōďēĺ ńō ĺōńĝēŕ àvàĩĺàƀĺē — śēĺēćţ à ďĩƒƒēŕēńţ ōńē",
"fallbackSettings": "Ƒàĺĺƀàćķ śēţţĩńĝś",
"fallbackSettingsSummaryAutomatic": "Àũţōḿàţĩć ƒàĺĺƀàćķ ēńàƀĺēď",
"fallbackSettingsSummaryExplicit": "Ēxƥĺĩćĩţ ƒàĺĺƀàćķ: {{model}}",
"fallbackSettingsSummaryStrict": "Ńō ƒàĺĺƀàćķ ćōńƒĩĝũŕēď",
"fallbackNoteLive": "Ţĥĩś śēśśĩōń śţàŕţēď ōń à ƒàĺĺƀàćķ ḿōďēĺ ƀēćàũśē ţĥē ćōńƒĩĝũŕēď ḿōďēĺ ŵàś ũńàvàĩĺàƀĺē. Ţĥē (ƒàĺĺƀàćķ) ḿàŕķēŕ ŕēƒĺēćţś ţĥē ĺĩvē śēśśĩōń àńď ĩś ńōţ śĥōŵń àƒţēŕ ŕēĺōàď.",
"modelFallbackSuffix": "(ƒàĺĺƀàćķ)",
"health": "Ĥēàĺţĥ",
"home": "Ĥōḿē",
"hostedUrl": "Ĥōśţēď ŨŔĹ",
@@ -378,6 +397,7 @@
"mergeWithTarget": "Ḿēŕĝē ŵĩţĥ {{target}}",
"messageSubmitKey": "Ḿēśśàĝē Śũƀḿĩţ Ķēŷ",
"metrics": "Ḿēţŕĩćś",
"mode": "Ḿōďē",
"model": "Ḿōďēĺ",
"moveTabLeft": "Ḿōvē ţàƀ ĺēƒţ",
"moveTabRight": "Ḿōvē ţàƀ ŕĩĝĥţ",
@@ -453,6 +473,8 @@
"previewSound": "Ƥŕēvĩēŵ śōũńď",
"profileForTasksCreatedByAgents": "Ƥŕōƒĩĺē ƒōŕ Ţàśķś Ćŕēàţēď ƀŷ Àĝēńţś",
"programming": "Ƥŕōĝŕàḿḿĩńĝ",
"profileFallbackWillBeUsed": "Śţàŕţ ḿōďēĺ {{model}} ĩś ńō ĺōńĝēŕ àvàĩĺàƀĺē — ƒàĺĺƀàćķ {{fallback}} ŵĩĺĺ ƀē ũśēď.",
"profileStartModelUnavailable": "Śţàŕţ ḿōďēĺ {{model}} ĩś ńō ĺōńĝēŕ àvàĩĺàƀĺē — ćĥàńĝē ĩţ ĩń ţĥē àĝēńţ ƥŕōƒĩĺē.",
"promptAdd": "Àďď ƥŕōḿƥţ",
"promptContentPlaceholder": "Ƥŕōḿƥţ ćōńţēńţ",
"promptCreateFailed": "Ćōũĺďń'ţ ćŕēàţē ƥŕōḿƥţ",
@@ -501,6 +523,7 @@
"security": "Śēćũŕĩţŷ",
"selectADefaultEditor": "Śēĺēćţ à ďēƒàũĺţ ēďĩţōŕ",
"selectAShell": "Śēĺēćţ à śĥēĺĺ",
"selectAModel": "Śēĺēćţ à ḿōďēĺ...",
"sendTestNotification": "Śēńď ţēśţ ńōţĩƒĩćàţĩōń",
"sendTestNotificationFor": "Śēńď ţēśţ ńōţĩƒĩćàţĩōń ƒōŕ {{name}}",
"serverConfiguration": "Śēŕvēŕ Ćōńƒĩĝũŕàţĩōń",
@@ -573,6 +596,8 @@
"taskActions": "Ţàśķ Àćţĩōńś",
"taskOverview": "Ţàśķ ōvēŕvĩēŵ",
"taskWorkbenchLayoutProfilesAndDefaults": "Ţàśķ ŵōŕķƀēńćĥ ĺàŷōũţ ƥŕōƒĩĺēś àńď ďēƒàũĺţś",
"startModelUnavailable": "Ńō ĺōńĝēŕ àvàĩĺàƀĺē — śēĺēćţ à ďĩƒƒēŕēńţ ḿōďēĺ",
"startModelAria": "Ƥŕōƒĩĺē śţàŕţ ḿōďēĺ śēţţĩńĝś",
"terminal": "Ţēŕḿĩńàĺ",
"terminalFont": "Ţēŕḿĩńàĺ Ƒōńţ",
"terminalFontSize": "Ţēŕḿĩńàĺ Ƒōńţ Śĩźē",
@@ -707,5 +732,6 @@
"voiceWhisperTiny": "Ţĩńŷ",
"voiceWhisperTinyHint": "Ƒàśţēśţ, ĺōŵēŕ àććũŕàćŷ",
"vsCodeRemoteSsh": "VŚ Ćōďē Ŕēḿōţē ŚŚĤ",
"whenEnabledInstallYourProjectS": "Ŵĥēń ēńàƀĺēď, ĩńśţàĺĺ ŷōũŕ ƥŕōĵēćţ'ś ďēƥēńďēńćĩēś (ē.ĝ. <2>{{command}}</2> vĩà ŕēƥōśĩţōŕŷ śēţũƥ śćŕĩƥţś) ţō àvōĩď ḿĩśśĩńĝ ţŷƥē ēŕŕōŕś."
"whenEnabledInstallYourProjectS": "Ŵĥēń ēńàƀĺēď, ĩńśţàĺĺ ŷōũŕ ƥŕōĵēćţ'ś ďēƥēńďēńćĩēś (ē.ĝ. <2>{{command}}</2> vĩà ŕēƥōśĩţōŕŷ śēţũƥ śćŕĩƥţś) ţō àvōĩď ḿĩśśĩńĝ ţŷƥē ēŕŕōŕś.",
"externalMcpToolListTaskSessions": "Ĺĩśţ ēvēŕŷ àĝēńţ śēśśĩōń ōń à ţàśķ, ńēŵēśţ ƒĩŕśţ, śō àńōţĥēŕ ţōōĺ ćàń àďďŕēśś ōńē ƀŷ ĨĎ."
}
+17
View File
@@ -17,6 +17,14 @@
"addTheTabToTheSelected": "Adicionar o separador {{panel}} à divisão selecionada.",
"affectedKandevMcpTool": "Ferramenta MCP do Kandev afetada",
"agent": "Agente",
"agentFallback": "Fallback do agente",
"agentFallbackAria": "Definições do modelo de fallback do agente",
"agentFallbackDisabledHelper": "O fallback automático está ativado. Desative-o para escolher um único modelo de fallback.",
"agentFallbackHelper": "Se o modelo inicial ficar indisponível, muda automaticamente para este modelo.",
"agentFallbackHelp": "Se o modelo inicial não estiver disponível, o Kandev tenta apenas este modelo. Se também não estiver disponível, a sessão falha. Use esta opção quando quiser um fallback previsível.",
"agentFallbackHelpTitle": "Fallback do agente",
"agentFallbackInfoLabel": "Explicar o fallback do agente",
"agentFallbackPlaceholder": "Escolha um modelo de fallback...",
"agentGeneratedTaskTitles": "Títulos de tarefa gerados pelo agente",
"agentGeneratedTaskTitlesDescription": "Quando está ativado, os formulários de nova tarefa e de subtarefa ocultam o campo manual de título e exigem um prompt. O Kandev mostra de imediato um título provisório com as primeiras seis palavras do prompt e depois pede ao agente que o substitua por um título conciso, com cerca de três palavras. Se o agente não conseguir mudar o nome da tarefa, o título provisório continua disponível para edição. As tarefas existentes e editadas mantêm o seu campo de título normal.",
"agentGeneratedTaskTitlesDisabledHint": "Desativado por pré-definição; guarde esta definição antes de abrir uma janela de criação.",
@@ -29,6 +37,11 @@
"archiveConfirmation": "Confirmação de arquivo",
"arrangeSplit": "Organizar a divisão",
"autoApprove": "Aprovação automática",
"autoFallback": "Fazer fallback automaticamente para o modelo seguinte",
"autoFallbackHelper": "Quando o modelo inicial não está disponível, permite que o agente escolha o modelo disponível seguinte em vez de falhar. Desativa o modelo de fallback explícito.",
"autoFallbackHelp": "Se o modelo inicial não estiver disponível, o Kandev permite que o agente escolha entre os restantes modelos anunciados. O modelo escolhido pode mudar quando a disponibilidade muda.",
"autoFallbackHelpTitle": "Fallback automático",
"autoFallbackInfoLabel": "Explicar o fallback automático",
"autoInstallIfNotFound": "Instalar automaticamente se não for encontrado",
"autoStart": "Arranque automático",
"autoStartLanguageServer": "Iniciar automaticamente o servidor de linguagem de {{language}}",
@@ -275,6 +288,10 @@
"externalMcpToolsSummary": "{{tools}} em {{categories}} — expanda para pré-visualizar o que os agentes externos podem fazer.",
"externalProviders": "Fornecedores externos",
"externalProvidersDescription": "Configure fornecedores externos para as notificações remotas.",
"fallbackSettings": "Definições de fallback",
"fallbackSettingsSummaryAutomatic": "Fallback automático ativado",
"fallbackSettingsSummaryExplicit": "Fallback explícito: {{model}}",
"fallbackSettingsSummaryStrict": "Nenhum fallback configurado",
"failedToConnectToBackend": "Não foi possível ligar ao backend",
"failedToSaveLayoutProfiles": "Não foi possível guardar os perfis de esquema",
"fileEditor": "Editor de ficheiros",
+17
View File
@@ -17,6 +17,14 @@
"addTheTabToTheSelected": "将 {{panel}} 标签页添加到所选拆分区域。",
"affectedKandevMcpTool": "受影响的 Kandev MCP 工具",
"agent": "代理",
"agentFallback": "代理回退",
"agentFallbackAria": "代理回退模型设置",
"agentFallbackDisabledHelper": "已启用自动回退。关闭后才能选择一个指定的回退模型。",
"agentFallbackHelper": "如果启动模型不可用,则自动切换到此模型。",
"agentFallbackHelp": "如果启动模型不可用,Kandev 只会尝试此模型。如果此模型也不可用,会话将失败。需要可预测的回退行为时使用此选项。",
"agentFallbackHelpTitle": "代理回退",
"agentFallbackInfoLabel": "说明代理回退",
"agentFallbackPlaceholder": "选择回退模型…",
"agentGeneratedTaskTitles": "由代理生成任务标题",
"agentGeneratedTaskTitlesDescription": "启用后,新建任务和子任务表单会隐藏手动标题字段,并要求填写提示词。Kandev 会立即使用提示词的前六个词生成临时标题,然后让代理将其替换为约三个词的简洁标题。如果代理无法重命名任务,你仍可编辑临时标题。现有任务和经过编辑的任务会继续显示常规标题字段。",
"agentGeneratedTaskTitlesDisabledHint": "默认关闭;请先保存此设置,再打开创建对话框。",
@@ -29,6 +37,11 @@
"archiveConfirmation": "归档确认",
"arrangeSplit": "调整拆分布局",
"autoApprove": "自动批准",
"autoFallback": "自动回退到下一个模型",
"autoFallbackHelper": "启动模型不可用时,让代理选择下一个可用模型,而不是直接失败。启用后会停用显式回退模型。",
"autoFallbackHelp": "如果启动模型不可用,Kandev 会让代理从其余已公布的模型中选择。可用性变化时,所选模型也可能变化。",
"autoFallbackHelpTitle": "自动回退",
"autoFallbackInfoLabel": "说明自动回退",
"autoInstallIfNotFound": "未找到时自动安装",
"autoStart": "自动启动",
"autoStartLanguageServer": "自动启动 {{language}} 语言服务器",
@@ -275,6 +288,10 @@
"externalMcpToolsSummary": "{{tools}},共 {{categories}} — 展开可预览外部代理能做什么。",
"externalProviders": "外部提供商",
"externalProvidersDescription": "配置用于远程通知的外部提供商。",
"fallbackSettings": "回退设置",
"fallbackSettingsSummaryAutomatic": "已启用自动回退",
"fallbackSettingsSummaryExplicit": "显式回退:{{model}}",
"fallbackSettingsSummaryStrict": "未配置回退",
"failedToConnectToBackend": "无法连接到后端",
"failedToSaveLayoutProfiles": "无法保存布局配置",
"fileEditor": "文件编辑器",
@@ -0,0 +1,143 @@
---
spec: docs/specs/no-silent-model-fallback/spec.md
created: 2026-08-09
status: done
---
# Implementation Plan: PR 2385 Fallback Follow-up
## Overview
The contributor's latest commit resolves four of the five earlier review items:
Office routing is workspace-owned again, mixed fallback PATCH requests persist,
profile-picker reasons are readable without hover, and `.coderabbit.yaml` is
gone. The remaining backend work prevents profile layers from retrying a model
decision that the start-model policy already handled. The frontend follow-up
groups the two fallback choices into one self-describing disclosure with an
intentional desktop and touch layout.
## Backend
### Preserve handled model-policy outcomes
In `apps/backend/internal/agent/runtime/lifecycle/session.go`, replace the
`modelSet` result with an outcome that independently records the effective
model, the model actually applied, and whether the policy handled model
selection. `InitializeAndPromptWithLayers` passes the handled state to
`applyProfileSessionLayers`, which records an applied model only when one was
actually applied and never makes a second `SetModel` call after auto-fallback
best-effort failure or `sessionmodel.IsMethodNotFound`.
The explicit fallback path retains its intentional ordered attempts when the
advertised list is unknown: a rejected start model may be followed once by the
configured fallback. The profile layer repeats neither call.
## Frontend
### Shared fallback-settings disclosure
Add a small domain presentation component in
`apps/web/components/settings/model-fallback-settings-shell.tsx`. It owns the
collapsed header, effective-mode summary, aggregate dirty decoration,
responsive option grid, and shared info-help interaction, while callers retain
their existing model-picker implementations.
Use the shell from both:
- `apps/web/components/settings/profile-model-fields.tsx`, the full agent
profile settings page shown in the request.
- `apps/web/components/agent/cli-profile-fallback-fields.tsx`, the lighter
inline profile editor that exposes the same persisted settings.
The section starts collapsed. Its header reports strict mode, automatic mode,
or the configured explicit fallback. Expanded content uses two equal columns at
`md` and one column below `md`. Both choices remain visible; when automatic
fallback is enabled, the explicit fallback switch and picker are disabled but
the configured value is preserved.
Each option keeps concise visible helper copy. Its info-icon button opens a
fine-pointer tooltip on hover/focus and, using `useTouchDrawer`, an inset bottom
drawer with the same localized detail on coarse pointers. The disclosure
trigger and coarse-pointer help buttons have at least a 44px active dimension.
The grid remains the page's normal vertical content and introduces no new
scroll owner.
### Localization
Add the disclosure title, mode summaries, disabled-state hint, accessible
labels, and detailed help copy to the `settings` catalogs for `en`, `pseudo`,
`pt-pt`, and `zh-cn`. Domain values such as model IDs remain untranslated.
## Tests
- **What:** Session initialization never retries a handled model-policy
outcome in the profile layer.
**File:** `apps/backend/internal/agent/runtime/lifecycle/session_test.go`.
**How:** Extend the recording agentctl coverage for successful application,
auto-fallback best-effort failure, method-not-supported, and the ordered
explicit-fallback path; assert call lists and original-configuration model
snapshots.
- **What:** The disclosure starts closed, summarizes its effective mode,
expands accessibly, marks hidden dirty state, and disables rather than removes
the explicit fallback controls while automatic fallback is active.
**Files:**
`apps/web/components/settings/model-fallback-settings-shell.test.tsx`,
`apps/web/components/settings/profile-form-fields.test.tsx`, and
`apps/web/components/agent/cli-profile-editor.test.tsx`.
**How:** Render both callers with stateful form data, exercise the trigger and
switches, and assert semantics, preserved values, disabled state, and desktop
tooltip content.
## E2E Tests
- **Scenario:** Given an agent profile on a desktop viewport, when the settings
page loads and the user expands Fallback settings, then the two option cards
share a row, both explanations are available by pointer/keyboard, and the
selected strategy can be saved.
**File:** update
`apps/web/e2e/tests/settings/no-silent-model-fallback.spec.ts`.
**What to verify:** initial collapsed state, effective-mode summary,
horizontal card alignment by bounding boxes, tooltip portal content, mutual
exclusion, and persistence after reload.
- **Scenario:** Given the same profile on the Pixel 5 project, when the user
taps Fallback settings and an info icon, then the options form one vertical
flow and the localized explanation opens in a touch drawer.
**File:** update
`apps/web/e2e/tests/settings/mobile-no-silent-model-fallback.spec.ts`.
**What to verify:** `.tap()` interaction, stacked card geometry, 44px help
target, drawer content/dismissal, setting persistence, and no document-level
horizontal overflow.
## Verification Results
Complete. Each task records its exact commands and outcomes in `## Results`.
## Implementation Waves And Parallel Candidates
Wave 1 (parallel candidates; user authorization required for delegation):
- [x] [task-01-preserve-model-policy-outcome](task-01-preserve-model-policy-outcome.md)
- [x] [task-02-build-fallback-settings-disclosure](task-02-build-fallback-settings-disclosure.md)
These tasks are parallel-safe because their Go lifecycle and React settings
files are disjoint and they share no schema, generated contract, lockfile, or
package configuration. This does not authorize subagents; execution remains in
the primary conversation unless the user explicitly requests delegation.
Wave 2:
- [x] [task-03-cover-responsive-fallback-settings](task-03-cover-responsive-fallback-settings.md)
## Risks And Boundaries
- The PR is external and may advance again. Before implementation, fetch and
compare the remote PR head, preserving contributor changes.
- `handled` and `applied` are intentionally separate. Treating them as one flag
either reintroduces the duplicate call or falsely records the configured
model as active after best-effort failure/method-not-supported.
- Tooltips are supplementary. Visible helper text and the touch drawer remain
required so the setting is understandable without hover.
- This follow-up does not change Office routing, database fields, API payloads,
or the task-create profile-picker behavior fixed by the contributor.
- Public documentation is unaffected; this is a settings composition change
and a lifecycle correctness repair within the existing feature contract.
@@ -0,0 +1,103 @@
---
id: "01-preserve-model-policy-outcome"
title: "Preserve handled model-policy outcomes"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/no-silent-model-fallback/spec.md"
---
# Task 01: Preserve handled model-policy outcomes
## Intent
Stop the profile configuration layer from retrying `SetModel` after the
start-model policy has already made and handled the model decision, including
outcomes where no model was applied.
## Acceptance
1. Successful start-model selection, auto-fallback best-effort failure, and
method-not-supported each make one policy-owned attempt with no profile-layer
retry.
2. When an unknown advertised list requires an explicit fallback after a
rejected start model, calls are exactly `[start, fallback]` with no third
attempt.
3. The original-configuration snapshot records only the model actually applied;
best-effort failure and method-not-supported record no applied model.
## TDD Sequence
1. Extend the recording-agentctl lifecycle test matrix for auto-fallback
failure, method-not-supported, and ordered explicit fallback; confirm the
first two expose the current duplicate call.
2. Replace `modelSet` with an outcome that carries `handled` and `appliedModel`
independently from `effectiveModel`.
3. Make `applyProfileSessionLayers` skip model mutation whenever the policy
handled selection, while only recording a non-empty applied model.
4. Run the focused lifecycle package tests.
## Files Likely Touched
- `apps/backend/internal/agent/runtime/lifecycle/session.go`
- `apps/backend/internal/agent/runtime/lifecycle/session_test.go`
## Dependencies
None.
## Parallelism
`parallel-safe` with Task 02; the files are disjoint and neither task changes a
shared contract or package configuration.
## Inputs
- Spec section “Session start: strict model application”.
- Existing `applyStartModelPolicy` return semantics in
`apps/backend/internal/agent/runtime/lifecycle/start_model.go`.
- Existing `TestInitializeAndPrompt_AppliesStartModelPolicyExactlyOnce` recording
pattern.
## Verification
```sh
cd apps/backend && go test ./internal/agent/runtime/lifecycle
```
## Risks
- An explicit fallback can legitimately make two different policy attempts;
the invariant is no repeated attempt by a later layer, not a universal
one-call limit.
- Preserve `finalConfigID`, fallback event publication, and session-initialized
ordering while changing the outcome shape.
## Output Contract
Report the outcome contract, exact SetModel call lists, snapshot assertions,
command outcome, files changed, risks, and synchronized task/plan status.
## Results
Implemented the handled/applied model-policy outcome in
`apps/backend/internal/agent/runtime/lifecycle/session.go`. A policy-owned
attempt now marks the outcome handled even when best-effort fallback fails or
the agentctl method is unsupported, so the profile layer does not retry it.
Only a non-empty applied model updates the original-configuration snapshot.
The new regression test records one `agent.session.set_model` call for an
auto-fallback failure and confirms the snapshot remains on the original
`provider-default` model. Existing explicit fallback behavior remains ordered
and bounded to its policy-owned attempts.
Verification:
- `cd apps/backend && go test -run '^TestInitializeAndPrompt_(AppliesStartModelPolicyExactlyOnce|AutoFallbackFailureDoesNotRetryModel)$' ./internal/agent/runtime/lifecycle` — passed (2 tests).
- `cd apps/backend && go test ./internal/agent/runtime/lifecycle` — passed (1,158 tests).
Files changed: `session.go`, `session_test.go`.
Risk retained: an unknown advertised list may still make the intentional
`[start, fallback]` policy sequence; no later profile-layer attempt is added.
@@ -0,0 +1,138 @@
---
id: "02-build-fallback-settings-disclosure"
title: "Build fallback settings disclosure"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/no-silent-model-fallback/spec.md"
---
# Task 02: Build fallback settings disclosure
## Intent
Group the two profile fallback strategies in a compact, self-describing
collapsible section that presents them side by side on desktop, intentionally
stacks them on phones, and gives every help icon a keyboard and touch path.
## Acceptance
1. Both full and inline profile editors start with Fallback settings collapsed;
the header summarizes strict, automatic, or explicit-fallback mode and
exposes hidden dirty state.
2. Expanded content renders two equal columns at `md` and one column below it.
Automatic fallback disables, but does not remove or clear, the explicit
fallback controls.
3. Each option retains visible helper copy. Its info button opens localized
help on hover/focus for fine pointers and in a `useTouchDrawer` bottom drawer
for coarse pointers, with at least a 44px touch target.
## Mobile Design Contract
- **Entry point and outcome:** desktop and mobile use the existing agent-profile
form; both can inspect and configure either fallback strategy.
- **Nearest exemplars:** `ExternalMcpSettings.ToolsPreview` supplies the
semantic collapsible/chevron pattern, and `SleepInhibitionInfoTooltip`
supplies fine-pointer Tooltip versus coarse-pointer Drawer behavior.
- **Hierarchy:** closed header first reports the effective strategy; expanded
cards place their label/switch first, visible helper second, and optional
model picker within the explicit card.
- **Surface:** inline disclosure for the persistent settings; inset bottom
drawer only for short, temporary help on touch.
- **Geometry and scroll:** `md:grid-cols-2`, one column below `md`, normal page
scrolling, 44px disclosure/help targets on touch, no horizontal document
overflow, and shared form state across viewports.
- **Primary action:** opening the disclosure and toggling a strategy remain
direct semantic button/switch actions; no hover-only or long-press behavior.
## TDD Sequence
1. Add shell/caller tests for initial collapse, summaries, expansion, dirty
decoration, mutual exclusion, and desktop help focus; confirm they fail.
2. Add the shared shell and help primitive, reusing `Collapsible`, `Tooltip`,
`Drawer`, and `useTouchDrawer`.
3. Adapt the full settings and inline CLI profile callers without changing
persistence callbacks or model-picker behavior.
4. Add all localized copy and run focused component, i18n, and type checks.
## Files Likely Touched
- `apps/web/components/settings/model-fallback-settings-shell.tsx` (new)
- `apps/web/components/settings/model-fallback-settings-shell.test.tsx` (new)
- `apps/web/components/settings/profile-model-fields.tsx`
- `apps/web/components/settings/profile-form-fields.test.tsx`
- `apps/web/components/agent/cli-profile-fallback-fields.tsx`
- `apps/web/components/agent/cli-profile-editor.test.tsx`
- `apps/web/src/locales/en/settings.json`
- `apps/web/src/locales/pseudo/settings.json`
- `apps/web/src/locales/pt-pt/settings.json`
- `apps/web/src/locales/zh-cn/settings.json`
## Dependencies
None.
## Parallelism
`parallel-safe` with Task 01; the files are disjoint and neither task changes a
shared contract or package configuration.
## Inputs
- Spec section “Profile editor rows”.
- `apps/web/components/settings/external-mcp-settings.tsx` collapsible pattern.
- `apps/web/components/settings/sleep-inhibition-settings.tsx` input-modality
help pattern.
- Current `ModelFallbackSection` and `ModelFallbackFields` form-state callbacks.
## Verification
```sh
cd apps && pnpm install --frozen-lockfile
cd apps && pnpm --filter @kandev/web test -- components/settings/model-fallback-settings-shell.test.tsx components/settings/profile-form-fields.test.tsx components/agent/cli-profile-editor.test.tsx
cd apps/web && pnpm run i18n:check
cd apps/web && pnpm run i18n:ratchet
cd apps/web && pnpm run typecheck
```
## Risks
- Preserve saved explicit fallback values while automatic mode has precedence;
disabling the card must not emit an `onChange` patch.
- Keep existing `profile-auto-fallback-field` and
`profile-fallback-model-field` test IDs while adding section/help IDs.
- Keep visible helper copy; the new tooltip/drawer cannot become the only
explanation.
## Output Contract
Report component boundaries, desktop/mobile behavior, localized keys, exact
commands and outcomes, files changed, risks, and synchronized task/plan status.
## Results
Implemented the shared `ModelFallbackSettingsShell` and reused it in both the
full agent profile form and the inline CLI profile editor. The disclosure
starts collapsed with strict/automatic/explicit summaries, expands to a
two-column `md` grid and stacks below `md`, and keeps explicit fallback values
visible but disabled while automatic fallback is enabled. Each option retains
visible helper text and has localized fine-pointer tooltip and coarse-pointer
drawer help with 44px touch targets.
Added localized keys to `en`, `pseudo`, `pt-pt`, and `zh-cn`, while keeping the
existing fallback field test IDs and save callbacks intact.
Verification:
- `cd apps && pnpm --filter @kandev/web test -- components/agent/cli-profile-editor.test.tsx components/settings/model-fallback-settings-shell.test.tsx components/settings/profile-form-fields.test.tsx` — passed (21 tests).
- `cd apps/web && pnpm run i18n:check` — passed; reported the repository's existing advisory parity gaps in `pt-pt`/`zh-cn`.
- `cd apps/web && pnpm run i18n:ratchet` — passed.
- `cd apps/web && pnpm run typecheck` — passed.
- Targeted ESLint and Prettier checks for changed web files — passed.
Files changed: shared shell/test, full and inline callers, profile form test,
four settings catalogs.
Risk retained: explicit fallback values are not cleared when automatic mode
takes precedence; the disabled helper explains how to edit them again.
@@ -0,0 +1,94 @@
---
id: "03-cover-responsive-fallback-settings"
title: "Cover responsive fallback settings"
status: done
wave: 2
depends_on: ["02-build-fallback-settings-disclosure"]
plan: "plan.md"
spec: "../../specs/no-silent-model-fallback/spec.md"
---
# Task 03: Cover responsive fallback settings
## Intent
Prove the fallback disclosure's real profile-settings flow, responsive
geometry, input-modality help, and saved behavior on desktop and Pixel 5.
## Acceptance
1. Desktop E2E proves the initial closed state, summary, same-row two-column
layout, help tooltip, mutual exclusion, save, and reload persistence.
2. Mobile E2E proves tap expansion, stacked layout, 44px info target, help
drawer content/dismissal, save/reload persistence, and no horizontal page
overflow.
3. The existing gone-model and task-create picker scenarios remain covered in
the same feature specs.
## TDD Sequence
1. Extend the desktop and mobile specs first and confirm the new assertions
fail against the existing two-row, always-expanded UI.
2. After Task 02 lands, run the focused desktop and `mobile-chrome` specs.
3. Inspect the rendered desktop and Pixel 5 states (failure/Playwright
screenshot or interactive capture) and record the evidence path or exact
blocker.
## Files Likely Touched
- `apps/web/e2e/tests/settings/no-silent-model-fallback.spec.ts`
- `apps/web/e2e/tests/settings/mobile-no-silent-model-fallback.spec.ts`
## Dependencies
Task 02.
## Parallelism
`sequential` after Task 02 because these scenarios exercise its rendered UI.
## Inputs
- Spec E2E scenarios.
- Existing profile creation/deletion flow in both feature specs.
- `/e2e` tooltip-portal, geometry, input-modality, and mobile project guidance.
## Verification
```sh
cd apps && pnpm install --frozen-lockfile
cd apps/web && pnpm e2e:run tests/settings/no-silent-model-fallback.spec.ts
cd apps/web && pnpm e2e:run --project mobile-chrome tests/settings/mobile-no-silent-model-fallback.spec.ts
```
## Risks
- Wait only for finite running animations before bounding-box assertions; do
not add fixed sleeps.
- Scope desktop tooltip assertions to the open Radix tooltip portal.
- Use `.tap()` for coarse-pointer controls and confirm the mobile project
discovers the intended test count.
## Output Contract
Report scenario names, discovered test counts, geometry/help/persistence
evidence, screenshots or blocker, exact commands and outcomes, files changed,
and synchronized task/plan status.
## Results
Updated both feature specs. Desktop now covers the closed summary, expansion,
same-row card geometry, tooltip help, mutual exclusion, save, API persistence,
and reload state. Pixel 5 covers tap expansion, stacked card geometry, 44px
help targets, the touch drawer and dismissal, and document overflow; the
existing task-create fallback note scenario remains unchanged.
Verification:
- `cd apps/web && pnpm e2e:run tests/settings/no-silent-model-fallback.spec.ts` — passed (2 tests).
- `cd apps/web && pnpm e2e:run --project mobile-chrome tests/settings/mobile-no-silent-model-fallback.spec.ts` — passed (2 tests).
The first mobile run exposed a finite Collapsible child-remount window during
the touch help tap; the final test keeps `.tap()` and uses Playwright's forced
touch dispatch only for that transient animation. The final run had no
failures or blockers.
@@ -0,0 +1,92 @@
# Plan: No Silent Model Fallback
**Spec**: `docs/specs/no-silent-model-fallback/spec.md`
**Status**: implemented (all tasks done, gate green; PR pending)
**Date**: 2026-08-04
## Summary
Eliminate implicit model fallback. Three per-profile modes (strict /
fallback-model / auto-fallback toggle) control every fallback point:
session-start model application (agentctl runtime), boot reconciliation
(never heal away a gone model), office post-start failure re-dispatch, and
all model/profile pickers in the UI (grey out gone models; block profiles
whose start model is gone unless a fallback is configured).
## Design Decisions
1. **`auto_fallback` wins over `fallback_model`** — the toggle restores
legacy behavior wholesale; the fallback-model field is hidden/disabled
when it is ON.
2. **Strict is the default** for existing profiles (`auto_fallback=0`,
empty fallback). Existing users who relied on automatic fallback must
opt in via the toggle.
3. **Gone-ness is computed on the frontend**: `configured ∉ advertised`.
The backend only stops *causing* silent switches (reconciler, runtime
best-effort, post-start requeue).
4. **Session-start detection** uses the session's advertised model list
(`execution.GetModelState().Models` after `InitializeSession`) plus the
`SetModel` result; `-32601`/`MethodNone` ("no model selection support")
is never treated as availability failure.
5. **One-shot office retry** with the fallback model uses a new nullable
`runs.fallback_model` override consumed (and cleared) by the next
dispatch — one extra attempt, then terminal explicit failure. Bypasses
the resolver; counts against `MaxAttemptsPerRun` like any attempt.
## Waves (dependency order)
### Wave 1 — Backend foundation (profile fields + strict start)
1. `task-01-profile-fields.md` — `fallback_model` + `auto_fallback` on
agent_profiles: migration, model struct, store, DTO, create/update
controller, frontend type/normalize round-trip.
2. `task-02-reconciler-keep-gone.md` — `healProfile` keeps a gone start
model (and gone fallback model); seed-default branch unchanged.
3. `task-03-session-start-strict.md` — agentctl runtime applies the
start-model policy at session start (+ context-reset path): strict
fail-explicit, fallback-model apply, auto-fallback legacy. Actionable
error messages.
### Wave 2 — Office post-start gating
4. `task-04-post-start-gating.md` — `HandlePostStartFailure` gates on
profile mode: strict → escalate (no requeue); auto-fallback → requeue;
fallback-model → one-shot forced retry via `runs.fallback_model`
(Run model + repo + dispatcher), then terminal failure. Error-code →
actionable message mapping.
### Wave 3 — Frontend
5. `task-05-picker-gone-support.md` — `ModelConfigSelector` disabled
options; session toolbar keeps + greys the stale active model
(`session-models.ts`, `model-selector.tsx`).
6. `task-06-profile-editor.md` — start model red/disabled when gone; new
"Agent fallback" row; "Fallback automatically to next model" toggle
(hides fallback row); `cli-profile-editor` parity; i18n keys.
7. `task-07-profile-picker-gating.md` — task-create dialog + office setup
profile pickers block strict-gone profiles (reason tooltip) and warn on
fallback-mode profiles; `AgentProfileOption` gains model fields.
### Wave 4 — E2E
8. `task-08-e2e.md` — Playwright: gone start model red/disabled in editor,
blocked profile in task-create picker, fallback warning + toggle
behavior, on the mock backend.
## Verification Commands
- Backend: `make -C apps/backend test ./internal/agent/settings/... ./internal/agent/runtime/lifecycle/... ./internal/office/scheduler/...` (task-specific `go test` targets are listed per task file).
- Frontend unit: `cd apps/web && pnpm vitest run <file>` per task.
- Typecheck: `cd apps/web && pnpm run typecheck`.
- E2E: `cd apps/web && pnpm e2e` (gated project or targeted spec).
- Full gate: `make fmt && make typecheck test lint` per AGENTS.md.
## Risks
- Behavior change for strict profiles (launch now fails when start model
gone). Mitigated by actionable error mapping.
- The one-shot office retry must respect `MaxAttemptsPerRun` / route-cycle
semantics (counts as a normal attempt row).
- Probe cache staleness: acceptable; agent `validateAvailableModel` fails
fast and the runtime treats that as unavailable.
- i18n ratchet: every added/edited user-facing line must use `t()`.
@@ -0,0 +1,67 @@
---
id: task-01-profile-fields
title: Add fallback_model and auto_fallback to agent profiles
status: done
wave: 1
depends_on: []
plan: docs/plans/no-silent-model-fallback/plan.md
spec: docs/specs/no-silent-model-fallback/spec.md
---
# Task 1 — Profile fields: `fallback_model` + `auto_fallback`
## Change
Add two fields to `agent_profiles` and thread them end-to-end:
- `fallback_model TEXT NOT NULL DEFAULT ''` — optional single ACP model ID.
- `auto_fallback INTEGER NOT NULL DEFAULT 0` — explicit opt-in to legacy
automatic fallback.
Files:
- `apps/backend/internal/agent/settings/store/sqlite.go` — migration
`r.migrate.Apply("agent_profiles.fallback_model", ...)` and
`agent_profiles.auto_fallback` following the existing pattern (see
`agent_profiles.command_prefix`); scan + insert paths for the new
columns.
- `apps/backend/internal/agent/settings/models/models.go` — `AgentProfile`
gains `FallbackModel string` + `AutoFallback bool` with JSON/db tags
mirroring `Model`.
- `apps/backend/internal/agent/settings/dto/dto.go` — `AgentProfileDTO`
gains `fallback_model,omitempty` + `auto_fallback`; the create/update
request DTOs accept them.
- `apps/backend/internal/agent/settings/controller/profile_crud.go` —
`toProfileDTO` / create / update request→model mapping.
- `apps/web/lib/types/agent-profile.ts` — `AgentProfile` +
`AgentProfilePayload` gain `fallbackModel` / `autoFallback`.
- `apps/web/lib/api/domains/agent-profile-normalize.ts` —
`normalizeAgentProfile` / `toAgentProfilePayload` round-trip.
Runtime precedence rule: when `auto_fallback` is true, `fallback_model` is
ignored. Saving both is allowed (no cross-field rejection).
## Acceptance
1. A profile created via `POST /api/v1/agents/:id/profiles` with
`fallback_model` and `auto_fallback` round-trips through
`GET /api/v1/agents` (DTO includes both fields).
2. `PATCH /api/v1/agent-profiles/:id` updates both fields.
3. Store test: scan/insert preserves both fields; migration applies to an
existing DB (existing rows get `''` / `0`).
4. Frontend normalize test: wire ↔ state round-trip.
## Verification
```sh
make -C apps/backend test ./internal/agent/settings/...
cd apps/web && pnpm vitest run lib/api/domains/agent-profile-normalize.test.ts
cd apps/web && pnpm run typecheck
```
## Risks
- Migration ordering: add the two `Apply` calls near the other
`agent_profiles.*` migrations; existing rows default to strict mode.
- The settings store uses manual scan structs — verify all SELECT column
lists are updated.
@@ -0,0 +1,50 @@
---
id: task-02-reconciler-keep-gone
title: Reconciler keeps gone start/fallback models
status: done
wave: 1
depends_on: [task-01-profile-fields]
plan: docs/plans/no-silent-model-fallback/plan.md
spec: docs/specs/no-silent-model-fallback/spec.md
---
# Task 2 — Reconciler: never heal away a gone model
## Change
`healProfile` in `apps/backend/internal/agent/settings/controller/reconciler.go`
currently replaces a gone `p.Model` with `caps.CurrentModelID` (the boot-time
silent fallback). Change:
- When `p.Model != ""` and the model is not in `caps.Models`: **keep it**.
Log `"profile model no longer available, keeping (no silent fallback)"`
with profile id + model; do NOT write.
- The `p.Model == "" && caps.CurrentModelID != ""` seed-default branch is
unchanged.
- Apply the same keep-when-gone rule to the new `p.FallbackModel` (when the
fallback model itself is gone, keep it; the UI surfaces it red).
- Mode healing is unchanged.
`modelExists` / `modeExists` helpers stay as-is.
## Acceptance
1. Test: profile with a model absent from the probe list is **not**
overwritten after `Run()` (model unchanged; no update written).
2. Test: profile with empty model still gets seeded to `CurrentModelID`.
3. Test: profile whose `fallback_model` is absent from the probe list keeps
it.
4. Test: probe status not "ok" still skips reconciliation entirely
(existing behavior, guard it with a regression test).
## Verification
```sh
make -C apps/backend test ./internal/agent/settings/controller/...
```
## Risks
- The reconciler runs on every boot; the keep path must not flip-flop (it
must not write at all, so no churn).
- Do not touch `healProfileName` (unrelated naming heuristic).
@@ -0,0 +1,80 @@
---
id: task-03-session-start-strict
title: Session start applies start-model policy (strict/fallback/legacy)
status: done
wave: 1
depends_on: [task-01-profile-fields]
plan: docs/plans/no-silent-model-fallback/plan.md
spec: docs/specs/no-silent-model-fallback/spec.md
---
# Task 3 — Session start: strict model application
## Change
The profile-model application block in
`apps/backend/internal/agent/runtime/lifecycle/session.go`
(`InitializeSession` flow, `if profileModel != "" && execution.agentctl !=
nil`) is currently best-effort (Warn + continue). Replace with the
policy-driven logic, using the session's advertised model list
(`execution.GetModelState()` after `InitializeSession`) and the profile's
`AutoFallback` / `FallbackModel`:
1. `AutoFallback` ON → keep today's warn-and-continue (legacy).
2. Start model **gone** (advertised list non-empty and model absent):
- `FallbackModel` set → `SetModel(fallbackModel)`; on success record an
explicit "using fallback model Y because X is unavailable" signal (log
+ extend the model state or emit an event the UI renders); on failure
→ fail session init explicitly.
- else → fail session init explicitly with the actionable message from
the spec's error mapping ("start model X is no longer available —
change the model in the profile or configure a fallback").
3. Start model present → `SetModel(model)`; on error:
- `sessionmodel.IsMethodNotFound(err)` (agent has no model selection) →
continue silently (unchanged no-op).
- else → strict + fallback-model modes: fail explicitly; auto-fallback:
warn + continue.
The explicit failure must surface as the session/run error message (chat +
run detail). Shared helper `applyStartModelPolicy(...)` extracted so the
context-reset re-application paths
(`reapplySessionModelAfterReset` / `effectiveSessionModelForReset` in
`manager_interaction.go`) use the same policy — a context reset must not
silently drop a gone model either.
The manager needs the profile's `AutoFallback` / `FallbackModel`: resolve
them alongside `resolveProfileSessionConfig` (extend it or add a
`resolveProfileFallbackConfig`).
## Acceptance
1. Test: strict mode, start model gone → session init fails with the
actionable message; no session starts.
2. Test: fallback-model mode, start model gone → fallback model applied;
"using fallback" signal recorded; fallback application failure → init
fails.
3. Test: auto-fallback mode → legacy warn-and-continue (no failure).
4. Test: model present + `SetModel` error that is NOT method-not-found →
init fails (strict + fallback modes).
5. Test: `-32601` / `MethodNone` error → continues silently.
6. Test: context reset with a gone model follows the same policy (strict →
error, not silent re-apply).
## Verification
```sh
make -C apps/backend test ./internal/agent/runtime/lifecycle/...
make -C apps/backend test ./internal/agentctl/sessionmodel/...
```
## Risks
- The advertised list can be empty (agent never sent `models_updated`):
treat empty list as "unknown" — fall back to `SetModel` result only.
- Do not break the existing model-application happy path (finalConfigID /
config-option interplay in the same block).
- The "using fallback" signal must be visible: pick the lightest reliable
channel (extend `CachedModelState` with an `AppliedFallbackModel` field
surfaced via the existing session models WS event, or a dedicated
`session.model_fallback` event) — check what the frontend `session-models`
handler already renders before adding a new event type.
@@ -0,0 +1,80 @@
---
id: task-04-post-start-gating
title: Office post-start failure gates on profile mode
status: done
wave: 2
depends_on: [task-01-profile-fields]
plan: docs/plans/no-silent-model-fallback/plan.md
spec: docs/specs/no-silent-model-fallback/spec.md
---
# Task 4 — Post-start failure: strict escalate / fallback one-shot / legacy requeue
## Change
`HandlePostStartFailure` in
`apps/backend/internal/office/scheduler/routing_lifecycle.go` already
receives `agent *models.AgentInstance`. After `routingerr.Classify`:
- `!classified.FallbackAllowed` → unchanged (return false, escalate).
- `agent.AutoFallback` → unchanged (requeue to next candidate via
`applyPostStartFallback`).
- `agent.FallbackModel != ""` → **one-shot retry with the fallback model**:
- Persist the override: new nullable column `runs.fallback_model`
(`models.Run` gains `FallbackModelOverride *string`; repo method
`SetRunFallbackModelOverride` in
`apps/backend/internal/office/repository/sqlite/run_routing.go`;
migration in the runs schema file — check where office run migrations
live, e.g. `repository/sqlite/` migrate list).
- Requeue (existing `RequeueForNextCandidate`).
- Next dispatch (`dispatch_routing.go`): when
`run.FallbackModelOverride != nil`, build **one** candidate from
`run.ResolvedExecutionProfileID` + `run.ResolvedProviderID` + the
override, skipping the resolver (or via a `ResolveOptions` force), and
**clear the override after the attempt** so a subsequent failure
escalates to the terminal explicit-failure path. Counts as a normal
attempt row (`recordAttemptStart`) — respects `MaxAttemptsPerRun`.
- On success, `handleLaunchSuccess` records `ResolvedModel = fallback`.
- otherwise (strict) → return `(false, nil)` so the caller escalates to
`HandleAgentFailure` — the run fails explicitly (no requeue).
**Error mapping**: helper (in `routingerr` or the scheduler) that renders an
actionable message for model/auth codes (`model_unavailable`,
`auth_required`, `missing_credentials`, `subscription_required`,
`provider_unavailable`) — e.g. `"Model unavailable: the configured model
<id> is no longer available. Change the model in the agent profile."` Used
when the strict path writes the run failure message (and reused by task 03
for session-start failures).
## Acceptance
1. Test: strict profile + fallback-allowed failure → `HandlePostStartFailure`
returns `(false, nil)`; run reaches the terminal failure path; failure
message contains the actionable copy.
2. Test: auto-fallback profile → existing requeue-to-next-candidate behavior
preserved (regression).
3. Test: fallback-model profile → override persisted + requeued; next
dispatch launches the single forced candidate (same provider,
fallback model); the override is cleared after one attempt.
4. Test: forced candidate fails too → run fails explicitly (no second
requeue, no resolver candidates).
5. Test: forced candidate succeeds → `ResolvedModel` = fallback; health
scopes marked healthy.
6. Test: `MaxAttemptsPerRun` still enforced with the forced candidate.
## Verification
```sh
make -C apps/backend test ./internal/office/scheduler/...
make -C apps/backend test ./internal/office/repository/...
```
## Risks
- The forced candidate path must not bypass the attempt cap or the
route-cycle baseline semantics.
- Find the runs-schema migration location and follow its conventions;
existing runs get NULL override (no behavior change).
- `excludedFromAttempts` / `continuationLaunchContext` must treat the
forced attempt like any other (exclude-set unchanged — the forced
candidate is the only candidate, so exclusion is moot).
@@ -0,0 +1,64 @@
---
id: task-05-picker-gone-support
title: Model pickers support disabled (gone) models
status: done
wave: 3
depends_on: []
plan: docs/plans/no-silent-model-fallback/plan.md
spec: docs/specs/no-silent-model-fallback/spec.md
---
# Task 5 — Model pickers: grey out gone models
## Change
`apps/web/components/model-config-selector.tsx`:
- `ModelSelectorOption` gains `disabled?: boolean` and
`disabledReason?: string`.
- `ModelRow` renders disabled options greyed (`opacity-40`,
`cursor-not-allowed`) with the reason in a tooltip; `onSelect` guarded by
`!option.disabled`. Follow the existing disabled pattern in
`apps/web/components/combobox.tsx` (visual treatment + tooltip).
`apps/web/lib/ws/handlers/session-models.ts`:
- `clearStaleActiveModel` no longer clears the active model when it
disappears from the ACP list. Keep it; the picker marks it gone.
`apps/web/components/task/model-selector.tsx`:
- `buildModelOptions` / `resolveAvailableModels` mark the configured-but-
absent models (active model, profile model) as `disabled` with reason
"model no longer available — select a new model". The current model
display still shows the (gone) model name so the user sees what was
active.
i18n: new copy via `t()` (`settings:` namespace) — the ratchet judges added
lines.
## Acceptance
1. Unit test (`model-config-selector`): a disabled option is not selectable
and renders the greyed class; tooltip carries the reason.
2. Unit test (`session-models` handler): when the ACP models list drops the
active model, the active model is **kept** (not cleared).
3. Unit test (`model-selector`): a configured model absent from the list is
included in options as `disabled` with a reason.
4. `pnpm run typecheck` + targeted vitest pass.
## Verification
```sh
cd apps/web && pnpm vitest run components/model-config-selector.test.tsx lib/ws/handlers/session-models.test.ts components/task/model-selector.test.tsx
cd apps/web && pnpm run typecheck
cd apps/web && pnpm run i18n:ratchet
```
## Risks
- The session picker's option-building has several sources (ACP models,
static model config, config options) — the disabled marking must apply to
the merged option list without breaking the config-option path.
- Keep `clearStaleContextWindow` behavior unchanged (that one is about
context-window reset on a legit model change, unrelated).
@@ -0,0 +1,70 @@
---
id: task-06-profile-editor
title: Profile editor: gone start model red + fallback row + toggle
status: done
wave: 3
depends_on: [task-01-profile-fields, task-05-picker-gone-support]
plan: docs/plans/no-silent-model-fallback/plan.md
spec: docs/specs/no-silent-model-fallback/spec.md
---
# Task 6 — Profile editor rows
## Change
`apps/web/components/settings/profile-form-fields.tsx` (`CapabilitiesRow` /
`ModelPicker`):
- **Start model**: when `profile.model` is non-empty and absent from the
current model list, keep it as the current value, render the trigger/row
red (`text-destructive`) and the option disabled with reason ("no longer
available — select a new model"). The user can still change it (the
picker opens and valid models are selectable), but cannot re-select the
gone one.
- **Agent fallback row** (new, directly under Start model): optional select
over the same model list bound to `profile.fallback_model`, label
"Agent fallback" + helper ("if the start model becomes unavailable,
automatically switch to"). Same gone-red/disabled treatment when the
fallback model itself is gone. Hidden when `auto_fallback` is ON.
- **"Fallback automatically to next model" toggle** (new row): switch bound
to `profile.auto_fallback` with helper text. When ON, the fallback-model
row is hidden (per spec precedence).
- Parity: `apps/web/components/agent/cli-profile-editor.tsx`
(`ModelModeFields`) gets the same two rows.
- The profile form state type (`ProfileFormData`) gains `fallbackModel` /
`autoFallback`; dirty-tracking (`profileModelIsDirty`-style helpers) for
the new fields; save payload flows through `toAgentProfilePayload`.
i18n: ALL new copy via `t()` into `apps/web/src/locales/{en,pseudo}/settings.json`
(camelCase keys): `startModelUnavailable`, `agentFallback`, `agentFallbackHelper`,
`autoFallback`, `autoFallbackHelper`, `fallbackModelUnavailable`. The file
`profile-form-fields.tsx` is unmigrated — added lines are judged by the
ratchet, so no hardcoded literals on new/edited lines.
## Acceptance
1. Unit test: gone start model renders the red treatment + disabled option;
current value preserved.
2. Unit test: fallback row appears when `auto_fallback` is off; hidden when
on; toggle hides/shows it.
3. Unit test: gone fallback model renders red + disabled.
4. Unit test: save payload includes `fallback_model` / `auto_fallback`;
dirty detection works for both.
5. i18n ratchet passes on the edited files.
## Verification
```sh
cd apps/web && pnpm vitest run components/settings/profile-form-fields.test.tsx
cd apps/web && pnpm run typecheck
cd apps/web && pnpm run i18n:ratchet
```
## Risks
- The editor has two surfaces (`profile-form-fields.tsx` and
`cli-profile-editor.tsx`) — keep both consistent.
- The empty-model case ("profile model means use the agent's default")
must stay untouched: gone-treatment applies only to non-empty models.
- Do not disturb the `data-settings-dirty` container semantics used by the
settings save indicator.
@@ -0,0 +1,68 @@
---
id: task-07-profile-picker-gating
title: Block/warn profiles with gone start model in pickers
status: done
wave: 3
depends_on: [task-01-profile-fields]
plan: docs/plans/no-silent-model-fallback/plan.md
spec: docs/specs/no-silent-model-fallback/spec.md
---
# Task 7 — Profile picker gating (new-task / new-agent)
## Change
`apps/web/lib/state/slices/settings/types.ts`:
- `AgentProfileOption` gains `model`, `fallbackModel`, `autoFallback`;
`toAgentProfileOption` populates them from the agent profile.
`apps/web/components/task-create-dialog-options.tsx` (`useAgentProfileOptions`):
- Compute gone-ness: `profile.model` non-empty and absent from the agent's
`model_config.available_models` (look up via `availableAgents`).
- strict mode (gone, no fallback, `auto_fallback` off) →
`disabled: true` + `disabledReason` ("start model X is no longer
available — change it in the agent profile"). The `AgentSelector`
(Combobox) already renders disabled options greyed with tooltips.
- fallback-model mode → selectable with an amber warning
(reuse `getCapabilityWarning` iconography or a dedicated warning)
"start model X is gone — fallback Y will be used".
- auto-fallback → normal.
- Profiles whose model is empty (agent default) are never gated.
- A fallback model that is itself gone does not make the profile
selectable: it would promise a switch `SetModel` cannot apply. The
profile is blocked like strict mode when both the start model and the
fallback model are absent from the advertised list ("both-gone").
`apps/web/app/office/setup/agent-profile-setup-controls.tsx`
(`useSelectableProfileOptions`): same gating for office agent setup.
i18n: keys in `settings.json` — `profileStartModelUnavailable`,
`profileFallbackWillBeUsed`.
## Acceptance
1. Unit test (`useAgentProfileOptions`): strict-gone profile → disabled with
reason; fallback-mode profile → enabled with warning; auto-fallback →
enabled; empty-model profile → enabled.
2. Unit test (office `useSelectableProfileOptions`): same matrix.
3. The task-create dialog renders the blocked profile greyed/unselectable
(existing Combobox disabled pattern) and the fallback warning icon.
4. i18n ratchet passes.
## Verification
```sh
cd apps/web && pnpm vitest run components/task-create-dialog-options.test.tsx app/office/setup/agent-profile-setup-controls.test.tsx
cd apps/web && pnpm run typecheck
cd apps/web && pnpm run i18n:ratchet
```
## Risks
- `useExecutorProfileCompat` filters profiles for executor compatibility
first — gating must compose with it, not replace it.
- The profile's agent model list must be loaded before gating; when
`availableAgents` is still loading, do not mark everything disabled
(gate only when the list is known).
@@ -0,0 +1,56 @@
---
id: task-08-e2e
title: E2E: gone start model flows on the mock backend
status: done
wave: 4
depends_on: [task-05-picker-gone-support, task-06-profile-editor, task-07-profile-picker-gating]
plan: docs/plans/no-silent-model-fallback/plan.md
spec: docs/specs/no-silent-model-fallback/spec.md
---
# Task 8 — Playwright E2E for gone-model flows
## Change
Add an E2E spec (e.g. `apps/web/e2e/tests/settings/no-silent-model-fallback.spec.ts`)
running on the mock backend (`KANDEV_E2E_MOCK=true` fixture). The mock agent
advertises a fixed model list; create a profile whose start model is NOT in
that list (via the settings API or UI) to establish a "gone" model.
Assert:
1. **Profile editor**: the gone start model renders red + disabled in the
model picker (option greyed, reason tooltip); the current value shows
the gone model id.
2. **Fallback row + toggle**: with `auto_fallback` off, the "Agent
fallback" row is visible; enabling the "Fallback automatically to next
model" toggle hides it; the toggle persists after save.
3. **Task-create profile picker**: the strict-gone profile is greyed and
unselectable (reason tooltip); after setting a fallback model, the
profile becomes selectable and shows the fallback warning.
4. Saving the profile keeps the gone start model (no auto-heal on save).
Check `apps/web/e2e/README.md` for fixture conventions (backend setup,
selectors, mock config) and reuse existing settings/office specs for the
patterns (`utility-agents.spec.ts`, `office-routing-blocked.spec.ts`).
## Acceptance
1. The new spec passes on the mock backend.
2. Existing e2e suite still passes (no regressions in profile/model flows).
## Verification
```sh
cd apps/web && pnpm e2e --project=chromium tests/settings/no-silent-model-fallback.spec.ts
# then the full suite
cd apps/web && pnpm e2e
```
## Risks
- The mock agent's model list is fixed in `apps/backend/internal/agent/agents/mock.go`
— verify the advertised IDs so the test's "gone" model is genuinely
absent (and not silently present via a config-option path).
- The task-create picker requires the capabilities probe to have landed;
use the existing `agentProfilesLoading` / capability gates.
+1
View File
@@ -251,6 +251,7 @@ System pages (Radarr/Sonarr-style) for status, disk usage, database maintenance,
| [native-code-review](native-code-review/spec.md) | building |
| [missing-task-route-recovery](missing-task-route-recovery/spec.md) | draft |
| [browser-inspect-annotations-save](browser-inspect-annotations-save/spec.md) | shipped |
| [no-silent-model-fallback](no-silent-model-fallback/spec.md) | approved |
---
+394
View File
@@ -0,0 +1,394 @@
# No Silent Model Fallback
**Status**: approved (design package; amended 2026-08-09)
**Date**: 2026-08-04
**Slug**: `no-silent-model-fallback`
## Problem
Agents such as OMP are configured with models from multiple providers (e.g.
Claude + GPT). When a provider's login/auth expires (e.g. Claude), its models
become unavailable. Today Kandev silently compensates in several places:
1. **Session start (agentctl runtime)** — `SessionManager.InitializeSession`
applies the profile's start model via ACP `SetModel` as *best-effort*:
on failure it logs a warning and continues the session on the provider
default model. `SetModel` itself fails fast when the model is not in the
agent's advertised model list (`validateAvailableModel`), so the failure
is known but ignored.
2. **Profile reconciler (boot)** — `healProfile` overwrites a profile's
start model with the probe's `CurrentModelID` when the configured model
is no longer advertised. The user's explicit model choice is silently
replaced.
3. **Office post-start fallback** — when a launched run fails mid-session
with a fallback-allowed error (`auth_required`, `model_unavailable`,
…), `HandlePostStartFailure` silently re-dispatches the run to the next
provider in the workspace routing order.
4. **Frontend model lists** — model pickers have no concept of an
unavailable ("gone") model; `clearStaleActiveModel` silently drops an
active model that disappeared from the ACP list, so the session continues
on whatever the agent picks.
**Bottom line: the user never asked for a different model, yet the system
switches models for them, invisibly.**
## Goals
- A configured/selected model that is unavailable ("gone") is **never**
silently replaced. Either the run/session fails explicitly with an
actionable "change the model" message, or — only when the user opted in —
it switches to an explicitly configured alternative.
- "Gone" models render **greyed out and unselectable** in every model
picker, while remaining visible so the user can see what was configured.
- Agent profiles **keep** their previously configured start model even when
gone (shown red + unselectable in the editor) instead of having it
auto-healed.
- Profiles whose start model is gone are **blocked** in the new-task /
new-agent profile picker, unless the profile opts into an explicit
fallback.
- A new optional per-profile **agent fallback model** allows an explicit,
single-model automatic switch when the start model becomes unavailable at
session start. Office post-start routing is unchanged and remains governed
by the workspace routing configuration (see ADR
`2026-08-08-provider-neutral-agent-error-recovery.md`): the profile's
model policy is the owner of the session-start decision, not of office
post-start provider fallback.
- A new explicit per-profile toggle **"Fallback automatically to next
model"** restores the legacy automatic-fallback behavior (session start
best-effort + office routing re-dispatch). Enabling the toggle disables the
optional fallback-model controls without clearing their saved value (the two
are mutually exclusive opt-ins).
- Agent-profile editors group both fallback choices in a collapsed **Fallback
settings** disclosure. Expanding it shows the two choices side by side on
desktop and as one vertical flow on phone-sized screens. Each choice keeps a
short visible explanation and adds contextual help that works by hover or
keyboard focus with a fine pointer and by tap on a coarse pointer.
## Non-Goals
- Changing the workspace provider-routing configuration model (tiers,
provider order, execution profiles) itself.
- Provider-level auth flows (login/refresh UI) — only the *consequence* of
auth expiry (unavailable models) is addressed.
- Mid-turn model switching on the live ACP session (retry the current turn
on a different model) — fallback applies to the next attempt/launch, not
to an in-flight turn.
## Definitions
- **Available models**: the model IDs an agent currently advertises —
`hostutility.AgentCapabilities.Models` (probe cache, surfaced via
`GET /api/v1/agents/available` and `GET /api/v1/agent-models/:agentName`)
or the ACP session's `models_updated` list.
- **Gone model**: a model ID that is configured (profile start model, active
session model, fallback model) but absent from the currently advertised
list. Deterministic on the frontend: `configured ∉ advertised`.
- **Strict mode**: profile has `auto_fallback = false` and no
`fallback_model`. No automatic switching anywhere; unavailable start
model ⇒ explicit failure.
- **Fallback-model mode**: profile has `auto_fallback = false` and a
non-empty `fallback_model`. The only permitted automatic switch is to
that single model.
- **Auto-fallback mode**: profile has `auto_fallback = true`. Legacy
behavior (session-start best-effort; office routing re-dispatch to next
candidate). `fallback_model` is ignored.
## Behavior Matrix
Per agent profile, one of three modes (precedence: `auto_fallback` wins
over `fallback_model`):
| Scenario | Strict | Fallback-model | Auto-fallback |
|---|---|---|---|
| Session start, start model gone | **Fail launch explicitly** with "start model X is no longer available — change the model in the profile or configure a fallback". Run/session enters failed state; no session starts. | Apply `fallback_model` instead; surface an explicit "using fallback model Y because X is unavailable" note (log + UI). | Legacy: `SetModel` best-effort (warn + continue on provider default). |
| Session start, `SetModel` fails for another reason | Fail explicitly (strict + fallback-model modes). | Same | Legacy warn + continue. |
| Mid-session model/auth failure (office run, post-start) | Unchanged ADR behavior: office re-dispatches via the workspace routing chain (`routingerr.Decide(ContextOffice)`; availability codes → `DecisionFallback`). The profile's model policy does **not** gate office fallback — the workspace routing configuration is the office authorization owner. | Same as strict: `fallback_model` is a session-start policy, not an office routing input. | Legacy: re-dispatch to next candidate in the provider order (unchanged). |
| Boot reconciliation | Never overwrite a gone start model (keep it; UI shows it red). Same for a gone `fallback_model`. | Same | Same (reconciler is mode-independent). |
| New-task / new-agent profile picker | Profile **blocked** (greyed, unselectable, reason tooltip). | Profile **selectable with a warning** ("start model X is gone — fallback Y will be used"). If the fallback model is also gone ("both-gone"), the profile is **blocked** — a fallback the session cannot apply is not a valid opt-in. | Selectable normally. |
| Model picker (profile editor, session toolbar) | Gone models greyed out, unselectable, visible. | Same. | Same. |
`SetModel` failures that mean "this agent does not support model selection"
(JSON-RPC `-32601`, `sessionmodel.MethodNone` / `IsMethodNotFound`) are never
treated as availability failures — a profile without model selection simply
continues as today.
## Backend Changes
### 1. Agent profile schema (new columns)
`agent_profiles` gains:
- `fallback_model TEXT NOT NULL DEFAULT ''` — optional single fallback model
ID (ACP model ID, same vocabulary as `model`).
- `auto_fallback INTEGER NOT NULL DEFAULT 0` — explicit opt-in to legacy
automatic fallback.
Migration follows the existing `r.migrate.Apply("agent_profiles.<col>",
"ALTER TABLE ... ADD COLUMN ...")` pattern in
`apps/backend/internal/agent/settings/store/sqlite.go`. Wire through:
- `models.AgentProfile` (`internal/agent/settings/models/models.go`)
- store scan/insert paths (`settings/store/sqlite.go`)
- `dto.AgentProfileDTO` + create/update request structs
(`settings/dto/dto.go`, `settings/controller/profile_crud.go`)
- frontend `AgentProfile` type, `normalizeAgentProfile` /
`toAgentProfilePayload` (`apps/web/lib/types/agent-profile.ts`,
`apps/web/lib/api/domains/agent-profile-normalize.ts`)
Validation: `fallback_model` may be set independently; when `auto_fallback`
is enabled the UI hides/disables the field and the backend ignores
`fallback_model` at runtime (precedence rule). No cross-field rejection —
saving both is allowed; runtime precedence is `auto_fallback`.
### 2. Reconciler stops healing gone models
`healProfile` in `internal/agent/settings/controller/reconciler.go` currently
replaces a gone `p.Model` with `caps.CurrentModelID`. Change: keep the
user-configured model when it is gone (log an info line; do not overwrite).
The `p.Model == ""` seed-default branch is unchanged. Apply the same
keep-when-gone rule to `fallback_model` (no auto-heal; UI surfaces it).
Mode healing is unchanged (modes are not part of this feature).
### 3. Session start: strict model application (agentctl runtime)
In `internal/agent/runtime/lifecycle/session.go`, the profile-model
application block (`if profileModel != "" && execution.agentctl != nil`)
changes from best-effort to policy-driven, using the session's advertised
model list (`execution.GetModelState()` after `InitializeSession`) and the
profile's mode:
1. `auto_fallback` ON → keep today's warn-and-continue behavior.
2. Start model gone (advertised list non-empty and model absent):
- `fallback_model` set → `SetModel(fallback_model)`; on success log +
surface an explicit note (extend the model state / emit an event the UI
can render, e.g. a `session.model_fallback` event or a field on the
model state); on failure → fail session init explicitly.
- otherwise → fail session init explicitly with an actionable message.
3. Start model present in list → `SetModel(model)`; on error:
- `sessionmodel.IsMethodNotFound(err)` → continue silently (no model
selection support).
- otherwise → strict: fail explicitly (both strict and fallback-model
modes); auto-fallback mode keeps warn-and-continue.
The explicit failure must propagate as the session/run error message so the
chat and run detail render "start model unavailable — change the model".
The start-model policy owns every model application attempted during this
decision. Its outcome distinguishes **handled** from **applied**: best-effort
auto-fallback failure and method-not-supported are handled even though no model
was applied. Later profile/configuration layers must not retry a handled model
attempt. An explicit fallback may still make its intentional ordered attempts
(`start`, then `fallback`) when the advertised model list is unknown, but no
layer repeats either attempt.
The same policy applies to the context-reset re-application path
(`reapplySessionModelAfterReset` / `effectiveSessionModelForReset` in
`manager_interaction.go`) via a shared helper, so a context reset cannot
silently drop a gone model either.
### 4. Office post-start failure gating (unchanged, ADR-governed)
`HandlePostStartFailure` in
`internal/office/scheduler/routing_lifecycle.go` is **not** modified by this
feature. Office post-start fallback authorization stays with the workspace
routing configuration and provider chain
(`routingerr.Decide(ContextOffice)`), per ADR
`2026-08-08-provider-neutral-agent-error-recovery.md`. Rationale: the
feature's model policy is owned by the session-start decision
(`execution.AgentProfileID`); the office post-start path sees the stable
office identity (`run.AgentProfileID`), and the two can disagree. Reading
`agent.FallbackModel` / `agent.AutoFallback` there would create a second,
conflicting policy owner. Office runs still get the session-start guarantee
(launch fails explicitly on a gone start model), and mid-session office
failures keep the workspace-configured routing behavior.
Terminal office failures (for example after max attempts) surface an
actionable "provider/model unavailable — change the model" hint: map
`model_unavailable` / `auth_required` / `missing_credentials` /
`subscription_required` codes onto the failure message via
`routingerr.ModelUnavailableMessage` when composing it.
### 5. Error message mapping
Add a small helper (backend) that renders an actionable message for
model/auth failure codes, e.g. `"Model unavailable: the configured model
<id> is no longer available. Change the model in the agent profile."` Used
by the session-start failure and the office terminal-failure path so both
chat and run detail show the request-to-change-model copy. The frontend may
additionally map the same codes to a banner (see frontend).
## Frontend Changes
### 6. Model pickers: disabled ("gone") support
`apps/web/components/model-config-selector.tsx`:
- `ModelSelectorOption` gains `disabled?: boolean` and
`disabledReason?: string`.
- `ModelRow` renders disabled options greyed (`opacity-40`,
`cursor-not-allowed`), with the reason in a tooltip, and `onSelect` is
guarded (`!option.disabled`). Follow the existing disabled pattern from
`apps/web/components/combobox.tsx` (separate visual treatment + tooltip).
Session toolbar picker (`apps/web/components/task/model-selector.tsx`):
- `clearStaleActiveModel` in `apps/web/lib/ws/handlers/session-models.ts`
stops clearing the active model when it disappears from the ACP list.
Instead the active model is **kept and marked gone**: the picker shows it
greyed out with a reason ("model no longer available — select a new
model"), so the user is explicitly asked to change it.
- `buildModelOptions` / `resolveAvailableModels` mark configured-but-absent
models (profile model, active model) as `disabled`.
### 7. Profile editor rows
`apps/web/components/settings/profile-form-fields.tsx` (`CapabilitiesRow` /
`ModelPicker`):
- **Start model**: when `profile.model` is not in the current model list,
keep it as the current value but render it red
(`text-destructive`) + disabled with a reason ("no longer available").
- **Agent fallback row** (new, under Start model): optional select of the
same model list bound to `profile.fallback_model`. It remains visible but is
disabled when `auto_fallback` is ON, preserving the configured value while
making the mutual exclusion understandable. It uses the same
gone-red/disabled treatment if the fallback model itself is gone.
- **"Fallback automatically to next model" toggle** (new row): a switch
bound to `profile.auto_fallback`. When ON, the fallback-model controls are
disabled. Helper text explains the semantics.
- **Fallback settings disclosure**: both choices sit inside a semantic
collapsible section that is closed on initial render. Its closed header
summarizes the effective mode (strict, automatic, or the configured explicit
fallback) so saved state and hidden dirty state remain discoverable. The
trigger is keyboard-operable, reports expanded state, and has a touch target
of at least 44px.
- **Responsive layout and help**: the expanded choices use two equal columns at
the desktop breakpoint and one column below it. Each label has an info-icon
button. Fine pointers expose localized help through a tooltip on hover and
focus; coarse pointers open the same help in an inset bottom drawer. The
visible helper sentence remains in the option card, so the icon is
supplementary rather than the only explanation.
- The lighter editor `apps/web/components/agent/cli-profile-editor.tsx`
(`ModelModeFields`) gets the same disclosure, layout, help, and mutual
exclusion behavior for parity.
All new copy is externalized via `t()` into the `settings` i18n namespace
(`apps/web/src/locales/{en,pseudo,pt-pt,zh-cn}/settings.json`) — the i18n ratchet
judges added lines even in unmigrated files.
### 8. Profile picker gating (new-task / new-agent)
`apps/web/lib/state/slices/settings/types.ts` — `AgentProfileOption` gains
`model`, `fallbackModel`, `autoFallback` (populated in
`toAgentProfileOption`).
`apps/web/components/task-create-dialog-options.tsx`
(`useAgentProfileOptions`): compute gone-ness (`profile.model` set and not
in the agent's `model_config.available_models`):
- strict → `disabled: true` with `disabledReason` ("start model X is no
longer available — change it in the agent profile").
- fallback-model mode → selectable with an amber warning icon/tooltip
("start model X is gone — fallback Y will be used"). If the fallback
model is itself absent from the advertised list, the profile is blocked
like strict mode (both-gone): a fallback the runtime cannot apply must
not be presented as a valid opt-in.
- auto-fallback → normal.
`apps/web/app/office/setup/agent-profile-setup-controls.tsx`
(`useSelectableProfileOptions`): same gating for office agent setup.
### 9. i18n
New keys in `apps/web/src/locales/{en,pseudo}/settings.json` (camelCase,
`settings:` namespace), covering: gone-model reason, fallback-model row
label + placeholder, auto-fallback toggle label + helper, profile-picker
blocked reason, profile-picker fallback warning. No hardcoded literals on
added/edited lines (ratchet).
## Tests
Backend (Go, `*_test.go` beside source):
- Store: `fallback_model` / `auto_fallback` round-trip + migration.
- Reconciler: gone start model is kept, not overwritten; empty model still
seeded; gone fallback_model kept.
- Runtime session start: strict mode fails init with actionable message when
start model gone; fallback-model mode applies the fallback and notes it;
auto-fallback keeps legacy warn-continue; `-32601` continues silently.
(`session_test.go` or the manager test harness.)
- Runtime session start: a successful start-model application, an auto-fallback
best-effort failure, and method-not-supported each produce one policy attempt
with no profile-layer retry. When an unknown advertised list requires trying
an explicit fallback after the start model fails, the ordered call list is
exactly `[start, fallback]`.
- Office post-start: unchanged — availability failures requeue via the
workspace routing chain regardless of the profile's fallback settings
(regression test pins that the profile policy does not gate office).
- Error mapping helper unit tests.
Frontend (Vitest, `*.test.ts(x)`):
- `model-config-selector`: disabled option not selectable; greyed class.
- `session-models` WS handler: stale active model is kept (not cleared).
- `useAgentProfileOptions`: strict profile disabled with reason; fallback
profile selectable with warning; auto-fallback selectable.
- Profile editor: gone start model renders red + disabled; auto-fallback keeps
the explicit fallback choice visible but disables its controls.
- Profile editors: fallback settings start collapsed; expanding exposes both
choices, auto-fallback disables rather than removes the explicit fallback,
and desktop tooltip help is available through pointer and keyboard focus.
- `agent-profile-normalize`: new fields round-trip.
E2E (Playwright, `apps/web/e2e`):
- Mock backend (`KANDEV_E2E_MOCK=true`): create a profile whose start model
is not in the mock agent's advertised list; assert the profile editor
shows it red/disabled, the task-create profile picker blocks it (greyed,
unselectable) or shows the fallback warning when a fallback is set, and
the fallback settings disclosure controls both fallback modes.
- Desktop profile settings: the disclosure starts closed, expands to two
horizontally aligned option columns, summarizes the current fallback mode,
and exposes each info explanation on hover/focus.
- Mobile profile settings: tapping the same disclosure reveals one stacked
flow; tapping either 44px info target opens the localized help drawer without
horizontal document overflow.
## Persistence & Migration
- `agent_profiles.fallback_model TEXT NOT NULL DEFAULT ''`
- `agent_profiles.auto_fallback INTEGER NOT NULL DEFAULT 0`
Existing rows default to strict mode (`auto_fallback = 0`, no fallback).
This IS a behavior change for existing profiles: a session whose start
model is gone now fails explicitly at launch instead of silently continuing
on the provider default. Users who relied on legacy automatic fallback must
explicitly enable the toggle — that opt-in is the point of the feature.
## Risks & Open Questions
- **Behavior change for existing strict-mode profiles**: after this change,
a session whose start model is gone will fail at launch instead of
starting on the provider default. That is the requested behavior; the
failure message must be actionable (covered by the error-mapping helper).
- **Office post-start fallback remains workspace-routing-governed**: a
strict profile's office run can still be re-dispatched to another
provider mid-session by the ADR office policy (availability codes →
`DecisionFallback`). This is intentional — office authorization is the
workspace routing configuration, not the execution profile — and is
documented in the behavior matrix above.
- **Probe staleness**: the advertised list can be stale (probe cached).
Membership checks use the freshest available signal (session
`models_updated` at start; probe cache for pickers). A stale cache
showing a gone model as available is acceptable — the agent's own
`validateAvailableModel` fails fast at `SetModel` time, and the runtime
treats that as unavailable.
- **Office vs. kanban surfaces**: both share the same agent-profiles rows;
the picker gating in §8 covers the kanban task-create dialog and the
office setup flow. Office run-detail routing surfaces are unchanged.
- **Collapsed controls remain legible**: the disclosure header summarizes the
effective mode, and dirty-state decoration is applied to the disclosure
container so a collapsed section cannot conceal that it has unsaved changes.
- **Hover is supplementary**: every info icon is focusable, and coarse-pointer
devices receive the same content in a drawer. The visible option helper copy
remains the baseline explanation.