diff --git a/.github/scripts/backend-tests-workflow-contract_test.py b/.github/scripts/backend-tests-workflow-contract_test.py index 396abccc9a..8ca6aa34c0 100644 --- a/.github/scripts/backend-tests-workflow-contract_test.py +++ b/.github/scripts/backend-tests-workflow-contract_test.py @@ -51,6 +51,11 @@ class BackendTestsWorkflowContractTest(unittest.TestCase): self.assertIn('"postgres-18:${POSTGRES_18_RESULT}"', self.workflow) self.assertIn("TestPreviousStableUpgrade", self.workflow) + def test_windows_job_has_headroom_for_hosted_runner_variance(self) -> None: + _, marker, windows_job = self.workflow.partition(" test-windows:\n") + self.assertTrue(marker) + self.assertIn("timeout-minutes: 40", windows_job) + def test_base_image_mirrors_postgres_18_at_the_same_digest(self) -> None: self.assertIn("POSTGRES_18_DIGEST: " + POSTGRES_18_DIGEST, self.base_image_workflow) self.assertIn("POSTGRES_18_SOURCE: docker.io/library/postgres:18@" + POSTGRES_18_DIGEST, self.base_image_workflow) diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index c1b9cb1bd9..a0fc7ecfcb 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -707,9 +707,10 @@ jobs: needs: changes if: needs.changes.outputs.run == 'true' runs-on: windows-latest - # The race-sensitive Windows package suite can approach 20 minutes on a - # busy hosted runner; keep the cap finite while allowing runner variance. - timeout-minutes: 30 + # Dependency hydration, build, vet, and the race-sensitive package suite + # can consume 30 minutes on a cold hosted runner. Keep the cap finite while + # leaving the final targeted regression checks time to run. + timeout-minutes: 40 # Least-privilege: this job only reads source and runs tests, no PR/issue # writes or release operations. Flagged by CodeQL workflow audit + matching # CodeRabbit nitpick. diff --git a/.kandev/review-notes.md b/.kandev/review-notes.md new file mode 100644 index 0000000000..38b01a1850 --- /dev/null +++ b/.kandev/review-notes.md @@ -0,0 +1,3 @@ +## Fixed during review +- apps/backend/internal/agent/runtime/lifecycle/start_model.go:116 -- auto-fallback decisions retained a configured fallback model in provider-default warning metadata; normalized the policy so auto-fallback ignores the fallback model consistently with the runtime contract (commit 649b4fa22) +- apps/backend/internal/agent/runtime/lifecycle/start_model.go:140 -- auto-fallback profiles failed when ACP model selection was unsupported; restored provider-default continuation with selection_unsupported warning metadata (commit fdfbffc9a) diff --git a/apps/backend/internal/agent/runtime/agentctl/agent.go b/apps/backend/internal/agent/runtime/agentctl/agent.go index 692c6b71cd..e1ad5b2d81 100644 --- a/apps/backend/internal/agent/runtime/agentctl/agent.go +++ b/apps/backend/internal/agent/runtime/agentctl/agent.go @@ -495,6 +495,18 @@ func (c *Client) readUpdatesStream( c.logger.Warn("failed to parse agent event", zap.Error(err)) continue } + if event.Type == streams.EventTypeSessionModels { + // Keep the snapshot on the client as well as in the lifecycle + // callback. A staged replacement client has no published execution + // state yet, so its asynchronous catalog must remain available to the + // bounded startup-policy wait. + c.setLastSessionModelState(&streams.SessionModelState{ + CurrentModelID: event.CurrentModelID, + Models: event.SessionModels, + ConfigOptions: event.ConfigOptions, + ConfigOptionsSettled: eventDataBool(event.Data, "config_options_settled"), + }) + } tracing.TraceAgentEvent(ctx, event.Type, event.SessionID, c.executionID, message) // Hand off to the ordered worker rather than running handler inline. @@ -693,6 +705,11 @@ func extractMCPRequestCorrelation(payload json.RawMessage) (sessionID string, pe return sessionID, pendingID } +func eventDataBool(data map[string]any, key string) bool { + value, ok := data[key].(bool) + return ok && value +} + // CloseUpdatesStream closes the agent events stream connection. func (c *Client) CloseUpdatesStream() { c.mu.Lock() diff --git a/apps/backend/internal/agent/runtime/agentctl/client.go b/apps/backend/internal/agent/runtime/agentctl/client.go index 4aabd77b4f..2e3147c166 100644 --- a/apps/backend/internal/agent/runtime/agentctl/client.go +++ b/apps/backend/internal/agent/runtime/agentctl/client.go @@ -62,9 +62,9 @@ type Client struct { pendingRequestConns map[string]*websocket.Conn pendingMu sync.Mutex - // lastSessionModelState is populated synchronously by session/new, - // session/reset, or session/load responses. Lifecycle policy evaluation can - // use it before the corresponding session_models event reaches its handler. + // lastSessionModelState is populated by session/new, session/reset, and + // session/load responses and by asynchronous session_models events. Lifecycle + // policy evaluation can use it before the event reaches its handler. lastSessionModelState *streams.SessionModelState } @@ -74,8 +74,8 @@ func (c *Client) setLastSessionModelState(state *streams.SessionModelState) { c.lastSessionModelState = cloneSessionModelState(state) } -// GetLastSessionModelState returns the model catalog included in the most -// recent session creation or load response. +// GetLastSessionModelState returns the latest session model catalog observed +// in a session response or asynchronous session_models event. func (c *Client) GetLastSessionModelState() *streams.SessionModelState { c.mu.RLock() defer c.mu.RUnlock() @@ -87,9 +87,10 @@ func cloneSessionModelState(state *streams.SessionModelState) *streams.SessionMo return nil } cloned := &streams.SessionModelState{ - CurrentModelID: state.CurrentModelID, - Models: append([]streams.SessionModelInfo(nil), state.Models...), - ConfigOptions: append([]streams.ConfigOption(nil), state.ConfigOptions...), + CurrentModelID: state.CurrentModelID, + Models: append([]streams.SessionModelInfo(nil), state.Models...), + ConfigOptions: append([]streams.ConfigOption(nil), state.ConfigOptions...), + ConfigOptionsSettled: state.ConfigOptionsSettled, } for i, model := range cloned.Models { if model.Meta == nil { diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go index ae1c4ad0eb..6f67becfb4 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go @@ -556,13 +556,34 @@ func (m *Manager) reapplySessionModelAfterReset( ) error { client, releaseClient := execution.AcquireAgentCtlClient() defer releaseClient() + return m.reapplySessionModelAfterResetWithClient(ctx, execution, client, newSessionID, modelID) +} + +func (m *Manager) reapplySessionModelAfterResetWithClient( + ctx context.Context, + execution *AgentExecution, + client *agentctlclient.Client, + newSessionID, modelID string, +) error { + return m.reapplySessionModelAfterResetWithClientAndState( + ctx, execution, client, execution.GetModelState(), newSessionID, modelID, + ) +} + +func (m *Manager) reapplySessionModelAfterResetWithClientAndState( + ctx context.Context, + execution *AgentExecution, + client *agentctlclient.Client, + modelState *CachedModelState, + newSessionID, modelID string, +) error { if client == nil || modelID == "" { return nil } policy := m.resolveStartModelPolicy(ctx, execution.AgentProfileID) policy.Model = modelID decision, err := applyStartModelPolicy( - ctx, m.logger, client, execution.GetModelState(), policy, + ctx, m.logger, client, modelState, policy, ) if err != nil { m.logger.Warn("failed to re-apply session model after context reset", @@ -576,8 +597,7 @@ func (m *Manager) reapplySessionModelAfterReset( } if decision.EffectiveModel != "" && (decision.Outcome == ModelSelectionOutcomeApplied || - decision.Outcome == ModelSelectionOutcomeExplicitFallback || - decision.Outcome == ModelSelectionOutcomeUniqueVariation) { + decision.Outcome == ModelSelectionOutcomeExplicitFallback) { m.logger.Info("re-applied session model after context reset", zap.String("execution_id", execution.ID), zap.String("session_id", execution.SessionID), @@ -594,19 +614,73 @@ func cacheFreshSessionModelState(execution *AgentExecution) bool { } client, releaseClient := execution.AcquireAgentCtlClient() defer releaseClient() - if client == nil { + return cacheSessionModelStateFromClient(execution, client) +} + +func cacheSessionModelStateFromClient(execution *AgentExecution, client *agentctlclient.Client) bool { + if execution == nil || client == nil { return false } + state, ready := sessionModelStateFromClient(client) + if !ready { + return false + } + execution.SetModelState(state) + return true +} + +func sessionModelStateFromClient(client *agentctlclient.Client) (*CachedModelState, bool) { + if client == nil { + return nil, false + } state := client.GetLastSessionModelState() if state == nil { + return nil, false + } + cached := &CachedModelState{ + CurrentModelID: state.CurrentModelID, + Models: state.Models, + ConfigOptions: state.ConfigOptions, + ConfigOptionsSettled: state.ConfigOptionsSettled, + } + if !freshSessionModelCatalogReady(cached) { + return nil, false + } + return cached, true +} + +func waitForFreshSessionModelStateFromClient( + ctx context.Context, + log *logger.Logger, + execution *AgentExecution, + client *agentctlclient.Client, +) bool { + if execution == nil || client == nil { return false } - execution.SetModelState(&CachedModelState{ - CurrentModelID: state.CurrentModelID, - Models: state.Models, - ConfigOptions: state.ConfigOptions, - }) - return true + if cacheSessionModelStateFromClient(execution, client) { + return true + } + + waitCtx, cancel := context.WithTimeout(ctx, freshSessionModelStateWait) + defer cancel() + ticker := time.NewTicker(freshSessionModelStatePoll) + defer ticker.Stop() + for { + select { + case <-waitCtx.Done(): + if log != nil { + log.Debug("fresh session model catalog was not reported before policy evaluation", + zap.String("execution_id", execution.ID), + zap.Error(waitCtx.Err())) + } + return false + case <-ticker.C: + if cacheSessionModelStateFromClient(execution, client) { + return true + } + } + } } // waitForFreshSessionModelState gives the agent stream a chance to deliver the diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction_runtime_config_reset_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction_runtime_config_reset_test.go index 873e28f25f..46cc7c6c9f 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction_runtime_config_reset_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction_runtime_config_reset_test.go @@ -99,8 +99,9 @@ func TestManager_ResetAgentContext_ReappliesSessionRuntimeConfig(t *testing.T) { require.Equal(t, v1.AgentStatusReady, exec.Status) } -func TestReapplySessionModel_UsesUniqueAdvertisedVariation(t *testing.T) { +func TestReapplySessionModel_RejectsUnadvertisedExactModel(t *testing.T) { mgr := newTestManager(t) + mgr.profileResolver = &restartProfileResolver{profile: &AgentProfileInfo{RequireExactModel: true}} mock := newRestartMockAgentctlServer(t, false, false) client := createTestClient(t, mock.server.URL) t.Cleanup(client.Close) @@ -114,12 +115,14 @@ func TestReapplySessionModel_UsesUniqueAdvertisedVariation(t *testing.T) { Models: []streams.SessionModelInfo{{ModelID: "opus[1m]"}}, }) - require.NoError(t, mgr.reapplySessionModelAfterReset(ctx, exec, "reset-session", "opus")) - require.Equal(t, []string{"opus[1m]"}, mock.getSetModelIDs()) + err := mgr.reapplySessionModelAfterReset(ctx, exec, "reset-session", "opus") + require.ErrorContains(t, err, "requested_not_advertised") + require.Empty(t, mock.getSetModelIDs()) } -func TestWorkspaceRebindModel_UsesUniqueAdvertisedVariation(t *testing.T) { +func TestWorkspaceRebindModel_RejectsUnadvertisedExactModel(t *testing.T) { mgr := newTestManager(t) + mgr.profileResolver = &restartProfileResolver{profile: &AgentProfileInfo{RequireExactModel: true}} mock := newRestartMockAgentctlServer(t, false, false) client := createTestClient(t, mock.server.URL) t.Cleanup(client.Close) @@ -133,15 +136,16 @@ func TestWorkspaceRebindModel_UsesUniqueAdvertisedVariation(t *testing.T) { Models: []streams.SessionModelInfo{{ModelID: "opus[1m]"}}, }) - require.NoError(t, mgr.reapplyReboundSessionConfig( + err := mgr.reapplyReboundSessionConfig( ctx, exec, "rebind-session", &CachedModelState{}, "opus", nil, - )) - require.Equal(t, []string{"opus[1m]"}, mock.getSetModelIDs()) + ) + require.ErrorContains(t, err, "requested_not_advertised") + require.Empty(t, mock.getSetModelIDs()) } func TestManager_RestartAgentProcess_ReappliesSessionRuntimeConfig(t *testing.T) { diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction_test.go index dd81a07fa0..ef31d75b1d 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction_test.go @@ -61,6 +61,10 @@ type restartMockAgentctlServer struct { resetResponseDelay time.Duration resetLateEvent *agentctl.AgentEvent resetLateEventSent chan struct{} + newLateEvent *agentctl.AgentEvent + newLateEventDelay time.Duration + newLateEventSent chan struct{} + newLateEventOnce sync.Once onReset func() onSessionNew func() onCacheRepair func() @@ -71,6 +75,13 @@ type restartConfigOption struct { Value string `json:"value"` } +func restartDefaultModelState() *streams.SessionModelState { + return &streams.SessionModelState{ + CurrentModelID: "claude-sonnet-4-20250514", + Models: []streams.SessionModelInfo{{ModelID: "claude-sonnet-4-20250514"}}, + } +} + func TestStopAgentWithReason_MissingExecutionIsClassified(t *testing.T) { mgr := &Manager{executionStore: NewExecutionStore(), logger: newTestLogger().WithFields()} @@ -346,6 +357,18 @@ func newRestartMockAgentctlServer(t *testing.T, failStop, failSessionNew bool) * if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { return } + if msg.Action == "agent.session.new" && m.newLateEvent != nil { + if m.newLateEventDelay > 0 { + time.Sleep(m.newLateEventDelay) + } + eventData, _ := json.Marshal(m.newLateEvent) + if err := conn.WriteMessage(websocket.TextMessage, eventData); err != nil { + return + } + if m.newLateEventSent != nil { + m.newLateEventOnce.Do(func() { close(m.newLateEventSent) }) + } + } } }) mux.HandleFunc("/api/v1/workspace/stream", func(w http.ResponseWriter, r *http.Request) { @@ -455,6 +478,7 @@ func (m *restartMockAgentctlServer) getSetOptions() []restartConfigOption { func TestManager_RestartAgentProcess_Success(t *testing.T) { mgr := newTestManager(t) mock := newRestartMockAgentctlServer(t, false, false) + mock.newModelState = restartDefaultModelState() client := createTestClient(t, mock.server.URL) t.Cleanup(client.Close) @@ -531,7 +555,7 @@ func TestManager_RestartAgentProcess_Success(t *testing.T) { } wsActions := mock.getWSActions() - if !slices.Equal(wsActions, []string{"agent.initialize", "agent.session.new"}) { + if !slices.Equal(wsActions, []string{"agent.initialize", "agent.session.new", "agent.session.set_model"}) { t.Fatalf("unexpected WS action order: %v", wsActions) } @@ -766,6 +790,7 @@ func TestPromptAgentWithDispatchCallbackTracksExecutionActivityUntilCompletion(t func TestManager_RestartAgentProcess_StopErrorIsNonFatal(t *testing.T) { mgr := newTestManager(t) mock := newRestartMockAgentctlServer(t, true, false) + mock.newModelState = restartDefaultModelState() client := createTestClient(t, mock.server.URL) t.Cleanup(client.Close) @@ -850,6 +875,7 @@ func TestManager_RestartAgentProcess_SessionInitFailure(t *testing.T) { func TestManager_RestartAgentProcess_ReappliesSessionMode(t *testing.T) { mgr := newTestManager(t) mock := newRestartMockAgentctlServer(t, false, false) + mock.newModelState = restartDefaultModelState() client := createTestClient(t, mock.server.URL) t.Cleanup(client.Close) @@ -890,6 +916,7 @@ func TestManager_RestartAgentProcess_ReappliesSessionMode(t *testing.T) { func TestManager_ResetAgentContext_ReappliesSessionMode(t *testing.T) { mgr := newTestManager(t) mock := newRestartMockAgentctlServer(t, false, false) + mock.modelState = restartDefaultModelState() client := createTestClient(t, mock.server.URL) t.Cleanup(client.Close) @@ -939,6 +966,7 @@ func TestManager_ResetAgentContext_ReappliesSessionMode(t *testing.T) { func TestManager_ResetAgentContext_ClearsIdleDispatchGate(t *testing.T) { mgr := newTestManager(t) mock := newRestartMockAgentctlServer(t, false, false) + mock.modelState = restartDefaultModelState() client := createTestClient(t, mock.server.URL) t.Cleanup(client.Close) @@ -976,11 +1004,15 @@ func TestManager_ResetAgentContext_ClearsIdleDispatchGate(t *testing.T) { require.Contains(t, mock.getWSActions(), "agent.prompt") } -// TestManager_ResetAgentContext_ReappliesSessionModel is the regression test -// for an ACP fast-path reset replacing the task's selected model with the -// provider default from the freshly-created session. -func TestManager_ResetAgentContext_ReappliesSessionModel(t *testing.T) { +// TestManager_ResetAgentContext_FailsWhenSessionModelCannotBeRestored ensures +// a fresh ACP session never continues on its provider default after losing a +// persisted model selection. +func TestManager_ResetAgentContext_FailsWhenSessionModelCannotBeRestored(t *testing.T) { mgr := newTestManager(t) + mgr.profileResolver = &restartProfileResolver{profile: &AgentProfileInfo{ + AgentName: "auggie", + RequireExactModel: true, + }} mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{ infos: map[string]*WorkspaceInfo{ "session-1": {SessionID: "session-1", RuntimeModel: "mock-smart"}, @@ -1013,7 +1045,9 @@ func TestManager_ResetAgentContext_ReappliesSessionModel(t *testing.T) { exec.SetModelState(&CachedModelState{CurrentModelID: "mock-fast"}) require.NoError(t, mgr.executionStore.Add(exec)) - require.NoError(t, mgr.ResetAgentContext(ctx, exec.ID)) + err := mgr.ResetAgentContext(ctx, exec.ID) + require.ErrorContains(t, err, `requested model "mock-smart" is unavailable (reason: catalog_empty)`) + require.Equal(t, v1.AgentStatusFailed, exec.Status) actions := mock.getWSActions() resetIndex := slices.Index(actions, "agent.session.reset") @@ -1022,7 +1056,7 @@ func TestManager_ResetAgentContext_ReappliesSessionModel(t *testing.T) { require.Equal(t, -1, modelIndex, "an empty fresh-session model catalog must not receive a model-selection request") require.Empty(t, mock.getSetModelIDs(), - "reset must continue on the fresh-session provider default when no model is advertised") + "reset must not send a speculative model-selection request") } func TestManager_ResetAgentContext_UsesSynchronousSessionModelCatalog(t *testing.T) { @@ -1081,6 +1115,7 @@ func TestManager_RestartAgentProcess_PrefersPersistedModeOverStaleCache(t *testi }, } mock := newRestartMockAgentctlServer(t, false, false) + mock.newModelState = restartDefaultModelState() client := createTestClient(t, mock.server.URL) t.Cleanup(client.Close) diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_kubernetes_refresh.go b/apps/backend/internal/agent/runtime/lifecycle/manager_kubernetes_refresh.go index 4887f324f1..de9761f692 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_kubernetes_refresh.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_kubernetes_refresh.go @@ -7,6 +7,7 @@ import ( "strings" kubeexecutor "github.com/kandev/kandev/internal/agent/kubernetes" + agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl" agentctltypes "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/agentruntime" "github.com/kandev/kandev/internal/secrets" @@ -211,6 +212,30 @@ func (m *Manager) prepareRestartedKubernetesAgentctl( if refresh.AgentConfig == nil || m.sessionManager == nil { return "", nil } + closeStagedStream, err := m.connectStagedKubernetesAgentctlStream(ctx, execution, client) + if err != nil { + return "", err + } + defer closeStagedStream() + + policy := m.resolveStartModelPolicy(ctx, execution.AgentProfileID) + runtimeModel, _, _ := m.sessionRuntimeOverrides(ctx, execution) + modelID := policy.Model + if runtimeModel != "" { + modelID = runtimeModel + } + var previousModelState *CachedModelState + restoreModelState := false + if modelID != "" { + previousModelState = execution.GetModelState() + execution.SetModelState(nil) + restoreModelState = true + defer func() { + if restoreModelState { + execution.SetModelState(previousModelState) + } + }() + } result, err := m.sessionManager.InitializeSession( ctx, client, refresh.AgentConfig, execution.ACPSessionID, execution.WorkspacePath, kubernetesRefreshMcpServers(refresh.McpServers), @@ -218,9 +243,66 @@ func (m *Manager) prepareRestartedKubernetesAgentctl( if err != nil { return "", fmt.Errorf("resume ACP session after Kubernetes restart: %w", err) } + if err := m.applyRestartedKubernetesModel( + ctx, execution, client, result.SessionID, modelID, + ); err != nil { + return "", fmt.Errorf("apply start model after Kubernetes restart: %w", err) + } + restoreModelState = false return result.SessionID, nil } +func (m *Manager) applyRestartedKubernetesModel( + ctx context.Context, + execution *AgentExecution, + client *agentctlclient.Client, + sessionID, modelID string, +) error { + if modelID == "" { + return nil + } + modelState, ready := sessionModelStateFromClient(client) + if !ready { + waitForFreshSessionModelStateFromClient(ctx, m.logger, execution, client) + modelState, ready = sessionModelStateFromClient(client) + } + if !ready { + // Keep policy evaluation independent from any late event delivered by + // the old, still-published client while the replacement is staged. + execution.SetModelState(nil) + if ctxErr := ctx.Err(); ctxErr != nil { + return fmt.Errorf("wait for replacement session model catalog: %w", ctxErr) + } + } + if ready { + execution.SetModelState(modelState) + } + return m.reapplySessionModelAfterResetWithClientAndState( + ctx, execution, client, modelState, sessionID, modelID, + ) +} + +func (m *Manager) connectStagedKubernetesAgentctlStream( + ctx context.Context, + execution *AgentExecution, + client *agentctlclient.Client, +) (func(), error) { + if client == nil { + return nil, errors.New("connect staged Kubernetes agentctl stream: client is unavailable") + } + if client.HasAgentStream() { + return client.CloseUpdatesStream, nil + } + var mcpHandler agentctlclient.MCPHandler + if m.streamManager != nil { + mcpHandler = m.streamManager.mcpHandlerFor(execution) + } + if err := client.StreamUpdates(ctx, func(agentctlclient.AgentEvent) {}, mcpHandler, nil); err != nil { + return nil, fmt.Errorf("connect staged Kubernetes agentctl stream: %w", err) + } + return client.CloseUpdatesStream, nil +} + func kubernetesRefreshMcpServers(configs []McpServerConfig) []agentctltypes.McpServer { servers := make([]agentctltypes.McpServer, 0, len(configs)) for _, config := range configs { diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_profile.go b/apps/backend/internal/agent/runtime/lifecycle/manager_profile.go index a52e5d9130..caf0fda1f2 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_profile.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_profile.go @@ -171,9 +171,10 @@ func (m *Manager) resolveProfileSessionConfigAndPolicy(ctx context.Context, prof return "", "", nil, StartModelPolicy{} } return info.Model, info.Mode, info.ConfigOptions, StartModelPolicy{ - Model: info.Model, - FallbackModel: info.FallbackModel, - AutoFallback: info.AutoFallback, + Model: info.Model, + FallbackModel: info.FallbackModel, + AutoFallback: info.AutoFallback, + RequireExactModel: info.RequireExactModel, } } @@ -190,9 +191,10 @@ func (m *Manager) resolveStartModelPolicy(ctx context.Context, profileID string) return StartModelPolicy{} } return StartModelPolicy{ - Model: info.Model, - FallbackModel: info.FallbackModel, - AutoFallback: info.AutoFallback, + Model: info.Model, + FallbackModel: info.FallbackModel, + AutoFallback: info.AutoFallback, + RequireExactModel: info.RequireExactModel, } } diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_profile_capabilities_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_profile_capabilities_test.go index d3e6fe89b0..804ff670e5 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_profile_capabilities_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_profile_capabilities_test.go @@ -85,12 +85,13 @@ func TestRuntimeNameOfNilBackend(t *testing.T) { func TestResolveProfileSessionConfigAndPolicyReadsProfileOnce(t *testing.T) { resolver := &countingProfileResolver{info: &AgentProfileInfo{ - ProfileID: "profile-1", - Model: "claude-opus-5", - Mode: "plan", - ConfigOptions: map[string]string{"reasoning": "high"}, - FallbackModel: "claude-sonnet-5", - AutoFallback: true, + ProfileID: "profile-1", + Model: "claude-opus-5", + Mode: "plan", + ConfigOptions: map[string]string{"reasoning": "high"}, + FallbackModel: "claude-sonnet-5", + AutoFallback: true, + RequireExactModel: true, }} mgr := newTestManager(t) mgr.profileResolver = resolver @@ -103,6 +104,7 @@ func TestResolveProfileSessionConfigAndPolicyReadsProfileOnce(t *testing.T) { require.Equal(t, "claude-opus-5", policy.Model) require.Equal(t, "claude-sonnet-5", policy.FallbackModel) require.True(t, policy.AutoFallback) + require.True(t, policy.RequireExactModel) require.Equal(t, int32(1), resolver.calls.Load(), "session start needs config and policy together; a second read would be a wasted DB hit") } diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_remote_status_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_remote_status_test.go index e48d1de740..f42feb2296 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_remote_status_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_remote_status_test.go @@ -13,6 +13,8 @@ import ( "github.com/stretchr/testify/require" "github.com/kandev/kandev/internal/agent/executor" + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/agentruntime" "github.com/kandev/kandev/internal/secrets" "github.com/kandev/kandev/internal/task/models" @@ -351,6 +353,115 @@ func TestPollOneRemoteStatusAtomicallyRefreshesRestartedKubernetesAgentctl(t *te require.Equal(t, "41002", getMetadataString(writer.running.Metadata, MetadataKeyKubernetesAgentctlRemotePort)) } +func TestPrepareRestartedKubernetesAgentctl_RejectsUnadvertisedExactModel(t *testing.T) { + mock := newRestartMockAgentctlServer(t, false, false) + mock.newModelState = &streams.SessionModelState{ + CurrentModelID: "provider-default", + Models: []streams.SessionModelInfo{{ModelID: "provider-default"}}, + } + client := createTestClient(t, mock.server.URL) + t.Cleanup(client.Close) + mgr := newRemoteStatusManager(t, &MockExecutor{name: executor.NameKubernetes}) + mgr.profileResolver = &restartProfileResolver{profile: &AgentProfileInfo{Model: "gpt-5.4", RequireExactModel: true}} + mgr.sessionManager = NewSessionManager(newTestLogger(), newTestStopCh(t)) + agentConfig, ok := newTestRegistry().Get("claude-acp") + require.True(t, ok) + execution := &AgentExecution{ + ID: "exec-1", TaskID: "task-1", SessionID: "session-1", AgentProfileID: "profile-1", + WorkspacePath: "/workspace", AgentCommand: "agent", agentctl: client, + } + refresh := &RemoteInstanceRefresh{ + Instance: &ExecutorInstance{Client: client}, ProcessRestarted: true, AgentConfig: agentConfig, + } + + _, err := mgr.prepareRestartedKubernetesAgentctl(context.Background(), execution, refresh) + + require.ErrorContains(t, err, `requested model "gpt-5.4" is unavailable (reason: requested_not_advertised`) + require.Empty(t, mock.getSetModelIDs(), "an unadvertised exact model must never be sent to ACP") +} + +func TestPrepareRestartedKubernetesAgentctl_RestoresPersistedRuntimeModel(t *testing.T) { + oldMock := newRestartMockAgentctlServer(t, false, false) + refreshedMock := newRestartMockAgentctlServer(t, false, false) + refreshedMock.newModelState = &streams.SessionModelState{ + CurrentModelID: "gpt-5.6-luna", + Models: []streams.SessionModelInfo{{ModelID: "gpt-5.6-luna"}}, + } + oldClient := createTestClient(t, oldMock.server.URL) + t.Cleanup(oldClient.Close) + refreshedClient := createTestClient(t, refreshedMock.server.URL) + t.Cleanup(refreshedClient.Close) + mgr := newRemoteStatusManager(t, &MockExecutor{name: executor.NameKubernetes}) + mgr.profileResolver = &restartProfileResolver{profile: &AgentProfileInfo{Model: "gpt-5.6-terra"}} + mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{infos: map[string]*WorkspaceInfo{ + "session-1": {RuntimeModel: "gpt-5.6-luna"}, + }} + mgr.sessionManager = NewSessionManager(newTestLogger(), newTestStopCh(t)) + agentConfig, ok := newTestRegistry().Get("claude-acp") + require.True(t, ok) + execution := &AgentExecution{ + ID: "exec-1", TaskID: "task-1", SessionID: "session-1", AgentProfileID: "profile-1", + WorkspacePath: "/workspace", AgentCommand: "agent", agentctl: oldClient, + } + execution.SetModelState(&CachedModelState{ + CurrentModelID: "gpt-5.6-luna", + Models: []streams.SessionModelInfo{{ModelID: "gpt-5.6-luna"}}, + }) + refresh := &RemoteInstanceRefresh{ + Instance: &ExecutorInstance{Client: refreshedClient}, ProcessRestarted: true, AgentConfig: agentConfig, + } + + _, err := mgr.prepareRestartedKubernetesAgentctl(context.Background(), execution, refresh) + + require.NoError(t, err) + require.Empty(t, oldMock.getSetModelIDs(), "the stale agentctl must not receive a model restore") + require.Equal(t, []string{"gpt-5.6-luna"}, refreshedMock.getSetModelIDs()) +} + +func TestPrepareRestartedKubernetesAgentctl_WaitsForDelayedReplacementCatalog(t *testing.T) { + mock := newRestartMockAgentctlServer(t, false, false) + mock.newModelState = &streams.SessionModelState{} + mock.newLateEventDelay = 20 * time.Millisecond + mock.newLateEventSent = make(chan struct{}) + mock.newLateEvent = &agentctl.AgentEvent{ + Type: streams.EventTypeSessionModels, + CurrentModelID: "gpt-5.6-luna", + SessionModels: []streams.SessionModelInfo{{ModelID: "gpt-5.6-luna"}}, + } + client := createTestClient(t, mock.server.URL) + t.Cleanup(client.Close) + mgr := newRemoteStatusManager(t, &MockExecutor{name: executor.NameKubernetes}) + mgr.profileResolver = &restartProfileResolver{profile: &AgentProfileInfo{Model: "gpt-5.6-luna"}} + mgr.sessionManager = NewSessionManager(newTestLogger(), newTestStopCh(t)) + agentConfig, ok := newTestRegistry().Get("claude-acp") + require.True(t, ok) + execution := &AgentExecution{ + ID: "exec-1", TaskID: "task-1", SessionID: "session-1", AgentProfileID: "profile-1", + WorkspacePath: "/workspace", AgentCommand: "agent", agentctl: newReadyAgentctlClient(t, newTestLogger()), + } + execution.SetModelState(&CachedModelState{ + CurrentModelID: "old-model", + Models: []streams.SessionModelInfo{{ModelID: "old-model"}}, + }) + refresh := &RemoteInstanceRefresh{ + Instance: &ExecutorInstance{Client: client}, ProcessRestarted: true, AgentConfig: agentConfig, + } + + _, err := mgr.prepareRestartedKubernetesAgentctl(context.Background(), execution, refresh) + + require.NoError(t, err) + require.Equal(t, []string{"gpt-5.6-luna"}, mock.getSetModelIDs()) + require.Eventually(t, func() bool { + state := execution.GetModelState() + return state != nil && state.CurrentModelID == "gpt-5.6-luna" + }, time.Second, time.Millisecond) + select { + case <-mock.newLateEventSent: + default: + t.Fatal("replacement session_models event was not sent through the staged stream") + } +} + func TestPollOneRemoteStatusReattachesKubernetesClientWithoutRestartingAgent(t *testing.T) { var configureCalls atomic.Int32 var startCalls atomic.Int32 diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_rebind.go b/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_rebind.go index dced735099..65a9ee7ed3 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_rebind.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_rebind.go @@ -195,17 +195,20 @@ func (m *Manager) createReboundACPSession(ctx context.Context, execution *AgentE execution.SetModelState(previousModel) return fmt.Errorf("create ACP session in rebound workspace: %w", err) } + if !cacheFreshSessionModelState(execution) && previousModelID != "" { + waitForFreshSessionModelState(ctx, m.logger, execution) + } + if err := m.reapplyReboundSessionConfig(ctx, execution, newSessionID, previousModel, previousModelID, previousMode); err != nil { + execution.SetModelState(previousModel) + return err + } + // The replacement ACP ID becomes externally visible only after its saved + // session configuration, including a strict model, has been restored. execution.ACPSessionID = newSessionID execution.setSessionInitialized(true) execution.resumeContextInjected = false execution.needsResumeContext = m.historyManager != nil && m.historyManager.HasHistory(execution.SessionID) - if !cacheFreshSessionModelState(execution) && previousModelID != "" { - waitForFreshSessionModelState(ctx, m.logger, execution) - } - if err := m.reapplyReboundSessionConfig(ctx, execution, newSessionID, previousModel, previousModelID, previousMode); err != nil { - return err - } if m.eventPublisher != nil { m.eventPublisher.PublishACPSessionCreatedWithAttempt( execution, newSessionID, ResumeAttemptIDFromContext(ctx), diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_sources_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_sources_test.go index 95c2b705b2..15e0da7699 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_sources_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_sources_test.go @@ -165,6 +165,36 @@ func TestRebindWorkspaceForSessionCreatesNewSessionWhenProviderCannotChangeResum } } +func TestRebindWorkspaceForSessionRestoresExistingACPSessionAfterStrictModelRestoreFailure(t *testing.T) { + server := newWorkspaceRebindAgentctlServer(t, false) + mgr, execution := workspaceSourceTestManager(t, server.URL, []string{"/old"}) + mgr.profileResolver = &restartProfileResolver{profile: &AgentProfileInfo{RequireExactModel: true}} + execution.AgentProfileID = "profile-1" + t.Cleanup(server.Close) + t.Cleanup(server.closeConnections) + mgr.registry = registry.NewRegistry(newTestLogger()) + mgr.registry.LoadDefaults() + + execution.AgentID = "opencode-acp" + execution.Status = v1.AgentStatusReady + execution.ACPSessionID = "acp-existing" + execution.SetModelState(&CachedModelState{CurrentModelID: "exact-model"}) + + err := mgr.RebindWorkspaceForSession(context.Background(), execution.SessionID, "/new-workspace") + if err == nil { + t.Fatal("RebindWorkspaceForSession unexpectedly succeeded") + } + if execution.ACPSessionID != "acp-existing" { + t.Fatalf("ACP session ID after rollback = %q, want existing ID", execution.ACPSessionID) + } + if model := execution.GetModelState(); model == nil || model.CurrentModelID != "exact-model" { + t.Fatalf("model state after rollback = %#v, want exact-model", model) + } + if loads := server.loads(); len(loads) != 1 || loads[0] != "acp-existing" { + t.Fatalf("rollback loaded ACP sessions = %v, want [acp-existing]", loads) + } +} + func TestRebindWorkspaceForSessionReadinessTimeoutRollsBack(t *testing.T) { server := newWorkspaceRebindAgentctlServer(t, true) mgr, execution := workspaceSourceTestManager(t, server.URL, []string{"/old"}) @@ -279,6 +309,10 @@ func newWorkspaceRebindAgentctlServer(t *testing.T, neverReady bool) *workspaceR response, _ := ws.NewResponse(request.ID, request.Action, map[string]any{ "success": true, "session_id": "acp-new", + "model_state": map[string]any{ + "current_model_id": "provider-default", + "models": []map[string]string{{"model_id": "provider-default"}}, + }, }) data, _ := json.Marshal(response) if conn.WriteMessage(websocket.TextMessage, data) != nil { diff --git a/apps/backend/internal/agent/runtime/lifecycle/profile_resolver.go b/apps/backend/internal/agent/runtime/lifecycle/profile_resolver.go index ca454d6a42..e49abd529f 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/profile_resolver.go +++ b/apps/backend/internal/agent/runtime/lifecycle/profile_resolver.go @@ -87,6 +87,7 @@ func (r *StoreProfileResolver) ResolveProfile(ctx context.Context, profileID str Mode: profile.Mode, FallbackModel: profile.FallbackModel, AutoFallback: profile.AutoFallback, + RequireExactModel: profile.RequireExactModel, ConfigOptions: profile.ConfigOptions, AutoApprove: profile.AutoApprove, DangerouslySkipPermissions: profile.DangerouslySkipPermissions, diff --git a/apps/backend/internal/agent/runtime/lifecycle/session.go b/apps/backend/internal/agent/runtime/lifecycle/session.go index a6d595a57b..c75146f4dd 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/session.go +++ b/apps/backend/internal/agent/runtime/lifecycle/session.go @@ -526,12 +526,10 @@ func (sm *SessionManager) applyStartModelPolicyToEffectiveModel( } effective.decision = decision if decision.Outcome == ModelSelectionOutcomeApplied || - decision.Outcome == ModelSelectionOutcomeExplicitFallback || - decision.Outcome == ModelSelectionOutcomeUniqueVariation { + decision.Outcome == ModelSelectionOutcomeExplicitFallback { effective.appliedModel = decision.EffectiveModel } - if decision.Outcome == ModelSelectionOutcomeExplicitFallback || - decision.Outcome == ModelSelectionOutcomeUniqueVariation { + if decision.Outcome == ModelSelectionOutcomeExplicitFallback { effective.model = decision.EffectiveModel } return effective diff --git a/apps/backend/internal/agent/runtime/lifecycle/session_model_selection_warning_test.go b/apps/backend/internal/agent/runtime/lifecycle/session_model_selection_warning_test.go deleted file mode 100644 index 0c10f33782..0000000000 --- a/apps/backend/internal/agent/runtime/lifecycle/session_model_selection_warning_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package lifecycle - -import ( - "context" - "testing" - "time" - - "github.com/kandev/kandev/internal/agent/agents" - agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" - "github.com/kandev/kandev/internal/agentctl/types/streams" - "github.com/kandev/kandev/pkg/agent" -) - -func TestInitializeAndPrompt_UniqueVariationPublishesProviderNeutralWarning(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) - eventBus := &MockEventBusWithTracking{} - sm.SetDependencies(NewEventPublisher(eventBus, log), streamMgr, nil, nil) - - client := createTestClient(t, mock.server.URL) - defer client.Close() - execution := &AgentExecution{ - ID: "exec-unique-variation", - TaskID: "task-1", - SessionID: "session-1", - WorkspacePath: "/workspace", - agentctl: client, - promptDoneCh: make(chan PromptCompletionSignal, 1), - } - execution.SetModelState(modelState("opus[1m]")) - 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: "opus"}, "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", setModelCalls) - } - - var warning *streams.ModelSelectionWarning - legacyFallbackEvents := 0 - for _, event := range eventBus.getStreamEvents() { - if event.Data == nil { - continue - } - if event.Data.ModelSelectionWarning != nil { - warning = event.Data.ModelSelectionWarning - } - if event.Data.Type == streams.EventTypeSessionModelFallback { - legacyFallbackEvents++ - } - } - if warning == nil { - t.Fatal("expected a model-selection warning event") - } - if warning.RequestedModel != "opus" { - t.Errorf("warning requested model = %q, want opus", warning.RequestedModel) - } - if warning.EffectiveModel != "opus[1m]" { - t.Errorf("warning effective model = %q, want opus[1m]", warning.EffectiveModel) - } - if warning.FallbackModel != "" { - t.Errorf("warning fallback model = %q, want empty", warning.FallbackModel) - } - if warning.Reason != ModelSelectionReasonUniqueVariationApplied { - t.Errorf("warning reason = %q, want %q", warning.Reason, ModelSelectionReasonUniqueVariationApplied) - } - if legacyFallbackEvents != 0 { - t.Fatalf("legacy fallback events = %d, want zero for unique variation", legacyFallbackEvents) - } -} diff --git a/apps/backend/internal/agent/runtime/lifecycle/session_test.go b/apps/backend/internal/agent/runtime/lifecycle/session_test.go index 9dfbef68c3..a54b3c7651 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/session_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/session_test.go @@ -304,10 +304,10 @@ func createTestClient(t *testing.T, serverURL string) *agentctl.Client { // --- Tests --- -// TestInitializeAndPromptWithLayers_UnadvertisedModelUsesProviderDefault -// verifies the executor-authoritative policy for both the profile model and a +// TestInitializeAndPromptWithLayers_UnadvertisedModelFailsBeforeInference +// documents the strict launch contract for both the profile model and a // persisted runtime override. -func TestInitializeAndPromptWithLayers_UnadvertisedModelUsesProviderDefault(t *testing.T) { +func TestInitializeAndPromptWithLayers_UnadvertisedModelFailsBeforeInference(t *testing.T) { for _, tc := range []struct { name string profileModel string @@ -353,43 +353,111 @@ func TestInitializeAndPromptWithLayers_UnadvertisedModelUsesProviderDefault(t *t }, } + readyCalls := 0 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 }, + func(executionID string) error { + readyCalls++ + return nil + }, tc.profileModel, "plan", nil, tc.runtimeModel, "", nil, - StartModelPolicy{}, + StartModelPolicy{RequireExactModel: true}, ) - if err != nil { - t.Fatalf("unadvertised model should not fail launch: %v", err) + if err == nil || !strings.Contains(err.Error(), "requested_not_advertised") { + t.Fatalf("unadvertised model error = %v, want requested_not_advertised", err) } - if !execution.sessionInitialized { - t.Error("provider-default continuation must be marked initialized") + if execution.sessionInitialized { + t.Error("strict unavailable model must not initialize the session") + } + if readyCalls != 0 { + t.Errorf("mark ready calls = %d, want 0", readyCalls) } for _, action := range mock.getActionLog() { - if action == "agent.session.set_model" { - t.Error("unadvertised model must not send a SetModel request") + if action == "agent.session.set_model" || action == "agent.prompt" { + t.Errorf("strict unavailable model must not dispatch %q", action) } } - var warning *AgentStreamEventPayload for _, event := range eventBus.getStreamEvents() { if event.Data != nil && event.Data.ModelSelectionWarning != nil { - warning = &event - break + t.Error("strict unavailable model must not publish a continuation warning") } } - if warning == nil { - t.Fatal("expected a model-selection warning event") - } - if warning.Data.ModelSelectionWarning.RequestedModel != "claude-gone" { - t.Errorf("warning requested model = %q, want claude-gone", warning.Data.ModelSelectionWarning.RequestedModel) - } }) } } +func TestInitializeAndPromptWithLayers_UnadvertisedModelAutoFallbackWarns(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) + 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(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}, + }, + } + + err := sm.InitializeAndPromptWithLayers( + context.Background(), execution, agentConfig, "", nil, nil, + func(executionID string) error { return nil }, + "claude-gone", "plan", nil, + "", "", nil, + StartModelPolicy{AutoFallback: true}, + ) + if err != nil { + t.Fatalf("auto fallback should continue on the provider default: %v", err) + } + if !execution.sessionInitialized { + t.Error("auto fallback continuation must initialize the session") + } + for _, action := range mock.getActionLog() { + if action == "agent.session.set_model" { + t.Error("unadvertised model must not send a SetModel request") + } + } + var warning *AgentStreamEventPayload + for _, event := range eventBus.getStreamEvents() { + if event.Data != nil && event.Data.ModelSelectionWarning != nil { + warning = &event + break + } + } + if warning == nil { + t.Fatal("expected a model-selection warning event") + } + if warning.Data.ModelSelectionWarning.RequestedModel != "claude-gone" { + t.Errorf("warning requested model = %q, want claude-gone", warning.Data.ModelSelectionWarning.RequestedModel) + } +} + // 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 @@ -636,6 +704,7 @@ func TestInitializeAndPrompt_AppliesProfileConfigOptions(t *testing.T) { } execution.SetModelState(&CachedModelState{ CurrentModelID: "default-model", + Models: []streams.SessionModelInfo{{ModelID: "sonnet"}}, ConfigOptions: []streams.ConfigOption{ { ID: "model", Category: "model", CurrentValue: "default-model", diff --git a/apps/backend/internal/agent/runtime/lifecycle/start_model.go b/apps/backend/internal/agent/runtime/lifecycle/start_model.go index 9d8099f8d7..6074372adc 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/start_model.go +++ b/apps/backend/internal/agent/runtime/lifecycle/start_model.go @@ -23,6 +23,10 @@ type StartModelPolicy struct { // AutoFallback keeps selection best-effort after an advertised model // rejects SetModel. It does not bypass the executor catalog. AutoFallback bool + // RequireExactModel makes the configured model an explicit identity + // requirement. Dormant fallback settings and advertised variations do not + // satisfy this policy. + RequireExactModel bool } // ModelSelectionOutcome describes the executor-authoritative result. @@ -91,7 +95,6 @@ func uniqueAdvertisedModelVariation(requested string, advertised []string) strin if requested == "" || strings.ContainsAny(requested, "[]") { return "" } - prefix := requested + "[" candidates := make(map[string]struct{}) for _, id := range advertised { @@ -128,11 +131,10 @@ func providerDefaultDecision(state *CachedModelState, policy StartModelPolicy, r } // applyStartModelPolicy applies the profile model only when the executor's -// ACP catalog advertises it. If the requested model is absent, it may apply an -// explicitly configured fallback only when that fallback is also advertised. -// All other mismatches continue on the agent's current/default model and -// return a warning decision. Errors from an advertised model remain explicit, -// except for method-not-supported and legacy auto-fallback mode. +// ACP catalog advertises it. An exact profile fails closed when the requested +// model cannot be attested before inference. Compatible profiles preserve the +// legacy fallback order and may continue on the provider default with a +// warning when no authorized alternate model is available. func applyStartModelPolicy( ctx context.Context, log *logger.Logger, @@ -140,10 +142,13 @@ func applyStartModelPolicy( state *CachedModelState, policy StartModelPolicy, ) (ModelSelectionDecision, error) { - if policy.Model == "" { + if strings.TrimSpace(policy.Model) == "" { + if policy.RequireExactModel { + return ModelSelectionDecision{}, fmt.Errorf("exact model is required when RequireExactModel is enabled") + } return ModelSelectionDecision{Outcome: ModelSelectionOutcomeNone}, nil } - if policy.AutoFallback { + if policy.RequireExactModel || policy.AutoFallback { policy.FallbackModel = "" } @@ -153,34 +158,44 @@ func applyStartModelPolicy( } advertised := advertisedModelIDs(state) if len(advertised) == 0 { - return providerDefaultDecision(state, policy, ModelSelectionReasonCatalogEmpty), nil + return unavailableStartModel(state, policy, ModelSelectionReasonCatalogEmpty) } if !containsModel(advertised, policy.Model) { - if policy.AutoFallback { - return providerDefaultDecision(state, policy, ModelSelectionReasonRequestedNotAdvertised), nil + if policy.RequireExactModel { + return unavailableStartModel(state, policy, ModelSelectionReasonRequestedNotAdvertised) } - if policy.FallbackModel != "" && containsModel(advertised, policy.FallbackModel) { + if !policy.AutoFallback && policy.FallbackModel != "" && containsModel(advertised, policy.FallbackModel) { return applyAdvertisedFallback(ctx, log, applier, state, policy, decision) } if variation := uniqueAdvertisedModelVariation(policy.Model, advertised); variation != "" { return applyUniqueAdvertisedVariation(ctx, log, applier, state, policy, decision, variation) } reason := ModelSelectionReasonRequestedNotAdvertised - if policy.FallbackModel != "" { + if !policy.AutoFallback && policy.FallbackModel != "" { reason = ModelSelectionReasonFallbackNotAdvertised } - return providerDefaultDecision(state, policy, reason), nil + return unavailableStartModel(state, policy, reason) } decision.SetModelCalled = true if err := applier.SetModel(ctx, policy.Model); err != nil { if sessionmodel.IsMethodNotFound(err) { - log.Debug("agent does not support model selection, continuing on provider default", - zap.String("model", policy.Model), zap.Error(err)) - decision = providerDefaultDecision(state, policy, ModelSelectionReasonSelectionUnsupported) - decision.SetModelCalled = true - return decision, nil + if policy.RequireExactModel { + unsupported, unavailableErr := unavailableStartModel(state, policy, ModelSelectionReasonSelectionUnsupported) + unsupported.SetModelCalled = true + return unsupported, unavailableErr + } + if policy.AutoFallback { + log.Debug("agent does not support model selection, continuing on provider default", + zap.String("model", policy.Model), zap.Error(err)) + decision = providerDefaultDecision(state, policy, ModelSelectionReasonSelectionUnsupported) + decision.SetModelCalled = true + return decision, nil + } + unsupported, unavailableErr := unavailableStartModel(state, policy, ModelSelectionReasonSelectionUnsupported) + unsupported.SetModelCalled = true + return unsupported, unavailableErr } if policy.AutoFallback { log.Warn("failed to set profile model via ACP (auto-fallback)", @@ -197,6 +212,27 @@ func applyStartModelPolicy( return decision, nil } +// unavailableStartModel permits provider-default inference only for profiles +// that explicitly authorize it. The error contains stable, provider-neutral +// evidence and never includes executor configuration or transport details. +func unavailableStartModel( + state *CachedModelState, + policy StartModelPolicy, + reason string, +) (ModelSelectionDecision, error) { + decision := providerDefaultDecision(state, policy, reason) + if !policy.RequireExactModel { + return decision, nil + } + if decision.EffectiveModel == "" { + return decision, fmt.Errorf("requested model %q is unavailable (reason: %s)", policy.Model, reason) + } + return decision, fmt.Errorf( + "requested model %q is unavailable (reason: %s, effective model: %q)", + policy.Model, reason, decision.EffectiveModel, + ) +} + func applyUniqueAdvertisedVariation( ctx context.Context, log *logger.Logger, @@ -207,19 +243,13 @@ func applyUniqueAdvertisedVariation( variation string, ) (ModelSelectionDecision, error) { decision.FallbackModel = "" - policy.FallbackModel = "" decision.SetModelCalled = true if err := applier.SetModel(ctx, variation); err != nil { - if sessionmodel.IsMethodNotFound(err) { + if sessionmodel.IsMethodNotFound(err) || policy.AutoFallback { decision = providerDefaultDecision(state, policy, ModelSelectionReasonSelectionUnsupported) decision.SetModelCalled = true return decision, nil } - if policy.AutoFallback { - decision = providerDefaultDecision(state, policy, ModelSelectionReasonSelectionFailedAutoFallback) - decision.SetModelCalled = true - return decision, nil - } return decision, fmt.Errorf("failed to set unique model variation %q: %w", variation, err) } decision.EffectiveModel = variation @@ -243,9 +273,12 @@ func applyAdvertisedFallback( decision.SetModelCalled = true if err := applier.SetModel(ctx, policy.FallbackModel); err != nil { if sessionmodel.IsMethodNotFound(err) { - decision = providerDefaultDecision(state, policy, ModelSelectionReasonSelectionUnsupported) - decision.SetModelCalled = true - return decision, nil + if policy.RequireExactModel { + unsupported, unavailableErr := unavailableStartModel(state, policy, ModelSelectionReasonSelectionUnsupported) + unsupported.SetModelCalled = true + return unsupported, unavailableErr + } + return providerDefaultDecision(state, policy, ModelSelectionReasonSelectionUnsupported), nil } return decision, fmt.Errorf("failed to set fallback model %q: %w", policy.FallbackModel, err) } diff --git a/apps/backend/internal/agent/runtime/lifecycle/start_model_executor_authority_test.go b/apps/backend/internal/agent/runtime/lifecycle/start_model_executor_authority_test.go index 405c9b679b..a08c03ef8e 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/start_model_executor_authority_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/start_model_executor_authority_test.go @@ -22,20 +22,22 @@ func TestApplyStartModelPolicyExecutorAuthority(t *testing.T) { wantErr string }{ { - name: "requested model absent does not call executor", - state: modelState("executor-default"), - policy: StartModelPolicy{Model: "host-only-model"}, - wantOutcome: ModelSelectionOutcomeProviderDefault, - wantReason: ModelSelectionReasonRequestedNotAdvertised, - wantWarning: true, + name: "exact model absent fails without calling executor", + state: modelState("executor-default"), + policy: StartModelPolicy{Model: "host-only-model", RequireExactModel: true}, + wantErr: `requested model "host-only-model" is unavailable (reason: requested_not_advertised)`, }, { - name: "empty catalog does not call executor", - state: &CachedModelState{}, - policy: StartModelPolicy{Model: "host-only-model", FallbackModel: "fallback"}, - wantOutcome: ModelSelectionOutcomeProviderDefault, - wantReason: ModelSelectionReasonCatalogEmpty, - wantWarning: true, + name: "exact model with empty catalog fails without calling executor", + state: &CachedModelState{}, + policy: StartModelPolicy{Model: "host-only-model", FallbackModel: "fallback", RequireExactModel: true}, + wantErr: `requested model "host-only-model" is unavailable (reason: catalog_empty)`, + }, + { + name: "exact model with nil catalog state fails without calling executor", + state: nil, + policy: StartModelPolicy{Model: "host-only-model", RequireExactModel: true}, + wantErr: `requested model "host-only-model" is unavailable (reason: catalog_empty)`, }, { name: "advertised fallback is applied", @@ -48,105 +50,7 @@ func TestApplyStartModelPolicyExecutorAuthority(t *testing.T) { wantWarning: true, }, { - name: "unique variation is applied when fallback is absent", - state: modelState("opus[1m]"), - policy: StartModelPolicy{Model: "opus"}, - wantCalls: []string{"opus[1m]"}, - wantOutcome: ModelSelectionOutcomeUniqueVariation, - wantReason: ModelSelectionReasonUniqueVariationApplied, - wantEffective: "opus[1m]", - wantWarning: true, - }, - { - name: "advertised fallback takes precedence over unique variation", - state: modelState("fallback", "opus[1m]"), - policy: StartModelPolicy{Model: "opus", FallbackModel: "fallback"}, - wantCalls: []string{"fallback"}, - wantOutcome: ModelSelectionOutcomeExplicitFallback, - wantReason: ModelSelectionReasonRequestedNotAdvertised, - wantEffective: "fallback", - wantWarning: true, - }, - { - name: "unadvertised fallback does not block unique variation", - state: modelState("opus[1m]"), - policy: StartModelPolicy{Model: "opus", FallbackModel: "fallback"}, - wantCalls: []string{"opus[1m]"}, - wantOutcome: ModelSelectionOutcomeUniqueVariation, - wantReason: ModelSelectionReasonUniqueVariationApplied, - wantEffective: "opus[1m]", - wantWarning: true, - }, - { - name: "auto fallback keeps unavailable model on provider default", - state: modelState("fallback", "opus[1m]"), - policy: StartModelPolicy{Model: "opus", FallbackModel: "fallback", AutoFallback: true}, - wantOutcome: ModelSelectionOutcomeProviderDefault, - wantReason: ModelSelectionReasonRequestedNotAdvertised, - wantWarning: true, - }, - { - name: "duplicate unique variation is one candidate", - state: modelState("opus[1m]", "opus[1m]"), - policy: StartModelPolicy{Model: "opus"}, - wantCalls: []string{"opus[1m]"}, - wantOutcome: ModelSelectionOutcomeUniqueVariation, - wantReason: ModelSelectionReasonUniqueVariationApplied, - wantEffective: "opus[1m]", - wantWarning: true, - }, - { - name: "multiple variations keep provider default", - state: modelState("opus[1m]", "opus[270k]"), - policy: StartModelPolicy{Model: "opus"}, - wantOutcome: ModelSelectionOutcomeProviderDefault, - wantReason: ModelSelectionReasonRequestedNotAdvertised, - wantWarning: true, - }, - { - name: "malformed variations keep provider default", - state: modelState("opus[]", "opus[1m", "opus[1m][fast]", "opus-pro[1m]"), - policy: StartModelPolicy{Model: "opus"}, - wantOutcome: ModelSelectionOutcomeProviderDefault, - wantReason: ModelSelectionReasonRequestedNotAdvertised, - wantWarning: true, - }, - { - name: "variation text remains opaque", - state: modelState("opus[1m, fast]"), - policy: StartModelPolicy{Model: "opus"}, - wantCalls: []string{"opus[1m, fast]"}, - wantOutcome: ModelSelectionOutcomeUniqueVariation, - wantReason: ModelSelectionReasonUniqueVariationApplied, - wantEffective: "opus[1m, fast]", - wantWarning: true, - }, - { - name: "case-sensitive variation matching keeps provider default", - state: modelState("Opus[1m]"), - policy: StartModelPolicy{Model: "opus"}, - wantOutcome: ModelSelectionOutcomeProviderDefault, - wantReason: ModelSelectionReasonRequestedNotAdvertised, - wantWarning: true, - }, - { - name: "bracketed request does not infer another variation", - state: modelState("opus[2m]"), - policy: StartModelPolicy{Model: "opus[1m]"}, - wantOutcome: ModelSelectionOutcomeProviderDefault, - wantReason: ModelSelectionReasonRequestedNotAdvertised, - wantWarning: true, - }, - { - name: "exact bracketed request remains exact", - state: modelState("opus[1m]", "opus[2m]"), - policy: StartModelPolicy{Model: "opus[1m]"}, - wantCalls: []string{"opus[1m]"}, - wantOutcome: ModelSelectionOutcomeApplied, - wantEffective: "opus[1m]", - }, - { - name: "advertised fallback method not supported keeps provider default", + name: "compatible advertised fallback method not supported uses provider default", state: modelState("fallback"), policy: StartModelPolicy{Model: "host-only-model", FallbackModel: "fallback"}, applierErrors: []error{methodNotFoundErr()}, @@ -156,45 +60,57 @@ func TestApplyStartModelPolicyExecutorAuthority(t *testing.T) { wantWarning: true, }, { - name: "unique variation method not supported keeps provider default", - state: modelState("opus[1m]"), - policy: StartModelPolicy{Model: "opus", FallbackModel: "fallback"}, - applierErrors: []error{methodNotFoundErr()}, - wantCalls: []string{"opus[1m]"}, - wantOutcome: ModelSelectionOutcomeProviderDefault, - wantReason: ModelSelectionReasonSelectionUnsupported, - wantWarning: true, - }, - { - name: "unadvertised fallback keeps provider default", - state: modelState("executor-default"), - policy: StartModelPolicy{Model: "host-only-model", FallbackModel: "host-fallback"}, - wantOutcome: ModelSelectionOutcomeProviderDefault, - wantReason: ModelSelectionReasonFallbackNotAdvertised, - wantWarning: true, + name: "unadvertised fallback fails", + state: modelState("executor-default"), + policy: StartModelPolicy{Model: "host-only-model", FallbackModel: "host-fallback", RequireExactModel: true}, + wantErr: `requested model "host-only-model" is unavailable (reason: requested_not_advertised)`, }, { name: "advertised requested model is applied", state: modelState("requested"), - policy: StartModelPolicy{Model: "requested"}, + policy: StartModelPolicy{Model: "requested", RequireExactModel: true}, wantCalls: []string{"requested"}, wantOutcome: ModelSelectionOutcomeApplied, wantEffective: "requested", }, { - name: "method not supported keeps provider default", + name: "exact model selection unsupported fails", state: modelState("requested"), - policy: StartModelPolicy{Model: "requested"}, + policy: StartModelPolicy{Model: "requested", RequireExactModel: true}, + applierErrors: []error{methodNotFoundErr()}, + wantCalls: []string{"requested"}, + wantErr: `requested model "requested" is unavailable (reason: selection_unsupported)`, + }, + { + name: "auto fallback allows unsupported model selection", + state: modelState("requested"), + policy: StartModelPolicy{Model: "requested", AutoFallback: true}, applierErrors: []error{methodNotFoundErr()}, wantCalls: []string{"requested"}, wantOutcome: ModelSelectionOutcomeProviderDefault, wantReason: ModelSelectionReasonSelectionUnsupported, wantWarning: true, }, + { + name: "auto fallback allows absent requested model", + state: modelState("executor-default"), + policy: StartModelPolicy{Model: "host-only-model", AutoFallback: true}, + wantOutcome: ModelSelectionOutcomeProviderDefault, + wantReason: ModelSelectionReasonRequestedNotAdvertised, + wantWarning: true, + }, + { + name: "auto fallback ignores configured fallback model", + state: modelState("executor-default", "fallback"), + policy: StartModelPolicy{Model: "host-only-model", FallbackModel: "fallback", AutoFallback: true}, + wantOutcome: ModelSelectionOutcomeProviderDefault, + wantReason: ModelSelectionReasonRequestedNotAdvertised, + wantWarning: true, + }, { name: "advertised apply error is explicit", state: modelState("requested"), - policy: StartModelPolicy{Model: "requested", FallbackModel: "fallback"}, + policy: StartModelPolicy{Model: "requested", FallbackModel: "fallback", RequireExactModel: true}, applierErrors: []error{errors.New("rejected")}, wantCalls: []string{"requested"}, wantErr: `failed to set start model "requested"`, @@ -209,6 +125,22 @@ func TestApplyStartModelPolicyExecutorAuthority(t *testing.T) { wantReason: ModelSelectionReasonSelectionFailedAutoFallback, wantWarning: true, }, + { + name: "compatible profile selects one advertised variation", + state: modelState("executor-default", "requested[1m]"), + policy: StartModelPolicy{Model: "requested"}, + wantCalls: []string{"requested[1m]"}, + wantOutcome: ModelSelectionOutcomeUniqueVariation, + wantReason: ModelSelectionReasonUniqueVariationApplied, + wantEffective: "requested[1m]", + wantWarning: true, + }, + { + name: "strict profile rejects advertised variation", + state: modelState("executor-default", "requested[1m]"), + policy: StartModelPolicy{Model: "requested", RequireExactModel: true}, + wantErr: `requested model "requested" is unavailable (reason: requested_not_advertised)`, + }, } for _, tt := range tests { @@ -228,6 +160,9 @@ func TestApplyStartModelPolicyExecutorAuthority(t *testing.T) { if !reflect.DeepEqual(applier.calls, tt.wantCalls) { t.Errorf("SetModel calls = %v, want %v", applier.calls, tt.wantCalls) } + if tt.wantErr != "" { + return + } if decision.Outcome != tt.wantOutcome { t.Errorf("outcome = %q, want %q", decision.Outcome, tt.wantOutcome) } @@ -240,9 +175,6 @@ func TestApplyStartModelPolicyExecutorAuthority(t *testing.T) { if decision.Warning != tt.wantWarning { t.Errorf("warning = %v, want %v", decision.Warning, tt.wantWarning) } - if decision.Outcome == ModelSelectionOutcomeUniqueVariation && decision.FallbackModel != "" { - t.Errorf("unique variation fallback model = %q, want empty", decision.FallbackModel) - } if tt.policy.AutoFallback && decision.FallbackModel != "" { t.Errorf("auto-fallback fallback model = %q, want empty", decision.FallbackModel) } diff --git a/apps/backend/internal/agent/runtime/lifecycle/types.go b/apps/backend/internal/agent/runtime/lifecycle/types.go index 8fb5d2cefb..74eb227ba2 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/types.go @@ -1305,7 +1305,10 @@ type AgentProfileInfo struct { FallbackModel string // AutoFallback opts the profile into the legacy automatic-fallback // behavior (session-start best-effort). - AutoFallback bool + AutoFallback bool + // RequireExactModel makes the configured model an explicit identity + // requirement. False preserves compatible pre-PR behavior. + RequireExactModel 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 diff --git a/apps/backend/internal/agent/settings/controller/agent_crud.go b/apps/backend/internal/agent/settings/controller/agent_crud.go index 561dce00a5..d44cf3478d 100644 --- a/apps/backend/internal/agent/settings/controller/agent_crud.go +++ b/apps/backend/internal/agent/settings/controller/agent_crud.go @@ -119,11 +119,12 @@ type CreateAgentRequest struct { } type CreateAgentProfileRequest struct { - Name string - Model string - FallbackModel string - AutoFallback bool - Mode string + Name string + Model string + FallbackModel string + AutoFallback bool + RequireExactModel 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. @@ -233,6 +234,9 @@ func (c *Controller) findMatchedAvailability(name string, results []discovery.Av // agent-create profile. Kept separate so CreateAgent can validate every profile // before inserting the agent row (avoiding an orphaned agent on a bad profile). func validateCreateProfileRequest(p CreateAgentProfileRequest) error { + if err := validateRequireExactModelPolicy(p.Model, p.RequireExactModel, false, false); err != nil { + return err + } if p.CLIFlags != nil { if err := validateCLIFlagDTOs(p.CLIFlags); err != nil { return err @@ -255,16 +259,17 @@ func (c *Controller) createAgentProfiles(ctx context.Context, agentID, displayNa cliFlags = seedCLIFlags(agentConfig) } profile := &models.AgentProfile{ - AgentID: agentID, - 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), - CommandPrefix: strings.TrimSpace(profileReq.CommandPrefix), + AgentID: agentID, + Name: profileReq.Name, + AgentDisplayName: displayName, + Model: profileReq.Model, + FallbackModel: strings.TrimSpace(profileReq.FallbackModel), + AutoFallback: profileReq.AutoFallback, + RequireExactModel: profileReq.RequireExactModel, + Mode: profileReq.Mode, + CLIFlags: cliFlags, + EnvVars: envVarsFromDTO(profileReq.EnvVars), + CommandPrefix: strings.TrimSpace(profileReq.CommandPrefix), } if err := c.repo.CreateAgentProfile(ctx, profile); err != nil { return nil, err diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go index ae03abde16..c476aae57f 100644 --- a/apps/backend/internal/agent/settings/controller/controller.go +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -49,6 +49,8 @@ var ( ErrCommandRequired = errors.New("command is required") ErrInvalidProfileEnvVars = errors.New("invalid profile env vars") ErrInvalidCommandPrefix = errors.New("invalid command prefix") + ErrRequireExactModelNeedsModel = errors.New("exact model requires a concrete model") + ErrRequireExactModelUnsupported = errors.New("exact model is not supported for this profile") ErrUnknownMCPStrategy = errors.New("unknown MCP strategy") ErrNotCustomTUIAgent = errors.New("agent is not a custom TUI agent") ErrDynamicAgentRoutingDisabled = errors.New("dynamic agent routing is disabled") diff --git a/apps/backend/internal/agent/settings/controller/exact_model_policy_test.go b/apps/backend/internal/agent/settings/controller/exact_model_policy_test.go new file mode 100644 index 0000000000..53a72e6c00 --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/exact_model_policy_test.go @@ -0,0 +1,32 @@ +package controller + +import ( + "errors" + "testing" +) + +func TestValidateRequireExactModelPolicy(t *testing.T) { + tests := []struct { + name string + model string + require bool + passthrough bool + dynamic bool + wantErr error + }{ + {name: "compatible empty", wantErr: nil}, + {name: "strict concrete", model: "provider/model", require: true, wantErr: nil}, + {name: "strict empty", require: true, wantErr: ErrRequireExactModelNeedsModel}, + {name: "strict passthrough", model: "provider/model", require: true, passthrough: true, wantErr: ErrRequireExactModelUnsupported}, + {name: "strict dynamic", model: "provider/model", require: true, dynamic: true, wantErr: ErrRequireExactModelUnsupported}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateRequireExactModelPolicy(tt.model, tt.require, tt.passthrough, tt.dynamic) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("error = %v, want %v", err, tt.wantErr) + } + }) + } +} diff --git a/apps/backend/internal/agent/settings/controller/profile_contract.go b/apps/backend/internal/agent/settings/controller/profile_contract.go index fe644e137e..c084b95e7d 100644 --- a/apps/backend/internal/agent/settings/controller/profile_contract.go +++ b/apps/backend/internal/agent/settings/controller/profile_contract.go @@ -7,20 +7,21 @@ import "github.com/kandev/kandev/internal/agent/settings/dto" // CreateProfile owns those defaults. func CreateProfileRequestFromDTO(request dto.ProfileCreateRequest) CreateProfileRequest { return CreateProfileRequest{ - AgentID: request.AgentID, - Name: request.Name, - Model: request.Model, - FallbackModel: request.FallbackModel, - AutoFallback: request.AutoFallback, - Mode: request.Mode, - ConfigOptions: request.ConfigOptions, - AllowIndexing: request.AllowIndexing, - AutoApprove: request.AutoApprove, - CLIPassthrough: request.CLIPassthrough, - CLIFlags: request.CLIFlags, - EnvVars: request.EnvVars, - CommandPrefix: request.CommandPrefix, - Dynamic: request.Dynamic, + AgentID: request.AgentID, + Name: request.Name, + Model: request.Model, + FallbackModel: request.FallbackModel, + AutoFallback: request.AutoFallback, + RequireExactModel: request.RequireExactModel, + Mode: request.Mode, + ConfigOptions: request.ConfigOptions, + AllowIndexing: request.AllowIndexing, + AutoApprove: request.AutoApprove, + CLIPassthrough: request.CLIPassthrough, + CLIFlags: request.CLIFlags, + EnvVars: request.EnvVars, + CommandPrefix: request.CommandPrefix, + Dynamic: request.Dynamic, } } @@ -29,21 +30,22 @@ func CreateProfileRequestFromDTO(request dto.ProfileCreateRequest) CreateProfile // empty values all the way to the controller. func UpdateProfileRequestFromDTO(request dto.ProfileUpdateRequest) UpdateProfileRequest { return UpdateProfileRequest{ - ID: request.ID, - Name: request.Name, - Model: request.Model, - FallbackModel: request.FallbackModel, - AutoFallback: request.AutoFallback, - Mode: request.Mode, - ConfigOptions: request.ConfigOptions, - AllowIndexing: request.AllowIndexing, - AutoApprove: request.AutoApprove, - CLIPassthrough: request.CLIPassthrough, - Enabled: request.Enabled, - CLIFlags: request.CLIFlags, - EnvVars: request.EnvVars, - CommandPrefix: request.CommandPrefix, - Dynamic: request.Dynamic, - Force: request.Force, + ID: request.ID, + Name: request.Name, + Model: request.Model, + FallbackModel: request.FallbackModel, + AutoFallback: request.AutoFallback, + RequireExactModel: request.RequireExactModel, + Mode: request.Mode, + ConfigOptions: request.ConfigOptions, + AllowIndexing: request.AllowIndexing, + AutoApprove: request.AutoApprove, + CLIPassthrough: request.CLIPassthrough, + Enabled: request.Enabled, + CLIFlags: request.CLIFlags, + EnvVars: request.EnvVars, + CommandPrefix: request.CommandPrefix, + Dynamic: request.Dynamic, + Force: request.Force, } } diff --git a/apps/backend/internal/agent/settings/controller/profile_crud.go b/apps/backend/internal/agent/settings/controller/profile_crud.go index 05143fec16..1991895c0e 100644 --- a/apps/backend/internal/agent/settings/controller/profile_crud.go +++ b/apps/backend/internal/agent/settings/controller/profile_crud.go @@ -25,16 +25,17 @@ import ( const dynamicProfileKind = "dynamic" type CreateProfileRequest struct { - AgentID string - Name string - Model string - FallbackModel string - AutoFallback bool - Mode string - ConfigOptions map[string]string - AllowIndexing bool - AutoApprove bool - CLIPassthrough bool + AgentID string + Name string + Model string + FallbackModel string + AutoFallback bool + RequireExactModel bool + Mode string + ConfigOptions map[string]string + AllowIndexing bool + AutoApprove bool + CLIPassthrough bool // CLIFlags is the explicit list to persist. When nil, the profile is // seeded from the agent's curated PermissionSettings() list so a fresh // profile opens with the agent's recommended flags (all disabled by @@ -59,6 +60,9 @@ func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest if !agOk { return nil, fmt.Errorf("unknown agent: %s", agent.Name) } + if err := validateRequireExactModelPolicy(req.Model, req.RequireExactModel, req.CLIPassthrough, agent.Name == agents.DynamicAgentID); err != nil { + return nil, err + } displayName, err := c.resolveDisplayName(agentConfig, agent.Name) if err != nil { return nil, err @@ -82,22 +86,23 @@ func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest return nil, err } profile := &models.AgentProfile{ - AgentID: req.AgentID, - 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, - AutoApprove: req.AutoApprove, - CLIPassthrough: req.CLIPassthrough, - Enabled: true, - CLIFlags: cliFlags, - EnvVars: envVarsFromDTO(req.EnvVars), - CommandPrefix: strings.TrimSpace(req.CommandPrefix), - UserModified: true, + AgentID: req.AgentID, + Name: req.Name, + AgentDisplayName: displayName, + Model: req.Model, + FallbackModel: strings.TrimSpace(req.FallbackModel), + AutoFallback: req.AutoFallback, + RequireExactModel: req.RequireExactModel, + Mode: req.Mode, + ConfigOptions: profileconfig.SanitizeConfigOptions(req.ConfigOptions), + AllowIndexing: req.AllowIndexing, + AutoApprove: req.AutoApprove, + CLIPassthrough: req.CLIPassthrough, + Enabled: true, + CLIFlags: cliFlags, + EnvVars: envVarsFromDTO(req.EnvVars), + CommandPrefix: strings.TrimSpace(req.CommandPrefix), + UserModified: true, } if err := c.repo.CreateAgentProfile(ctx, profile); err != nil { return nil, err @@ -321,16 +326,17 @@ func dynamicProfileDTO(profile *models.DynamicAgentProfile, routes []models.Dyna } type UpdateProfileRequest struct { - ID string - Name *string - Model *string - FallbackModel *string - AutoFallback *bool - Mode *string - ConfigOptions *map[string]string - AllowIndexing *bool - AutoApprove *bool - CLIPassthrough *bool + ID string + Name *string + Model *string + FallbackModel *string + AutoFallback *bool + RequireExactModel *bool + Mode *string + ConfigOptions *map[string]string + AllowIndexing *bool + AutoApprove *bool + CLIPassthrough *bool // Enabled replaces the value when non-nil. Nil means "leave unchanged". Enabled *bool // CLIFlags replaces the entire list when non-nil. Nil means "leave @@ -347,7 +353,7 @@ type UpdateProfileRequest struct { func enabledOnlyUpdate(req UpdateProfileRequest) bool { return req.Enabled != nil && req.Name == nil && req.Model == nil && - req.FallbackModel == nil && req.AutoFallback == nil && req.Mode == nil && + req.FallbackModel == nil && req.AutoFallback == nil && req.RequireExactModel == 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 && req.Dynamic == nil @@ -393,6 +399,9 @@ func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest if req.AutoFallback != nil { profile.AutoFallback = *req.AutoFallback } + if req.RequireExactModel != nil { + profile.RequireExactModel = *req.RequireExactModel + } if req.Mode != nil { profile.Mode = *req.Mode } @@ -408,6 +417,9 @@ func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest if req.CLIPassthrough != nil { profile.CLIPassthrough = *req.CLIPassthrough } + if err := validateRequireExactModelPolicy(profile.Model, profile.RequireExactModel, profile.CLIPassthrough, isDynamic); err != nil { + return nil, err + } if req.Enabled != nil { dynamicRefs, err := c.listDynamicProfileReferences(ctx, req.ID) if err != nil { @@ -488,6 +500,22 @@ func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest return &result, nil } +// validateRequireExactModelPolicy validates the merged profile policy before +// it reaches storage. Dynamic selectors and terminal passthrough profiles do +// not own a concrete ACP model, so they cannot promise exact selection. +func validateRequireExactModelPolicy(model string, requireExactModel, cliPassthrough, dynamic bool) error { + if !requireExactModel { + return nil + } + if dynamic || cliPassthrough { + return ErrRequireExactModelUnsupported + } + if strings.TrimSpace(model) == "" { + return ErrRequireExactModelNeedsModel + } + return nil +} + func (c *Controller) updateDynamicProfileAtomically( ctx context.Context, profile *models.AgentProfile, @@ -630,6 +658,7 @@ func duplicateClone(source *models.AgentProfile) *models.AgentProfile { Model: source.Model, FallbackModel: strings.TrimSpace(source.FallbackModel), AutoFallback: source.AutoFallback, + RequireExactModel: source.RequireExactModel, Mode: source.Mode, ConfigOptions: profileconfig.SanitizeConfigOptions(cloneStringMap(source.ConfigOptions)), AllowIndexing: source.AllowIndexing, @@ -1122,27 +1151,28 @@ func (c *Controller) decorateAgentDTO(ctx context.Context, result *dto.AgentDTO) func toProfileDTO(profile *models.AgentProfile) dto.AgentProfileDTO { return dto.AgentProfileDTO{ - ID: profile.ID, - AgentID: profile.AgentID, - Kind: profileKind(profile), - 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, - AutoApprove: profile.AutoApprove, - CLIFlags: cliFlagsToDTO(profile.CLIFlags), - EnvVars: envVarsToDTO(profile.EnvVars), - CLIPassthrough: profile.CLIPassthrough, - Enabled: profile.Enabled, - CommandPrefix: profile.CommandPrefix, - UserModified: profile.UserModified, - WorkspaceID: profile.WorkspaceID, - CreatedAt: profile.CreatedAt, - UpdatedAt: profile.UpdatedAt, + ID: profile.ID, + AgentID: profile.AgentID, + Kind: profileKind(profile), + Name: profile.Name, + AgentDisplayName: profile.AgentDisplayName, + Model: profile.Model, + FallbackModel: profile.FallbackModel, + AutoFallback: profile.AutoFallback, + RequireExactModel: profile.RequireExactModel, + Mode: profile.Mode, + ConfigOptions: profileconfig.SanitizeConfigOptions(profile.ConfigOptions), + AllowIndexing: profile.AllowIndexing, + AutoApprove: profile.AutoApprove, + CLIFlags: cliFlagsToDTO(profile.CLIFlags), + EnvVars: envVarsToDTO(profile.EnvVars), + CLIPassthrough: profile.CLIPassthrough, + Enabled: profile.Enabled, + CommandPrefix: profile.CommandPrefix, + UserModified: profile.UserModified, + WorkspaceID: profile.WorkspaceID, + CreatedAt: profile.CreatedAt, + UpdatedAt: profile.UpdatedAt, } } diff --git a/apps/backend/internal/agent/settings/controller/profile_duplicate_test.go b/apps/backend/internal/agent/settings/controller/profile_duplicate_test.go index 7bf8c3378e..d3e055162e 100644 --- a/apps/backend/internal/agent/settings/controller/profile_duplicate_test.go +++ b/apps/backend/internal/agent/settings/controller/profile_duplicate_test.go @@ -17,18 +17,19 @@ func sourceProfile() *models.AgentProfile { failureThreshold := 4 lastRun := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) return &models.AgentProfile{ - ID: "source-1", - AgentID: "agent-1", - Name: "Default", - AgentDisplayName: "Claude Code", - Model: "claude-sonnet", - FallbackModel: "claude-haiku", - AutoFallback: true, - Mode: "plan", - ConfigOptions: map[string]string{"effort": "high"}, - AllowIndexing: true, - AutoApprove: true, - CLIPassthrough: true, + ID: "source-1", + AgentID: "agent-1", + Name: "Default", + AgentDisplayName: "Claude Code", + Model: "claude-sonnet", + FallbackModel: "claude-haiku", + AutoFallback: true, + RequireExactModel: true, + Mode: "plan", + ConfigOptions: map[string]string{"effort": "high"}, + AllowIndexing: true, + AutoApprove: true, + CLIPassthrough: true, CLIFlags: []models.CLIFlag{ {Description: "Allow all tools", Flag: "--allow-all-tools", Enabled: true}, {Description: "Custom", Flag: "--my-flag value", Enabled: false}, @@ -102,9 +103,9 @@ func TestDuplicateProfile_CopiesFullConfiguration(t *testing.T) { if result.AgentDisplayName != source.AgentDisplayName { t.Errorf("copy display name = %q, want %q", result.AgentDisplayName, source.AgentDisplayName) } - if result.Model != source.Model || result.FallbackModel != source.FallbackModel || !result.AutoFallback { - t.Errorf("copy model fields = (%q, %q, %v), want (%q, %q, true)", - result.Model, result.FallbackModel, result.AutoFallback, source.Model, source.FallbackModel) + if result.Model != source.Model || result.FallbackModel != source.FallbackModel || !result.AutoFallback || !result.RequireExactModel { + t.Errorf("copy model fields = (%q, %q, %v, %v), want (%q, %q, true, true)", + result.Model, result.FallbackModel, result.AutoFallback, result.RequireExactModel, source.Model, source.FallbackModel) } if result.Mode != source.Mode { t.Errorf("copy mode = %q, want %q", result.Mode, source.Mode) diff --git a/apps/backend/internal/agent/settings/dto/dto.go b/apps/backend/internal/agent/settings/dto/dto.go index 47b574294e..60445f367e 100644 --- a/apps/backend/internal/agent/settings/dto/dto.go +++ b/apps/backend/internal/agent/settings/dto/dto.go @@ -20,13 +20,14 @@ type AgentProfileDTO struct { 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"` + AutoFallback bool `json:"auto_fallback"` + RequireExactModel bool `json:"require_exact_model"` + 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. diff --git a/apps/backend/internal/agent/settings/dto/profile_contract.go b/apps/backend/internal/agent/settings/dto/profile_contract.go index ddf766b784..7ef814d83c 100644 --- a/apps/backend/internal/agent/settings/dto/profile_contract.go +++ b/apps/backend/internal/agent/settings/dto/profile_contract.go @@ -9,42 +9,44 @@ import ( // creation. Value fields retain the existing create defaults; omitted optional // fields are left at their domain defaults. type ProfileCreateRequest struct { - AgentID string `json:"agent_id"` - Name string `json:"name"` - 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"` - AutoApprove bool `json:"auto_approve,omitempty"` - CLIPassthrough bool `json:"cli_passthrough,omitempty"` - CLIFlags []CLIFlagDTO `json:"cli_flags,omitempty"` - EnvVars []ProfileEnvVarDTO `json:"env_vars,omitempty"` - CommandPrefix string `json:"command_prefix,omitempty"` - Dynamic *DynamicAgentProfileDTO `json:"dynamic,omitempty"` + AgentID string `json:"agent_id"` + Name string `json:"name"` + Model string `json:"model,omitempty"` + FallbackModel string `json:"fallback_model,omitempty"` + AutoFallback bool `json:"auto_fallback,omitempty"` + RequireExactModel bool `json:"require_exact_model,omitempty"` + Mode string `json:"mode,omitempty"` + ConfigOptions map[string]string `json:"config_options,omitempty"` + AllowIndexing bool `json:"allow_indexing,omitempty"` + AutoApprove bool `json:"auto_approve,omitempty"` + CLIPassthrough bool `json:"cli_passthrough,omitempty"` + CLIFlags []CLIFlagDTO `json:"cli_flags,omitempty"` + EnvVars []ProfileEnvVarDTO `json:"env_vars,omitempty"` + CommandPrefix string `json:"command_prefix,omitempty"` + Dynamic *DynamicAgentProfileDTO `json:"dynamic,omitempty"` } // ProfileUpdateRequest is the shared partial wire contract. A nil pointer // means that the caller omitted the field. A non-nil pointer preserves an // explicit false, empty string, empty map, or empty list. type ProfileUpdateRequest struct { - ID string `json:"id"` - 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"` - AutoApprove *bool `json:"auto_approve,omitempty"` - CLIPassthrough *bool `json:"cli_passthrough,omitempty"` - Enabled *bool `json:"enabled,omitempty"` - CLIFlags *[]CLIFlagDTO `json:"cli_flags,omitempty"` - EnvVars *[]ProfileEnvVarDTO `json:"env_vars,omitempty"` - CommandPrefix *string `json:"command_prefix,omitempty"` - Dynamic *DynamicAgentProfileDTO `json:"dynamic,omitempty"` - Force bool `json:"force,omitempty"` + ID string `json:"id"` + Name *string `json:"name,omitempty"` + Model *string `json:"model,omitempty"` + FallbackModel *string `json:"fallback_model,omitempty"` + AutoFallback *bool `json:"auto_fallback,omitempty"` + RequireExactModel *bool `json:"require_exact_model,omitempty"` + Mode *string `json:"mode,omitempty"` + ConfigOptions *map[string]string `json:"config_options,omitempty"` + AllowIndexing *bool `json:"allow_indexing,omitempty"` + AutoApprove *bool `json:"auto_approve,omitempty"` + CLIPassthrough *bool `json:"cli_passthrough,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + CLIFlags *[]CLIFlagDTO `json:"cli_flags,omitempty"` + EnvVars *[]ProfileEnvVarDTO `json:"env_vars,omitempty"` + CommandPrefix *string `json:"command_prefix,omitempty"` + Dynamic *DynamicAgentProfileDTO `json:"dynamic,omitempty"` + Force bool `json:"force,omitempty"` } func (r ProfileCreateRequest) Validate() error { @@ -83,6 +85,7 @@ func ProfileContractFields() []ProfileContractField { {Path: "model", JSONType: "string", Support: "read_write", Description: "Preferred model; empty uses the agent default."}, {Path: "fallback_model", JSONType: "string", Support: "read_write", Description: "Optional fallback model."}, {Path: "auto_fallback", JSONType: "boolean", Support: "read_write", Description: "Enable automatic fallback behavior."}, + {Path: "require_exact_model", JSONType: "boolean", Support: "read_write", Description: "Require the configured model to be advertised and applied."}, {Path: "mode", JSONType: "string", Support: "read_write", Description: "Agent operating mode."}, {Path: "config_options", JSONType: "object", Support: "read_write", Description: "Typed provider configuration options.", Replacement: true}, {Path: "allow_indexing", JSONType: "boolean", Support: "compatibility", Description: "Legacy indexing permission field."}, diff --git a/apps/backend/internal/agent/settings/dto/profile_contract_test.go b/apps/backend/internal/agent/settings/dto/profile_contract_test.go index ffa1570e81..1e59ceae50 100644 --- a/apps/backend/internal/agent/settings/dto/profile_contract_test.go +++ b/apps/backend/internal/agent/settings/dto/profile_contract_test.go @@ -36,7 +36,7 @@ func TestProfileContractFieldsAreUniqueAndClassified(t *testing.T) { } seen[field.Path] = struct{}{} } - for _, required := range []string{"name", "model", "fallback_model", "auto_fallback", "mode", "config_options", "cli_flags", "env_vars", "command_prefix", "enabled"} { + for _, required := range []string{"name", "model", "fallback_model", "auto_fallback", "require_exact_model", "mode", "config_options", "cli_flags", "env_vars", "command_prefix", "enabled"} { if _, ok := seen[required]; !ok { t.Fatalf("missing profile field %q", required) } diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go index e3c738fcc2..014da261ee 100644 --- a/apps/backend/internal/agent/settings/handlers/handlers.go +++ b/apps/backend/internal/agent/settings/handlers/handlers.go @@ -405,14 +405,15 @@ 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"` - CommandPrefix string `json:"command_prefix,omitempty"` + Name string `json:"name"` + Model string `json:"model"` + FallbackModel string `json:"fallback_model,omitempty"` + AutoFallback bool `json:"auto_fallback"` + RequireExactModel bool `json:"require_exact_model"` + Mode string `json:"mode,omitempty"` + CLIFlags []dto.CLIFlagDTO `json:"cli_flags,omitempty"` + EnvVars []dto.ProfileEnvVarDTO `json:"env_vars,omitempty"` + CommandPrefix string `json:"command_prefix,omitempty"` } func (h *Handlers) httpCreateAgent(c *gin.Context) { @@ -432,14 +433,15 @@ func (h *Handlers) httpCreateAgent(c *gin.Context) { return } 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, - CommandPrefix: profile.CommandPrefix, + Name: profile.Name, + Model: profile.Model, + FallbackModel: profile.FallbackModel, + AutoFallback: profile.AutoFallback, + RequireExactModel: profile.RequireExactModel, + Mode: profile.Mode, + CLIFlags: profile.CLIFlags, + EnvVars: profile.EnvVars, + CommandPrefix: profile.CommandPrefix, }) } resp, err := h.controller.CreateAgent(c.Request.Context(), controller.CreateAgentRequest{ @@ -448,7 +450,8 @@ func (h *Handlers) httpCreateAgent(c *gin.Context) { Profiles: profiles, }) if err != nil { - if errors.Is(err, controller.ErrInvalidProfileEnvVars) || errors.Is(err, controller.ErrInvalidCommandPrefix) { + if errors.Is(err, controller.ErrInvalidProfileEnvVars) || errors.Is(err, controller.ErrInvalidCommandPrefix) || + errors.Is(err, controller.ErrRequireExactModelNeedsModel) || errors.Is(err, controller.ErrRequireExactModelUnsupported) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -621,7 +624,8 @@ func (h *Handlers) httpCreateProfile(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if errors.Is(err, controller.ErrInvalidProfileEnvVars) || errors.Is(err, controller.ErrInvalidCommandPrefix) { + if errors.Is(err, controller.ErrInvalidProfileEnvVars) || errors.Is(err, controller.ErrInvalidCommandPrefix) || + errors.Is(err, controller.ErrRequireExactModelNeedsModel) || errors.Is(err, controller.ErrRequireExactModelUnsupported) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -657,7 +661,8 @@ func (h *Handlers) httpUpdateProfile(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "agent profile not found"}) return } - if errors.Is(err, controller.ErrInvalidProfileEnvVars) || errors.Is(err, controller.ErrInvalidCommandPrefix) { + if errors.Is(err, controller.ErrInvalidProfileEnvVars) || errors.Is(err, controller.ErrInvalidCommandPrefix) || + errors.Is(err, controller.ErrRequireExactModelNeedsModel) || errors.Is(err, controller.ErrRequireExactModelUnsupported) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } diff --git a/apps/backend/internal/agent/settings/models/models.go b/apps/backend/internal/agent/settings/models/models.go index 0c9c970a6d..2c8baae8fd 100644 --- a/apps/backend/internal/agent/settings/models/models.go +++ b/apps/backend/internal/agent/settings/models/models.go @@ -63,6 +63,10 @@ type AgentProfile struct { // enabled, FallbackModel is ignored and the UI hides its field. AutoFallback bool `json:"auto_fallback" db:"auto_fallback"` + // RequireExactModel makes the configured model an explicit per-profile + // identity requirement. False preserves compatible pre-PR behavior. + RequireExactModel bool `json:"require_exact_model" db:"require_exact_model"` + // 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 diff --git a/apps/backend/internal/agent/settings/store/sqlite.go b/apps/backend/internal/agent/settings/store/sqlite.go index c32934b2e5..14cd14ecac 100644 --- a/apps/backend/internal/agent/settings/store/sqlite.go +++ b/apps/backend/internal/agent/settings/store/sqlite.go @@ -193,6 +193,7 @@ func (r *sqliteRepository) initSchema() error { // 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`) + _ = r.migrate.Apply("agent_profiles.require_exact_model", `ALTER TABLE agent_profiles ADD COLUMN require_exact_model INTEGER NOT NULL DEFAULT 0`) if err := r.migrate.Err(); err != nil { return fmt.Errorf("required agent settings migration: %w", err) } @@ -320,6 +321,10 @@ func (r *sqliteRepository) recreateAgentProfilesWithoutModelCheck() error { srcHasCLIFlags := columnExists(tx, "agent_profiles", "cli_flags") srcHasEnvVars := columnExists(tx, "agent_profiles", "env_vars") srcHasEnabled := columnExists(tx, "agent_profiles", "enabled") + srcHasCommandPrefix := columnExists(tx, "agent_profiles", "command_prefix") + srcHasFallbackModel := columnExists(tx, "agent_profiles", "fallback_model") + srcHasAutoFallback := columnExists(tx, "agent_profiles", "auto_fallback") + srcHasRequireExactModel := columnExists(tx, "agent_profiles", "require_exact_model") srcCols := `id, agent_id, name, agent_display_name, model, mode, migrated_from, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, user_modified, plan, created_at, updated_at, deleted_at` @@ -336,6 +341,22 @@ func (r *sqliteRepository) recreateAgentProfilesWithoutModelCheck() error { srcCols += ", enabled" dstCols += ", enabled" } + if srcHasCommandPrefix { + srcCols += ", command_prefix" + dstCols += ", command_prefix" + } + if srcHasFallbackModel { + srcCols += ", fallback_model" + dstCols += ", fallback_model" + } + if srcHasAutoFallback { + srcCols += ", auto_fallback" + dstCols += ", auto_fallback" + } + if srcHasRequireExactModel { + srcCols += ", require_exact_model" + dstCols += ", require_exact_model" + } if _, err := tx.Exec(`CREATE TABLE agent_profiles_new ( id TEXT PRIMARY KEY, @@ -357,6 +378,10 @@ func (r *sqliteRepository) recreateAgentProfilesWithoutModelCheck() error { created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, deleted_at TIMESTAMP, + command_prefix TEXT NOT NULL DEFAULT '', + fallback_model TEXT NOT NULL DEFAULT '', + auto_fallback INTEGER NOT NULL DEFAULT 0, + require_exact_model INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE )`); err != nil { return fmt.Errorf("create new table: %w", err) @@ -987,7 +1012,7 @@ func (r *sqliteRepository) insertAgentProfile(ctx context.Context, execer profil max_concurrent_sessions, cooldown_sec, skip_idle_runs, consecutive_failures, failure_threshold, executor_preference, budget_monthly_cents, settings, permissions, - command_prefix, fallback_model, auto_fallback + command_prefix, fallback_model, auto_fallback, require_exact_model ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, @@ -998,7 +1023,7 @@ func (r *sqliteRepository) insertAgentProfile(ctx context.Context, execer profil ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ? + ?, ?, ?, ? ) `), profile.ID, profile.AgentID, profile.Name, profile.AgentDisplayName, profile.Model, @@ -1015,6 +1040,7 @@ func (r *sqliteRepository) insertAgentProfile(ctx context.Context, execer profil profile.CommandPrefix, profile.FallbackModel, dialect.BoolToInt(profile.AutoFallback), + dialect.BoolToInt(profile.RequireExactModel), ) return err } @@ -1262,7 +1288,7 @@ func (r *sqliteRepository) updateAgentProfile(ctx context.Context, execer profil consecutive_failures = ?, failure_threshold = ?, executor_preference = ?, budget_monthly_cents = ?, settings = ?, permissions = ?, - command_prefix = ?, fallback_model = ?, auto_fallback = ? + command_prefix = ?, fallback_model = ?, auto_fallback = ?, require_exact_model = ? WHERE id = ? AND deleted_at IS NULL `), profile.AgentID, profile.Name, profile.AgentDisplayName, profile.Model, nullableString(profile.Mode), nullableString(profile.MigratedFrom), @@ -1279,6 +1305,7 @@ func (r *sqliteRepository) updateAgentProfile(ctx context.Context, execer profil profile.CommandPrefix, profile.FallbackModel, dialect.BoolToInt(profile.AutoFallback), + dialect.BoolToInt(profile.RequireExactModel), profile.ID) if err != nil { return err @@ -1349,7 +1376,8 @@ const agentProfileSelectColumns = ` COALESCE(budget_monthly_cents, 0), COALESCE(settings, '{}'), COALESCE(permissions, '{}'), COALESCE(command_prefix, ''), - COALESCE(fallback_model, ''), COALESCE(auto_fallback, 0) + COALESCE(fallback_model, ''), COALESCE(auto_fallback, 0), + COALESCE(require_exact_model, 0) FROM agent_profiles` func (r *sqliteRepository) GetAgentProfile(ctx context.Context, id string) (*models.AgentProfile, error) { @@ -1530,6 +1558,7 @@ func scanAgentProfile(scanner interface { var skipIdleRuns int var failureThreshold int var autoFallback int + var requireExactModel int if err := scanner.Scan( &profile.ID, &profile.AgentID, @@ -1572,6 +1601,7 @@ func scanAgentProfile(scanner interface { &profile.CommandPrefix, &profile.FallbackModel, &autoFallback, + &requireExactModel, ); err != nil { return nil, err } @@ -1589,6 +1619,7 @@ func scanAgentProfile(scanner interface { profile.UserModified = userModified == 1 profile.SkipIdleRuns = skipIdleRuns == 1 profile.AutoFallback = autoFallback == 1 + profile.RequireExactModel = requireExactModel == 1 profile.Role = models.AgentRole(role) profile.Status = models.AgentStatus(status) profile.ConfigOptions = configOptionsFromSettings(profile.Settings) diff --git a/apps/backend/internal/agent/settings/store/sqlite_fallback_test.go b/apps/backend/internal/agent/settings/store/sqlite_fallback_test.go index ef6e58ea99..9af65a81ba 100644 --- a/apps/backend/internal/agent/settings/store/sqlite_fallback_test.go +++ b/apps/backend/internal/agent/settings/store/sqlite_fallback_test.go @@ -21,12 +21,13 @@ func TestFallbackModel_RoundTrip(t *testing.T) { } profile := &models.AgentProfile{ - AgentID: agent.ID, - Name: "hybrid", - AgentDisplayName: "OMP", - Model: "claude-sonnet-4-5", - FallbackModel: "gpt-5", - AutoFallback: false, + AgentID: agent.ID, + Name: "hybrid", + AgentDisplayName: "OMP", + Model: "claude-sonnet-4-5", + FallbackModel: "gpt-5", + AutoFallback: false, + RequireExactModel: true, } if err := repo.CreateAgentProfile(ctx, profile); err != nil { t.Fatalf("create profile: %v", err) @@ -42,10 +43,14 @@ func TestFallbackModel_RoundTrip(t *testing.T) { if got.AutoFallback { t.Errorf("auto_fallback mismatch: got true, want false") } + if !got.RequireExactModel { + t.Errorf("require_exact_model mismatch: got false, want true") + } // Update: flip the toggle on and change the fallback. got.AutoFallback = true got.FallbackModel = "gpt-5.2" + got.RequireExactModel = false if err := repo.UpdateAgentProfile(ctx, got); err != nil { t.Fatalf("update profile: %v", err) } @@ -59,12 +64,26 @@ func TestFallbackModel_RoundTrip(t *testing.T) { if !got2.AutoFallback { t.Errorf("auto_fallback after update mismatch: got false, want true") } + if got2.RequireExactModel { + t.Errorf("require_exact_model after explicit clear: got true, want false") + } + + got2.RequireExactModel = true + if err := repo.UpdateAgentProfile(ctx, got2); err != nil { + t.Fatalf("re-enable exact model: %v", err) + } + got3, err := repo.GetAgentProfile(ctx, profile.ID) + if err != nil { + t.Fatalf("re-get profile after re-enable: %v", err) + } + if !got3.RequireExactModel { + t.Errorf("require_exact_model after re-enable: 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) { +// TestFallbackModel_DefaultsCompatible verifies a profile created without the +// new fields keeps the compatible behavior (empty fallback, both toggles off). +func TestFallbackModel_DefaultsCompatible(t *testing.T) { repo := newFreshRepo(t) ctx := context.Background() if err := repo.CreateAgent(ctx, &models.Agent{Name: "claude-acp"}); err != nil { @@ -92,6 +111,9 @@ func TestFallbackModel_DefaultsStrict(t *testing.T) { if got.AutoFallback { t.Errorf("expected auto_fallback off, got true") } + if got.RequireExactModel { + t.Errorf("expected require_exact_model off, got true") + } } // TestFallbackModel_SchemaReplay verifies the fallback_model / auto_fallback @@ -111,12 +133,13 @@ func TestFallbackModel_SchemaReplay(t *testing.T) { 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, + AgentID: agent.ID, + Name: "hybrid", + AgentDisplayName: "OMP", + Model: "claude-sonnet-4-5", + FallbackModel: "deepseek/deepseek-v4-flash", + AutoFallback: true, + RequireExactModel: true, } if err := repo.CreateAgentProfile(ctx, profile); err != nil { t.Fatalf("create profile: %v", err) @@ -138,4 +161,7 @@ func TestFallbackModel_SchemaReplay(t *testing.T) { if !got.AutoFallback { t.Errorf("auto_fallback after replay mismatch: got false, want true") } + if !got.RequireExactModel { + t.Errorf("require_exact_model after replay mismatch: got false, want true") + } } diff --git a/apps/backend/internal/agent/settings/store/sqlite_migration_test.go b/apps/backend/internal/agent/settings/store/sqlite_migration_test.go index be1c47bb0c..ae5f0a897b 100644 --- a/apps/backend/internal/agent/settings/store/sqlite_migration_test.go +++ b/apps/backend/internal/agent/settings/store/sqlite_migration_test.go @@ -217,6 +217,37 @@ func TestMigration_LegacyDB_PreservesEnvVarsColumn(t *testing.T) { } } +// TestMigration_LegacyDB_PreservesRequireExactModel verifies that a database +// which already has the additive field keeps it while the model CHECK table +// recreation runs. This protects upgrades that stop during the migration +// sequence and are opened again by a newer binary. +func TestMigration_LegacyDB_PreservesRequireExactModel(t *testing.T) { + db := newLegacyDB(t) + ctx := context.Background() + if _, err := db.Exec(`ALTER TABLE agent_profiles ADD COLUMN require_exact_model INTEGER NOT NULL DEFAULT 0`); err != nil { + t.Fatalf("add require_exact_model to legacy schema: %v", err) + } + if _, err := db.Exec(`INSERT INTO agents (id, name, created_at, updated_at) VALUES ('a1', 'test-agent', datetime('now'), datetime('now'))`); err != nil { + t.Fatalf("seed agent: %v", err) + } + if _, err := db.Exec(`INSERT INTO agent_profiles (id, agent_id, name, agent_display_name, model, require_exact_model, created_at, updated_at) + VALUES ('p1', 'a1', 'Strict', 'Test', 'model-1', 1, datetime('now'), datetime('now'))`); err != nil { + t.Fatalf("seed strict profile: %v", err) + } + + repo, err := newSQLiteRepository(db, db, nil, false) + if err != nil { + t.Fatalf("newSQLiteRepository: %v", err) + } + profile, err := repo.GetAgentProfile(ctx, "p1") + if err != nil { + t.Fatalf("get profile: %v", err) + } + if !profile.RequireExactModel { + t.Fatal("require_exact_model was cleared during legacy table recreation") + } +} + // TestMigration_LegacyDB_MCPConfigSurvives verifies that agent_profile_mcp_configs // rows (which FK-reference agent_profiles) survive the table recreation. func TestMigration_LegacyDB_MCPConfigSurvives(t *testing.T) { diff --git a/apps/backend/internal/agentctl/types/streams/agent.go b/apps/backend/internal/agentctl/types/streams/agent.go index d3465f2aeb..a92f863c93 100644 --- a/apps/backend/internal/agentctl/types/streams/agent.go +++ b/apps/backend/internal/agentctl/types/streams/agent.go @@ -482,6 +482,9 @@ type SessionModelState struct { CurrentModelID string `json:"current_model_id,omitempty"` Models []SessionModelInfo `json:"models,omitempty"` ConfigOptions []ConfigOption `json:"config_options,omitempty"` + // ConfigOptionsSettled distinguishes a complete empty provider snapshot + // from the transient empty state sent before startup settles. + ConfigOptionsSettled bool `json:"config_options_settled,omitempty"` } // AuthMethodInfo represents an authentication method from ACP initialize. diff --git a/apps/backend/internal/backendapp/adapters.go b/apps/backend/internal/backendapp/adapters.go index 112d923a0a..4261966b06 100644 --- a/apps/backend/internal/backendapp/adapters.go +++ b/apps/backend/internal/backendapp/adapters.go @@ -975,6 +975,9 @@ func (a *lifecycleAdapter) ResolveAgentProfile(ctx context.Context, profileID st AgentName: info.AgentName, Model: info.Model, Mode: info.Mode, + FallbackModel: info.FallbackModel, + AutoFallback: info.AutoFallback, + RequireExactModel: info.RequireExactModel, ConfigOptions: info.ConfigOptions, AutoApprove: info.AutoApprove, DangerouslySkipPermissions: info.DangerouslySkipPermissions, diff --git a/apps/backend/internal/backendapp/canvas_edit_test.go b/apps/backend/internal/backendapp/canvas_edit_test.go index 8a6ebb7c0a..8fd9604371 100644 --- a/apps/backend/internal/backendapp/canvas_edit_test.go +++ b/apps/backend/internal/backendapp/canvas_edit_test.go @@ -220,32 +220,32 @@ func stringContains(value, want string) bool { func newCanvasEditTestDependencies() (*fakeCanvasEditTaskStore, *fakeCanvasEditCanvasStore, *fakeCanvasEditReleaseStore, *fakeCanvasEditArtifactStore) { return &fakeCanvasEditTaskStore{ - workspace: &models.Workspace{ - ID: "workspace-a", - DefaultAgentProfileID: stringPointer("profile-default"), - DefaultExecutorID: stringPointer("executor-default"), - }, - }, &fakeCanvasEditCanvasStore{canvas: &canvas.Canvas{ - ID: "canvas-a", - PluginInstanceID: "instance-a", - WorkspaceID: "workspace-a", - ScopeKind: canvas.ScopeWorkspace, - Status: instances.StatusActive, - ActiveReleaseID: "release-a", - ActiveReleaseStatus: instances.ValidationValid, - EffectiveGrants: []canvas.GrantProjection{{ - PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: instances.ScopeWorkspace, - }}, - }}, &fakeCanvasEditReleaseStore{release: instances.Release{ - ID: "release-a", - InstanceID: "instance-a", - PackageDigest: "digest-a", - ArtifactPath: "releases/digest-a", - ValidationStatus: instances.ValidationValid, - }}, &fakeCanvasEditArtifactStore{files: map[string][]byte{ - "manifest.yaml": []byte("manifest"), - "index.html": []byte("index"), - }} + workspace: &models.Workspace{ + ID: "workspace-a", + DefaultAgentProfileID: stringPointer("profile-default"), + DefaultExecutorID: stringPointer("executor-default"), + }, + }, &fakeCanvasEditCanvasStore{canvas: &canvas.Canvas{ + ID: "canvas-a", + PluginInstanceID: "instance-a", + WorkspaceID: "workspace-a", + ScopeKind: canvas.ScopeWorkspace, + Status: instances.StatusActive, + ActiveReleaseID: "release-a", + ActiveReleaseStatus: instances.ValidationValid, + EffectiveGrants: []canvas.GrantProjection{{ + PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: instances.ScopeWorkspace, + }}, + }}, &fakeCanvasEditReleaseStore{release: instances.Release{ + ID: "release-a", + InstanceID: "instance-a", + PackageDigest: "digest-a", + ArtifactPath: "releases/digest-a", + ValidationStatus: instances.ValidationValid, + }}, &fakeCanvasEditArtifactStore{files: map[string][]byte{ + "manifest.yaml": []byte("manifest"), + "index.html": []byte("index"), + }} } func stringPointer(value string) *string { return &value } diff --git a/apps/backend/internal/office/retention/sweep.go b/apps/backend/internal/office/retention/sweep.go index 9c972d19cf..c8e69a8c34 100644 --- a/apps/backend/internal/office/retention/sweep.go +++ b/apps/backend/internal/office/retention/sweep.go @@ -299,13 +299,13 @@ func (s *Sweeper) sweepRuns(ctx context.Context, q queryer, cfg TableSettings, n }}, satelliteResults{} } return SweptTableResult{TableSweepResult: TableSweepResult{ - Deleted: result.RunsDeleted, - Backlog: eligible > int64(batchLimit), - }}, satelliteResults{ - RunEvents: TableSweepResult{Deleted: result.RunEventsDeleted}, - RouteAttempts: TableSweepResult{Deleted: result.RouteAttemptsDeleted}, - RunSkills: TableSweepResult{Deleted: result.RunSkillsDeleted}, - } + Deleted: result.RunsDeleted, + Backlog: eligible > int64(batchLimit), + }}, satelliteResults{ + RunEvents: TableSweepResult{Deleted: result.RunEventsDeleted}, + RouteAttempts: TableSweepResult{Deleted: result.RouteAttemptsDeleted}, + RunSkills: TableSweepResult{Deleted: result.RunSkillsDeleted}, + } } // retentionCutoff turns a table's configured window into the instant a diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index 9ce112c9e5..a59bbf4c26 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -3014,6 +3014,20 @@ func (s *Service) switchSessionForStepWithPolicies( newAgentProfileID string, startPolicy models.WorkflowProfileSessionStartPolicy, endPolicy models.WorkflowProfileSessionEndPolicy, +) (*models.TaskSession, error) { + return s.switchSessionForStepWithPoliciesAndCandidate( + ctx, taskID, currentSession, newAgentProfileID, startPolicy, endPolicy, nil, + ) +} + +func (s *Service) switchSessionForStepWithPoliciesAndCandidate( + ctx context.Context, + taskID string, + currentSession *models.TaskSession, + newAgentProfileID string, + startPolicy models.WorkflowProfileSessionStartPolicy, + endPolicy models.WorkflowProfileSessionEndPolicy, + validatedExisting *models.TaskSession, ) (*models.TaskSession, error) { startPolicy = models.NormalizeWorkflowProfileSessionStartPolicy(string(startPolicy)) endPolicy = models.NormalizeWorkflowProfileSessionEndPolicy(string(endPolicy)) @@ -3026,13 +3040,17 @@ func (s *Service) switchSessionForStepWithPolicies( zap.String("profile_session_end_policy", string(endPolicy))) var existing *models.TaskSession if startPolicy == models.WorkflowProfileSessionStartPolicyReuse { - var lookupErr error - existing, lookupErr = s.findReusableSessionForProfile(ctx, taskID, newAgentProfileID, currentSession.ID) - if lookupErr != nil { - s.logger.Warn("failed to look up reusable session, falling through to create new", - zap.String("task_id", taskID), - zap.String("agent_profile_id", newAgentProfileID), - zap.Error(lookupErr)) + if validatedExisting != nil { + existing = validatedExisting + } else { + var lookupErr error + existing, lookupErr = s.findReusableSessionForProfile(ctx, taskID, newAgentProfileID, currentSession.ID) + if lookupErr != nil { + s.logger.Warn("failed to look up reusable session, falling through to create new", + zap.String("task_id", taskID), + zap.String("agent_profile_id", newAgentProfileID), + zap.Error(lookupErr)) + } } } targetSession := currentSession @@ -3785,13 +3803,28 @@ func (s *Service) prepareWorkflowStepSession( effectiveProfile := s.resolveStepAgentProfile(ctx, step) startPolicy := s.resolveStepProfileSessionStartPolicy(step) if shouldKeepCurrentWorkflowStepSession(effectiveProfile, session.AgentProfileID, startPolicy) { + requiresFreshSession, err := s.workflowEntryRequiresFreshExactModelSession(ctx, session, step, sourceStep, effectiveProfile) + if err != nil { + return nil, false, err + } + if requiresFreshSession { + if sourceStep == nil { + return nil, false, fmt.Errorf("workflow profile switch source step is unavailable") + } + endPolicy := s.resolveStepProfileSessionEndPolicy(sourceStep) + return s.replaceExactModelWorkflowStepSession(ctx, taskID, session, step, effectiveProfile, endPolicy, entryIDs...) + } return s.keepCurrentWorkflowStepSession(ctx, taskID, session, step, entryIDs...) } if sourceStep == nil { return nil, false, fmt.Errorf("workflow profile switch source step is unavailable") } + startPolicy, validatedExisting, err := s.exactModelWorkflowStartPolicy(ctx, taskID, session.ID, step, sourceStep, effectiveProfile, startPolicy) + if err != nil { + return nil, false, err + } endPolicy := s.resolveStepProfileSessionEndPolicy(sourceStep) - newSession, err := s.switchSessionForStepWithPolicies(ctx, taskID, session, effectiveProfile, startPolicy, endPolicy) + newSession, err := s.switchSessionForStepWithPoliciesAndCandidate(ctx, taskID, session, effectiveProfile, startPolicy, endPolicy, validatedExisting) if err != nil { return nil, false, err } @@ -3801,6 +3834,122 @@ func (s *Service) prepareWorkflowStepSession( return newSession, true, nil } +// replaceExactModelWorkflowStepSession creates a clean session for a step whose +// profile matches the current session but whose persisted runtime model drifted +// from the profile's configured model. It shares the switch path's source +// binding semantics so entry routing observes one uniform hand-off. +func (s *Service) replaceExactModelWorkflowStepSession( + ctx context.Context, + taskID string, + session *models.TaskSession, + step *wfmodels.WorkflowStep, + profileID string, + endPolicy models.WorkflowProfileSessionEndPolicy, + entryIDs ...int64, +) (*models.TaskSession, bool, error) { + newSession, err := s.createNewSessionForStepWithEndPolicy(ctx, taskID, session, profileID, endPolicy) + if err != nil { + return nil, false, err + } + if err := s.recordWorkflowSourceBinding(ctx, taskID, step, newSession, entryIDs...); err != nil { + return nil, false, err + } + return newSession, true, nil +} + +func (s *Service) exactModelWorkflowStartPolicy( + ctx context.Context, + taskID, currentSessionID string, + step, sourceStep *wfmodels.WorkflowStep, + profileID string, + startPolicy models.WorkflowProfileSessionStartPolicy, +) (models.WorkflowProfileSessionStartPolicy, *models.TaskSession, error) { + if startPolicy != models.WorkflowProfileSessionStartPolicyReuse { + return startPolicy, nil, nil + } + existing, err := s.findReusableSessionForProfile(ctx, taskID, profileID, currentSessionID) + if err != nil { + s.logger.Warn("failed to inspect reusable session for exact model identity", + zap.String("task_id", taskID), + zap.String("agent_profile_id", profileID), + zap.Error(err)) + return startPolicy, nil, fmt.Errorf("find reusable session for exact model identity: %w", err) + } + if existing == nil { + // The lookup above is the validated candidate decision. Do not return + // reuse with a nil candidate, because the switch path would perform a + // second lookup against a potentially changed session set. + return models.WorkflowProfileSessionStartPolicyNew, nil, nil + } + requiresFreshSession, err := s.workflowEntryRequiresFreshExactModelSession(ctx, existing, step, sourceStep, profileID) + if err != nil { + return startPolicy, nil, err + } + if requiresFreshSession { + return models.WorkflowProfileSessionStartPolicyNew, nil, nil + } + return startPolicy, existing, nil +} + +// workflowEntryRequiresFreshExactModelSession prevents a workflow lane from +// resuming a parked session whose persisted provider model disagrees with the +// profile's configured model policy. Explicit session overrides remain valid +// within a lane; this boundary applies only while entering a different workflow +// step. Only profiles with exactness explicitly enabled require a fresh session +// when the persisted effective model is unknown or different. +func (s *Service) workflowEntryRequiresFreshExactModelSession( + ctx context.Context, + session *models.TaskSession, + step, sourceStep *wfmodels.WorkflowStep, + profileID string, +) (bool, error) { + if session == nil || step == nil || sourceStep == nil || sourceStep.ID == step.ID || profileID == "" { + return false, nil + } + drifted, err := s.sessionHasUnauthorizedExactModelDrift(ctx, session, profileID) + if err != nil || !drifted { + return drifted, err + } + profile, err := s.agentManager.ResolveAgentProfile(ctx, profileID) + if err != nil { + return false, fmt.Errorf("resolve exact workflow profile %q: %w", profileID, err) + } + effective, _ := models.LoadEffectiveSessionRuntimeConfig(session) + s.logger.Info("creating fresh workflow session for exact model identity", + zap.String("session_id", session.ID), + zap.String("profile_id", profileID), + zap.String("source_step_id", sourceStep.ID), + zap.String("target_step_id", step.ID), + zap.String("configured_model", profile.Model), + zap.String("persisted_model", effective.Model)) + return true, nil +} + +func (s *Service) sessionHasUnauthorizedExactModelDrift( + ctx context.Context, + session *models.TaskSession, + profileID string, +) (bool, error) { + if session == nil || profileID == "" { + return false, nil + } + profile, err := s.agentManager.ResolveAgentProfile(ctx, profileID) + if err != nil { + return false, fmt.Errorf("resolve exact workflow profile %q: %w", profileID, err) + } + if profile == nil { + return false, fmt.Errorf("resolve exact workflow profile %q: profile is unavailable", profileID) + } + if !profile.RequireExactModel || profile.Model == "" { + return false, nil + } + effective, ok := models.LoadEffectiveSessionRuntimeConfig(session) + if !ok || effective.Model == "" { + return true, nil + } + return effective.Model != profile.Model, nil +} + func (s *Service) keepCurrentWorkflowStepSession( ctx context.Context, taskID string, @@ -3859,7 +4008,16 @@ func (s *Service) preflightWorkflowStepCredentials( effectiveProfile := s.resolveStepAgentProfile(ctx, targetStep) startPolicy := s.resolveStepProfileSessionStartPolicy(targetStep) if shouldKeepCurrentWorkflowStepSession(effectiveProfile, currentSession.AgentProfileID, startPolicy) { - return nil + if effectiveProfile == "" || startPolicy != models.WorkflowProfileSessionStartPolicyReuse { + return nil + } + drifted, err := s.sessionHasUnauthorizedExactModelDrift(ctx, currentSession, effectiveProfile) + if err != nil { + return err + } + if !drifted { + return nil + } } targetSession := currentSession if startPolicy == models.WorkflowProfileSessionStartPolicyReuse { diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_profile_session_policy_resolution_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_profile_session_policy_resolution_test.go index f5cefad1cc..e5f807fade 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow_profile_session_policy_resolution_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_profile_session_policy_resolution_test.go @@ -2,15 +2,130 @@ package orchestrator import ( "context" + "errors" "testing" "time" + "github.com/stretchr/testify/require" + "github.com/kandev/kandev/internal/orchestrator/watcher" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/workflow/engine" wfmodels "github.com/kandev/kandev/internal/workflow/models" ) +// firstReusableLookupErrorRepo makes only the initial lookup fail. A second +// lookup returns the parked profile session, reproducing the unsafe path where +// preparation used to continue and promote a session it had not inspected. +type firstReusableLookupErrorRepo struct { + sessionExecutorStore + calls int +} + +func (r *firstReusableLookupErrorRepo) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) { + r.calls++ + if r.calls == 1 { + return nil, errors.New("initial reusable-session lookup failed") + } + return r.sessionExecutorStore.ListTaskSessions(ctx, taskID) +} + +// firstReusableLookupEmptyRepo makes only the validated lookup empty. A +// second lookup would return the parked profile session, reproducing the +// unsafe reuse-without-a-candidate path. +type firstReusableLookupEmptyRepo struct { + sessionExecutorStore + calls int +} + +func (r *firstReusableLookupEmptyRepo) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) { + r.calls++ + if r.calls == 1 { + return []*models.TaskSession{}, nil + } + return r.sessionExecutorStore.ListTaskSessions(ctx, taskID) +} + +func TestPrepareWorkflowStepSession_FailsClosedWhenInitialReusableLookupFails(t *testing.T) { + ctx := context.Background() + fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark) + parked := &models.TaskSession{ + ID: "session-b", TaskID: "t1", AgentProfileID: "profile-b", ExecutorID: "exec-local", + ExecutorProfileID: "ep1", TaskEnvironmentID: "env-1", State: models.TaskSessionStateWaitingForInput, + StartedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), + } + require.NoError(t, fixture.repo.CreateTaskSession(ctx, parked)) + flaky := &firstReusableLookupErrorRepo{sessionExecutorStore: fixture.svc.repo} + fixture.svc.repo = flaky + target := &wfmodels.WorkflowStep{ + ID: "step-b", WorkflowID: "wf1", AgentProfileID: "profile-b", + ProfileSessionStartPolicy: models.WorkflowProfileSessionStartPolicyReuse, + } + source := &wfmodels.WorkflowStep{ID: "step-a", WorkflowID: "wf1", AgentProfileID: "profile-a"} + + _, switched, err := fixture.svc.prepareWorkflowStepSession(ctx, "t1", fixture.current, target, source) + + require.ErrorContains(t, err, "initial reusable-session lookup failed") + require.False(t, switched) + require.Equal(t, 1, flaky.calls, "a failed initial lookup must not permit a later reuse lookup") + persisted, err := fixture.repo.GetTaskSession(ctx, parked.ID) + require.NoError(t, err) + require.False(t, persisted.IsPrimary, "the uninspected parked session must not be promoted") +} + +func TestExactModelWorkflowStartPolicyForcesNewAfterEmptyValidatedLookup(t *testing.T) { + ctx := context.Background() + fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark) + parked := &models.TaskSession{ + ID: "session-b", TaskID: "t1", AgentProfileID: "profile-b", ExecutorID: "exec-local", + ExecutorProfileID: "ep1", TaskEnvironmentID: "env-1", State: models.TaskSessionStateWaitingForInput, + StartedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), + } + require.NoError(t, fixture.repo.CreateTaskSession(ctx, parked)) + flaky := &firstReusableLookupEmptyRepo{sessionExecutorStore: fixture.svc.repo} + fixture.svc.repo = flaky + target := &wfmodels.WorkflowStep{ + ID: "step-b", WorkflowID: "wf1", AgentProfileID: "profile-b", + ProfileSessionStartPolicy: models.WorkflowProfileSessionStartPolicyReuse, + } + source := &wfmodels.WorkflowStep{ID: "step-a", WorkflowID: "wf1", AgentProfileID: "profile-a"} + + startPolicy, selected, err := fixture.svc.exactModelWorkflowStartPolicy( + ctx, "t1", fixture.current.ID, target, source, "profile-b", + models.WorkflowProfileSessionStartPolicyReuse, + ) + + require.NoError(t, err) + require.Equal(t, models.WorkflowProfileSessionStartPolicyNew, startPolicy) + require.Nil(t, selected, "an empty validated lookup must not return a reuse candidate") + require.Equal(t, 1, flaky.calls, "the exact-model decision must use one validated lookup") +} + +func TestPreflightWorkflowStepCredentials_ValidatesSameProfileReplacement(t *testing.T) { + ctx := context.Background() + fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyNew, models.WorkflowProfileSessionEndPolicyPark) + fixture.current.AgentProfileID = "profile-b" + fixture.stepGetter.workflowAgentProfileID = "profile-b" + fixture.svc.executor.SetGitHubCredentialBroker( + fakeSwitchSessionCredentialIssuer{}, "https://kandev.example/api/v1/github/credentials/resolve", + ) + repository := &models.Repository{ + ID: "repo1", WorkspaceID: "ws1", Name: "widgets", SourceType: "local", + Provider: "acme-forge", RemoteURL: "https://forge.example/acme/widgets.git", + } + require.NoError(t, fixture.repo.CreateRepository(ctx, repository)) + require.NoError(t, fixture.repo.CreateTaskRepository(ctx, &models.TaskRepository{ + ID: "taskrepo1", TaskID: "t1", RepositoryID: repository.ID, + })) + + err := fixture.svc.preflightWorkflowStepCredentials(ctx, "t1", fixture.current, &wfmodels.WorkflowStep{ + ID: "step-b", WorkflowID: "wf1", AgentProfileID: "profile-b", + ProfileSessionStartPolicy: models.WorkflowProfileSessionStartPolicyNew, + }) + + require.ErrorContains(t, err, "repo1") +} + func TestResolveStepProfileSessionEndPolicyDefaultsToPark(t *testing.T) { svc := &Service{} diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_profile_session_policy_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_profile_session_policy_test.go index bca8934020..898a2e0ab1 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow_profile_session_policy_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_profile_session_policy_test.go @@ -603,6 +603,230 @@ func TestSwitchSessionForStep_ReuseOnStartParkOnEndRoundTrip(t *testing.T) { } } +// TestPrepareWorkflowStepSession_QAToPRDoesNotReuseSolRuntime verifies that a +// completion-driven QA to PR transition owns a new exact-profile session. +// The source session's runtime model and ACP resume identity must never become +// PR launch state merely because the workflow transition is immediate. +func TestPrepareWorkflowStepSession_QAToPRDoesNotReuseSolRuntime(t *testing.T) { + ctx := context.Background() + fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyNew, models.WorkflowProfileSessionEndPolicyPark) + + fixture.current.AgentProfileID = "qa-sol-profile" + fixture.current.AgentProfileSnapshot = map[string]interface{}{"model": "gpt-5.6-sol"} + fixture.current.Metadata = map[string]interface{}{ + models.SessionMetaKeyRuntimeConfig: models.SessionRuntimeConfig{Model: "gpt-5.6-sol"}, + } + require.NoError(t, fixture.repo.UpdateTaskSession(ctx, fixture.current)) + + qaRuntime, err := fixture.repo.GetExecutorRunningBySessionID(ctx, fixture.current.ID) + require.NoError(t, err) + qaRuntime.ResumeToken = "qa-sol-acp-token" + require.NoError(t, fixture.repo.UpsertExecutorRunning(ctx, qaRuntime)) + + prStep := &wfmodels.WorkflowStep{ + ID: "step-pr", + WorkflowID: "wf1", + AgentProfileID: "pr-gpt-5.4-profile", + ProfileSessionStartPolicy: models.WorkflowProfileSessionStartPolicyNew, + } + qaStep := &wfmodels.WorkflowStep{ + ID: "step-qa", WorkflowID: "wf1", AgentProfileID: "qa-sol-profile", + ProfileSessionEndPolicy: models.WorkflowProfileSessionEndPolicyPark, + } + + prSession, switched, err := fixture.svc.prepareWorkflowStepSession(ctx, "t1", fixture.current, prStep, qaStep) + require.NoError(t, err) + require.True(t, switched) + require.NotNil(t, prSession) + require.NotEqual(t, fixture.current.ID, prSession.ID) + require.Equal(t, "pr-gpt-5.4-profile", prSession.AgentProfileID) + require.Empty(t, prSession.AgentProfileSnapshot["model"]) + _, inheritedRuntime := models.LoadSessionRuntimeConfig(prSession.Metadata) + require.False(t, inheritedRuntime) + + qaSession, err := fixture.repo.GetTaskSession(ctx, fixture.current.ID) + require.NoError(t, err) + require.False(t, qaSession.IsPrimary) + require.Equal(t, "gpt-5.6-sol", qaSession.AgentProfileSnapshot["model"]) + + _, err = fixture.repo.GetExecutorRunningBySessionID(ctx, prSession.ID) + require.Error(t, err, "the destination must not inherit the QA ACP resume identity before launch") +} + +// TestPrepareWorkflowStepSession_HumanQAToWorkReplacesExactProfileRuntimeOverride +// covers a parked primary whose profile ID is already the Work profile but +// whose prior Human-QA turn persisted a different runtime model. Entering an +// exact Work lane must create a clean session before any auto-start prompt can +// resume that stale ACP identity. +func TestPrepareWorkflowStepSession_HumanQAToWorkReplacesExactProfileRuntimeOverride(t *testing.T) { + ctx := context.Background() + fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark) + fixture.agentMgr.resolveProfileInfo = &executor.AgentProfileInfo{ + Model: "gpt-5.6-terra", RequireExactModel: true, + } + fixture.current.AgentProfileID = "terra-work-profile" + fixture.current.AgentProfileSnapshot = map[string]interface{}{"model": "gpt-5.6-terra"} + fixture.current.Metadata = map[string]interface{}{ + models.SessionMetaKeyRuntimeConfig: models.SessionRuntimeConfig{Model: "gpt-5.6-luna"}, + } + require.NoError(t, fixture.repo.UpdateTaskSession(ctx, fixture.current)) + + workStep := &wfmodels.WorkflowStep{ + ID: "step-work", + WorkflowID: "wf1", + AgentProfileID: "terra-work-profile", + ProfileSessionStartPolicy: models.WorkflowProfileSessionStartPolicyReuse, + } + humanQAStep := &wfmodels.WorkflowStep{ + ID: "step-human-qa", WorkflowID: "wf1", AgentProfileID: "terra-work-profile", + ProfileSessionEndPolicy: models.WorkflowProfileSessionEndPolicyPark, + } + + workSession, switched, err := fixture.svc.prepareWorkflowStepSession(ctx, "t1", fixture.current, workStep, humanQAStep) + require.NoError(t, err) + require.True(t, switched) + require.NotNil(t, workSession) + require.NotEqual(t, fixture.current.ID, workSession.ID) + require.Equal(t, "terra-work-profile", workSession.AgentProfileID) + _, inheritedRuntime := models.LoadSessionRuntimeConfig(workSession.Metadata) + require.False(t, inheritedRuntime, "a Work launch must not inherit Human-QA's Luna runtime model") + + previous, err := fixture.repo.GetTaskSession(ctx, fixture.current.ID) + require.NoError(t, err) + require.False(t, previous.IsPrimary) +} + +func TestPrepareWorkflowStepSession_ExactProfileModelMismatchDoesNotReuseWhenProfileLookupFails(t *testing.T) { + ctx := context.Background() + fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark) + fixture.agentMgr.resolveProfileErr = errors.New("agent profile lookup failed") + fixture.current.AgentProfileID = "terra-work-profile" + fixture.current.Metadata = map[string]interface{}{ + models.SessionMetaKeyRuntimeConfig: models.SessionRuntimeConfig{Model: "gpt-5.6-luna"}, + } + require.NoError(t, fixture.repo.UpdateTaskSession(ctx, fixture.current)) + + workStep := &wfmodels.WorkflowStep{ID: "step-work", WorkflowID: "wf1", AgentProfileID: "terra-work-profile"} + humanQAStep := &wfmodels.WorkflowStep{ID: "step-human-qa", WorkflowID: "wf1", AgentProfileID: "terra-work-profile"} + + _, switched, err := fixture.svc.prepareWorkflowStepSession(ctx, "t1", fixture.current, workStep, humanQAStep) + require.ErrorContains(t, err, "resolve exact workflow profile") + require.False(t, switched) + + stored, err := fixture.repo.GetTaskSession(ctx, fixture.current.ID) + require.NoError(t, err) + require.True(t, stored.IsPrimary) + require.Equal(t, models.TaskSessionStateRunning, stored.State) +} + +func TestPrepareWorkflowStepSession_ExactProfileDoesNotPromoteParkedMismatchedRuntime(t *testing.T) { + ctx := context.Background() + fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark) + fixture.agentMgr.resolveProfileInfo = &executor.AgentProfileInfo{Model: "gpt-5.6-terra", RequireExactModel: true} + + parked := &models.TaskSession{ + ID: "session-parked-terra", TaskID: "t1", AgentProfileID: "terra-work-profile", + ExecutorID: "exec-local", ExecutorProfileID: "ep1", TaskEnvironmentID: "env-1", + State: models.TaskSessionStateWaitingForInput, StartedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), + Metadata: map[string]interface{}{ + models.SessionMetaKeyRuntimeConfig: models.SessionRuntimeConfig{Model: "gpt-5.6-luna"}, + }, + } + require.NoError(t, fixture.repo.CreateTaskSession(ctx, parked)) + seedExecutorRunning(t, fixture.repo, parked.ID, parked.TaskID, "execution-parked-terra") + parkedRuntime, err := fixture.repo.GetExecutorRunningBySessionID(ctx, parked.ID) + require.NoError(t, err) + parkedRuntime.ResumeToken = "stale-luna-acp-token" + require.NoError(t, fixture.repo.UpsertExecutorRunning(ctx, parkedRuntime)) + + workStep := &wfmodels.WorkflowStep{ + ID: "step-work", WorkflowID: "wf1", AgentProfileID: "terra-work-profile", + ProfileSessionStartPolicy: models.WorkflowProfileSessionStartPolicyReuse, + } + qaStep := &wfmodels.WorkflowStep{ + ID: "step-qa", WorkflowID: "wf1", AgentProfileID: "qa-profile", + ProfileSessionEndPolicy: models.WorkflowProfileSessionEndPolicyPark, + } + + workSession, switched, err := fixture.svc.prepareWorkflowStepSession(ctx, "t1", fixture.current, workStep, qaStep) + require.NoError(t, err) + require.True(t, switched) + require.NotNil(t, workSession) + require.NotEqual(t, parked.ID, workSession.ID, "a parked ACP session with a mismatched runtime model must not be promoted") + require.Equal(t, "terra-work-profile", workSession.AgentProfileID) + _, inheritedRuntime := models.LoadSessionRuntimeConfig(workSession.Metadata) + require.False(t, inheritedRuntime, "the fresh session must not inherit the parked ACP runtime model") + + storedParked, err := fixture.repo.GetTaskSession(ctx, parked.ID) + require.NoError(t, err) + require.False(t, storedParked.IsPrimary, "the stale parked session must remain unpromoted") + storedRuntime, err := fixture.repo.GetExecutorRunningBySessionID(ctx, parked.ID) + require.NoError(t, err) + require.Equal(t, "stale-luna-acp-token", storedRuntime.ResumeToken, "the stale ACP identity must not be selected for the new step") +} + +func TestPrepareWorkflowStepSession_HumanQAToWorkKeepsAuthorizedFallbackSession(t *testing.T) { + ctx := context.Background() + fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark) + fixture.agentMgr.resolveProfileInfo = &executor.AgentProfileInfo{ + Model: "gpt-5.6-terra", FallbackModel: "gpt-5.6-luna", + } + fixture.current.AgentProfileID = "terra-with-luna-fallback" + fixture.current.Metadata = map[string]interface{}{ + models.SessionMetaKeyRuntimeConfig: models.SessionRuntimeConfig{Model: "gpt-5.6-luna"}, + } + require.NoError(t, fixture.repo.UpdateTaskSession(ctx, fixture.current)) + + workStep := &wfmodels.WorkflowStep{ + ID: "step-work", WorkflowID: "wf1", AgentProfileID: "terra-with-luna-fallback", + } + humanQAStep := &wfmodels.WorkflowStep{ID: "step-human-qa", WorkflowID: "wf1"} + + workSession, switched, err := fixture.svc.prepareWorkflowStepSession(ctx, "t1", fixture.current, workStep, humanQAStep) + require.NoError(t, err) + require.False(t, switched) + require.Equal(t, fixture.current.ID, workSession.ID) +} + +func TestPrepareWorkflowStepSession_FallbackProfileDoesNotReuseUnauthorizedRuntime(t *testing.T) { + ctx := context.Background() + fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark) + fixture.agentMgr.resolveProfileInfo = &executor.AgentProfileInfo{ + Model: "gpt-5.6-terra", FallbackModel: "gpt-5.6-luna", RequireExactModel: true, + } + + parked := &models.TaskSession{ + ID: "session-parked-terra", TaskID: "t1", AgentProfileID: "terra-with-luna-fallback", + ExecutorID: "exec-local", ExecutorProfileID: "ep1", TaskEnvironmentID: "env-1", + State: models.TaskSessionStateWaitingForInput, StartedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), + Metadata: map[string]interface{}{ + models.SessionMetaKeyRuntimeConfig: models.SessionRuntimeConfig{Model: "gpt-5.6-sol"}, + }, + } + require.NoError(t, fixture.repo.CreateTaskSession(ctx, parked)) + seedExecutorRunning(t, fixture.repo, parked.ID, parked.TaskID, "execution-parked-terra") + + workStep := &wfmodels.WorkflowStep{ + ID: "step-work", WorkflowID: "wf1", AgentProfileID: "terra-with-luna-fallback", + ProfileSessionStartPolicy: models.WorkflowProfileSessionStartPolicyReuse, + } + qaStep := &wfmodels.WorkflowStep{ + ID: "step-qa", WorkflowID: "wf1", AgentProfileID: "qa-profile", + ProfileSessionEndPolicy: models.WorkflowProfileSessionEndPolicyPark, + } + + workSession, switched, err := fixture.svc.prepareWorkflowStepSession(ctx, "t1", fixture.current, workStep, qaStep) + require.NoError(t, err) + require.True(t, switched) + require.NotNil(t, workSession) + require.NotEqual(t, parked.ID, workSession.ID, "a parked ACP session with an unauthorized runtime model must not be promoted") + require.Equal(t, "terra-with-luna-fallback", workSession.AgentProfileID) + + storedParked, err := fixture.repo.GetTaskSession(ctx, parked.ID) + require.NoError(t, err) + require.False(t, storedParked.IsPrimary, "the stale parked session must remain unpromoted") +} + func TestSwitchSessionForStep_NewOnStartParkOnEndRoundTrip(t *testing.T) { ctx := context.Background() fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyNew, models.WorkflowProfileSessionEndPolicyPark) diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 6dd24691b7..16180f8f36 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -459,6 +459,9 @@ type AgentProfileInfo struct { AgentName string Model string Mode string + FallbackModel string + AutoFallback bool + RequireExactModel bool ConfigOptions map[string]string AutoApprove bool DangerouslySkipPermissions bool diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index 81a2ed342e..1738f39406 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -1390,6 +1390,9 @@ func (e *Executor) resolveAgentProfileSnapshot(ctx context.Context, agentProfile "model": profileInfo.Model, "mode": profileInfo.Mode, "config_options": maps.Clone(profileInfo.ConfigOptions), + "fallback_model": profileInfo.FallbackModel, + "auto_fallback": profileInfo.AutoFallback, + "require_exact_model": profileInfo.RequireExactModel, "auto_approve": profileInfo.AutoApprove, "dangerously_skip_permissions": profileInfo.DangerouslySkipPermissions, "cli_passthrough": profileInfo.CLIPassthrough, diff --git a/apps/backend/internal/settingscatalog/defaults.go b/apps/backend/internal/settingscatalog/defaults.go index 287c1b806d..e7f34527a9 100644 --- a/apps/backend/internal/settingscatalog/defaults.go +++ b/apps/backend/internal/settingscatalog/defaults.go @@ -742,6 +742,7 @@ func profileDomain() DomainDescriptor { profileWritable("agent_profile.model", "model", "Model", "string", "Preferred model. Empty inherits the provider default.", false), profileWritable("agent_profile.fallback_model", "fallback_model", "Fallback model", "string", "Optional fallback model.", false), profileWritable("agent_profile.auto_fallback", "auto_fallback", "Automatic fallback", "boolean", "Enable automatic fallback behavior.", false), + profileWritable("agent_profile.require_exact_model", "require_exact_model", "Require exact model", "boolean", "Require the configured model to be advertised and applied.", false), profileWritable("agent_profile.mode", "mode", "Mode", "string", "Agent operating mode.", false), profileWritable("agent_profile.config_options", "config_options", "Configuration options", "object", "Typed provider options. Supplied maps replace the saved map.", true), profileWritable("agent_profile.allow_indexing", "allow_indexing", "Allow indexing", "boolean", "Legacy compatibility permission.", false), diff --git a/apps/web/app/actions/agents.ts b/apps/web/app/actions/agents.ts index c2b4c9cb82..897497ec61 100644 --- a/apps/web/app/actions/agents.ts +++ b/apps/web/app/actions/agents.ts @@ -109,6 +109,7 @@ export async function createAgentProfileAction( kind?: AgentProfileKind; fallback_model?: string; auto_fallback?: boolean; + require_exact_model?: boolean; mode?: string; config_options?: Record; cli_passthrough: boolean; @@ -136,6 +137,7 @@ export async function updateAgentProfileAction( kind?: AgentProfileKind; fallback_model?: string; auto_fallback?: boolean; + require_exact_model?: boolean; mode?: string; config_options?: Record; allow_indexing?: boolean; diff --git a/apps/web/app/settings/agents/[agentId]/agent-save-helpers.test.ts b/apps/web/app/settings/agents/[agentId]/agent-save-helpers.test.ts index 793e5d7e8f..130d950cba 100644 --- a/apps/web/app/settings/agents/[agentId]/agent-save-helpers.test.ts +++ b/apps/web/app/settings/agents/[agentId]/agent-save-helpers.test.ts @@ -112,6 +112,12 @@ describe("toAgentProfilePatch", () => { }); }); + it("maps require_exact_model to the camelCase field", () => { + expect(toAgentProfilePatch({ require_exact_model: true })).toEqual({ + requireExactModel: true, + }); + }); + it("omits undefined keys so partial patches do not clobber unrelated fields", () => { expect(toAgentProfilePatch({ cli_passthrough: false })).toEqual({ cliPassthrough: false }); expect(toAgentProfilePatch({})).toEqual({}); diff --git a/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts b/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts index 8882b89055..50302446ba 100644 --- a/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts +++ b/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts @@ -38,6 +38,7 @@ export function toAgentProfilePatch(patch: Partial): Partial { const modelComboboxes = screen.getAllByTestId("profile-model-combobox-trigger"); expect((modelComboboxes.at(-1) as HTMLButtonElement).disabled).toBe(true); }); + + it("keeps dormant fallback values while exact model mode is enabled", () => { + const profile = { + id: toAgentProfileId("p-strict"), + name: "strict", + agentId: "claude", + agentDisplayName: "Claude", + model: MODEL_ID, + fallbackModel: "claude-opus-4", + autoFallback: true, + requireExactModel: true, + mode: "", + allowIndexing: false, + autoApprove: false, + cliFlags: [], + cliPassthrough: false, + createdAt: CREATED_AT, + updatedAt: CREATED_AT, + }; + + render( + + + , + ); + + expect(screen.getByTestId("profile-fallback-settings-summary").textContent).toContain( + "Exact model required", + ); + fireEvent.click(screen.getByTestId("profile-fallback-settings-trigger")); + expect( + screen.getByRole("switch", { name: "Fallback automatically to next model" }), + ).toHaveProperty("disabled", true); + expect(screen.getByRole("switch", { name: "Agent fallback" })).toHaveProperty("disabled", true); + }); }); describe("CliProfileEditor recommended flags", () => { diff --git a/apps/web/components/agent/cli-profile-editor.tsx b/apps/web/components/agent/cli-profile-editor.tsx index 1c8e5b988f..6a079b648d 100644 --- a/apps/web/components/agent/cli-profile-editor.tsx +++ b/apps/web/components/agent/cli-profile-editor.tsx @@ -56,6 +56,7 @@ type FormState = { model: string; fallbackModel: string; autoFallback: boolean; + requireExactModel: boolean; mode: string; cliFlags: CLIFlag[]; cliPassthrough: boolean; @@ -74,6 +75,7 @@ function fromExistingProfile(profile: AgentProfile): FormState { model: profile.model ?? "", fallbackModel: profile.fallbackModel ?? "", autoFallback: profile.autoFallback ?? false, + requireExactModel: profile.requireExactModel ?? false, mode: profile.mode ?? "", cliFlags: profile.cliFlags ?? [], cliPassthrough: profile.cliPassthrough ?? false, @@ -95,6 +97,7 @@ function fromDefaultAgent( model: cfg?.default_model ?? "", fallbackModel: "", autoFallback: false, + requireExactModel: false, mode: cfg?.current_mode_id ?? "", cliFlags: seedDefaultCLIFlags(permissionSettings), cliPassthrough: false, @@ -334,10 +337,12 @@ function ModelModeFieldsBinding({ model={form.model} fallbackModel={form.fallbackModel} autoFallback={form.autoFallback} + requireExactModel={form.requireExactModel} mode={form.mode} onModelChange={(v) => patch({ model: v })} onFallbackModelChange={(v) => patch({ fallbackModel: v })} onAutoFallbackChange={(v) => patch({ autoFallback: v })} + onRequireExactModelChange={(v) => patch({ requireExactModel: v })} onModeChange={(v) => patch({ mode: v })} /> ); @@ -348,10 +353,12 @@ type ModelModeFieldsProps = { model: string; fallbackModel: string; autoFallback: boolean; + requireExactModel: boolean; mode: string; onModelChange: (v: string) => void; onFallbackModelChange: (v: string) => void; onAutoFallbackChange: (v: boolean) => void; + onRequireExactModelChange: (v: boolean) => void; onModeChange: (v: string) => void; }; @@ -360,10 +367,12 @@ function ModelModeFields({ model, fallbackModel, autoFallback, + requireExactModel, mode, onModelChange, onFallbackModelChange, onAutoFallbackChange, + onRequireExactModelChange, onModeChange, }: ModelModeFieldsProps) { const { t } = useTranslation(); @@ -415,9 +424,11 @@ function ModelModeFields({ fallbackModel={fallbackModel} fallbackModelGone={fallbackModelGone} autoFallback={autoFallback} + requireExactModel={requireExactModel} currentModelId={modelConfig.current_model_id} onFallbackModelChange={onFallbackModelChange} onAutoFallbackChange={onAutoFallbackChange} + onRequireExactModelChange={onRequireExactModelChange} /> ); @@ -573,6 +584,7 @@ async function saveExistingProfile(id: string, form: FormState): Promise void; +}) { + const { t } = useTranslation(); + return ( +
+
+
+ + +
+ +
+

{t("settings:requireExactModelHelper")}

+
+ ); +} + +function ExplicitFallbackOption({ + availableModels, + fallbackModel, + fallbackModelGone, + autoFallback, + requireExactModel, + currentModelId, + onFallbackModelChange, +}: { + availableModels: { id: string; name: string }[]; + fallbackModel: string; + fallbackModelGone: boolean; + autoFallback: boolean; + requireExactModel: boolean; + currentModelId: string | undefined; + onFallbackModelChange: (value: string) => void; +}) { + const { t } = useTranslation(); + const [optedIn, setOptedIn] = useState(false); + const fallbackEnabled = Boolean(fallbackModel) || optedIn; + return ( +
+
+
+ + +
+ { + setOptedIn(checked); + if (!checked) onFallbackModelChange(""); + }} + aria-label={t("settings:agentFallback")} + /> +
+ {fallbackEnabled && ( + + )} +

+ {autoFallback || requireExactModel + ? t("settings:agentFallbackDisabledHelper") + : t("settings:agentFallbackHelper")} +

+
+ ); +} + // ModelFallbackFields renders the no-silent-model-fallback controls for the // inline CLI profile editor inside the shared fallback-settings disclosure. export function ModelFallbackFields({ @@ -17,27 +112,33 @@ export function ModelFallbackFields({ fallbackModel, fallbackModelGone, autoFallback, + requireExactModel, currentModelId, onFallbackModelChange, onAutoFallbackChange, + onRequireExactModelChange, }: { availableModels: { id: string; name: string }[]; fallbackModel: string; fallbackModelGone: boolean; autoFallback: boolean; + requireExactModel: boolean; currentModelId: string | undefined; onFallbackModelChange: (v: string) => void; onAutoFallbackChange: (v: boolean) => void; + onRequireExactModelChange: (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 ( + } fallbackModel={fallbackModel} automaticOption={
@@ -48,6 +149,7 @@ export function ModelFallbackFields({
@@ -56,51 +158,15 @@ export function ModelFallbackFields({ } explicitOption={ -
-
-
- - -
- { - setOptedIn(checked); - if (!checked) onFallbackModelChange(""); - }} - aria-label={t("settings:agentFallback")} - /> -
- {fallbackEnabled && ( - - )} -

- {autoFallback - ? t("settings:agentFallbackDisabledHelper") - : t("settings:agentFallbackHelper")} -

-
+ } /> ); diff --git a/apps/web/components/settings/agent-profile-dirty.ts b/apps/web/components/settings/agent-profile-dirty.ts index 8d608be5f5..4e0e63dbaf 100644 --- a/apps/web/components/settings/agent-profile-dirty.ts +++ b/apps/web/components/settings/agent-profile-dirty.ts @@ -18,6 +18,7 @@ export function isProfileDirty( draft.model !== savedProfile.model, (draft.fallbackModel ?? "") !== (savedProfile.fallbackModel ?? ""), (draft.autoFallback ?? false) !== (savedProfile.autoFallback ?? false), + (draft.requireExactModel ?? false) !== (savedProfile.requireExactModel ?? false), (draft.mode ?? "") !== (savedProfile.mode ?? ""), !areConfigOptionsEqual(draft.configOptions, savedProfile.configOptions), arePermissionsDirty(draft, savedProfile, permissionSettings), diff --git a/apps/web/components/settings/agent-profile-page-state.ts b/apps/web/components/settings/agent-profile-page-state.ts index 964ce6a50b..aa500945a9 100644 --- a/apps/web/components/settings/agent-profile-page-state.ts +++ b/apps/web/components/settings/agent-profile-page-state.ts @@ -196,6 +196,14 @@ export function useProfileSave({ }); return; } + if (draft.requireExactModel && !draft.model.trim()) { + toast({ + title: translate("settings:requireExactModelValidationTitle"), + description: translate("settings:requireExactModelValidation"), + variant: "error", + }); + return; + } // Model is optional — an empty profile model means "use the agent's // default", which is applied through ACP session model selection at session start. setSaveStatus("loading"); @@ -209,6 +217,7 @@ export function useProfileSave({ model: draft.model, fallback_model: draft.fallbackModel ?? "", auto_fallback: draft.autoFallback ?? false, + require_exact_model: draft.requireExactModel ?? false, mode: draft.mode, config_options: draft.configOptions ?? {}, ...permissionsToProfilePatch(draft), diff --git a/apps/web/components/settings/agent-profile-page.tsx b/apps/web/components/settings/agent-profile-page.tsx index 7f636609e7..af1fc8de52 100644 --- a/apps/web/components/settings/agent-profile-page.tsx +++ b/apps/web/components/settings/agent-profile-page.tsx @@ -78,6 +78,27 @@ type ProfileEditorProps = { initialMcpConfig?: AgentProfileMcpConfig | null; }; +function toProfileFormData( + profile: AgentProfile, + permissionSettings: Record, +): ProfileFormData { + const permissionValues = profilePermissionValues(profile, permissionSettings); + return { + name: profile.name, + model: profile.model, + fallback_model: profile.fallbackModel ?? "", + auto_fallback: profile.autoFallback ?? false, + require_exact_model: profile.requireExactModel ?? false, + mode: profile.mode ?? "", + config_options: profile.configOptions ?? {}, + auto_approve: permissionValues.auto_approve, + allow_indexing: permissionValues.allow_indexing, + cli_passthrough: profile.cliPassthrough, + cli_flags: profile.cliFlags ?? [], + command_prefix: profile.commandPrefix ?? "", + }; +} + type ProfileEditorHeaderProps = { agentName: string; agentDisplayName: string; @@ -240,8 +261,6 @@ function ProfileSettingsCard({ const handleFormChange = (patch: Partial) => { onDraftChange(toAgentProfilePatch(patch)); }; - const permissionValues = profilePermissionValues(draft, permissionSettings); - const savedPermissionValues = profilePermissionValues(savedProfile, permissionSettings); return ( Strict option} automaticOption={Automatic option} explicitOption={Explicit option} />, @@ -20,9 +30,7 @@ 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(fallbackSettingsTrigger).getAttribute("aria-expanded")).toBe("false"); expect(screen.getByTestId("profile-fallback-settings-summary").textContent).toContain( "Explicit fallback: gpt-5", ); @@ -37,11 +45,9 @@ describe("ModelFallbackSettingsShell", () => { it("reveals both fallback options in the responsive grid", () => { renderShell({ autoFallback: true }); - fireEvent.click(screen.getByTestId("profile-fallback-settings-trigger")); + fireEvent.click(screen.getByTestId(fallbackSettingsTrigger)); - expect( - screen.getByTestId("profile-fallback-settings-trigger").getAttribute("aria-expanded"), - ).toBe("true"); + expect(screen.getByTestId(fallbackSettingsTrigger).getAttribute("aria-expanded")).toBe("true"); expect(screen.getByTestId("profile-fallback-settings-grid").className).toContain( "md:grid-cols-2", ); @@ -52,4 +58,15 @@ describe("ModelFallbackSettingsShell", () => { "Explicit option", ); }); + + it("summarizes and renders the exact-model option", () => { + renderShell({ requireExactModel: true, fallbackModel: "gpt-5" }); + expect(screen.getByTestId("profile-fallback-settings-summary").textContent).toContain( + exactModelSummary, + ); + fireEvent.click(screen.getByTestId(fallbackSettingsTrigger)); + expect(screen.getByTestId("profile-require-exact-model-option").textContent).toContain( + "Strict option", + ); + }); }); diff --git a/apps/web/components/settings/model-fallback-settings-shell.tsx b/apps/web/components/settings/model-fallback-settings-shell.tsx index 8216712e1a..d55233517d 100644 --- a/apps/web/components/settings/model-fallback-settings-shell.tsx +++ b/apps/web/components/settings/model-fallback-settings-shell.tsx @@ -17,7 +17,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kande import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; import { cn } from "@/lib/utils"; -export type FallbackOptionKind = "automatic" | "explicit"; +export type FallbackOptionKind = "automatic" | "explicit" | "strict"; function helpCopy(kind: FallbackOptionKind, t: (key: string) => string) { if (kind === "automatic") { @@ -27,6 +27,13 @@ function helpCopy(kind: FallbackOptionKind, t: (key: string) => string) { body: t("settings:autoFallbackHelp"), }; } + if (kind === "strict") { + return { + label: t("settings:requireExactModelInfoLabel"), + title: t("settings:requireExactModelHelpTitle"), + body: t("settings:requireExactModelHelp"), + }; + } return { label: t("settings:agentFallbackInfoLabel"), title: t("settings:agentFallbackHelpTitle"), @@ -94,24 +101,31 @@ export function ModelFallbackSettingsShell({ autoFallback, fallbackModel, isDirty, + requireExactModel = false, + strictOption, automaticOption, explicitOption, }: { autoFallback: boolean; fallbackModel: string; isDirty?: boolean; + requireExactModel?: boolean; + strictOption?: ReactNode; automaticOption: ReactNode; explicitOption: ReactNode; }) { const { t } = useTranslation(); const [open, setOpen] = useState(false); - let summary = t("settings:fallbackSettingsSummaryStrict"); + let summary = t("settings:fallbackSettingsSummaryExecutorDefault"); if (fallbackModel) { summary = t("settings:fallbackSettingsSummaryExplicit", { model: fallbackModel }); } if (autoFallback) { summary = t("settings:fallbackSettingsSummaryAutomatic"); } + if (requireExactModel) { + summary = t("settings:fallbackSettingsSummaryExact"); + } return ( + {strictOption && ( +
+ {strictOption} +
+ )}
{ expect(trigger.textContent).toContain("claude-gone"); }); - it("names a unique advertised variation while preserving the saved model", () => { + it("does not infer an advertised variation for a gone saved model", () => { renderForm(formData({ model: "opus" }), { ...modelConfig, available_models: [{ id: "opus[1m]", name: "Opus (1m)" }], }); - const advisory = screen.getByTestId("profile-model-variation-advisory"); - expect(advisory.textContent).toContain("opus[1m]"); - expect(advisory.textContent).toContain("opus"); + expect(screen.queryByTestId("profile-model-variation-advisory")).toBeNull(); expect( screen.getByRole("button", { name: profileStartModelSettingsLabel }).textContent, ).toContain("opus"); @@ -178,6 +176,21 @@ describe("ProfileFormFields no-silent-model-fallback rows", () => { expect(screen.queryByTestId("profile-auto-fallback-field")).not.toBeNull(); }); + it("disables fallback controls while exact model is required", () => { + renderForm(formData({ require_exact_model: true, fallback_model: "mock-fast" })); + expandFallbackSettings(); + expect( + screen.getByRole("switch", { name: "Require exact model" }).getAttribute("data-state"), + ).toBe("checked"); + expect( + screen.getByRole("switch", { name: "Fallback automatically to next model" }), + ).toHaveProperty("disabled", true); + expect(screen.getByRole("switch", { name: "Agent fallback" })).toHaveProperty("disabled", true); + expect(screen.getByTestId("profile-fallback-settings-summary").textContent).toContain( + "Exact model required", + ); + }); + it("marks a gone fallback model red in its picker", () => { renderForm(formData({ fallback_model: "gpt-gone" })); expandFallbackSettings(); diff --git a/apps/web/components/settings/profile-form-fields.tsx b/apps/web/components/settings/profile-form-fields.tsx index aec630d67f..0b498f5edf 100644 --- a/apps/web/components/settings/profile-form-fields.tsx +++ b/apps/web/components/settings/profile-form-fields.tsx @@ -56,6 +56,7 @@ export type ProfileFormData = { fallback_model?: string; /** Legacy automatic-fallback opt-in; hides the fallback_model field. */ auto_fallback?: boolean; + require_exact_model?: boolean; mode: string; config_options?: Record; cli_passthrough: boolean; diff --git a/apps/web/components/settings/profile-model-fields.tsx b/apps/web/components/settings/profile-model-fields.tsx index c558d7059e..6fa427987d 100644 --- a/apps/web/components/settings/profile-model-fields.tsx +++ b/apps/web/components/settings/profile-model-fields.tsx @@ -15,6 +15,7 @@ import { import { profileAutoFallbackIsDirty, profileFallbackModelIsDirty, + profileRequireExactModelIsDirty, } from "@/components/settings/profile-capability-helpers"; import { FallbackOptionHelp, @@ -23,7 +24,6 @@ import { import type { ModelConfig, ModeEntry, ModelEntry } from "@/lib/types/http"; import type { PermissionKey } from "@/lib/agent-permissions"; import type { CLIFlag } from "@/lib/types/http"; -import { findUniqueModelVariation } from "@/lib/model-variation"; import { SettingsFieldDescription, SettingsFieldLabel, @@ -36,6 +36,7 @@ export type ProfileFormData = { fallback_model?: string; /** Legacy automatic-fallback opt-in; hides the fallback_model field. */ auto_fallback?: boolean; + require_exact_model?: boolean; mode: string; config_options?: Record; cli_passthrough: boolean; @@ -84,12 +85,6 @@ export function ModelPicker({ // 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)); - const uniqueVariation = modelIsGone - ? findUniqueModelVariation( - profile.model, - modelOptions.map((model) => model.id), - ) - : null; if (modelIsGone) { modelOptions.unshift({ id: profile.model!, @@ -123,14 +118,6 @@ export function ModelPicker({ keepOpenOnModelChange={keepOpenOnModelChange} triggerClassName={modelIsGone ? "text-destructive" : undefined} /> - {uniqueVariation && ( -

- {t("settings:modelVariationAdvisory", { - model: profile.model, - variation: uniqueVariation, - })} -

- )}
); } @@ -269,8 +256,9 @@ function FallbackModelPicker({ // 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. +// configured fallback would be redundant. Strict mode disables both fallback +// controls while preserving their saved values. Compatible mode keeps the +// legacy explicit/automatic/default order. export function ModelFallbackSection({ profile, models, @@ -290,13 +278,36 @@ export function ModelFallbackSection({ }) { const { t } = useTranslation(); const autoFallback = profile.auto_fallback ?? false; + const requireExactModel = profile.require_exact_model ?? false; const isDirty = profileAutoFallbackIsDirty(profile, baselineProfile) || - profileFallbackModelIsDirty(profile, baselineProfile); + profileFallbackModelIsDirty(profile, baselineProfile) || + profileRequireExactModelIsDirty(profile, baselineProfile); return ( +
+
+ + {t("settings:requireExactModel")} + + +
+ onChange({ require_exact_model: checked })} + aria-label={t("settings:requireExactModel")} + /> +
+ + {t("settings:requireExactModelHelper")} + + + } fallbackModel={profile.fallback_model ?? ""} isDirty={isDirty} automaticOption={ @@ -316,6 +327,7 @@ export function ModelFallbackSection({ onChange({ auto_fallback: checked })} + disabled={requireExactModel} aria-label={t("settings:autoFallback")} /> @@ -330,7 +342,7 @@ export function ModelFallbackSection({ baselineProfile={baselineProfile} labelCls={labelCls} gapCls={gapCls} - disabled={autoFallback} + disabled={autoFallback || requireExactModel} onChange={onChange} /> } diff --git a/apps/web/components/task-create-dialog-options.test.tsx b/apps/web/components/task-create-dialog-options.test.tsx index 9450c49459..8c26d148d8 100644 --- a/apps/web/components/task-create-dialog-options.test.tsx +++ b/apps/web/components/task-create-dialog-options.test.tsx @@ -58,6 +58,19 @@ const AGENT_WITH_GPT: AvailableAgent = { }, } as unknown as AvailableAgent; +const AGENT_WITH_OPUS_VARIATION: AvailableAgent = { + name: "omp-acp", + available: true, + model_config: { + default_model: "opus[1m]", + available_models: [{ id: "opus[1m]", name: "Opus (1m)" }], + current_model_id: "opus[1m]", + available_modes: [], + supports_dynamic_models: false, + status: "ok", + }, +} as unknown as AvailableAgent; + const GONE_MODEL = "claude-gone"; const DATA_DISABLED = "data-disabled"; const MODEL_PROBE_WARNING_TEST_ID = "agent-profile-model-probe-warning"; @@ -105,6 +118,14 @@ function renderOptions(profiles: AgentProfileOption[]) { return screen.getByTestId("option-0"); } +function getModelProbeWarning() { + return screen.getByTestId(MODEL_PROBE_WARNING_TEST_ID); +} + +function getModelProbeWarningLabel() { + return getModelProbeWarning().getAttribute("aria-label"); +} + beforeEach(() => { vi.clearAllMocks(); setAvailableAgents([AGENT_WITH_GPT]); @@ -205,122 +226,81 @@ describe("useAgentProfileOptions recent-use ordering", () => { }); }); -describe("useAgentProfileOptions model-independent labels", () => { - // @covers AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.1 - it.each([ - ["exact", "gpt-5", ["gpt-5"]], - ["missing", GONE_MODEL, ["gpt-5"]], - ["unique variation", "opus", ["opus[1m]"]], - ["multiple variations", "opus", ["opus[1m]", "opus[270k]"]], - ["legacy effort IDs", "gpt-6-astra", ["gpt-6-astra[low]", "gpt-6-astra[high]"]], - ["bracketed request", "opus[1m]", ["opus[270k]"]], - ["empty catalog", GONE_MODEL, []], - ["provider default", "", ["gpt-5"]], - ])("does not show host model advisories in either label: %s", (_, model, models) => { - setAvailableAgents([ - { - ...AGENT_WITH_GPT, - model_config: { - ...AGENT_WITH_GPT.model_config, - available_models: (models as string[]).map((id) => ({ id, name: id })), - }, - }, +describe("useAgentProfileOptions executor-authoritative model hint", () => { + it("keeps a profile whose start model is absent from the host probe selectable", () => { + const option = renderOptions([profileOption({ model: GONE_MODEL })]); + expect(option.getAttribute(DATA_DISABLED)).toBeNull(); + expect(getModelProbeWarningLabel()).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("does not infer a unique advertised variation in the host warning", () => { + setAvailableAgents([AGENT_WITH_OPUS_VARIATION]); + const option = renderOptions([profileOption({ model: "opus" })]); + + expect(option.getAttribute(DATA_DISABLED)).toBeNull(); + expect(getModelProbeWarningLabel()).toContain("opus"); + expect(getModelProbeWarningLabel()).not.toContain("opus[1m]"); + }); + + 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", () => { + const option = renderOptions([profileOption({ model: GONE_MODEL, fallback_model: "gpt-5" })]); + expect(option.getAttribute(DATA_DISABLED)).toBeNull(); + expect(option.getAttribute("data-reason")).toBeNull(); + + expect(getModelProbeWarningLabel()).toContain(GONE_MODEL); + }); + + it("keeps a profile selectable when both saved models are absent from the host probe", () => { + const option = renderOptions([ + profileOption({ model: GONE_MODEL, fallback_model: "other-gone" }), ]); - const profile = profileOption({ model: model as string }); - const { result } = renderHook(() => useAgentProfileOptions([profile])); - const option = result.current[0]!; - expect(option.disabled).toBeUndefined(); - expect(option.disabledReason).toBeUndefined(); + expect(option.getAttribute(DATA_DISABLED)).toBeNull(); + expect(getModelProbeWarningLabel()).toContain(GONE_MODEL); + }); + + it("keeps auto-fallback profiles selectable and shows the host probe hint", () => { + const option = renderOptions([profileOption({ model: GONE_MODEL, auto_fallback: true })]); + expect(option.getAttribute(DATA_DISABLED)).toBeNull(); + expect(getModelProbeWarningLabel()).toContain(GONE_MODEL); + }); + + it("renders the host probe hint as one compact warning trigger", () => { + const option = renderOptions([profileOption({ model: GONE_MODEL })]); + + expect(getModelProbeWarning()).toBeTruthy(); + expect(option.textContent).not.toContain( + "The host probe did not advertise claude-gone. The selected executor will decide the model at launch.", + ); + }); + + it("uses a non-interactive warning indicator in the selected trigger", () => { + const { result } = renderHook(() => + useAgentProfileOptions([profileOption({ model: GONE_MODEL })]), + ); + render( -
{option.renderLabel()}
-
{option.renderTriggerLabel?.()}
+
{result.current[0]?.renderTriggerLabel?.()}
, ); - for (const label of ["option-label", "selected-label"]) { - const element = screen.getByTestId(label); - expect(element.textContent).toContain("hybrid"); - expect(element.querySelector("button, .tabler-icon-alert-triangle")).toBeNull(); - expect(element.querySelector('[title*="host probe"]')).toBeNull(); - } - }); - // @covers AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.3 - it.each(["auth_required", "not_installed", "failed"] as const)( - "preserves %s health indicators when the saved model is absent", - (capability_status) => { - setAvailableAgents([]); - const profile = profileOption({ - model: GONE_MODEL, - capability_status, - capability_error: "Agent needs attention", - }); - const { result } = renderHook(() => useAgentProfileOptions([profile])); - const option = result.current[0]!; - render( - -
{option.renderLabel()}
-
{option.renderTriggerLabel?.()}
-
, - ); - expect(screen.getAllByTitle("Agent needs attention")).toHaveLength(2); - expect(screen.queryByTestId(MODEL_PROBE_WARNING_TEST_ID)).toBeNull(); - }, - ); - - // @covers AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.6 - it.each([false, true])( - "preserves saved profile settings with auto fallback %s", - (auto_fallback) => { - const profile = profileOption({ - model: GONE_MODEL, - fallback_model: "other-gone", - auto_fallback, - cli_passthrough: true, - }); - const saved = structuredClone(profile); - const option = renderOptions([profile]); - expect(option.getAttribute(DATA_DISABLED)).toBeNull(); - expect(option.getAttribute("data-reason")).toBeNull(); - expect(screen.queryByTestId(MODEL_PROBE_WARNING_TEST_ID)).toBeNull(); - expect(option.querySelector(".tabler-icon-terminal-2")).not.toBeNull(); - expect(profile).toEqual(saved); - }, - ); - - it("keeps labels stable when a pending host catalog changes", () => { - setAvailableAgents([]); - const profile = profileOption({ model: GONE_MODEL }); - const { rerender } = render(); - const initialLabel = screen.getByTestId("option-0").innerHTML; - setAvailableAgents([AGENT_WITH_GPT]); - rerender(); - expect(screen.getByTestId("option-0").innerHTML).toBe(initialLabel); expect(screen.queryByTestId(MODEL_PROBE_WARNING_TEST_ID)).toBeNull(); + expect(screen.getByTitle(/claude-gone/)).toBeTruthy(); + }); + + 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(); }); }); - -it("refreshes capability health from the host snapshot without inspecting model IDs", () => { - setAvailableAgents([ - { - ...AGENT_WITH_GPT, - available: false, - model_config: { - ...AGENT_WITH_GPT.model_config, - error: "Agent unavailable", - }, - }, - ]); - const { result } = renderHook(() => - useAgentProfileOptions([profileOption({ model: GONE_MODEL })]), - ); - const option = result.current[0]!; - render( - -
{option.renderLabel()}
-
{option.renderTriggerLabel?.()}
-
, - ); - expect(screen.getAllByTitle("Agent unavailable")).toHaveLength(2); - expect(screen.queryByTestId(MODEL_PROBE_WARNING_TEST_ID)).toBeNull(); -}); diff --git a/apps/web/components/task-create-dialog-options.tsx b/apps/web/components/task-create-dialog-options.tsx index ecf3e7f99a..09c98d1cc9 100644 --- a/apps/web/components/task-create-dialog-options.tsx +++ b/apps/web/components/task-create-dialog-options.tsx @@ -1,12 +1,21 @@ "use client"; -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { t } from "@/lib/i18n"; -import { IconGitBranch, IconTerminal2 } from "@tabler/icons-react"; +import { IconAlertTriangle, IconGitBranch, IconTerminal2 } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@kandev/ui/drawer"; import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import type { LocalRepository, Repository, @@ -14,17 +23,16 @@ 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 { useFeature } from "@/hooks/domains/features/use-feature"; -import { - isSelectableAgentProfile, - refreshProfileCapabilities, -} from "@/lib/state/slices/settings/types"; +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 { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; import { branchOptionValue, buildBranchKeywords } from "./branch-picker-options"; import { ensureAgentProfileRecentUseLoaded, @@ -41,6 +49,52 @@ type OptionItem = { disabledReason?: string; }; +function ModelProbeWarning({ note }: { note: string }) { + const usesTouchDrawer = useTouchDrawer(); + const [drawerOpen, setDrawerOpen] = useState(false); + const trigger = ( + + ); + + if (usesTouchDrawer) { + return ( + + {trigger} + + + {note} + {note} + + + + ); + } + + return ( + + {trigger} + + {note} + + + ); +} + +function ModelProbeWarningIndicator({ note }: { note: string }) { + return ; +} + export function useRepositoryOptions( repositories: Repository[], discoveredRepositories: LocalRepository[], @@ -129,15 +183,19 @@ 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[], context?: AgentProfileRecentUseContext, ): OptionItem[] { const { t } = useTranslation(); - // Keep capability discovery alive for every selector surface. The host - // catalog supplies health status only; it never participates in model-ID - // matching or selector eligibility. - const availableAgents = useAvailableAgents(); + const { items: availableAgents } = useAvailableAgents(); const dynamicRoutingEnabled = useFeature("dynamicAgentRouting"); const storeApi = useAppStoreApi(); const recentUseLoaded = useAppStore((state) => !context || state.agentProfileRecentUse.loaded); @@ -149,13 +207,9 @@ export function useAgentProfileOptions( void ensureAgentProfileRecentUseLoaded(storeApi); }, [context, recentUseLoaded, storeApi]); return useMemo(() => { - const profilesWithCapabilities = refreshProfileCapabilities( - agentProfiles, - availableAgents.items, - ); // Disabled profiles stay in the store (existing sessions keep their // labels) but are never offered as a choice for new work. - const selectable = profilesWithCapabilities.filter((profile) => + const selectable = agentProfiles.filter((profile) => isSelectableAgentProfile(profile, dynamicRoutingEnabled), ); const orderedProfiles = context @@ -167,7 +221,20 @@ export function useAgentProfileOptions( const profileLabel = parts[1] ?? ""; const isPassthrough = profile.cli_passthrough === true; const warning = getCapabilityWarning(profile.capability_status, profile.capability_error); - const renderProfileLabel = () => ( + // The host-utility probe is an editing hint only. The selected + // executor owns the launch-time model catalog, so a host-only mismatch + // must never remove a profile from the task selector. + const advertised = advertisedModelIDs(availableAgents, profile.agent_name); + const startModelGone = Boolean( + profile.model && advertised.length > 0 && !advertised.includes(profile.model), + ); + let modelProbeNote: string | undefined; + if (startModelGone) { + modelProbeNote = t("settings:profileStartModelNotAdvertisedOnHost", { + model: profile.model, + }); + } + const renderProfileLabel = (modelProbeWarning: React.ReactNode) => ( @@ -176,6 +243,7 @@ export function useAgentProfileOptions( {warning && ( )} + {modelProbeWarning} {isPassthrough && ( @@ -198,11 +266,15 @@ export function useAgentProfileOptions( label: profile.label, disabled: undefined, disabledReason: undefined, - renderLabel: renderProfileLabel, - renderTriggerLabel: renderProfileLabel, + renderLabel: () => + renderProfileLabel(modelProbeNote ? : null), + renderTriggerLabel: () => + renderProfileLabel( + modelProbeNote ? : null, + ), }; }); - }, [agentProfiles, availableAgents.items, context, dynamicRoutingEnabled, recentProfileIds, t]); + }, [agentProfiles, availableAgents, context, dynamicRoutingEnabled, recentProfileIds, t]); } export function useExecutorOptions(executors: Executor[]): OptionItem[] { diff --git a/apps/web/components/task/chat/messages/status-message.test.tsx b/apps/web/components/task/chat/messages/status-message.test.tsx index 430d3a7083..c7ace4f784 100644 --- a/apps/web/components/task/chat/messages/status-message.test.tsx +++ b/apps/web/components/task/chat/messages/status-message.test.tsx @@ -43,7 +43,7 @@ describe("StatusMessage model selection warnings", () => { expect(screen.getByText("The saved model was not advertised by the executor.")).toBeTruthy(); }); - it("explains when the executor applied the only advertised variation", () => { + it("renders a retired variation reason from a persisted legacy warning", () => { const comment = modelSelectionWarningMessage(); comment.metadata = { ...comment.metadata, diff --git a/apps/web/components/task/chat/messages/status-message.tsx b/apps/web/components/task/chat/messages/status-message.tsx index afc8e37440..408304dee0 100644 --- a/apps/web/components/task/chat/messages/status-message.tsx +++ b/apps/web/components/task/chat/messages/status-message.tsx @@ -180,11 +180,15 @@ function modelSelectionReasonLabel(reason: string | undefined): string { const keyByReason: Record = { requested_not_advertised: "task:modelSelectionReasonRequestedNotAdvertised", fallback_not_advertised: "task:modelSelectionReasonFallbackNotAdvertised", - unique_variation_applied: "task:modelSelectionReasonUniqueVariation", catalog_empty: "task:modelSelectionReasonCatalogEmpty", selection_unsupported: "task:modelSelectionReasonUnsupported", selection_failed_auto_fallback: "task:modelSelectionReasonAutoFallback", }; + // Old persisted rows can contain this retired reason. Rendering it is a + // migration-only compatibility path; no active selection policy uses it. + if (reason === "unique_variation_applied") { + return t("task:modelSelectionReasonUniqueVariation"); + } return reason ? t(keyByReason[reason] ?? "task:modelSelectionReasonUnknown", { reason }) : t(UNKNOWN_TASK_KEY); diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index af83f20727..80f54d49a7 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -718,6 +718,7 @@ export class ApiClient { model: string; fallback_model?: string; auto_fallback?: boolean; + require_exact_model?: boolean; auto_approve?: boolean; mode?: string; config_options?: Record; @@ -732,6 +733,7 @@ export class ApiClient { model: opts.model, fallback_model: opts.fallback_model, auto_fallback: opts.auto_fallback, + require_exact_model: opts.require_exact_model, auto_approve: opts.auto_approve, mode: opts.mode, config_options: opts.config_options, @@ -762,6 +764,9 @@ export class ApiClient { patch: { name?: string; model?: string; + fallback_model?: string; + auto_fallback?: boolean; + require_exact_model?: boolean; mode?: string; config_options?: Record; cli_passthrough?: boolean; diff --git a/apps/web/e2e/tests/session/mobile-model-mismatch-warning.spec.ts b/apps/web/e2e/tests/session/mobile-model-mismatch-warning.spec.ts index 5fb3599c4c..312e3cd1cd 100644 --- a/apps/web/e2e/tests/session/mobile-model-mismatch-warning.spec.ts +++ b/apps/web/e2e/tests/session/mobile-model-mismatch-warning.spec.ts @@ -1,15 +1,16 @@ import { expect, test } from "../../fixtures/test-base"; import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; -import { waitForSessionDone } from "../../helpers/session"; +import { waitForSessionDone, waitForSessionState } from "../../helpers/session"; import { SessionPage } from "../../pages/session-page"; import { createMismatchedProfile, + createStrictMismatchedProfile, readModelSelectionWarnings, UNADVERTISED_MODEL, } from "./model-mismatch-warning-helpers"; test.describe("model mismatch warning on mobile", () => { - test("shows the persisted warning without horizontal overflow", async ({ + test("auto-fallback shows the persisted warning without horizontal overflow", async ({ testPage, apiClient, seedData, @@ -61,4 +62,46 @@ test.describe("model mismatch warning on mobile", () => { await apiClient.deleteAgentProfile(profile.id, true).catch(() => {}); } }); + + test("shows the exact-model launch failure without horizontal overflow", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + const profile = await createStrictMismatchedProfile( + apiClient, + "Mobile exact model failure profile", + ); + try { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Mobile exact executor model mismatch", + profile.id, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); + await waitForSessionState(apiClient, { + taskId: task.id, + sessionId: task.session_id, + expectedState: "FAILED", + message: "Waiting for mobile exact model mismatch failure", + }); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.activeChat().getByText("Failed to start agent Mock")).toBeVisible({ + timeout: 15_000, + }); + await assertNoDocumentHorizontalOverflow(testPage, "mobile exact model mismatch failure"); + } finally { + await apiClient.deleteAgentProfile(profile.id, true).catch(() => {}); + } + }); }); diff --git a/apps/web/e2e/tests/session/model-mismatch-warning-helpers.ts b/apps/web/e2e/tests/session/model-mismatch-warning-helpers.ts index bcd21deb9d..1cbaa616d9 100644 --- a/apps/web/e2e/tests/session/model-mismatch-warning-helpers.ts +++ b/apps/web/e2e/tests/session/model-mismatch-warning-helpers.ts @@ -14,7 +14,23 @@ export async function createMismatchedProfile( const { agents } = await apiClient.listAgents(); const agent = agents.find((item) => item.name === "mock-agent") ?? agents[0]; if (!agent) throw new Error("The E2E fixture must provide a mock agent"); - return apiClient.createAgentProfile(agent.id, name, { model: UNADVERTISED_MODEL }); + return apiClient.createAgentProfile(agent.id, name, { + model: UNADVERTISED_MODEL, + auto_fallback: true, + }); +} + +export async function createStrictMismatchedProfile( + apiClient: ApiClient, + name: string, +): Promise { + const { agents } = await apiClient.listAgents(); + const agent = agents.find((item) => item.name === "mock-agent") ?? agents[0]; + if (!agent) throw new Error("The E2E fixture must provide a mock agent"); + return apiClient.createAgentProfile(agent.id, name, { + model: UNADVERTISED_MODEL, + require_exact_model: true, + }); } export async function createModelVariationProfile( diff --git a/apps/web/e2e/tests/session/model-mismatch-warning.spec.ts b/apps/web/e2e/tests/session/model-mismatch-warning.spec.ts index 6f5f961f9c..c12028efdc 100644 --- a/apps/web/e2e/tests/session/model-mismatch-warning.spec.ts +++ b/apps/web/e2e/tests/session/model-mismatch-warning.spec.ts @@ -1,17 +1,15 @@ import { expect, test } from "../../fixtures/test-base"; -import { waitForSessionDone } from "../../helpers/session"; +import { waitForSessionDone, waitForSessionState } from "../../helpers/session"; import { SessionPage } from "../../pages/session-page"; import { - createModelVariationProfile, createMismatchedProfile, - MODEL_VARIATION_BASE, - UNIQUE_MODEL_VARIATION, + createStrictMismatchedProfile, readModelSelectionWarnings, UNADVERTISED_MODEL, } from "./model-mismatch-warning-helpers"; test.describe("model mismatch warning", () => { - test("continues with the executor default and persists one warning after reload", async ({ + test("auto-fallback continues with the executor default and persists one warning after reload", async ({ testPage, apiClient, seedData, @@ -77,21 +75,17 @@ test.describe("model mismatch warning", () => { } }); - test("applies one unique advertised variation and persists its warning", async ({ + test("shows an exact-model launch failure instead of an unauthorized warning", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(120_000); - const profile = await createModelVariationProfile( - apiClient, - "Unique model variation profile", - "unique", - ); + const profile = await createStrictMismatchedProfile(apiClient, "Exact model failure profile"); try { const task = await apiClient.createTaskWithAgent( seedData.workspaceId, - "Unique model variation warning", + "Exact executor model mismatch", profile.id, { description: "/e2e:simple-message", @@ -100,98 +94,21 @@ test.describe("model mismatch warning", () => { repository_ids: [seedData.repositoryId], }, ); - expect(task.session_id).toBeTruthy(); - await waitForSessionDone( - apiClient, - task.id, - task.session_id!, - "Waiting for unique model variation task continuation", - ); - - await expect - .poll(() => readModelSelectionWarnings(apiClient, task.session_id!), { - timeout: 15_000, - message: "Waiting for the unique model variation warning", - }) - .toHaveLength(1); - const warnings = await readModelSelectionWarnings(apiClient, task.session_id!); - expect(warnings[0]?.metadata).toMatchObject({ - kind: "model_selection_warning", - reason: "unique_variation_applied", - requested_model: MODEL_VARIATION_BASE, - effective_model: UNIQUE_MODEL_VARIATION, - agent_id: "mock-agent", + if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); + await waitForSessionState(apiClient, { + taskId: task.id, + sessionId: task.session_id, + expectedState: "FAILED", + message: "Waiting for exact model mismatch failure", }); - expect(warnings[0]?.metadata?.fallback_model).toBeUndefined(); - expect((await apiClient.getAgentProfile(profile.id)).model).toBe(MODEL_VARIATION_BASE); + expect(await readModelSelectionWarnings(apiClient, task.session_id)).toHaveLength(0); await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); - await expect( - session.activeChat().getByText("The executor could not use the saved model selection."), - ).toBeVisible({ timeout: 15_000 }); - await expect(session.activeChat()).toContainText(UNIQUE_MODEL_VARIATION); - - await testPage.reload(); - await session.waitForLoad(); - await expect( - session.activeChat().getByText("The executor could not use the saved model selection."), - ).toBeVisible({ timeout: 15_000 }); - await expect(session.activeChat()).toContainText(UNIQUE_MODEL_VARIATION); - await expect - .poll(() => readModelSelectionWarnings(apiClient, task.session_id!)) - .toHaveLength(1); - } finally { - await apiClient.deleteAgentProfile(profile.id, true).catch(() => {}); - } - }); - - test("does not infer a model when the executor advertises multiple variations", async ({ - apiClient, - seedData, - }) => { - test.setTimeout(120_000); - const profile = await createModelVariationProfile( - apiClient, - "Ambiguous model variation profile", - "ambiguous", - ); - try { - const task = await apiClient.createTaskWithAgent( - seedData.workspaceId, - "Ambiguous model variation warning", - profile.id, - { - description: "/e2e:simple-message", - workflow_id: seedData.workflowId, - workflow_step_id: seedData.startStepId, - repository_ids: [seedData.repositoryId], - }, - ); - expect(task.session_id).toBeTruthy(); - await waitForSessionDone( - apiClient, - task.id, - task.session_id!, - "Waiting for ambiguous model variation task continuation", - ); - - await expect - .poll(() => readModelSelectionWarnings(apiClient, task.session_id!), { - timeout: 15_000, - message: "Waiting for the ambiguous model variation warning", - }) - .toHaveLength(1); - const warnings = await readModelSelectionWarnings(apiClient, task.session_id!); - expect(warnings[0]?.metadata).toMatchObject({ - kind: "model_selection_warning", - reason: "requested_not_advertised", - requested_model: MODEL_VARIATION_BASE, - effective_model: "mock-fast", - agent_id: "mock-agent", + await expect(session.activeChat().getByText("Failed to start agent Mock")).toBeVisible({ + timeout: 15_000, }); - expect((await apiClient.getAgentProfile(profile.id)).model).toBe(MODEL_VARIATION_BASE); } finally { await apiClient.deleteAgentProfile(profile.id, true).catch(() => {}); } diff --git a/apps/web/e2e/tests/settings/mobile-no-silent-model-fallback.spec.ts b/apps/web/e2e/tests/settings/mobile-no-silent-model-fallback.spec.ts index 240dbd4731..5b3d7d3707 100644 --- a/apps/web/e2e/tests/settings/mobile-no-silent-model-fallback.spec.ts +++ b/apps/web/e2e/tests/settings/mobile-no-silent-model-fallback.spec.ts @@ -2,18 +2,83 @@ import { expect, test } from "../../fixtures/test-base"; import { KanbanPage } from "../../pages/kanban-page"; import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; import { - createExecutorOnlyModelProfile, createModelVariationProfile, createMismatchedProfile, + MODEL_VARIATION_BASE, + UNIQUE_MODEL_VARIATION, + UNADVERTISED_MODEL, } from "../session/model-mismatch-warning-helpers"; -import { launchExecutorOnlyModelProfile } from "./profile-model-selection-helpers"; test.describe("executor-authoritative model selection on mobile", () => { - test("keeps the host-mismatched profile reachable by touch", async ({ + test("saves exact mode and restores dormant fallback settings by touch", async ({ testPage, apiClient, - prCapture, }) => { + test.setTimeout(60_000); + const { agents } = await apiClient.listAgents(); + const agent = agents.find((item) => item.name === "mock-agent") ?? agents[0]; + if (!agent) throw new Error("The E2E fixture must provide an agent"); + const profile = await apiClient.createAgentProfile(agent.id, "Mobile exact model settings", { + model: "mock-fast", + fallback_model: "mock-smart", + auto_fallback: false, + }); + + try { + await testPage.goto(`/settings/agents/${agent.name}/profiles/${profile.id}`); + const trigger = testPage.getByTestId("profile-fallback-settings-trigger"); + await expect(trigger).toBeVisible({ timeout: 15_000 }); + await trigger.tap(); + const exactSwitch = testPage.getByRole("switch", { name: "Require exact model" }); + await expect(exactSwitch).toHaveAttribute("data-state", "unchecked"); + await exactSwitch.tap(); + await expect(exactSwitch).toHaveAttribute("data-state", "checked"); + await expect(testPage.getByRole("switch", { name: "Agent fallback" })).toBeDisabled(); + await expect(testPage.getByTestId("profile-fallback-settings-summary")).toContainText( + "Exact model required", + ); + await expect( + testPage.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, + ), + ).resolves.toBe(true); + + const saveButton = testPage.getByRole("button", { name: /^Save( changes)?$/i }).first(); + await saveButton.tap(); + await expect(testPage.getByText(/unsaved changes/i)).toBeHidden({ timeout: 15_000 }); + await expect + .poll(() => apiClient.getAgentProfile(profile.id)) + .toMatchObject({ + requireExactModel: true, + fallbackModel: "mock-smart", + }); + + await testPage.reload(); + await expect(trigger).toBeVisible({ timeout: 15_000 }); + await trigger.tap(); + await testPage.getByRole("switch", { name: "Require exact model" }).tap(); + await testPage + .getByRole("button", { name: /^Save( changes)?$/i }) + .first() + .tap(); + await expect(testPage.getByText(/unsaved changes/i)).toBeHidden({ timeout: 15_000 }); + await expect + .poll(() => apiClient.getAgentProfile(profile.id)) + .toMatchObject({ + requireExactModel: false, + fallbackModel: "mock-smart", + }); + await expect( + testPage.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, + ), + ).resolves.toBe(true); + } finally { + await apiClient.deleteAgentProfile(profile.id, true).catch(() => {}); + } + }); + + test("keeps the host-mismatched profile reachable by touch", async ({ testPage, apiClient }) => { const profile = await createMismatchedProfile(apiClient, "Mobile host mismatch profile"); try { const kanban = new KanbanPage(testPage); @@ -32,31 +97,35 @@ test.describe("executor-authoritative model selection on mobile", () => { .getByRole("option", { name: profile.name, exact: false }); await expect(option).toBeVisible(); await expect(option).toBeEnabled(); - await expect(option.getByTestId("agent-profile-model-probe-warning")).toHaveCount(0); - await expect(option.locator(".tabler-icon-alert-triangle")).toHaveCount(0); - await prCapture.screenshot("mobile-profile-options", { - caption: "Saved profiles remain selectable without host-model warnings.", - }); - await option.tap(); - await expect(testPage.getByRole("listbox")).not.toBeVisible(); - await expect(selector).toContainText(profile.name); - await prCapture.screenshot("mobile-selected-profile", { - caption: "The selected profile keeps its name without a model advisory.", - }); - await expect(selector.locator("button, .tabler-icon-alert-triangle")).toHaveCount(0); - await assertNoDocumentHorizontalOverflow(testPage, "mobile profile selection"); + const warning = option.getByTestId("agent-profile-model-probe-warning"); + await expect(warning).toBeVisible(); + const warningText = `The host probe did not advertise ${UNADVERTISED_MODEL}. The selected executor will decide the model at launch.`; + await expect(dialog).not.toContainText(warningText); + await warning.tap(); + await expect( + testPage + .locator('[data-slot="drawer-content"][data-state="open"]') + .filter({ hasText: warningText }), + ).toBeVisible(); + expect( + await testPage + .locator('[data-slot="drawer-content"][data-state="open"]') + .filter({ hasText: warningText }) + .evaluate((element) => getComputedStyle(element).zIndex), + ).toBe("80"); + await assertNoDocumentHorizontalOverflow(testPage, "mobile model mismatch selector"); } finally { await apiClient.deleteAgentProfile(profile.id, true).catch(() => {}); } }); - test("selects a unique-variation profile by touch without model help", async ({ + test("does not infer one host-advertised variation by touch without overflow", async ({ testPage, apiClient, }) => { const profile = await createModelVariationProfile( apiClient, - "Mobile unique host variation profile", + "Mobile no inferred host variation profile", "unique", ); try { @@ -75,28 +144,19 @@ test.describe("executor-authoritative model selection on mobile", () => { .getByRole("option", { name: profile.name, exact: false }); await expect(option).toBeVisible(); await expect(option).toBeEnabled(); - await expect(option.getByTestId("agent-profile-model-probe-warning")).toHaveCount(0); - await expect(option.locator(".tabler-icon-alert-triangle")).toHaveCount(0); - await option.tap(); - await expect(testPage.getByRole("listbox")).not.toBeVisible(); - await expect(selector).toContainText(profile.name); - await expect(selector.locator("button, .tabler-icon-alert-triangle")).toHaveCount(0); - await assertNoDocumentHorizontalOverflow(testPage, "mobile profile selection"); + const warning = option.getByTestId("agent-profile-model-probe-warning"); + await expect(warning).toBeVisible(); + const warningText = `The host probe did not advertise ${MODEL_VARIATION_BASE}. The selected executor will decide the model at launch.`; + await expect(warning).not.toHaveAccessibleName(UNIQUE_MODEL_VARIATION); + await warning.tap(); + await expect( + testPage + .locator('[data-slot="drawer-content"][data-state="open"]') + .filter({ hasText: warningText }), + ).toBeVisible(); + await assertNoDocumentHorizontalOverflow(testPage, "mobile no inferred variation advisory"); } finally { await apiClient.deleteAgentProfile(profile.id, true).catch(() => {}); } }); - // @covers AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.5 - // @covers AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.6 - test("launches a host-mismatched profile on its requested executor model without a warning", async ({ - testPage, - apiClient, - }) => { - const profile = await createExecutorOnlyModelProfile(apiClient); - try { - await launchExecutorOnlyModelProfile(testPage, apiClient, profile, true); - } finally { - await apiClient.deleteAgentProfile(profile.id, true); - } - }); }); diff --git a/apps/web/e2e/tests/settings/mobile-pr3473-qa-visual-capture.spec.ts b/apps/web/e2e/tests/settings/mobile-pr3473-qa-visual-capture.spec.ts new file mode 100644 index 0000000000..aa5c576925 --- /dev/null +++ b/apps/web/e2e/tests/settings/mobile-pr3473-qa-visual-capture.spec.ts @@ -0,0 +1,62 @@ +import { expect, test } from "../../fixtures/test-base"; +import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; +import { KanbanPage } from "../../pages/kanban-page"; +import { + createMismatchedProfile, + UNADVERTISED_MODEL, +} from "../session/model-mismatch-warning-helpers"; + +test.describe("PR 3473 QA mobile visual evidence", () => { + test("captures mobile warning surfaces", async ({ testPage, apiClient, prCapture }) => { + const profile = await createMismatchedProfile(apiClient, "PR 3473 mobile visual profile"); + try { + const { agents } = await apiClient.listAgents(); + const agent = agents.find((item) => item.name === "mock-agent") ?? agents[0]; + if (!agent) throw new Error("The E2E fixture must provide a mock agent"); + + await testPage.goto(`/settings/agents/${agent.name}/profiles/${profile.id}`); + const modelSelector = testPage.getByRole("button", { + name: "Profile start model settings", + }); + await expect(modelSelector).toBeVisible({ timeout: 15_000 }); + await expect(modelSelector).toContainText(UNADVERTISED_MODEL); + await assertNoDocumentHorizontalOverflow(testPage, "mobile profile start model unavailable"); + await prCapture.screenshot("mobile-settings-start-model-unavailable", { + caption: + "Mobile profile settings preserve the exact saved model without horizontal overflow.", + fullPage: true, + }); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await testPage.reload({ waitUntil: "networkidle" }); + await testPage.getByRole("button", { name: "Add task" }).tap(); + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + await dialog.getByTestId("agent-profile-selector").tap(); + const option = testPage + .getByRole("listbox") + .getByRole("option", { name: profile.name, exact: false }); + await expect(option).toBeVisible(); + const warning = option.getByTestId("agent-profile-model-probe-warning"); + await expect(warning).toBeVisible(); + await warning.tap(); + const warningText = `The host probe did not advertise ${UNADVERTISED_MODEL}. The selected executor will decide the model at launch.`; + const warningDrawer = testPage + .locator('[data-slot="drawer-content"][data-state="open"]') + .filter({ hasText: warningText }); + await expect(warningDrawer).toBeVisible(); + await expect(warningDrawer).toHaveCSS("transform", "none"); + expect(await warningDrawer.evaluate((element) => getComputedStyle(element).zIndex)).toBe( + "80", + ); + await assertNoDocumentHorizontalOverflow(testPage, "mobile task create host probe warning"); + await prCapture.screenshot("mobile-task-create-host-probe-warning", { + caption: + "Mobile task creation keeps the exact-profile option touch-selectable and opens the advisory warning in a drawer.", + }); + } finally { + await apiClient.deleteAgentProfile(profile.id, true).catch(() => undefined); + } + }); +}); diff --git a/apps/web/e2e/tests/settings/no-silent-model-fallback.spec.ts b/apps/web/e2e/tests/settings/no-silent-model-fallback.spec.ts index 008b2d6ae4..e54143c1ca 100644 --- a/apps/web/e2e/tests/settings/no-silent-model-fallback.spec.ts +++ b/apps/web/e2e/tests/settings/no-silent-model-fallback.spec.ts @@ -1,14 +1,83 @@ import { expect, test } from "../../fixtures/test-base"; import { KanbanPage } from "../../pages/kanban-page"; import { - createExecutorOnlyModelProfile, createModelVariationProfile, createMismatchedProfile, + MODEL_VARIATION_BASE, + UNIQUE_MODEL_VARIATION, + UNADVERTISED_MODEL, } from "../session/model-mismatch-warning-helpers"; -import { launchExecutorOnlyModelProfile } from "./profile-model-selection-helpers"; test.describe("executor-authoritative model selection", () => { - test("keeps a host-mismatched profile selectable", async ({ testPage, apiClient, prCapture }) => { + test("saves exact mode and restores dormant fallback settings", async ({ + testPage, + apiClient, + }) => { + test.setTimeout(60_000); + const { agents } = await apiClient.listAgents(); + const agent = agents.find((item) => item.name === "mock-agent") ?? agents[0]; + if (!agent) throw new Error("The E2E fixture must provide an agent"); + const profile = await apiClient.createAgentProfile(agent.id, "Exact model settings profile", { + model: "mock-fast", + fallback_model: "mock-smart", + auto_fallback: false, + }); + + try { + await testPage.goto(`/settings/agents/${agent.name}/profiles/${profile.id}`); + const trigger = testPage.getByTestId("profile-fallback-settings-trigger"); + await expect(trigger).toBeVisible({ timeout: 15_000 }); + await trigger.click(); + + const exactSwitch = testPage.getByRole("switch", { name: "Require exact model" }); + await expect(exactSwitch).toHaveAttribute("data-state", "unchecked"); + await exactSwitch.click(); + await expect(exactSwitch).toHaveAttribute("data-state", "checked"); + await expect( + testPage.getByRole("switch", { name: "Fallback automatically to next model" }), + ).toBeDisabled(); + await expect(testPage.getByRole("switch", { name: "Agent fallback" })).toBeDisabled(); + await expect(testPage.getByTestId("profile-fallback-settings-summary")).toContainText( + "Exact model required", + ); + + 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 }); + await expect + .poll(() => apiClient.getAgentProfile(profile.id)) + .toMatchObject({ + requireExactModel: true, + fallbackModel: "mock-smart", + }); + + await testPage.reload(); + await expect(trigger).toBeVisible({ timeout: 15_000 }); + await expect(testPage.getByTestId("profile-fallback-settings-summary")).toContainText( + "Exact model required", + ); + await trigger.click(); + await expect(testPage.getByRole("switch", { name: "Require exact model" })).toHaveAttribute( + "data-state", + "checked", + ); + await testPage.getByRole("switch", { name: "Require exact model" }).click(); + const saveAfterDisable = testPage.getByRole("button", { name: /^Save( changes)?$/i }).first(); + await saveAfterDisable.click(); + await expect(testPage.getByText(/unsaved changes/i)).toBeHidden({ timeout: 15_000 }); + await expect + .poll(() => apiClient.getAgentProfile(profile.id)) + .toMatchObject({ + requireExactModel: false, + fallbackModel: "mock-smart", + }); + } finally { + await apiClient.deleteAgentProfile(profile.id, true).catch(() => {}); + } + }); + + test("keeps a host-mismatched profile selectable", async ({ testPage, apiClient }) => { const profile = await createMismatchedProfile(apiClient, "Host mismatch selectable profile"); try { const kanban = new KanbanPage(testPage); @@ -27,33 +96,36 @@ test.describe("executor-authoritative model selection", () => { .getByRole("option", { name: profile.name, exact: false }); await expect(option).toBeVisible(); await expect(option).toBeEnabled(); - await expect(option.getByTestId("agent-profile-model-probe-warning")).toHaveCount(0); - await expect(option.locator(".tabler-icon-alert-triangle")).toHaveCount(0); - await prCapture.screenshot("desktop-profile-options", { - caption: "Saved profiles remain selectable without host-model warnings.", - }); - const search = testPage.locator("[cmdk-input]"); - await search.fill(profile.name); - await search.press("Enter"); - await expect(testPage.getByRole("listbox")).not.toBeVisible(); - await expect(selector).toContainText(profile.name); - await prCapture.screenshot("desktop-selected-profile", { - caption: "The selected profile keeps its name without a model advisory.", - }); - await expect(selector.locator(".tabler-icon-alert-triangle")).toHaveCount(0); + const warning = option.getByTestId("agent-profile-model-probe-warning"); + await expect(warning).toBeVisible(); + const warningText = `The host probe did not advertise ${UNADVERTISED_MODEL}. The selected executor will decide the model at launch.`; + await expect(dialog).not.toContainText(warningText); + await warning.hover(); + await expect( + testPage + .locator('[data-slot="tooltip-content"]:not([data-state="closed"])') + .filter({ hasText: warningText }), + ).toBeVisible(); + expect( + await testPage + .locator('[data-slot="tooltip-content"]:not([data-state="closed"])') + .filter({ hasText: warningText }) + .evaluate((element) => getComputedStyle(element).zIndex), + ).toBe("80"); + await option.click(); await expect(selector.locator("button")).toHaveCount(0); } finally { await apiClient.deleteAgentProfile(profile.id, true).catch(() => {}); } }); - test("selects a unique-variation profile without a host model advisory", async ({ + test("does not infer one host-advertised variation while keeping the profile selectable", async ({ testPage, apiClient, }) => { const profile = await createModelVariationProfile( apiClient, - "Unique host variation selectable profile", + "No inferred host variation selectable profile", "unique", ); try { @@ -72,27 +144,19 @@ test.describe("executor-authoritative model selection", () => { .getByRole("option", { name: profile.name, exact: false }); await expect(option).toBeVisible(); await expect(option).toBeEnabled(); - await expect(option.getByTestId("agent-profile-model-probe-warning")).toHaveCount(0); - await expect(option.locator(".tabler-icon-alert-triangle")).toHaveCount(0); + const warning = option.getByTestId("agent-profile-model-probe-warning"); + await expect(warning).toBeVisible(); + const warningText = `The host probe did not advertise ${MODEL_VARIATION_BASE}. The selected executor will decide the model at launch.`; + await expect(warning).not.toHaveAccessibleName(UNIQUE_MODEL_VARIATION); + await warning.hover(); + await expect( + testPage + .locator('[data-slot="tooltip-content"]:not([data-state="closed"])') + .filter({ hasText: warningText }), + ).toBeVisible(); await option.click(); - await expect(testPage.getByRole("listbox")).not.toBeVisible(); - await expect(selector).toContainText(profile.name); - await expect(selector.locator(".tabler-icon-alert-triangle")).toHaveCount(0); } finally { await apiClient.deleteAgentProfile(profile.id, true).catch(() => {}); } }); - // @covers AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.5 - // @covers AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.6 - test("launches a host-mismatched profile on its requested executor model without a warning", async ({ - testPage, - apiClient, - }) => { - const profile = await createExecutorOnlyModelProfile(apiClient); - try { - await launchExecutorOnlyModelProfile(testPage, apiClient, profile, false); - } finally { - await apiClient.deleteAgentProfile(profile.id, true); - } - }); }); diff --git a/apps/web/e2e/tests/settings/pr3473-qa-visual-capture.spec.ts b/apps/web/e2e/tests/settings/pr3473-qa-visual-capture.spec.ts new file mode 100644 index 0000000000..4b4608987d --- /dev/null +++ b/apps/web/e2e/tests/settings/pr3473-qa-visual-capture.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from "../../fixtures/test-base"; +import { KanbanPage } from "../../pages/kanban-page"; +import { + createMismatchedProfile, + UNADVERTISED_MODEL, +} from "../session/model-mismatch-warning-helpers"; + +test.describe("PR 3473 QA visual evidence", () => { + test("captures desktop warning surfaces", async ({ testPage, apiClient, prCapture }) => { + const profile = await createMismatchedProfile(apiClient, "PR 3473 desktop visual profile"); + try { + const { agents } = await apiClient.listAgents(); + const agent = agents.find((item) => item.name === "mock-agent") ?? agents[0]; + if (!agent) throw new Error("The E2E fixture must provide a mock agent"); + + await testPage.goto(`/settings/agents/${agent.name}/profiles/${profile.id}`); + const modelSelector = testPage.getByRole("button", { + name: "Profile start model settings", + }); + await expect(modelSelector).toBeVisible({ timeout: 15_000 }); + await expect(modelSelector).toContainText(UNADVERTISED_MODEL); + await prCapture.screenshot("desktop-settings-start-model-unavailable", { + caption: + "Desktop profile settings preserve the exact saved model instead of silently substituting a host-advertised model.", + fullPage: true, + }); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await testPage.reload({ waitUntil: "networkidle" }); + await kanban.createTaskButton.first().click(); + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + await dialog.getByTestId("agent-profile-selector").click(); + const option = testPage + .getByRole("listbox") + .getByRole("option", { name: profile.name, exact: false }); + await expect(option).toBeVisible(); + const warning = option.getByTestId("agent-profile-model-probe-warning"); + await expect(warning).toBeVisible(); + await warning.hover(); + const warningText = `The host probe did not advertise ${UNADVERTISED_MODEL}. The selected executor will decide the model at launch.`; + const warningTooltip = testPage + .locator('[data-slot="tooltip-content"]:not([data-state="closed"])') + .filter({ hasText: warningText }); + await expect(warningTooltip).toBeVisible(); + await expect(warningTooltip).toHaveCSS("opacity", "1"); + expect(await warningTooltip.evaluate((element) => getComputedStyle(element).zIndex)).toBe( + "80", + ); + await prCapture.screenshot("desktop-task-create-host-probe-warning", { + caption: + "Desktop task creation keeps the exact-profile option selectable and scopes the host-probe warning to an advisory tooltip.", + }); + } finally { + await apiClient.deleteAgentProfile(profile.id, true).catch(() => undefined); + } + }); +}); diff --git a/apps/web/e2e/tests/settings/profile-model-selection-helpers.ts b/apps/web/e2e/tests/settings/profile-model-selection-helpers.ts index 07eff9a910..9aca5248c1 100644 --- a/apps/web/e2e/tests/settings/profile-model-selection-helpers.ts +++ b/apps/web/e2e/tests/settings/profile-model-selection-helpers.ts @@ -98,6 +98,7 @@ async function assertRequestedModel( const saved = await apiClient.getAgentProfile(profile.id); expect(saved.model).toBe(profile.model); expect(saved.config_options).toEqual(profile.config_options); - expect(saved.fallback_model).toBe(profile.fallback_model); - expect(saved.auto_fallback).toBe(profile.auto_fallback); + expect(saved.fallbackModel).toBe(profile.fallbackModel); + expect(saved.autoFallback).toBe(profile.autoFallback); + expect(saved.requireExactModel).toBe(profile.requireExactModel); } diff --git a/apps/web/hooks/domains/integrations/use-remote-repositories.test.tsx b/apps/web/hooks/domains/integrations/use-remote-repositories.test.tsx index d28a3ee4cf..d102080982 100644 --- a/apps/web/hooks/domains/integrations/use-remote-repositories.test.tsx +++ b/apps/web/hooks/domains/integrations/use-remote-repositories.test.tsx @@ -448,6 +448,7 @@ describe("useRemoteRepositories provider refreshes", () => { expect(mocks.fetchAccessibleRepos).toHaveBeenCalledTimes(2); expect(result.current.availableProviders).toEqual(["github"]); }); + await waitFor(() => expect(result.current.loading).toBe(false)); expect(mocks.listUserProjects).toHaveBeenCalledTimes(1); expect(mocks.listAzureDevOpsProjects).toHaveBeenCalledTimes(1); }); diff --git a/apps/web/lib/api/domains/agent-profile-normalize.test.ts b/apps/web/lib/api/domains/agent-profile-normalize.test.ts index dc1700c8e8..f608e9f6ad 100644 --- a/apps/web/lib/api/domains/agent-profile-normalize.test.ts +++ b/apps/web/lib/api/domains/agent-profile-normalize.test.ts @@ -34,6 +34,7 @@ const expectedCamelCaseProfile = { model: "claude-sonnet-4-5", fallbackModel: "", autoFallback: false, + requireExactModel: false, mode: "acp", allowIndexing: true, autoApprove: false, @@ -127,15 +128,18 @@ describe("normalizeAgentProfile", () => { name: "default", fallback_model: "gpt-5", auto_fallback: true, + require_exact_model: true, }); expect(result.fallbackModel).toBe("gpt-5"); expect(result.autoFallback).toBe(true); + expect(result.requireExactModel).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); + expect(result.requireExactModel).toBe(false); }); it("normalizes a dynamic profile document and preserves candidate order", () => { @@ -249,18 +253,20 @@ describe("toAgentProfilePayload", () => { expect(payload).toEqual({ id: SAMPLE_ID, name: "default", command_prefix: SAMPLE_PREFIX }); }); - it("maps fallbackModel/autoFallback to fallback_model/auto_fallback", () => { + it("maps fallbackModel/autoFallback/requireExactModel to snake_case", () => { const payload = toAgentProfilePayload({ id: toAgentProfileId(SAMPLE_ID), name: "default", fallbackModel: "gpt-5", autoFallback: true, + requireExactModel: true, }); expect(payload).toEqual({ id: SAMPLE_ID, name: "default", fallback_model: "gpt-5", auto_fallback: true, + require_exact_model: true, }); }); diff --git a/apps/web/lib/api/domains/agent-profile-normalize.ts b/apps/web/lib/api/domains/agent-profile-normalize.ts index 9d86d0f7d5..a9bb3fc415 100644 --- a/apps/web/lib/api/domains/agent-profile-normalize.ts +++ b/apps/web/lib/api/domains/agent-profile-normalize.ts @@ -224,6 +224,7 @@ export function normalizeAgentProfile(raw: unknown): AgentProfile { model: pickString(profile, "model", "model"), fallbackModel: pickString(profile, "fallbackModel", "fallback_model"), autoFallback: pickBool(profile, "autoFallback", "auto_fallback"), + requireExactModel: pickBool(profile, "requireExactModel", "require_exact_model"), mode: (profile.mode as string | undefined) ?? undefined, configOptions: pickConfigOptions(profile), allowIndexing: pickBool(profile, "allowIndexing", "allow_indexing"), @@ -271,6 +272,7 @@ export function toAgentProfilePayload( setPayloadField(payload, "model", profile.model); setPayloadField(payload, "fallback_model", profile.fallbackModel); setPayloadField(payload, "auto_fallback", profile.autoFallback); + setPayloadField(payload, "require_exact_model", profile.requireExactModel); setPayloadField(payload, "mode", profile.mode); setPayloadField(payload, "config_options", profile.configOptions); setPayloadField(payload, "allow_indexing", profile.allowIndexing); diff --git a/apps/web/lib/model-variation.test.ts b/apps/web/lib/model-variation.test.ts deleted file mode 100644 index b9aaa1655e..0000000000 --- a/apps/web/lib/model-variation.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { findUniqueModelVariation } from "./model-variation"; - -describe("findUniqueModelVariation", () => { - it.each([ - { - name: "finds one bracketed variation", - requested: "opus", - advertised: ["opus[1m]"], - expected: "opus[1m]", - }, - { - name: "deduplicates the same variation", - requested: "opus", - advertised: ["opus[1m]", "opus[1m]"], - expected: "opus[1m]", - }, - { - name: "keeps opaque variation text", - requested: "opus", - advertised: ["opus[1m, fast]"], - expected: "opus[1m, fast]", - }, - ])("$name", ({ requested, advertised, expected }) => { - expect(findUniqueModelVariation(requested, advertised)).toBe(expected); - }); - - it.each([ - { - name: "returns no candidate for an empty catalog", - requested: "opus", - advertised: [], - }, - { - name: "returns no candidate for ambiguous variations", - requested: "opus", - advertised: ["opus[1m]", "opus[270k]"], - }, - { - name: "returns no candidate for case differences", - requested: "opus", - advertised: ["Opus[1m]"], - }, - { - name: "returns no candidate for a bracketed request", - requested: "opus[1m]", - advertised: ["opus[2m]"], - }, - { - name: "returns no candidate for malformed IDs", - requested: "opus", - advertised: ["opus[]", "opus[1m", "opus[1m][fast]", "opus-pro[1m]"], - }, - ])("$name", ({ requested, advertised }) => { - expect(findUniqueModelVariation(requested, advertised)).toBeNull(); - }); -}); diff --git a/apps/web/lib/model-variation.ts b/apps/web/lib/model-variation.ts deleted file mode 100644 index e9ce9066c4..0000000000 --- a/apps/web/lib/model-variation.ts +++ /dev/null @@ -1,17 +0,0 @@ -function isModelVariation(requested: string, advertised: string): boolean { - const prefix = `${requested}[`; - if (!advertised.startsWith(prefix) || !advertised.endsWith("]")) return false; - - const variant = advertised.slice(prefix.length, -1); - return variant.length > 0 && !variant.includes("[") && !variant.includes("]"); -} - -export function findUniqueModelVariation( - requested: string, - advertised: readonly string[], -): string | null { - if (!requested || requested.includes("[") || requested.includes("]")) return null; - - const candidates = new Set(advertised.filter((model) => isModelVariation(requested, model))); - return candidates.size === 1 ? ([...candidates][0] ?? null) : null; -} diff --git a/apps/web/lib/settings-discovery/contract.generated.json b/apps/web/lib/settings-discovery/contract.generated.json index 9123f3a279..a6178077d8 100644 --- a/apps/web/lib/settings-discovery/contract.generated.json +++ b/apps/web/lib/settings-discovery/contract.generated.json @@ -188,6 +188,21 @@ "change_timing": "future_sessions", "settings_href": "/settings/agents" }, + { + "key": "agent_profile.require_exact_model", + "field_path": "require_exact_model", + "label": "require_exact_model", + "description": "Require the configured model to be advertised and applied.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, { "key": "agent_profile.mode", "field_path": "mode", @@ -6188,7 +6203,8 @@ "fallback_model", "mode", "model", - "name" + "name", + "require_exact_model" ], "user_settings": [ "agent_generated_task_titles", diff --git a/apps/web/lib/settings-discovery/coverage-inventory.ts b/apps/web/lib/settings-discovery/coverage-inventory.ts index c70c49b0a3..7842f18cef 100644 --- a/apps/web/lib/settings-discovery/coverage-inventory.ts +++ b/apps/web/lib/settings-discovery/coverage-inventory.ts @@ -67,6 +67,14 @@ const source = (...paths: string[]) => paths; const PROFILE_TASK = "task-01-profile-contract"; const PROFILE_CRUD_SOURCE = "apps/backend/internal/agent/settings/controller/profile_crud.go"; const PROFILE_DTO_SOURCE = "apps/backend/internal/agent/settings/dto/dto.go"; +const PROFILE_MODEL_FIELDS_SOURCE = source( + "apps/web/components/settings/profile-model-fields.tsx", + PROFILE_CRUD_SOURCE, +); +const PROFILE_MODEL_CONFIG_SOURCE = source( + "apps/web/components/settings/profile-model-config.ts", + PROFILE_CRUD_SOURCE, +); const USER_TASK = "task-05-user-preferences"; const USER_DTO_SOURCE = "apps/backend/internal/user/dto/dto.go"; const WORKFLOW_TASK = "task-06-workflow-settings"; @@ -90,6 +98,9 @@ const supported = ( owner, }); +const profileField = (id: string, fieldPath: string, sourcePaths: string[]) => + supported(id, "agent_profile", fieldPath, sourcePaths, PROFILE_TASK); + type SettingsExceptionInput = { id: string; domain: string; @@ -144,26 +155,13 @@ const UI_SETTINGS_COVERAGE_INVENTORY: SettingsCoverageEvidence[] = [ ), PROFILE_TASK, ), - supported( - "agent-profile-model", - "agent_profile", - "model", - source("apps/web/components/settings/profile-model-fields.tsx", PROFILE_CRUD_SOURCE), - PROFILE_TASK, - ), - supported( - "agent-profile-fallback-model", - "agent_profile", - "fallback_model", - source("apps/web/components/settings/profile-model-config.ts", PROFILE_CRUD_SOURCE), - PROFILE_TASK, - ), - supported( - "agent-profile-auto-fallback", - "agent_profile", - "auto_fallback", - source("apps/web/components/settings/profile-model-config.ts", PROFILE_CRUD_SOURCE), - PROFILE_TASK, + profileField("agent-profile-model", "model", PROFILE_MODEL_FIELDS_SOURCE), + profileField("agent-profile-fallback-model", "fallback_model", PROFILE_MODEL_CONFIG_SOURCE), + profileField("agent-profile-auto-fallback", "auto_fallback", PROFILE_MODEL_CONFIG_SOURCE), + profileField( + "agent-profile-require-exact-model", + "require_exact_model", + PROFILE_MODEL_FIELDS_SOURCE, ), supported( "agent-profile-mode", diff --git a/apps/web/lib/settings-discovery/profile-contract.generated.json b/apps/web/lib/settings-discovery/profile-contract.generated.json index c24c945e37..05273de04a 100644 --- a/apps/web/lib/settings-discovery/profile-contract.generated.json +++ b/apps/web/lib/settings-discovery/profile-contract.generated.json @@ -75,6 +75,21 @@ "change_timing": "future_sessions", "settings_href": "/settings/agents" }, + { + "key": "agent_profile.require_exact_model", + "field_path": "require_exact_model", + "label": "require_exact_model", + "description": "Require the configured model to be advertised and applied.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, { "key": "agent_profile.mode", "field_path": "mode", @@ -317,7 +332,8 @@ "fallback_model", "mode", "model", - "name" + "name", + "require_exact_model" ], "user_settings": [ "agent_generated_task_titles", diff --git a/apps/web/lib/settings-discovery/profile-contract.ts b/apps/web/lib/settings-discovery/profile-contract.ts index 4ff82da79f..c998988484 100644 --- a/apps/web/lib/settings-discovery/profile-contract.ts +++ b/apps/web/lib/settings-discovery/profile-contract.ts @@ -21,6 +21,7 @@ export const PROFILE_EDITABLE_FIELD_PATHS = [ "model", "fallback_model", "auto_fallback", + "require_exact_model", "mode", "config_options", "allow_indexing", diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts index 9e0181f4dd..7cb1b10299 100644 --- a/apps/web/lib/state/slices/settings/types.ts +++ b/apps/web/lib/state/slices/settings/types.ts @@ -74,6 +74,8 @@ export type AgentProfileOption = { fallback_model?: string; /** Legacy automatic-fallback opt-in. */ auto_fallback?: boolean; + /** Explicit exact-model policy opt-in. */ + require_exact_model?: boolean; workspace_id?: string; /** Persisted profile revision (RFC3339 updated_at), used to prefer newer * WS-delivered options over a stale in-flight response. */ @@ -283,6 +285,7 @@ export function toAgentProfileOption( model?: string; fallbackModel?: string; autoFallback?: boolean; + requireExactModel?: boolean; enabled?: boolean; }, ): AgentProfileOption { @@ -297,6 +300,7 @@ export function toAgentProfileOption( model: profile.model ?? undefined, fallback_model: profile.fallbackModel ?? undefined, auto_fallback: profile.autoFallback ?? undefined, + require_exact_model: profile.requireExactModel ?? undefined, workspace_id: profile.workspaceId, updatedAt: profile.updatedAt, enabled: profile.enabled ?? true, diff --git a/apps/web/lib/types/agent-profile.ts b/apps/web/lib/types/agent-profile.ts index 2abe59eddb..07e70a1242 100644 --- a/apps/web/lib/types/agent-profile.ts +++ b/apps/web/lib/types/agent-profile.ts @@ -120,6 +120,8 @@ export type AgentProfile = { * on, the fallbackModel field is hidden/ignored. */ autoFallback?: boolean; + /** Require the configured model to be advertised and applied before prompting. */ + requireExactModel?: boolean; /** Optional ACP session mode applied via `session/set_mode`. */ mode?: string; /** Dynamic ACP session config options applied via `session/set_config_option`. */ @@ -211,6 +213,7 @@ export type AgentProfilePayload = { model: string; fallback_model?: string; auto_fallback?: boolean; + require_exact_model?: boolean; mode?: string; config_options?: Record; allow_indexing: boolean; diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index a430427113..5a3178a93e 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -367,6 +367,9 @@ export type AgentProfilePayload = { name: string; agent_display_name: string; model: string; + fallback_model?: string; + auto_fallback?: boolean; + require_exact_model?: boolean; auto_approve: boolean; dangerously_skip_permissions: boolean; allow_indexing: boolean; diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index a56c49c9e9..2e34b5bd55 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -55,6 +55,11 @@ "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", + "requireExactModel": "Require exact model", + "requireExactModelHelper": "Only start the session when this exact model is advertised and applied. Saved fallback settings stay available when you turn this off.", + "requireExactModelInfoLabel": "Explain exact model requirement", + "requireExactModelHelpTitle": "Require exact model", + "requireExactModelHelp": "The session fails if the configured model is not advertised or cannot be applied. Automatic and explicit fallback settings stay saved but are inactive.", "autoStartLanguageServer": "Auto-start {{language}} language server", "builtIn": "Built-in", "builtInBrowserPanel": "Built-in browser panel", @@ -334,6 +339,10 @@ "fallbackSettingsSummaryAutomatic": "Automatic fallback enabled", "fallbackSettingsSummaryExplicit": "Explicit fallback: {{model}}", "fallbackSettingsSummaryStrict": "No fallback configured", + "fallbackSettingsSummaryExact": "Exact model required", + "fallbackSettingsSummaryExecutorDefault": "Executor default allowed", + "requireExactModelValidationTitle": "Model required", + "requireExactModelValidation": "Select a concrete model before enabling exact model requirement.", "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)", "globalSecrets": "Global Secrets", @@ -521,6 +530,7 @@ "previewSound": "Preview sound", "profileForTasksCreatedByAgents": "Profile for Tasks Created by Agents", "programming": "Programming", + "profileStartModelNotAdvertisedOnHost": "The host probe did not advertise {{model}}. The selected executor will decide the model at launch.", "modelVariationAdvisory": "The host catalog has one possible variation of {{model}}: {{variation}}. The saved model remains unchanged until launch.", "promptAdd": "Add prompt", "promptEditorAriaLabel": "Prompt editor", diff --git a/apps/web/src/locales/pseudo/settings.json b/apps/web/src/locales/pseudo/settings.json index c83fa85335..a22e14ca07 100644 --- a/apps/web/src/locales/pseudo/settings.json +++ b/apps/web/src/locales/pseudo/settings.json @@ -55,6 +55,11 @@ "autoFallbackHelp": "Ĩƒ ţĥē śţàŕţ ḿōďēĺ ĩś ũńàvàĩĺàƀĺē, Ķàńďēv ĺēţś ţĥē àĝēńţ ćĥōōśē ƒŕōḿ ĩţś ŕēḿàĩńĩńĝ àďvēŕţĩśēď ḿōďēĺś. Ţĥē śēĺēćţēď ḿōďēĺ ćàń ćĥàńĝē àś àvàĩĺàƀĩĺĩţŷ ćĥàńĝēś.", "autoFallbackHelpTitle": "Àũţōḿàţĩć ƒàĺĺƀàćķ", "autoFallbackInfoLabel": "Ēxƥĺàĩń àũţōḿàţĩć ƒàĺĺƀàćķ", + "requireExactModel": "Ŕēqũĩŕē ēxàćţ ḿōďēĺ", + "requireExactModelHelper": "Ōńĺŷ śţàŕţ ţĥē śēśśĩōń ŵĥēń ţĥĩś ēxàćţ ḿōďēĺ ĩś àďvēŕţĩśēď àńď àƥƥĺĩēď. Śàvēď ƒàĺĺƀàćķ śēţţĩńĝś śţàŷ àvàĩĺàƀĺē ŵĥēń ŷōũ ţũŕń ţĥĩś ōƒƒ.", + "requireExactModelInfoLabel": "Ēxƥĺàĩń ēxàćţ ḿōďēĺ ŕēqũĩŕēḿēńţ", + "requireExactModelHelpTitle": "Ŕēqũĩŕē ēxàćţ ḿōďēĺ", + "requireExactModelHelp": "Ţĥē śēśśĩōń ƒàĩĺś ĩƒ ţĥē ćōńƒĩĝũŕēď ḿōďēĺ ĩś ńōţ àďvēŕţĩśēď ōŕ ćàńńōţ ƀē àƥƥĺĩēď. Àũţōḿàţĩć àńď ēxƥĺĩćĩţ ƒàĺĺƀàćķ śēţţĩńĝś śţàŷ śàvēď ƀũţ ĩńàćţĩvē.", "autoStartLanguageServer": "Àũţō-śţàŕţ {{language}} ĺàńĝũàĝē śēŕvēŕ", "builtIn": "Ɓũĩĺţ-ĩń", "builtInBrowserPanel": "Ɓũĩĺţ-ĩń ƀŕōŵśēŕ ƥàńēĺ", @@ -334,6 +339,10 @@ "fallbackSettingsSummaryAutomatic": "Àũţōḿàţĩć ƒàĺĺƀàćķ ēńàƀĺēď", "fallbackSettingsSummaryExplicit": "Ēxƥĺĩćĩţ ƒàĺĺƀàćķ: {{model}}", "fallbackSettingsSummaryStrict": "Ńō ƒàĺĺƀàćķ ćōńƒĩĝũŕēď", + "fallbackSettingsSummaryExact": "Ēxàćţ ḿōďēĺ ŕēqũĩŕēď", + "fallbackSettingsSummaryExecutorDefault": "Ēxēćũţōŕ ďēƒàũĺţ àĺĺōŵēď", + "requireExactModelValidationTitle": "Ḿōďēĺ ŕēqũĩŕēď", + "requireExactModelValidation": "Śēĺēćţ à ćōńćŕēţē ḿōďēĺ ƀēƒōŕē ēńàƀĺĩńĝ ēxàćţ ḿōďēĺ ŕēqũĩŕēḿēńţ.", "fallbackNoteLive": "Ţĥĩś śēśśĩōń śţàŕţēď ōń à ƒàĺĺƀàćķ ḿōďēĺ ƀēćàũśē ţĥē ćōńƒĩĝũŕēď ḿōďēĺ ŵàś ũńàvàĩĺàƀĺē. Ţĥē (ƒàĺĺƀàćķ) ḿàŕķēŕ ŕēƒĺēćţś ţĥē ĺĩvē śēśśĩōń àńď ĩś ńōţ śĥōŵń àƒţēŕ ŕēĺōàď.", "modelFallbackSuffix": "(ƒàĺĺƀàćķ)", "globalSecrets": "Ĝĺōƀàĺ Śēćŕēţś", @@ -521,6 +530,7 @@ "previewSound": "Ƥŕēvĩēŵ śōũńď", "profileForTasksCreatedByAgents": "Ƥŕōƒĩĺē ƒōŕ Ţàśķś Ćŕēàţēď ƀŷ Àĝēńţś", "programming": "Ƥŕōĝŕàḿḿĩńĝ", + "profileStartModelNotAdvertisedOnHost": "Ţĥē ĥōśţ ƥŕōƀē ďĩď ńōţ àďvēŕţĩśē {{model}}. Ţĥē śēĺēćţēď ēxēćũţōŕ ŵĩĺĺ ďēćĩďē ţĥē ḿōďēĺ àţ ĺàũńćĥ.", "modelVariationAdvisory": "Ţĥē ĥōśţ ćàţàĺōĝ ĥàś ōńē ƥōśśĩƀĺē vàŕĩàţĩōń ōƒ {{model}}: {{variation}}. Ţĥē śàvēď ḿōďēĺ ŕēḿàĩńś ũńćĥàńĝēď ũńţĩĺ ĺàũńćĥ.", "promptAdd": "Àďď ƥŕōḿƥţ", "promptEditorAriaLabel": "Ƥŕōḿƥţ ēďĩţōŕ", diff --git a/apps/web/src/locales/pt-pt/settings.json b/apps/web/src/locales/pt-pt/settings.json index 75d74dc251..daf3f81dea 100644 --- a/apps/web/src/locales/pt-pt/settings.json +++ b/apps/web/src/locales/pt-pt/settings.json @@ -51,6 +51,11 @@ "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", + "requireExactModel": "Exigir o modelo exato", + "requireExactModelHelper": "Inicie a sessão apenas quando este modelo exato for anunciado e aplicado. As definições de fallback guardadas ficam disponíveis quando desativar esta opção.", + "requireExactModelInfoLabel": "Explicar o requisito do modelo exato", + "requireExactModelHelpTitle": "Exigir o modelo exato", + "requireExactModelHelp": "A sessão falha se o modelo configurado não for anunciado ou não puder ser aplicado. As definições de fallback ficam guardadas, mas inativas.", "autoInstallIfNotFound": "Instalar automaticamente se não for encontrado", "autoStart": "Arranque automático", "autoStartLanguageServer": "Iniciar automaticamente o servidor de linguagem de {{language}}", @@ -324,6 +329,10 @@ "fallbackSettingsSummaryAutomatic": "Fallback automático ativado", "fallbackSettingsSummaryExplicit": "Fallback explícito: {{model}}", "fallbackSettingsSummaryStrict": "Nenhum fallback configurado", + "fallbackSettingsSummaryExact": "Modelo exato exigido", + "fallbackSettingsSummaryExecutorDefault": "Permitir o modelo do executor", + "requireExactModelValidationTitle": "Modelo obrigatório", + "requireExactModelValidation": "Selecione um modelo concreto antes de ativar o requisito do modelo exato.", "failedToConnectToBackend": "Não foi possível ligar ao backend", "failedToSaveLayoutProfiles": "Não foi possível guardar os perfis de esquema", "fileEditor": "Editor de ficheiros", @@ -771,6 +780,7 @@ "workspacesAndAccess": "Workspaces e acesso", "modelVariationAdvisory": "O catálogo do anfitrião tem uma única variação possível de {{model}}: {{variation}}. O modelo guardado permanece inalterado até ao arranque.", "navUnits": "Unidades da organização", + "profileStartModelNotAdvertisedOnHost": "A sondagem do anfitrião não anunciou {{model}}. O executor selecionado decidirá o modelo no arranque.", "unitsTitle": "Unidades da organização", "unitsDescription": "Uma unidade contém equipas, áreas de trabalho e pessoas. Pertencer a uma unidade dá acesso a todas as áreas de trabalho abaixo dela, incluindo as criadas depois.", "unitsEmpty": "Ainda não há unidades.", diff --git a/apps/web/src/locales/zh-cn/settings.json b/apps/web/src/locales/zh-cn/settings.json index 97bad6176d..4cf019876d 100644 --- a/apps/web/src/locales/zh-cn/settings.json +++ b/apps/web/src/locales/zh-cn/settings.json @@ -55,6 +55,11 @@ "autoFallbackHelp": "如果启动模型不可用,Kandev 会让智能体从其余已公布的模型中选择。可用性变化时,所选模型也可能变化。", "autoFallbackHelpTitle": "自动回退", "autoFallbackInfoLabel": "说明自动回退", + "requireExactModel": "要求精确模型", + "requireExactModelHelper": "仅当此精确模型已公布并成功应用时才启动会话。关闭此选项后,已保存的回退设置仍可使用。", + "requireExactModelInfoLabel": "说明精确模型要求", + "requireExactModelHelpTitle": "要求精确模型", + "requireExactModelHelp": "如果配置的模型未公布或无法应用,会话将失败。自动和显式回退设置会保留,但不会生效。", "autoStartLanguageServer": "自动启动 {{language}} 语言服务器", "builtIn": "内置", "builtInBrowserPanel": "内置浏览器面板", @@ -337,6 +342,10 @@ "fallbackSettingsSummaryAutomatic": "已启用自动回退", "fallbackSettingsSummaryExplicit": "显式回退:{{model}}", "fallbackSettingsSummaryStrict": "未配置回退", + "fallbackSettingsSummaryExact": "已要求精确模型", + "fallbackSettingsSummaryExecutorDefault": "允许使用执行器默认模型", + "requireExactModelValidationTitle": "需要模型", + "requireExactModelValidation": "启用精确模型要求前,请选择一个具体模型。", "fallbackNoteLive": "此会话因配置的模型不可用而以备用模型启动。(备用)标记仅反映当前会话,重新加载后不再显示。", "modelFallbackSuffix": "(备用)", "globalSecrets": "全局密钥", @@ -771,6 +780,7 @@ "keyValueValuePlaceholder": "值", "modelVariationAdvisory": "主机目录中有一个可能的 {{model}} 变体:{{variation}}。保存的模型在启动前保持不变。", "navUnits": "组织单元", + "profileStartModelNotAdvertisedOnHost": "主机探测未公布 {{model}}。所选执行器将在启动时决定使用的模型。", "unitsTitle": "组织单元", "unitsDescription": "单元包含团队、工作区和成员。加入某个单元即可访问其下的所有工作区,包括之后创建的。", "unitsEmpty": "尚无单元。", diff --git a/apps/web/src/locales/zh-hk/account.json b/apps/web/src/locales/zh-hk/account.json index e8ca3578c5..37b4ed283a 100644 --- a/apps/web/src/locales/zh-hk/account.json +++ b/apps/web/src/locales/zh-hk/account.json @@ -7,7 +7,7 @@ "cancel": "取消", "changePassword": "修改密碼", "couldNotChangePassword": "無法修改密碼。", - "couldNotUpdateHostnameResolution": "無法更新主機名稱解析。", + "couldNotUpdateHostnameResolution": "無法更新主機名解析。", "couldNotCreateToken": "無法建立權杖。", "createApiToken": "建立 API 權杖", "createApiTokenDescription": "授予與你的帳戶相同的存取權限,供腳本或 CLI 使用。請為其命名,便於日後識別。", @@ -22,7 +22,7 @@ "failedToSaveLastSeenDisplay": "無法儲存上次活動的顯示方式。", "failedToRevokeSession": "無法吊銷工作階段。", "ipAddress": "IP", - "hostname": "主機名稱", + "hostname": "主機名", "hostnameNotAvailable": "不適用", "lastSeen": "上次活動", "lastSeenDisplay": "上次活動顯示", @@ -42,8 +42,8 @@ "saving": "正在儲存…", "securityPageDescription": "修改密碼並查看已登入你帳戶的設備。", "securityPageTitle": "個人數據與密碼", - "resolveDeviceHostnames": "解析裝置主機名稱", - "resolveDeviceHostnamesDescription": "使用系統 DNS 解析器解析已登入裝置的 IP 位址。", + "resolveDeviceHostnames": "解析設備主機名", + "resolveDeviceHostnamesDescription": "使用系統 DNS 解析器解析已登入設備的 IP 地址。", "signOut": "登出", "thisDevice": "此設備", "tokenCreated": "權杖已建立", diff --git a/apps/web/src/locales/zh-hk/agents.json b/apps/web/src/locales/zh-hk/agents.json index 8b46be0c60..db9d733976 100644 --- a/apps/web/src/locales/zh-hk/agents.json +++ b/apps/web/src/locales/zh-hk/agents.json @@ -199,7 +199,7 @@ "runtimeUpdateStatusUnknown": "無法擷取 {{name}} 的最新版本。更新控制項仍可使用。", "runtimeVersionMarkerGroup": "({{markers}})", "runtimeVersionOption": "{{version}} {{markers}}", - "selectRuntimeVersion": "要啟用的版本", + "selectRuntimeVersion": "要激活的版本", "updateAvailableWithVersions": "將 {{name}} 從 {{current}} 更新到 {{latest}}", "updateRuntime": "更新執行時", "rollBackRuntime": "回滾執行時", @@ -252,53 +252,53 @@ "saveProfile": "儲存設定", "savingProfile": "正在儲存…", "dynamicAgents": "動態代理", - "dynamicAgentsDescription": "透過有序的具體代理設定檔列表路由一個邏輯設定檔。", - "dynamicProfileDisabled": "動態代理程式路由已停用。", - "dynamicProfileDescription": "選擇按順序執行的具體設定檔,以便在失敗時切換。", + "dynamicAgentsDescription": "通過有序的具體代理設定列表路由一個邏輯設定。", + "dynamicProfileDisabled": "動態代理路由已停用。", + "dynamicProfileDescription": "選擇按順序執行的具體設定,以便在失敗時切換。", "dynamicProfileSettings": "動態設定檔設定", - "dynamicCandidates": "候選設定檔", - "dynamicCandidatesDescription": "候選設定檔按順序執行。只有在已設定的錯誤動作允許時,才會使用下一個候選設定檔。", - "dynamicRoutingPolicyTitle": "Kandev 何時切換候選設定檔", - "dynamicRoutingPolicySwitchable": "切換可以由已分類的供應商故障觸發,包括驗證或訂閱問題、缺少憑證或設定、配額或速率限制、模型不可用、容量不足、網絡問題,以及供應商不可用或過載。", - "dynamicRoutingPolicyExcluded": "對於工作、儲存庫、權限、工具或執行階段錯誤、傳輸中斷,或執行中途結果不明確的情況,Kandev 不會切換。這些情況會停止並等待手動恢復,以避免重複工作。", - "dynamicRoutingPolicyRecovery": "切換後,發生故障的供應商會暫停 1 分鐘,或者暫停至已提供的重設時間。之後,Kandev 會先執行一次獨佔健康探測,再次使用該供應商。", - "dynamicPolicyWaitExplanation": "重設等待會在每個候選設定檔和錯誤類別中使用一次可信的未來重設時間,並且只在時間不超過最大等待時間時使用。等待結束後,Kandev 會先進行一次新嘗試,再套用重試策略。", + "dynamicCandidates": "候選設定", + "dynamicCandidatesDescription": "候選設定按順序執行。只有在設定的錯誤操作允許時,才會使用下一個候選設定。", + "dynamicRoutingPolicyTitle": "Kandev 何時切換候選設定", + "dynamicRoutingPolicySwitchable": "切換可以由已分類的提供者故障觸發,包括身份驗證或訂閲問題、缺少認證資料或設定、配額或速率限制、模型不可用、容量不足、網絡問題,以及提供者不可用或過載。", + "dynamicRoutingPolicyExcluded": "對於任務、儲存庫、權限、工具或執行時錯誤、傳輸中斷,或執行中途結果不明確的情況,Kandev 不會切換。這些情況會停止並等待手動恢復,以避免重複工作。", + "dynamicRoutingPolicyRecovery": "切換後,發生故障的提供者會暫停 1 分鐘,或者暫停到已提供的重置時間。之後,Kandev 會先執行一次獨佔健康探測,再次使用該提供者。", + "dynamicPolicyWaitExplanation": "重置等待會在每個候選設定和錯誤類別中使用一次可信的未來重置時間,並且只在時間不超過最大等待時間時使用。等待結束後,Kandev 會先進行一次新嘗試,再應用重試策略。", "dynamicTransientErrors": "暫時性錯誤", - "dynamicTransientErrorsDescription": "臨時狀況,例如容量不足、速率限制、網絡故障、逾時、供應商中斷和傳輸中斷。", + "dynamicTransientErrorsDescription": "臨時狀況,例如容量不足、速率限制、網絡故障、超時、提供者中斷和傳輸中斷。", "dynamicHardErrors": "硬錯誤", - "dynamicHardErrorsDescription": "帳戶或設定狀況,例如訂閱限制、額度耗盡、驗證、缺少憑證、模型不可用或供應商設定問題。", - "dynamicPolicyRetry": "重試此候選設定檔", - "dynamicPolicyWaitForReset": "等待供應商重設", + "dynamicHardErrorsDescription": "帳戶或設定狀況,例如訂閲限制、額度耗盡、身份驗證、缺少認證資料、模型不可用或提供者設定問題。", + "dynamicPolicyRetry": "重試此候選設定", + "dynamicPolicyWaitForReset": "等待提供者重置", "dynamicPolicyAfterExhausted": "恢復操作耗盡後", "dynamicPolicyMaxRetries": "最大重試次數", "dynamicPolicyInitialInterval": "初始間隔(秒)", - "dynamicPolicyMaxWait": "最大重設等待時間(秒)", - "dynamicPolicyValidation": "儲存前請修正標示的策略值。", - "dynamicPolicyMaxRetriesValidation": "請輸入 1 至 10 次重試。", - "dynamicPolicyInitialIntervalValidation": "請輸入 1 秒至 1 小時之間的初始間隔。", - "dynamicPolicyMaxWaitValidation": "請輸入 1 秒至 7 日之間的重設等待時間。", + "dynamicPolicyMaxWait": "最大重置等待時間(秒)", + "dynamicPolicyValidation": "儲存前請修正突出顯示的策略值。", + "dynamicPolicyMaxRetriesValidation": "請輸入 1 到 10 次重試。", + "dynamicPolicyInitialIntervalValidation": "請輸入 1 秒到 1 小時之間的初始間隔。", + "dynamicPolicyMaxWaitValidation": "請輸入 1 秒到 7 天之間的重置等待時間。", "dynamicPolicySchedule": "最多重試 {{retries}} 次。第一次重試等待 {{first}} 秒,之後間隔加倍。", - "dynamicPolicyNoRetry": "不重試同一候選設定檔。Kandev 會立即套用所選結果。", - "dynamicPolicyWaitDescription": "當可信的未來重設或重試時間不超過最大等待時間時使用它。", - "dynamicPolicySkip": "略過候選設定檔", + "dynamicPolicyNoRetry": "不重試同一候選設定。Kandev 會立即應用所選結果。", + "dynamicPolicyWaitDescription": "當可信的未來重置或重試時間不超過最大等待時間時使用它。", + "dynamicPolicySkip": "跳過候選設定", "dynamicPolicyStop": "停止並手動恢復", - "dynamicPolicyRetryHelp": "在套用所選結果前,以指數退避方式重試同一候選設定檔。", - "dynamicPolicyWaitHelp": "等待可信的未來供應商重設或重試時間。等待時間有限制,並且每個候選設定檔和類別只使用一次。", - "dynamicPolicyOutcomeHelp": "控制重設等待和同一候選設定檔的所有重試都耗盡後 Kandev 的操作。", + "dynamicPolicyRetryHelp": "在應用所選結果前,以指數退避方式重試同一候選設定。", + "dynamicPolicyWaitHelp": "等待可信的未來提供者重置或重試時間。等待時間有限制,並且每個候選設定和類別只使用一次。", + "dynamicPolicyOutcomeHelp": "控制重置等待和同一候選設定的所有重試都耗盡後 Kandev 的操作。", "dynamicPolicyOptionInfo": "解釋 {{option}} 策略", - "addDynamicCandidate": "新增候選設定檔", - "noDynamicCandidates": "至少新增一個具體設定檔。", - "moveDynamicCandidateUp": "上移候選設定檔", - "moveDynamicCandidateDown": "下移候選設定檔", - "removeDynamicCandidate": "移除候選設定檔", - "dynamicCandidatePickerDescription": "選擇符合動態路由條件的具體設定檔。", - "searchDynamicCandidates": "搜尋代理設定檔...", - "noDynamicCandidatesFound": "找不到符合條件的代理設定檔。", - "dynamicRouteAction": "供應商發生錯誤時", + "addDynamicCandidate": "新增候選設定", + "noDynamicCandidates": "至少新增一個具體設定。", + "moveDynamicCandidateUp": "上移候選設定", + "moveDynamicCandidateDown": "下移候選設定", + "removeDynamicCandidate": "移除候選設定", + "dynamicCandidatePickerDescription": "選擇一個符合動態路由條件的具體設定。", + "searchDynamicCandidates": "搜尋代理設定...", + "noDynamicCandidatesFound": "未找到符合條件的代理設定。", + "dynamicRouteAction": "提供者出錯時", "dynamicRouteActionInfo": "說明{{action}}路由行為", - "dynamicRouteRetryDescription": "供應商發生錯誤後重試相同候選設定檔。適用於可能暫時恢復的故障。", - "dynamicRouteTryNextDescription": "本次執行略過此候選設定檔,繼續使用下一個符合條件的設定檔。", - "dynamicRouteStopDescription": "此候選設定檔失敗後停止路由。工作會等待手動恢復操作。", - "dynamicProfileSaved": "動態設定檔已儲存。", + "dynamicRouteRetryDescription": "提供者出錯後重試同一候選設定。適用於可能暫時恢復的故障。", + "dynamicRouteTryNextDescription": "本次執行跳過此候選設定,繼續使用下一個符合條件的設定。", + "dynamicRouteStopDescription": "此候選設定失敗後停止路由。任務會等待手動恢復操作。", + "dynamicProfileSaved": "動態設定已儲存。", "adminOnly": "只有管理員可以更改代理和代理設定檔。" } diff --git a/apps/web/src/locales/zh-hk/auth.json b/apps/web/src/locales/zh-hk/auth.json index 2112e4e4d1..a9bdaa55da 100644 --- a/apps/web/src/locales/zh-hk/auth.json +++ b/apps/web/src/locales/zh-hk/auth.json @@ -16,7 +16,7 @@ "joinThisKandevDeployment": "加入此 Kandev 部署。", "joining": "正在加入…", "orContinueWith": "或使用以下方式繼續", - "organizationUnavailable": "您的組織目前無法使用。請聯絡您的 Kandev 管理員。", + "organizationUnavailable": "您的組織目前不可用。請聯繫您的 Kandev 管理員。", "password": "密碼", "setUpKandev": "設定 Kandev", "setupFailed": "初始化失敗。請重試。", diff --git a/apps/web/src/locales/zh-hk/automations.json b/apps/web/src/locales/zh-hk/automations.json index b4c9eeb44e..8dcfb4705a 100644 --- a/apps/web/src/locales/zh-hk/automations.json +++ b/apps/web/src/locales/zh-hk/automations.json @@ -32,13 +32,13 @@ "comingSoonSuffix": "(即將推出)", "completeRequiredFields": "請先填寫必填的自動化欄位,再儲存。", "completed": "已完成", - "contextBetweenRunsConcurrencyLock": "此選項一次只支援一個執行中的工作。", - "contextBetweenRunsDescription": "選擇每次執行是重新開始,還是繼續使用相同的對話和檔案。", - "contextBetweenRunsNewTask": "每次執行都開始新工作", - "contextBetweenRunsNewTaskDescription": "每次執行都會使用獨立的對話和檔案。這些工作不會出現在看板或側邊欄。此選項適用於互相獨立的工作和並行執行。", - "contextBetweenRunsNewTaskNormalDescription": "每次執行都會使用獨立的對話和檔案。這些工作會出現在看板和側邊欄。此選項適用於互相獨立的工作和並行執行。", + "contextBetweenRunsConcurrencyLock": "此選項一次只支援一個執行中的任務。", + "contextBetweenRunsDescription": "選擇每次執行是從頭開始,還是繼續使用相同的對話和檔案。", + "contextBetweenRunsNewTask": "每次執行都開始新任務", + "contextBetweenRunsNewTaskDescription": "每次執行都會使用獨立的對話和檔案。這些任務不會出現在看板或側邊欄中。此選項適用於相互獨立的作業和併發執行。", + "contextBetweenRunsNewTaskNormalDescription": "每次執行都會使用獨立的對話和檔案。這些任務會出現在看板和側邊欄中。此選項適用於相互獨立的作業和併發執行。", "contextBetweenRunsReuseThread": "繼續之前的工作階段", - "contextBetweenRunsReuseThreadDescription": "執行會繼續使用相同的對話和檔案,讓代理保留之前的上下文和變更。執行會依次進行。", + "contextBetweenRunsReuseThreadDescription": "執行會繼續使用相同的對話和檔案,因此代理可以保留之前的上下文和更改。執行會依次執行。", "contextBetweenRunsTitle": "執行之間的上下文", "configurationLabel": "設定", "couldNotLoadWorkflows": "無法載入工作流程", @@ -52,7 +52,7 @@ "cronHelp": "標準五欄位 cron 或快捷方式({{shortcuts}})", "delete": "刪除", "deleteAutomationTitle": "刪除自動化?", - "deleteAutomationDescription": "這將永久刪除 {{name}}。此操作無法復原。", + "deleteAutomationDescription": "這將永久刪除 {{name}}。此操作無法撤銷。", "deleteAll": "全部刪除", "deleteAllRuns": "刪除所有執行記錄", "deleteAllRunsDescription": "這將永久刪除此自動化的所有執行記錄;包括目前未載入的記錄;以及關聯的任務。此操作無法撤銷。", @@ -123,9 +123,9 @@ "replyToThisRun": "回覆此執行…", "repositoryLabel": "儲存庫", "repositoryNone": "無;不使用儲存庫", - "repositoryModeTitle": "儲存庫存取權", - "repositoryModeDescription": "新增一個或多個儲存庫與基礎分支組合,或在沒有儲存庫的情況下執行。", - "repositoryModeNoneDescription": "在工作專屬的暫存工作區中執行,不使用儲存庫檔案。", + "repositoryModeTitle": "儲存庫存取", + "repositoryModeDescription": "新增一個或多個儲存庫與基礎分支組合,或在無儲存庫的情況下執行。", + "repositoryModeNoneDescription": "在任務專屬的臨時工作區中執行,不使用儲存庫檔案。", "repositoryPlaceholderAuto": "自動", "required": "必填", "resizeRuns": "調整執行記錄區域大小", @@ -188,14 +188,14 @@ "taskTitleLabel": "任務標題", "taskTitlePlaceholder": "[自動] 自動化名稱", "taskModeTitle": "執行目標", - "taskModeDescription": "選擇每次觸發只保留在自動化記錄中,還是建立普通任務。", - "taskModeAutomationRun": "只在自動化記錄中執行", - "taskModeAutomationRunDescription": "每次執行使用私人任務和對話。這些任務不會出現在看板或側邊欄。", + "taskModeDescription": "選擇每次觸發是隻保留在自動化歷史中,還是建立普通任務。", + "taskModeAutomationRun": "僅在自動化歷史中執行", + "taskModeAutomationRunDescription": "每次執行使用私有任務和對話。這些任務不會出現在看板或側邊欄中。", "taskModeNormalTask": "建立普通任務", - "taskModeNormalTaskDescription": "每次執行都會在選取的工作流程中建立普通任務。任務會出現在看板和側邊欄。", + "taskModeNormalTaskDescription": "每次執行都會在選定的工作流程中建立普通任務。任務會出現在看板和側邊欄中。", "workflowRequiredLabel": "工作流程(必填)", "selectWorkflowRequired": "選擇工作流程", - "normalTaskWorkflowHelp": "選擇工作流程,讓此任務出現在看板和側邊欄。", + "normalTaskWorkflowHelp": "選擇工作流程,讓此任務出現在看板和側邊欄中。", "thenDescription": "每次此自動化觸發時都會建立一個新任務", "thenTitle": "然後", "triggerCategoryGithub": "GitHub", diff --git a/apps/web/src/locales/zh-hk/azuredevops.json b/apps/web/src/locales/zh-hk/azuredevops.json index bffd13de33..423a892454 100644 --- a/apps/web/src/locales/zh-hk/azuredevops.json +++ b/apps/web/src/locales/zh-hk/azuredevops.json @@ -149,7 +149,7 @@ "placeholderDescriptionBody": "描述正文", "placeholderIdDescription": "工作項或拉取要求 ID", "placeholderProjectDescription": "項目名稱", - "placeholderSourceBranchDescription": "來源分支", + "placeholderSourceBranchDescription": "源分支", "placeholderStateDescription": "目前狀態", "placeholderTargetBranchDescription": "目標分支", "placeholderTypeDescription": "工作項類型", diff --git a/apps/web/src/locales/zh-hk/canvases.json b/apps/web/src/locales/zh-hk/canvases.json index 0246c5b0e5..cb09179433 100644 --- a/apps/web/src/locales/zh-hk/canvases.json +++ b/apps/web/src/locales/zh-hk/canvases.json @@ -7,73 +7,73 @@ "createCanvasTaskTitle": "建立畫布", "createCanvasTaskPrompt": "建立一個新的 Kandev 畫布,並顯示列出現有任務的協調視圖。", "loadingCanvas": "正在載入畫布", - "loadingCanvasDescription": "正在載入畫布中繼資料。", - "pendingFirstRelease": "等待第一個版本", - "pendingFirstReleaseDescription": "此畫布尚未有已發佈的版本。", - "pendingPermission": "需要審閱權限", + "loadingCanvasDescription": "正在載入畫布元數據。", + "pendingFirstRelease": "等待首個版本", + "pendingFirstReleaseDescription": "此畫布還沒有已發佈的版本。", + "pendingPermission": "需要審核權限", "pendingPermissionDescription": "活動版本需要獲得權限批准後才能開啟。", - "loadingRuntime": "正在載入畫布執行階段", - "loadingRuntimeDescription": "正在準備已發佈的畫布應用程式。", - "ready": "已準備就緒", - "readyDescription": "畫布應用程式已準備就緒。", + "loadingRuntime": "正在載入畫布執行時", + "loadingRuntimeDescription": "正在準備已發佈的畫布應用。", + "ready": "已就緒", + "readyDescription": "畫布應用已就緒。", "offline": "畫布離線", - "offlineDescription": "無法連接畫布執行階段。請檢查連線後再試。", + "offlineDescription": "無法存取畫布執行時。請檢查連接後重試。", "invalidRelease": "版本無效", - "invalidReleaseDescription": "活動版本未能通過驗證。請檢視版本或編輯畫布。", - "unavailable": "畫布無法使用", - "unavailableDescription": "活動畫布版本無法使用。", - "archived": "畫布已封存", - "archivedDescription": "請在工作區設定中還原此畫布,然後再次開啟。", + "invalidReleaseDescription": "活動版本未通過驗證。請查看版本或編輯畫布。", + "unavailable": "畫布不可用", + "unavailableDescription": "活動畫布版本不可用。", + "archived": "畫布已歸檔", + "archivedDescription": "請在工作區設定中恢復此畫布,然後再次開啟。", "loadFailed": "載入畫布失敗。", "actionFailed": "畫布操作失敗。", "canvasActions": "畫布操作", "editCanvas": "編輯畫布", - "editCanvasHelp": "開啟畫布製作工作。", + "editCanvasHelp": "開啟畫布創作任務。", "promoteCanvas": "提升畫布", - "promoteCanvasHelp": "讓此工作畫布顯示在工作區導覽中。", - "promoteCanvasUnavailable": "有效版本獲批准後才可提升。", - "openInNewTab": "在新分頁開啟", - "openInNewTabHelp": "在專注的瀏覽器分頁中開啟此畫布。", - "promoteCanvasDescription": "將 {{title}} 移至工作區範圍前,請先檢閱權限。", - "loadingPermissions": "正在載入要求的權限", - "promotionScopeChange": "提升後,此畫布會出現在工作區導覽中。", - "noAdditionalPermissions": "未要求其他權限。", + "promoteCanvasHelp": "讓此任務畫布顯示在工作區導航中。", + "promoteCanvasUnavailable": "有效版本獲批准後才能提升。", + "openInNewTab": "在新標籤頁中開啟", + "openInNewTabHelp": "在聚焦的瀏覽器標籤頁中開啟此畫布。", + "promoteCanvasDescription": "將 {{title}} 移至工作區範圍前,請先查看權限。", + "loadingPermissions": "正在載入請求的權限", + "promotionScopeChange": "提升後,此畫布會出現在工作區導航中。", + "noAdditionalPermissions": "未請求其他權限。", "sharedState": "共享狀態", "promotingCanvas": "正在提升畫布", "confirmPromotion": "確認提升", - "releasesAndPermissions": "版本及權限", - "releasesAndPermissionsHelp": "檢視版本並批准要求的權限。", - "releasesDescription": "檢視此畫布的待處理權限及保留版本。", + "releasesAndPermissions": "版本和權限", + "releasesAndPermissionsHelp": "查看版本並批准請求的權限。", + "releasesDescription": "查看此畫布的待處理權限和保留版本。", "loadingReleases": "正在載入版本", "noReleases": "沒有可用版本。", "approveRelease": "批准版本", "rejectRelease": "拒絕版本", - "rollbackRelease": "回復版本", + "rollbackRelease": "回滾版本", "permissionDeclaration": "已聲明的權限", "missingPermissions": "仍需的權限", "retry": "重試", - "refresh": "重新整理", - "statusActive": "使用中", - "statusArchived": "已封存", + "refresh": "刷新", + "statusActive": "活動", + "statusArchived": "已歸檔", "statusPending": "待處理", "statusDisabled": "已停用", "statusError": "錯誤", "workspaceCanvases": "工作區畫布", "workspaceCanvasesDescription": "管理 {{workspace}} 中可用的畫布。", "loadingCanvases": "正在載入工作區畫布", - "noWorkspaceCanvases": "工作區中暫時沒有畫布。", - "activeCanvases": "使用中畫布", - "archivedCanvases": "已封存畫布", - "permissionsAndReleases": "權限及版本", + "noWorkspaceCanvases": "工作區中還沒有畫布。", + "activeCanvases": "活動畫布", + "archivedCanvases": "已歸檔畫布", + "permissionsAndReleases": "權限和版本", "openCanvas": "開啟畫布", - "restoreCanvas": "還原", - "archiveCanvas": "封存", + "restoreCanvas": "恢復", + "archiveCanvas": "歸檔", "removeCanvas": "移除", - "removeCanvasDescription": "移除 {{title}} 及其畫布版本?此操作無法復原。", - "runtimeTokenExpired": "執行階段權杖已過期。請重試。", + "removeCanvasDescription": "移除 {{title}} 及其畫布版本?此操作無法撤銷。", + "runtimeTokenExpired": "執行時權杖已過期。請重試。", "promotionSourceScope": "來源範圍", "promotionSourceActor": "來源操作者", - "promotionSourceTask": "來源工作", + "promotionSourceTask": "來源任務", "promotionSourceSession": "來源工作階段", "promotionTargetScope": "目標範圍", "promotionPlacement": "工作區位置", diff --git a/apps/web/src/locales/zh-hk/chat.json b/apps/web/src/locales/zh-hk/chat.json index d7ba697cfb..540bf01a9a 100644 --- a/apps/web/src/locales/zh-hk/chat.json +++ b/apps/web/src/locales/zh-hk/chat.json @@ -98,7 +98,7 @@ "quickChatDialogIntro": "與代理程式開始快速對話,無需建立正式任務。", "quickChatIntro": "就想法、問題或程式碼庫與代理程式聊天。", "quickChatIntroSecondary": "快速聊天獨立於任務看板。", - "quickChatTabOrderSaveFailed": "無法儲存快速聊天分頁順序。", + "quickChatTabOrderSaveFailed": "無法儲存快速聊天標籤頁順序。", "quickChatTabOrderSaveFailedDescription": "目前順序會保留在此處。重新載入可恢復上次儲存的順序。", "renameChat": "重命名聊天", "renameSavedOnThisDeviceOnly": "重命名僅儲存在此設備上", diff --git a/apps/web/src/locales/zh-hk/common.json b/apps/web/src/locales/zh-hk/common.json index 2f8cade43b..5b154275a1 100644 --- a/apps/web/src/locales/zh-hk/common.json +++ b/apps/web/src/locales/zh-hk/common.json @@ -78,7 +78,7 @@ "commandGoToStats": "前往統計", "commandGoToStatsKeywords": "統計, 數據分析, 分析, 指標", "commandGoToThreads": "前往對話流", - "commandGoToThreadsKeywords": "對話流, 代理程式, 即時, 欄", + "commandGoToThreadsKeywords": "對話流, 代理程式, 實時, 列", "commandGroupActions": "操作", "commandGroupAgent": "代理程式", "commandGroupCommands": "命令", diff --git a/apps/web/src/locales/zh-hk/github.json b/apps/web/src/locales/zh-hk/github.json index b25edeb8f0..343c2edfd0 100644 --- a/apps/web/src/locales/zh-hk/github.json +++ b/apps/web/src/locales/zh-hk/github.json @@ -402,7 +402,7 @@ "prTaskStatusCi": "CI", "prTaskStatusMerge": "合併", "prTaskStatusNumber": "PR #{{number}}", - "prTaskStatusRepositoryNumber": "{{repository}} 拉取請求 #{{number}}", + "prTaskStatusRepositoryNumber": "{{repository}} 拉取要求 #{{number}}", "prTaskStatusReview": "審閱", "prTaskStatusState": "狀態", "prepareAppOnGithub": "在 GitHub 上準備應用", diff --git a/apps/web/src/locales/zh-hk/jira.json b/apps/web/src/locales/zh-hk/jira.json index 1b7f14bea3..e7360fd797 100644 --- a/apps/web/src/locales/zh-hk/jira.json +++ b/apps/web/src/locales/zh-hk/jira.json @@ -212,7 +212,7 @@ "builtinViewAssignedToMe": "指派給我", "builtinViewUnassigned": "未指派", "connectWithAtlassian": "連接 Atlassian", - "connecting": "正在連線...", + "connecting": "正在連接...", "oauth2": "OAuth 2.0", "oauthConnected": "已通過 OAuth 連接。權杖會自動刷新。", "oauthDescription": "通過 Atlassian OAuth 2.1(含 PKCE)進行身份驗證。無需建立應用或提供認證資料。點擊即可在瀏覽器中授權。權杖過期後會自動刷新。", @@ -220,7 +220,7 @@ "oauthTokenExpires": "權杖過期時間:{{date}}", "oauthPasteInstructions": "在新標籤頁中完成授權", "oauthPasteHelp": "在 Atlassian 上批准後,你將被重定向到一個無法載入的 localhost 網址。請從瀏覽器地址欄複製完整網址並貼上到下方。", - "oauthPopupBlocked": "瀏覽器已封鎖授權視窗。請允許彈出式視窗,然後再試。", + "oauthPopupBlocked": "瀏覽器已阻止授權窗口。請允許彈出窗口,然後重試。", "oauthComplete": "完成", "oauthCancel": "取消", "oauthPasteInvalid": "網址無效。請確保複製了包含 code 和 state 參數的完整重定向網址。" diff --git a/apps/web/src/locales/zh-hk/kanban.json b/apps/web/src/locales/zh-hk/kanban.json index a487dea335..1a181d07c9 100644 --- a/apps/web/src/locales/zh-hk/kanban.json +++ b/apps/web/src/locales/zh-hk/kanban.json @@ -110,9 +110,9 @@ "wipTaskCount": "{{label}} 個任務", "workflow": "工作流程", "workflowStepFallback": "工作流程步驟 {{stepId}}", - "columns": "欄", - "autoHideEmptyColumns": "自動隱藏空白欄", - "allEmptyStepsAutoHidden": "所有空白步驟都已隱藏。在「欄」中關閉「自動隱藏空白欄」即可重新顯示它們。", + "columns": "列", + "autoHideEmptyColumns": "自動隱藏空列", + "allEmptyStepsAutoHidden": "所有空步驟都已隱藏。在「列」中關閉「自動隱藏空列」即可重新顯示它們。", "columnsForWorkflow": "{{name}} 的列", "needsReassignment": "需要重新分配", "blockedByCount_one": "被 {{count}} 項阻塞", diff --git a/apps/web/src/locales/zh-hk/office.json b/apps/web/src/locales/zh-hk/office.json index 607525b753..80ba00142f 100644 --- a/apps/web/src/locales/zh-hk/office.json +++ b/apps/web/src/locales/zh-hk/office.json @@ -582,9 +582,9 @@ "pauseUnavailable": "未選擇工作區。", "pauseWorkspace": "暫停工作區", "pauseWorkspaceConfirm": "暫停工作區", - "pauseWorkspaceDescription": "停止此工作區中新的 Office 例程和事件啓動。系統會要求停止正在執行的 Office 執行。若停止失敗,部分工作可能繼續執行。", + "pauseWorkspaceDescription": "停止此工作區中新的 Office 例程和事件啓動。系統會請求停止正在執行的 Office 執行。若停止失敗,部分工作可能繼續執行。", "pauseWorkspacePartialFailure_one": "有一個 Office 執行未能停止({{count}})。工作區仍處於暫停狀態。", - "pauseWorkspacePartialFailure_other": "部分 Office 執行未能停止({{count}})。工作區仍處於暫停狀態。", + "pauseWorkspacePartialFailure_other": "有些 Office 執行未能停止({{count}})。工作區仍處於暫停狀態。", "pauseWorkspaceTitle": "要暫停此工作區嗎?", "pausing": "暫停中...", "pausedConsecutiveFailures_one": "已暫停;連續失敗 {{count}} 次", diff --git a/apps/web/src/locales/zh-hk/orgs.json b/apps/web/src/locales/zh-hk/orgs.json index 610d930b43..718d52647d 100644 --- a/apps/web/src/locales/zh-hk/orgs.json +++ b/apps/web/src/locales/zh-hk/orgs.json @@ -3,34 +3,34 @@ "adminCreate": "建立管理員", "adminCreated": "管理員已建立。", "adminDescription": "管理員只能在自己的組織中建立帳戶,因此新組織需要在此建立首位管理員。", - "adminEmail": "電子郵件", + "adminEmail": "電郵地址", "adminName": "顯示名稱", "adminPassword": "密碼", - "adminPasswordHint": "至少 8 個字元。請告知對方並要求其登入後變更。", + "adminPasswordHint": "至少 8 個字符。請告知對方並要求其登入後修改。", "adminTitle": "{{name}} 的首位管理員", - "boundaryDescription": "工作區、任務、工作階段、密鑰和帳戶均屬於一個組織。身為執行個體維運人員,您管理組織本身;但不會取得任何組織內部的存取權限,包括您自己的組織。", + "boundaryDescription": "工作區、任務、工作階段、密鑰和帳戶均歸屬於一個組織。作為實例運維人員,您管理組織本身;但不會獲得任何組織內部的存取權限,包括您自己的組織。", "boundaryTitle": "組織隔離的內容", "create": "建立", "createDescription": "先建立組織,然後新增其首位管理員。此後該組織將自行管理。", - "createTitle": "新增組織", + "createTitle": "新建組織", "created": "組織已建立。", "defaultBadge": "預設", "delete": "刪除組織", "deleteConfirm": "刪除組織", "deleteConfirmLabel": "輸入 {{slug}} 以確認", "deleteTitle": "刪除 {{name}}", - "deleteWarning": "這將移除該組織中的所有工作區、任務、工作階段和帳戶,且無法復原。", + "deleteWarning": "這將移除該組織中的所有工作區、任務、工作階段和帳戶,且無法撤銷。", "deleted": "組織已刪除。", - "description": "每個組織都是獨立的租戶。使用者僅屬於一個組織,組織之間無法看到彼此的工作區、任務或密鑰。", - "genericError": "無法儲存該變更。", + "description": "每個組織都是獨立的租户。用戶僅屬於一個組織,組織之間無法看到彼此的工作區、任務或密鑰。", + "genericError": "無法儲存該更改。", "nameLabel": "組織名稱", "namePlaceholder": "Acme Corp", "navOrganizations": "組織", - "operatorOnly": "只有執行個體維運人員可以管理組織。", - "renamed": "組織已重新命名。", + "operatorOnly": "只有實例運維人員可以管理組織。", + "renamed": "組織已重命名。", "resume": "恢復", "resumed": "組織已恢復。", - "statusActive": "使用中", + "statusActive": "活動", "statusSuspended": "已暫停", "suspend": "暫停", "suspended": "組織已暫停。", diff --git a/apps/web/src/locales/zh-hk/settings.json b/apps/web/src/locales/zh-hk/settings.json index 3ee19d9185..f19c7837d6 100644 --- a/apps/web/src/locales/zh-hk/settings.json +++ b/apps/web/src/locales/zh-hk/settings.json @@ -55,6 +55,11 @@ "autoFallbackHelp": "如果啓動模型不可用,Kandev 會讓代理程式從其餘已公佈的模型中選擇。可用性變化時,所選模型也可能變化。", "autoFallbackHelpTitle": "自動後備", "autoFallbackInfoLabel": "說明自動後備", + "requireExactModel": "要求精確模型", + "requireExactModelHelper": "只有在此精確模型已公佈並成功套用時才啟動工作階段。關閉此選項後,已儲存的後備設定仍可使用。", + "requireExactModelInfoLabel": "說明精確模型要求", + "requireExactModelHelpTitle": "要求精確模型", + "requireExactModelHelp": "如果設定的模型未公佈或無法套用,工作階段會失敗。自動及明確後備設定會保留,但不會生效。", "autoStartLanguageServer": "自動啓動 {{language}} 語言伺服器", "builtIn": "內置", "builtInBrowserPanel": "內置瀏覽器面板", @@ -337,6 +342,10 @@ "fallbackSettingsSummaryAutomatic": "已啓用自動後備", "fallbackSettingsSummaryExplicit": "明確指定的後備:{{model}}", "fallbackSettingsSummaryStrict": "未設定後備", + "fallbackSettingsSummaryExact": "已要求精確模型", + "fallbackSettingsSummaryExecutorDefault": "允許使用執行器預設模型", + "requireExactModelValidationTitle": "需要模型", + "requireExactModelValidation": "啟用精確模型要求前,請選擇一個具體模型。", "fallbackNoteLive": "此工作階段因設定的模型不可用而以備用模型啓動。(備用)標記僅反映目前工作階段,重新載入後不再顯示。", "modelFallbackSuffix": "(備用)", "globalSecrets": "全局密鑰", @@ -770,22 +779,23 @@ "keyValueKeyPlaceholder": "鍵", "keyValueValuePlaceholder": "值", "modelVariationAdvisory": "主機目錄中有一個可能的 {{model}} 變體:{{variation}}。儲存的模型在啓動前保持不變。", - "navUnits": "組織單位", - "unitsTitle": "組織單位", - "unitsDescription": "單位包含團隊、工作區和成員。加入某個單位即可存取其下的所有工作區,包括之後建立的。", - "unitsEmpty": "尚無單位。", + "navUnits": "組織單元", + "profileStartModelNotAdvertisedOnHost": "主機探測未公佈 {{model}}。所選執行器將在啓動時決定使用的模型。", + "unitsTitle": "組織單元", + "unitsDescription": "單元包含團隊、工作區和成員。加入某個單元即可存取其下的所有工作區,包括之後建立的。", + "unitsEmpty": "尚無單元。", "unitRootBadge": "整個組織", "unitPersonalBadge": "個人", - "unitAddChild": "新增單位", + "unitAddChild": "新增單元", "unitMembers": "成員", - "unitDelete": "刪除單位", - "unitCreateTitle": "在 {{parent}} 中新增單位", - "unitCreateDescription": "部門和團隊都是單位,差別只在於所處位置。", + "unitDelete": "刪除單元", + "unitCreateTitle": "在 {{parent}} 中新建單元", + "unitCreateDescription": "部門和團隊都是單元,區別只在於所處位置。", "unitNamePlaceholder": "平台", "unitCreate": "建立", "unitMembersTitle": "{{unit}} 的成員", - "unitMembersDescription": "這裡的每個人都能以你指定的角色存取該單位下的所有工作區。", - "unitNoMembers": "該單位尚無成員。", + "unitMembersDescription": "這裏的每個人都能以你指定的角色存取該單元下的所有工作區。", + "unitNoMembers": "該單元暫無成員。", "unitAddMember": "新增", "unitRemoveMember": "移除成員", "unitPickPerson": "選擇成員", diff --git a/apps/web/src/locales/zh-hk/sidebar.json b/apps/web/src/locales/zh-hk/sidebar.json index 1e2aab8257..bafff5dcd1 100644 --- a/apps/web/src/locales/zh-hk/sidebar.json +++ b/apps/web/src/locales/zh-hk/sidebar.json @@ -120,6 +120,6 @@ "morePluginItems": "更多外掛程式項", "newViewNumbered": "新視圖 {{suffix}}", "untitledView": "未命名視圖", - "workspaceContextRefreshFailed": "無法重新整理任務。", + "workspaceContextRefreshFailed": "無法刷新任務。", "workspaceContextAccessDenied": "您已無法存取此工作區。" } diff --git a/apps/web/src/locales/zh-hk/stats.json b/apps/web/src/locales/zh-hk/stats.json index 4d944da781..315a7f15b8 100644 --- a/apps/web/src/locales/zh-hk/stats.json +++ b/apps/web/src/locales/zh-hk/stats.json @@ -82,10 +82,10 @@ "workload": "工作負載", "failedToLoadSection": "載入該部分失敗", "failedToLoadStats": "載入統計數據失敗", - "statsAccessDenied": "你沒有權限查看這些統計資料。", - "statsTemporarilyUnavailable": "統計資料暫時無法使用。請再試一次。", + "statsAccessDenied": "您無權存取這些統計資訊。", + "statsTemporarilyUnavailable": "統計資訊暫時不可用。請重試。", "retry": "重試", - "retrying": "重試中…", + "retrying": "正在重試…", "loadingStats": "正在載入統計數據…", "noGitActivity": "無 git 活動", "subtitleReady": "{{tasks}} · {{sessions}} · {{duration}}", diff --git a/apps/web/src/locales/zh-hk/system.json b/apps/web/src/locales/zh-hk/system.json index 4873b12bad..677f22a298 100644 --- a/apps/web/src/locales/zh-hk/system.json +++ b/apps/web/src/locales/zh-hk/system.json @@ -13,7 +13,7 @@ "actionDone": "完成", "actionFailed": "失敗", "actionRunning": "執行中…", - "backupsAdminOnly": "只有管理員可以建立、下載、還原或刪除備份。", + "backupsAdminOnly": "只有管理員可以建立、下載、恢復或刪除備份。", "backupsColumnActions": "操作", "backupsColumnCreated": "建立時間", "backupsColumnKind": "類型", @@ -527,8 +527,8 @@ "storageSystemTemporaryNotApplicableDetail": "此資料夾不存在。", "storageSystemTemporaryResolvedRoot": "請求路徑 {{requested}} 解析為 {{resolved}}。", "storageSystemTemporaryRoot": "{{path}}:{{size}} · {{status}}", - "storageSystemTemporarySkippedCount_one": "已略過 {{count}} 個條目", - "storageSystemTemporarySkippedCount_other": "已略過 {{count}} 個條目", + "storageSystemTemporarySkippedCount_one": "已跳過 {{count}} 個條目", + "storageSystemTemporarySkippedCount_other": "已跳過 {{count}} 個條目", "storageTitle": "存儲", "storageToastAnalysisStarted": "已開始存儲分析", "storageToastCacheAdopted": "已接管 Go 構建快取", diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index cc1e44749f..ed92b1292a 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -39,7 +39,7 @@ "addReviewer": "+ 新增審閱人", "addShort": "+ 新增", "addSubIssue": "新增子議題", - "addToWorkspace": "加入工作區", + "addToWorkspace": "新增到工作區", "addYourCommentOrInstruction": "新增你的評論或指示…", "addedRemoved": "新增 {{added}} · 刪除 {{removed}}", "adding": "正在新增…", @@ -225,7 +225,7 @@ "cancelling": "正在取消…", "cannotMarkDoneAwaitingApprovalFrom": "無法標記為完成:正在等待 {{names}} 的批准", "cannotMarkDoneAwaitingApprovals": "無法標記為完成:正在等待批准", - "cannotMarkDoneBeforeFinalWorkflowStep": "無法標記為完成:請先將任務移至工作流的最後一步", + "cannotMarkDoneBeforeFinalWorkflowStep": "無法標記為完成:請先將任務移至工作流程的最後一步", "cannotPreviewThisFile": "無法預覽此檔案", "changeGithubIssue": "更改 GitHub 議題", "changes": "變更", @@ -955,7 +955,7 @@ "selectedAgentNotConfiguredFor": "{{agent}} 未在 {{target}} 上設定。請在「設定 → 執行器」中設定代理程式認證資料。", "selectedAgentProfileFallback": "所選代理程式設定檔", "selectedAgentProfileFallbackInline": "所選代理程式設定檔", - "selectedAgentProfileUnavailable": "{{agent}} 不可用。請選擇相容的代理程式設定檔。", + "selectedAgentProfileUnavailable": "{{agent}} 不可用。請選擇兼容的代理程式設定檔。", "noContent": "無內容", "noDecisionYet": "尚未決定", "noDocumentsFound": "未找到文檔。", @@ -1072,7 +1072,7 @@ "plan": "計劃", "planCommentCount_one": "{{count}} 條計劃評論", "planCommentCount_other": "{{count}} 條計劃評論", - "planContentTooLarge": "您的計劃為 {{submitted}},超過了 {{limit}} 的限制。請縮短文件後重新儲存。", + "planContentTooLarge": "您的計劃為 {{submitted}},超過了 {{limit}} 的限制。請縮短文檔後重新儲存。", "planHistory": "計劃歷史", "planIsEmpty": "計劃為空", "planLoadError": "無法載入計劃。", @@ -2022,8 +2022,8 @@ "resumeFailedWorkspaceReadOnly": "工作區已以只讀模式還原", "continueOnNewBranch": "在新分支上繼續", "restoreReadOnlyWorkspace": "還原只讀工作區", - "sessionRecoveryGuardInProgress": "此工作階段的代理程式在後端重啓後仍在進行協調。這會自動清除;請稍後再試。", - "sessionRecoveryGuardUnstoppable": "後端重啓後,無法停止此工作階段先前的代理程式處理程序。請重啓後端以清除此狀態,然後再試一次。", + "sessionRecoveryGuardInProgress": "此工作階段的代理在後端重啓後仍在協調中。此狀態會自動解除,請稍後再試。", + "sessionRecoveryGuardUnstoppable": "後端重啓後無法停止此工作階段之前的代理程序。請重啓後端以解除此狀態,然後重試。", "gitOperationFailedGeneric": "操作失敗", "invalidGitHubUrl": "GitHub 連結無效 - 應為 github.com/owner/repo 或 .../pull/123", "invalidRegex": "正則表達式無效", @@ -2145,17 +2145,17 @@ "branchPolicyOptionSummary": "從 {{base}} 到 {{target}}", "branchPolicySelected": "{{name}}(從 {{base}})", "branchPolicyUnavailable": "基礎分支不可用。請刷新分支或編輯此策略。", - "branchPolicyRequiresSingleRepository": "使用本機執行器時,分支策略需要單一儲存庫。", + "branchPolicyRequiresSingleRepository": "使用本地執行器時,分支策略需要單個儲存庫。", "assignedTo": "指派給", "unassigned": "未指派", "assignToMe": "指派給我", "searchPeople": "搜尋成員", - "noPeopleFound": "找不到成員", + "noPeopleFound": "未找到成員", "assignedToName": "指派給 {{name}}", "uploadConflictTitle_one": "{{count}} 個檔案已存在", "uploadConflictTitle_other": "{{count}} 個檔案已存在", - "uploadConflictBody": "請選擇如何處理每個檔案。確認之前不會上載任何內容。", - "uploadConflictApplyToAll": "套用至全部", + "uploadConflictBody": "請選擇如何處理每個檔案。確認之前不會上傳任何內容。", + "uploadConflictApplyToAll": "應用於全部", "uploadConflictKeepBoth": "兩者都保留", "uploadConflictReplace": "替換", "uploadConflictSkip": "跳過", diff --git a/apps/web/src/locales/zh-hk/workspaces.json b/apps/web/src/locales/zh-hk/workspaces.json index 95942a7f5f..e4a3590e78 100644 --- a/apps/web/src/locales/zh-hk/workspaces.json +++ b/apps/web/src/locales/zh-hk/workspaces.json @@ -225,44 +225,44 @@ "branchPolicyGitflowCreated": "Gitflow 策略已建立", "branchPolicyGitflowFailed": "無法建立 Gitflow 策略。", "branchPolicySummary": "基礎:{{base}}。模板:{{template}}。拉取要求目標:{{target}}。", - "defaultVisibility.description": "適用於此後建立的工作區。現有工作區維持其目前的可見性。", - "defaultVisibility.existingUnaffected": "變更此設定絕不會變更現有工作區。", + "defaultVisibility.description": "適用於此後建立的工作區。現有工作區保持其目前可見性。", + "defaultVisibility.existingUnaffected": "更改此設定絕不會更改現有工作區。", "defaultVisibility.label": "新工作區為", "defaultVisibility.savedOrg": "新工作區將對組織可見。", - "defaultVisibility.savedPrivate": "新工作區將為私人。", + "defaultVisibility.savedPrivate": "新工作區將為私有。", "defaultVisibility.title": "新工作區預設設定", "teamAccess.addMember": "新增", "teamAccess.addMemberLabel": "新增同事", - "teamAccess.addMemberPlaceholder": "選擇一位人員", - "teamAccess.description": "選擇誰可以存取此工作區,並為同事指派角色。", - "teamAccess.genericError": "無法儲存該變更。", - "teamAccess.makeOwner": "設為擁有者", - "teamAccess.memberAdded": "已新增成員。", - "teamAccess.memberRemoved": "已移除成員。", + "teamAccess.addMemberPlaceholder": "選擇一個人", + "teamAccess.description": "選擇誰可以存取此工作區,併為同事分配角色。", + "teamAccess.genericError": "無法儲存該更改。", + "teamAccess.makeOwner": "設為所有者", + "teamAccess.memberAdded": "成員已新增。", + "teamAccess.memberRemoved": "成員已移除。", "teamAccess.membersLabel": "成員", - "teamAccess.noMembers": "尚無成員。擁有者一律具有完整存取權限。", + "teamAccess.noMembers": "暫無成員。所有者始終擁有完全存取權限。", "teamAccess.noUsersToAdd": "所有人都已是成員", "teamAccess.nowOrgVisible": "此工作區現在對組織可見。", - "teamAccess.nowPrivate": "此工作區現在為私人。", - "teamAccess.ownershipTransferred": "{{name}} 現在是擁有者。", + "teamAccess.nowPrivate": "此工作區現在為私有。", + "teamAccess.ownershipTransferred": "{{name}} 現在是所有者。", "teamAccess.removeMember": "移除成員", "teamAccess.roleCollaborator": "協作者", "teamAccess.roleLabel": "角色", - "teamAccess.roleOwner": "擁有者", - "teamAccess.roleUpdated": "已更新角色。", - "teamAccess.roleViewer": "檢視者", + "teamAccess.roleOwner": "所有者", + "teamAccess.roleUpdated": "角色已更新。", + "teamAccess.roleViewer": "查看者", "teamAccess.title": "團隊存取", "teamAccess.visibilityLabel": "可見性", "teamAccess.visibilityOrg": "組織中的所有人", - "teamAccess.visibilityOrgHint": "除訪客外的所有使用者都可以開啟此工作區並處理其任務。", - "teamAccess.visibilityPrivate": "私人", - "teamAccess.visibilityPrivateHint": "只有擁有者和下方列出的成員可以開啟此工作區。", + "teamAccess.visibilityOrgHint": "除訪客外的所有用戶都可以開啟此工作區並處理其任務。", + "teamAccess.visibilityPrivate": "私有", + "teamAccess.visibilityPrivateHint": "只有所有者和下方列出的成員可以開啟此工作區。", "placement.title": "位置", - "placement.description": "該工作區所屬的單位,也就決定了誰能存取它。移動它是改變存取範圍的唯一方式:角色按最高權限合併,無法就地收回。", - "placement.pick": "選擇單位", - "placement.search": "搜尋單位", - "placement.none": "找不到單位", + "placement.description": "該工作區所屬的單元,也就決定了誰能存取它。移動它是改變存取範圍的唯一方式:角色按最高權限合併,無法就地收回。", + "placement.pick": "選擇單元", + "placement.search": "搜尋單元", + "placement.none": "未找到單元", "placement.moved": "工作區已移動。", "placement.failed": "無法移動該工作區。", - "placement.ownerOnly": "僅工作區擁有者可移動" + "placement.ownerOnly": "僅工作區所有者可移動" } diff --git a/apps/web/src/locales/zh-tw/account.json b/apps/web/src/locales/zh-tw/account.json index e199c0446f..c840579668 100644 --- a/apps/web/src/locales/zh-tw/account.json +++ b/apps/web/src/locales/zh-tw/account.json @@ -7,7 +7,7 @@ "cancel": "取消", "changePassword": "修改密碼", "couldNotChangePassword": "無法修改密碼。", - "couldNotUpdateHostnameResolution": "無法更新主機名稱解析。", + "couldNotUpdateHostnameResolution": "無法更新主機名解析。", "couldNotCreateToken": "無法建立權杖。", "createApiToken": "建立 API 權杖", "createApiTokenDescription": "授予與你的帳戶相同的存取權限,供指令碼或 CLI 使用。請為其命名,便於日後識別。", @@ -22,7 +22,7 @@ "failedToSaveLastSeenDisplay": "無法儲存上次活動的顯示方式。", "failedToRevokeSession": "無法吊銷工作階段。", "ipAddress": "IP", - "hostname": "主機名稱", + "hostname": "主機名", "hostnameNotAvailable": "不適用", "lastSeen": "上次活動", "lastSeenDisplay": "上次活動顯示", @@ -42,8 +42,8 @@ "saving": "正在儲存…", "securityPageDescription": "修改密碼並檢視已登入你帳戶的裝置。", "securityPageTitle": "個人資料與密碼", - "resolveDeviceHostnames": "解析裝置主機名稱", - "resolveDeviceHostnamesDescription": "使用系統 DNS 解析器解析已登入裝置的 IP 位址。", + "resolveDeviceHostnames": "解析裝置主機名", + "resolveDeviceHostnamesDescription": "使用系統 DNS 解析器解析已登入裝置的 IP 地址。", "signOut": "登出", "thisDevice": "此裝置", "tokenCreated": "權杖已建立", diff --git a/apps/web/src/locales/zh-tw/agents.json b/apps/web/src/locales/zh-tw/agents.json index 7e27bc4370..cbc02be843 100644 --- a/apps/web/src/locales/zh-tw/agents.json +++ b/apps/web/src/locales/zh-tw/agents.json @@ -252,53 +252,53 @@ "saveProfile": "儲存設定", "savingProfile": "正在儲存…", "dynamicAgents": "動態代理", - "dynamicAgentsDescription": "透過有序的具體代理設定檔清單路由一個邏輯設定檔。", - "dynamicProfileDisabled": "動態代理程式路由已停用。", - "dynamicProfileDescription": "選擇按順序執行的具體設定檔,以便在失敗時切換。", + "dynamicAgentsDescription": "通過有序的具體代理設定清單路由一個邏輯設定。", + "dynamicProfileDisabled": "動態代理路由已停用。", + "dynamicProfileDescription": "選擇按順序執行的具體設定,以便在失敗時切換。", "dynamicProfileSettings": "動態設定檔設定", - "dynamicCandidates": "候選設定檔", - "dynamicCandidatesDescription": "候選設定檔按順序執行。只有在已設定的錯誤動作允許時,才會使用下一個候選設定檔。", - "dynamicRoutingPolicyTitle": "Kandev 何時切換候選設定檔", - "dynamicRoutingPolicySwitchable": "切換可以由已分類的供應商故障觸發,包括驗證或訂閱問題、缺少憑證或設定、配額或速率限制、模型無法使用、容量不足、網路問題,以及供應商無法使用或過載。", - "dynamicRoutingPolicyExcluded": "對於工作、儲存庫、權限、工具或執行階段錯誤、傳輸中斷,或執行中途結果不明確的情況,Kandev 不會切換。這些情況會停止並等待手動復原,以避免重複工作。", - "dynamicRoutingPolicyRecovery": "切換後,發生故障的供應商會暫停 1 分鐘,或者暫停至已提供的重設時間。之後,Kandev 會先執行一次獨佔健康探測,再次使用該供應商。", - "dynamicPolicyWaitExplanation": "重設等待會在每個候選設定檔和錯誤類別中使用一次可信的未來重設時間,並且只在時間不超過最大等待時間時使用。等待結束後,Kandev 會先進行一次新嘗試,再套用重試策略。", + "dynamicCandidates": "候選設定", + "dynamicCandidatesDescription": "候選設定按順序執行。只有在設定的錯誤操作允許時,才會使用下一個候選設定。", + "dynamicRoutingPolicyTitle": "Kandev 何時切換候選設定", + "dynamicRoutingPolicySwitchable": "切換可以由已分類的提供者故障觸發,包括身分驗證或訂閱問題、缺少認證資訊或設定、配額或速率限制、模型不可用、容量不足、網路問題,以及提供者不可用或過載。", + "dynamicRoutingPolicyExcluded": "對於任務、儲存庫、權限、工具或執行時錯誤、傳輸中斷,或執行中途結果不明確的情況,Kandev 不會切換。這些情況會停止並等待手動恢復,以避免重複工作。", + "dynamicRoutingPolicyRecovery": "切換後,發生故障的提供者會暫停 1 分鐘,或者暫停到已提供的重置時間。之後,Kandev 會先執行一次獨佔健康探測,再次使用該提供者。", + "dynamicPolicyWaitExplanation": "重置等待會在每個候選設定和錯誤類別中使用一次可信的未來重置時間,並且只在時間不超過最大等待時間時使用。等待結束後,Kandev 會先進行一次新嘗試,再應用重試策略。", "dynamicTransientErrors": "暫時性錯誤", - "dynamicTransientErrorsDescription": "臨時狀況,例如容量不足、速率限制、網路故障、逾時、供應商中斷和傳輸中斷。", + "dynamicTransientErrorsDescription": "臨時狀況,例如容量不足、速率限制、網路故障、超時、提供者中斷和傳輸中斷。", "dynamicHardErrors": "硬錯誤", - "dynamicHardErrorsDescription": "帳戶或設定狀況,例如訂閱限制、額度耗盡、驗證、缺少憑證、模型無法使用或供應商設定問題。", - "dynamicPolicyRetry": "重試此候選設定檔", - "dynamicPolicyWaitForReset": "等待供應商重設", - "dynamicPolicyAfterExhausted": "復原操作耗盡後", + "dynamicHardErrorsDescription": "帳戶或設定狀況,例如訂閱限制、額度耗盡、身分驗證、缺少認證資訊、模型不可用或提供者設定問題。", + "dynamicPolicyRetry": "重試此候選設定", + "dynamicPolicyWaitForReset": "等待提供者重置", + "dynamicPolicyAfterExhausted": "恢復操作耗盡後", "dynamicPolicyMaxRetries": "最大重試次數", "dynamicPolicyInitialInterval": "初始間隔(秒)", - "dynamicPolicyMaxWait": "最大重設等待時間(秒)", - "dynamicPolicyValidation": "儲存前請修正標示的策略值。", - "dynamicPolicyMaxRetriesValidation": "請輸入 1 至 10 次重試。", - "dynamicPolicyInitialIntervalValidation": "請輸入 1 秒至 1 小時之間的初始間隔。", - "dynamicPolicyMaxWaitValidation": "請輸入 1 秒至 7 日之間的重設等待時間。", + "dynamicPolicyMaxWait": "最大重置等待時間(秒)", + "dynamicPolicyValidation": "儲存前請修正突出顯示的策略值。", + "dynamicPolicyMaxRetriesValidation": "請輸入 1 到 10 次重試。", + "dynamicPolicyInitialIntervalValidation": "請輸入 1 秒到 1 小時之間的初始間隔。", + "dynamicPolicyMaxWaitValidation": "請輸入 1 秒到 7 天之間的重置等待時間。", "dynamicPolicySchedule": "最多重試 {{retries}} 次。第一次重試等待 {{first}} 秒,之後間隔加倍。", - "dynamicPolicyNoRetry": "不重試同一候選設定檔。Kandev 會立即套用所選結果。", - "dynamicPolicyWaitDescription": "當可信的未來重設或重試時間不超過最大等待時間時使用它。", - "dynamicPolicySkip": "略過候選設定檔", - "dynamicPolicyStop": "停止並手動復原", - "dynamicPolicyRetryHelp": "在套用所選結果前,以指數退避方式重試同一候選設定檔。", - "dynamicPolicyWaitHelp": "等待可信的未來供應商重設或重試時間。等待時間有限制,並且每個候選設定檔和類別只使用一次。", - "dynamicPolicyOutcomeHelp": "控制重設等待和同一候選設定檔的所有重試都耗盡後 Kandev 的操作。", + "dynamicPolicyNoRetry": "不重試同一候選設定。Kandev 會立即應用所選結果。", + "dynamicPolicyWaitDescription": "當可信的未來重置或重試時間不超過最大等待時間時使用它。", + "dynamicPolicySkip": "跳過候選設定", + "dynamicPolicyStop": "停止並手動恢復", + "dynamicPolicyRetryHelp": "在應用所選結果前,以指數退避方式重試同一候選設定。", + "dynamicPolicyWaitHelp": "等待可信的未來提供者重置或重試時間。等待時間有限制,並且每個候選設定和類別只使用一次。", + "dynamicPolicyOutcomeHelp": "控制重置等待和同一候選設定的所有重試都耗盡後 Kandev 的操作。", "dynamicPolicyOptionInfo": "解釋 {{option}} 策略", - "addDynamicCandidate": "新增候選設定檔", - "noDynamicCandidates": "至少新增一個具體設定檔。", - "moveDynamicCandidateUp": "上移候選設定檔", - "moveDynamicCandidateDown": "下移候選設定檔", - "removeDynamicCandidate": "移除候選設定檔", - "dynamicCandidatePickerDescription": "選擇符合動態路由條件的具體設定檔。", - "searchDynamicCandidates": "搜尋代理設定檔...", - "noDynamicCandidatesFound": "找不到符合條件的代理設定檔。", - "dynamicRouteAction": "供應商發生錯誤時", + "addDynamicCandidate": "新增候選設定", + "noDynamicCandidates": "至少新增一個具體設定。", + "moveDynamicCandidateUp": "上移候選設定", + "moveDynamicCandidateDown": "下移候選設定", + "removeDynamicCandidate": "移除候選設定", + "dynamicCandidatePickerDescription": "選擇一個符合動態路由條件的具體設定。", + "searchDynamicCandidates": "搜尋代理設定...", + "noDynamicCandidatesFound": "未找到符合條件的代理設定。", + "dynamicRouteAction": "提供者出錯時", "dynamicRouteActionInfo": "說明{{action}}路由行為", - "dynamicRouteRetryDescription": "供應商發生錯誤後重試相同候選設定檔。適用於可能暫時恢復的故障。", - "dynamicRouteTryNextDescription": "本次執行略過此候選設定檔,繼續使用下一個符合條件的設定檔。", - "dynamicRouteStopDescription": "此候選設定檔失敗後停止路由。工作會等待手動恢復操作。", - "dynamicProfileSaved": "動態設定檔已儲存。", + "dynamicRouteRetryDescription": "提供者出錯後重試同一候選設定。適用於可能暫時恢復的故障。", + "dynamicRouteTryNextDescription": "本次執行跳過此候選設定,繼續使用下一個符合條件的設定。", + "dynamicRouteStopDescription": "此候選設定失敗後停止路由。任務會等待手動恢復操作。", + "dynamicProfileSaved": "動態設定已儲存。", "adminOnly": "只有管理員可以更改代理和代理設定檔。" } diff --git a/apps/web/src/locales/zh-tw/auth.json b/apps/web/src/locales/zh-tw/auth.json index 5783fcc7c5..4aaaa14ab7 100644 --- a/apps/web/src/locales/zh-tw/auth.json +++ b/apps/web/src/locales/zh-tw/auth.json @@ -16,7 +16,7 @@ "joinThisKandevDeployment": "加入此 Kandev 部署。", "joining": "正在加入…", "orContinueWith": "或使用以下方式繼續", - "organizationUnavailable": "您的組織目前無法使用。請聯絡您的 Kandev 管理員。", + "organizationUnavailable": "您的組織目前不可用。請聯絡您的 Kandev 管理員。", "password": "密碼", "setUpKandev": "設定 Kandev", "setupFailed": "初始化失敗。請重試。", diff --git a/apps/web/src/locales/zh-tw/automations.json b/apps/web/src/locales/zh-tw/automations.json index 4f729542f4..f4d5edb71b 100644 --- a/apps/web/src/locales/zh-tw/automations.json +++ b/apps/web/src/locales/zh-tw/automations.json @@ -32,13 +32,13 @@ "comingSoonSuffix": "(即將推出)", "completeRequiredFields": "請先填寫必填的自動化欄位,再儲存。", "completed": "已完成", - "contextBetweenRunsConcurrencyLock": "此選項一次只支援一個執行中的工作。", - "contextBetweenRunsDescription": "選擇每次執行是重新開始,還是繼續使用相同的對話和檔案。", - "contextBetweenRunsNewTask": "每次執行都開始新工作", - "contextBetweenRunsNewTaskDescription": "每次執行都會使用獨立的對話和檔案。這些工作不會出現在看板或側邊欄。此選項適用於互相獨立的工作和並行執行。", - "contextBetweenRunsNewTaskNormalDescription": "每次執行都會使用獨立的對話和檔案。這些工作會出現在看板和側邊欄。此選項適用於互相獨立的工作和並行執行。", + "contextBetweenRunsConcurrencyLock": "此選項一次只支援一個執行中的任務。", + "contextBetweenRunsDescription": "選擇每次執行是從頭開始,還是繼續使用相同的對話和檔案。", + "contextBetweenRunsNewTask": "每次執行都開始新任務", + "contextBetweenRunsNewTaskDescription": "每次執行都會使用獨立的對話和檔案。這些任務不會出現在看板或側邊欄中。此選項適用於相互獨立的作業和併發執行。", + "contextBetweenRunsNewTaskNormalDescription": "每次執行都會使用獨立的對話和檔案。這些任務會出現在看板和側邊欄中。此選項適用於相互獨立的作業和併發執行。", "contextBetweenRunsReuseThread": "繼續之前的工作階段", - "contextBetweenRunsReuseThreadDescription": "執行會繼續使用相同的對話和檔案,讓代理保留之前的上下文和變更。執行會依次進行。", + "contextBetweenRunsReuseThreadDescription": "執行會繼續使用相同的對話和檔案,因此代理可以保留之前的上下文和更改。執行會依次執行。", "contextBetweenRunsTitle": "執行之間的上下文", "configurationLabel": "設定", "couldNotLoadWorkflows": "無法載入工作流程", @@ -52,7 +52,7 @@ "cronHelp": "標準五欄位 cron 或快捷方式({{shortcuts}})", "delete": "刪除", "deleteAutomationTitle": "刪除自動化?", - "deleteAutomationDescription": "這將永久刪除 {{name}}。此操作無法復原。", + "deleteAutomationDescription": "這將永久刪除 {{name}}。此操作無法撤銷。", "deleteAll": "全部刪除", "deleteAllRuns": "刪除所有執行記錄", "deleteAllRunsDescription": "這將永久刪除此自動化的所有執行記錄;包括目前未載入的記錄;以及關聯的任務。此操作無法撤銷。", @@ -123,9 +123,9 @@ "replyToThisRun": "回覆此執行…", "repositoryLabel": "儲存庫", "repositoryNone": "無;不使用儲存庫", - "repositoryModeTitle": "儲存庫存取權", - "repositoryModeDescription": "新增一個或多個儲存庫與基礎分支組合,或在沒有儲存庫的情況下執行。", - "repositoryModeNoneDescription": "在任務專屬的暫存工作區中執行,不使用儲存庫檔案。", + "repositoryModeTitle": "儲存庫存取", + "repositoryModeDescription": "新增一個或多個儲存庫與基礎分支組合,或在無儲存庫的情況下執行。", + "repositoryModeNoneDescription": "在任務專屬的臨時工作區中執行,不使用儲存庫檔案。", "repositoryPlaceholderAuto": "自動", "required": "必填", "resizeRuns": "調整執行記錄區域大小", @@ -188,14 +188,14 @@ "taskTitleLabel": "任務標題", "taskTitlePlaceholder": "[自動] 自動化名稱", "taskModeTitle": "執行目標", - "taskModeDescription": "選擇每次觸發只保留在自動化記錄中,還是建立普通任務。", - "taskModeAutomationRun": "只在自動化記錄中執行", - "taskModeAutomationRunDescription": "每次執行使用私人任務和對話。這些任務不會出現在看板或側邊欄。", + "taskModeDescription": "選擇每次觸發是隻保留在自動化歷史中,還是建立普通任務。", + "taskModeAutomationRun": "僅在自動化歷史中執行", + "taskModeAutomationRunDescription": "每次執行使用私有任務和對話。這些任務不會出現在看板或側邊欄中。", "taskModeNormalTask": "建立普通任務", - "taskModeNormalTaskDescription": "每次執行都會在選取的工作流程中建立普通任務。任務會出現在看板和側邊欄。", + "taskModeNormalTaskDescription": "每次執行都會在選定的工作流程中建立普通任務。任務會出現在看板和側邊欄中。", "workflowRequiredLabel": "工作流程(必填)", "selectWorkflowRequired": "選擇工作流程", - "normalTaskWorkflowHelp": "選擇工作流程,讓此任務出現在看板和側邊欄。", + "normalTaskWorkflowHelp": "選擇工作流程,讓此任務出現在看板和側邊欄中。", "thenDescription": "每次此自動化觸發時都會建立一個新任務", "thenTitle": "然後", "triggerCategoryGithub": "GitHub", diff --git a/apps/web/src/locales/zh-tw/azuredevops.json b/apps/web/src/locales/zh-tw/azuredevops.json index 2617411eff..134ee9b28a 100644 --- a/apps/web/src/locales/zh-tw/azuredevops.json +++ b/apps/web/src/locales/zh-tw/azuredevops.json @@ -146,13 +146,13 @@ "personalAccessToken": "個人存取權杖", "placeholderTitleDescription": "Azure DevOps 工作項或提取要求的標題", "placeholderUrlDescription": "Azure DevOps 工作項或提取要求的 URL", - "placeholderDescriptionBody": "描述內容", + "placeholderDescriptionBody": "描述正文", "placeholderIdDescription": "工作項或提取要求 ID", "placeholderProjectDescription": "專案名稱", - "placeholderSourceBranchDescription": "來源分支", + "placeholderSourceBranchDescription": "源分支", "placeholderStateDescription": "目前狀態", "placeholderTargetBranchDescription": "目標分支", - "placeholderTypeDescription": "工作項類型", + "placeholderTypeDescription": "工作項型別", "planningAndEffort": "規劃與工作量", "policiesLabelled": "策略:{{state}}", "pollIntervalSeconds": "輪詢間隔(秒)", diff --git a/apps/web/src/locales/zh-tw/canvases.json b/apps/web/src/locales/zh-tw/canvases.json index 73a17382c4..8e0d99119b 100644 --- a/apps/web/src/locales/zh-tw/canvases.json +++ b/apps/web/src/locales/zh-tw/canvases.json @@ -7,70 +7,70 @@ "createCanvasTaskTitle": "建立畫布", "createCanvasTaskPrompt": "建立一個新的 Kandev 畫布,並顯示列出現有任務的協調檢視。", "loadingCanvas": "正在載入畫布", - "loadingCanvasDescription": "正在載入畫布中繼資料。", - "pendingFirstRelease": "等待第一個版本", - "pendingFirstReleaseDescription": "此畫布尚未有已發佈的版本。", - "pendingPermission": "需要審查權限", - "pendingPermissionDescription": "活動版本需要獲得權限核准後才能開啟。", - "loadingRuntime": "正在載入畫布執行階段", - "loadingRuntimeDescription": "正在準備已發佈的畫布應用程式。", + "loadingCanvasDescription": "正在載入畫布後設資料。", + "pendingFirstRelease": "等待首個版本", + "pendingFirstReleaseDescription": "此畫布還沒有已釋出的版本。", + "pendingPermission": "需要稽核權限", + "pendingPermissionDescription": "活動版本需要獲得權限批准後才能開啟。", + "loadingRuntime": "正在載入畫布執行時", + "loadingRuntimeDescription": "正在準備已釋出的畫布應用。", "ready": "已就緒", - "readyDescription": "畫布應用程式已就緒。", + "readyDescription": "畫布應用已就緒。", "offline": "畫布離線", - "offlineDescription": "無法存取畫布執行階段。請檢查連線後重試。", + "offlineDescription": "無法存取畫布執行時。請檢查連線後重試。", "invalidRelease": "版本無效", "invalidReleaseDescription": "活動版本未通過驗證。請檢視版本或編輯畫布。", - "unavailable": "畫布無法使用", - "unavailableDescription": "活動畫布版本無法使用。", - "archived": "畫布已封存", - "archivedDescription": "請在工作區設定中還原此畫布,然後再次開啟。", + "unavailable": "畫布不可用", + "unavailableDescription": "活動畫布版本不可用。", + "archived": "畫布已歸檔", + "archivedDescription": "請在工作區設定中恢復此畫布,然後再次開啟。", "loadFailed": "載入畫布失敗。", - "actionFailed": "畫布動作失敗。", - "canvasActions": "畫布動作", + "actionFailed": "畫布操作失敗。", + "canvasActions": "畫布操作", "editCanvas": "編輯畫布", - "editCanvasHelp": "開啟畫布創作工作。", + "editCanvasHelp": "開啟畫布創作任務。", "promoteCanvas": "提升畫布", "promoteCanvasHelp": "讓此任務畫布顯示在工作區導覽中。", - "promoteCanvasUnavailable": "有效版本獲核准後才能提升。", - "openInNewTab": "在新分頁中開啟", - "openInNewTabHelp": "在專注的瀏覽器分頁中開啟此畫布。", + "promoteCanvasUnavailable": "有效版本獲批准後才能提升。", + "openInNewTab": "在新標籤頁中開啟", + "openInNewTabHelp": "在聚焦的瀏覽器標籤頁中開啟此畫布。", "promoteCanvasDescription": "將 {{title}} 移至工作區範圍前,請先檢視權限。", - "loadingPermissions": "正在載入要求的權限", + "loadingPermissions": "正在載入請求的權限", "promotionScopeChange": "提升後,此畫布會出現在工作區導覽中。", - "noAdditionalPermissions": "未要求其他權限。", + "noAdditionalPermissions": "未請求其他權限。", "sharedState": "共享狀態", "promotingCanvas": "正在提升畫布", "confirmPromotion": "確認提升", "releasesAndPermissions": "版本和權限", - "releasesAndPermissionsHelp": "檢視版本並核准要求的權限。", + "releasesAndPermissionsHelp": "檢視版本並批准請求的權限。", "releasesDescription": "檢視此畫布的待處理權限和保留版本。", "loadingReleases": "正在載入版本", "noReleases": "沒有可用版本。", - "approveRelease": "核准版本", + "approveRelease": "批准版本", "rejectRelease": "拒絕版本", - "rollbackRelease": "回復版本", + "rollbackRelease": "回滾版本", "permissionDeclaration": "已宣告的權限", "missingPermissions": "仍需的權限", "retry": "重試", "refresh": "重新整理", "statusActive": "活動", - "statusArchived": "已封存", + "statusArchived": "已歸檔", "statusPending": "待處理", "statusDisabled": "已停用", "statusError": "錯誤", "workspaceCanvases": "工作區畫布", "workspaceCanvasesDescription": "管理 {{workspace}} 中可用的畫布。", "loadingCanvases": "正在載入工作區畫布", - "noWorkspaceCanvases": "工作區中尚無畫布。", + "noWorkspaceCanvases": "工作區中還沒有畫布。", "activeCanvases": "活動畫布", - "archivedCanvases": "已封存畫布", + "archivedCanvases": "已歸檔畫布", "permissionsAndReleases": "權限和版本", "openCanvas": "開啟畫布", - "restoreCanvas": "還原", - "archiveCanvas": "封存", + "restoreCanvas": "恢復", + "archiveCanvas": "歸檔", "removeCanvas": "移除", - "removeCanvasDescription": "移除 {{title}} 及其畫布版本?此動作無法復原。", - "runtimeTokenExpired": "執行階段權杖已過期。請重試。", + "removeCanvasDescription": "移除 {{title}} 及其畫布版本?此操作無法撤銷。", + "runtimeTokenExpired": "執行時權杖已過期。請重試。", "promotionSourceScope": "來源範圍", "promotionSourceActor": "來源操作者", "promotionSourceTask": "來源任務", diff --git a/apps/web/src/locales/zh-tw/chat.json b/apps/web/src/locales/zh-tw/chat.json index 20c552c576..bcb2b04b6e 100644 --- a/apps/web/src/locales/zh-tw/chat.json +++ b/apps/web/src/locales/zh-tw/chat.json @@ -98,7 +98,7 @@ "quickChatDialogIntro": "與代理程式開始快速對話,無需建立正式任務。", "quickChatIntro": "就想法、問題或程式碼庫與代理程式聊天。", "quickChatIntroSecondary": "快速聊天獨立於任務看板。", - "quickChatTabOrderSaveFailed": "無法儲存快速聊天分頁順序。", + "quickChatTabOrderSaveFailed": "無法儲存快速聊天標籤頁順序。", "quickChatTabOrderSaveFailedDescription": "目前順序會保留在此處。重新載入可恢復上次儲存的順序。", "renameChat": "重新命名聊天", "renameSavedOnThisDeviceOnly": "重新命名僅儲存在此裝置上", diff --git a/apps/web/src/locales/zh-tw/common.json b/apps/web/src/locales/zh-tw/common.json index ba06bdb673..574fecd1cc 100644 --- a/apps/web/src/locales/zh-tw/common.json +++ b/apps/web/src/locales/zh-tw/common.json @@ -78,7 +78,7 @@ "commandGoToStats": "前往統計", "commandGoToStatsKeywords": "統計, 資料分析, 分析, 指標", "commandGoToThreads": "前往對話流", - "commandGoToThreadsKeywords": "對話流, 代理程式, 即時, 欄", + "commandGoToThreadsKeywords": "對話流, 代理程式, 即時, 列", "commandGroupActions": "操作", "commandGroupAgent": "代理程式", "commandGroupCommands": "命令", diff --git a/apps/web/src/locales/zh-tw/github.json b/apps/web/src/locales/zh-tw/github.json index 96539ab65c..667ef06351 100644 --- a/apps/web/src/locales/zh-tw/github.json +++ b/apps/web/src/locales/zh-tw/github.json @@ -402,7 +402,7 @@ "prTaskStatusCi": "CI", "prTaskStatusMerge": "合併", "prTaskStatusNumber": "PR #{{number}}", - "prTaskStatusRepositoryNumber": "{{repository}} 拉取請求 #{{number}}", + "prTaskStatusRepositoryNumber": "{{repository}} 提取要求 #{{number}}", "prTaskStatusReview": "審閱", "prTaskStatusState": "狀態", "prepareAppOnGithub": "在 GitHub 上準備應用", diff --git a/apps/web/src/locales/zh-tw/jira.json b/apps/web/src/locales/zh-tw/jira.json index 4a03bc97e0..ba3946926b 100644 --- a/apps/web/src/locales/zh-tw/jira.json +++ b/apps/web/src/locales/zh-tw/jira.json @@ -220,7 +220,7 @@ "oauthTokenExpires": "權杖過期時間:{{date}}", "oauthPasteInstructions": "在新標籤頁中完成授權", "oauthPasteHelp": "在 Atlassian 上批准後,你將被重定向到一個無法載入的 localhost 網址。請從瀏覽器位址列複製完整網址並貼上到下方。", - "oauthPopupBlocked": "瀏覽器已封鎖授權視窗。請允許彈出式視窗,然後再試。", + "oauthPopupBlocked": "瀏覽器已阻止授權視窗。請允許彈出視窗,然後重試。", "oauthComplete": "完成", "oauthCancel": "取消", "oauthPasteInvalid": "網址無效。請確保複製了包含 code 和 state 引數的完整重定向網址。" diff --git a/apps/web/src/locales/zh-tw/kanban.json b/apps/web/src/locales/zh-tw/kanban.json index 0d12cf9be2..00fbba497f 100644 --- a/apps/web/src/locales/zh-tw/kanban.json +++ b/apps/web/src/locales/zh-tw/kanban.json @@ -110,9 +110,9 @@ "wipTaskCount": "{{label}} 個任務", "workflow": "工作流程", "workflowStepFallback": "工作流程步驟 {{stepId}}", - "columns": "欄", - "autoHideEmptyColumns": "自動隱藏空白欄", - "allEmptyStepsAutoHidden": "所有空白步驟都已隱藏。在「欄」中關閉「自動隱藏空白欄」即可重新顯示它們。", + "columns": "列", + "autoHideEmptyColumns": "自動隱藏空列", + "allEmptyStepsAutoHidden": "所有空步驟都已隱藏。在「列」中關閉「自動隱藏空列」即可重新顯示它們。", "columnsForWorkflow": "{{name}} 的列", "needsReassignment": "需要重新分配", "blockedByCount_one": "被 {{count}} 項阻塞", diff --git a/apps/web/src/locales/zh-tw/office.json b/apps/web/src/locales/zh-tw/office.json index 1bb1a62c3d..0e46057d46 100644 --- a/apps/web/src/locales/zh-tw/office.json +++ b/apps/web/src/locales/zh-tw/office.json @@ -582,9 +582,9 @@ "pauseUnavailable": "未選擇工作區。", "pauseWorkspace": "暫停工作區", "pauseWorkspaceConfirm": "暫停工作區", - "pauseWorkspaceDescription": "停止此工作區中新的 Office 例程和事件啟動。系統會要求停止正在執行的 Office 執行。若停止失敗,部分工作可能繼續執行。", + "pauseWorkspaceDescription": "停止此工作區中新的 Office 例程和事件啟動。系統會請求停止正在執行的 Office 執行。若停止失敗,部分工作可能繼續執行。", "pauseWorkspacePartialFailure_one": "有一個 Office 執行未能停止({{count}})。工作區仍處於暫停狀態。", - "pauseWorkspacePartialFailure_other": "部分 Office 執行未能停止({{count}})。工作區仍處於暫停狀態。", + "pauseWorkspacePartialFailure_other": "有些 Office 執行未能停止({{count}})。工作區仍處於暫停狀態。", "pauseWorkspaceTitle": "要暫停此工作區嗎?", "pausing": "暫停中...", "pausedConsecutiveFailures_one": "已暫停;連續失敗 {{count}} 次", diff --git a/apps/web/src/locales/zh-tw/orgs.json b/apps/web/src/locales/zh-tw/orgs.json index 610d930b43..4275fe6d9d 100644 --- a/apps/web/src/locales/zh-tw/orgs.json +++ b/apps/web/src/locales/zh-tw/orgs.json @@ -3,34 +3,34 @@ "adminCreate": "建立管理員", "adminCreated": "管理員已建立。", "adminDescription": "管理員只能在自己的組織中建立帳戶,因此新組織需要在此建立首位管理員。", - "adminEmail": "電子郵件", + "adminEmail": "電子郵件地址", "adminName": "顯示名稱", "adminPassword": "密碼", - "adminPasswordHint": "至少 8 個字元。請告知對方並要求其登入後變更。", + "adminPasswordHint": "至少 8 個字元。請告知對方並要求其登入後修改。", "adminTitle": "{{name}} 的首位管理員", - "boundaryDescription": "工作區、任務、工作階段、密鑰和帳戶均屬於一個組織。身為執行個體維運人員,您管理組織本身;但不會取得任何組織內部的存取權限,包括您自己的組織。", + "boundaryDescription": "工作區、任務、工作階段、金鑰和帳戶均歸屬於一個組織。作為實例運維人員,您管理組織本身;但不會獲得任何組織內部的存取權限,包括您自己的組織。", "boundaryTitle": "組織隔離的內容", "create": "建立", "createDescription": "先建立組織,然後新增其首位管理員。此後該組織將自行管理。", - "createTitle": "新增組織", + "createTitle": "新建組織", "created": "組織已建立。", "defaultBadge": "預設", "delete": "刪除組織", "deleteConfirm": "刪除組織", "deleteConfirmLabel": "輸入 {{slug}} 以確認", "deleteTitle": "刪除 {{name}}", - "deleteWarning": "這將移除該組織中的所有工作區、任務、工作階段和帳戶,且無法復原。", + "deleteWarning": "這將移除該組織中的所有工作區、任務、工作階段和帳戶,且無法撤銷。", "deleted": "組織已刪除。", - "description": "每個組織都是獨立的租戶。使用者僅屬於一個組織,組織之間無法看到彼此的工作區、任務或密鑰。", - "genericError": "無法儲存該變更。", + "description": "每個組織都是獨立的租戶。使用者僅屬於一個組織,組織之間無法看到彼此的工作區、任務或金鑰。", + "genericError": "無法儲存該更改。", "nameLabel": "組織名稱", "namePlaceholder": "Acme Corp", "navOrganizations": "組織", - "operatorOnly": "只有執行個體維運人員可以管理組織。", + "operatorOnly": "只有實例運維人員可以管理組織。", "renamed": "組織已重新命名。", "resume": "恢復", "resumed": "組織已恢復。", - "statusActive": "使用中", + "statusActive": "活動", "statusSuspended": "已暫停", "suspend": "暫停", "suspended": "組織已暫停。", diff --git a/apps/web/src/locales/zh-tw/settings.json b/apps/web/src/locales/zh-tw/settings.json index 9564ba5553..316c48d585 100644 --- a/apps/web/src/locales/zh-tw/settings.json +++ b/apps/web/src/locales/zh-tw/settings.json @@ -55,6 +55,11 @@ "autoFallbackHelp": "如果啟動模型不可用,Kandev 會讓代理程式從其餘已公佈的模型中選擇。可用性變化時,所選模型也可能變化。", "autoFallbackHelpTitle": "自動備援", "autoFallbackInfoLabel": "說明自動備援", + "requireExactModel": "要求精確模型", + "requireExactModelHelper": "只有在此精確模型已公布並成功套用時才啟動工作階段。關閉此選項後,已儲存的備援設定仍可使用。", + "requireExactModelInfoLabel": "說明精確模型要求", + "requireExactModelHelpTitle": "要求精確模型", + "requireExactModelHelp": "如果設定的模型未公布或無法套用,工作階段會失敗。自動及明確備援設定會保留,但不會生效。", "autoStartLanguageServer": "自動啟動 {{language}} 語言伺服器", "builtIn": "內建", "builtInBrowserPanel": "內建瀏覽器面板", @@ -337,6 +342,10 @@ "fallbackSettingsSummaryAutomatic": "已啟用自動備援", "fallbackSettingsSummaryExplicit": "明確指定的備援:{{model}}", "fallbackSettingsSummaryStrict": "未設定備援", + "fallbackSettingsSummaryExact": "已要求精確模型", + "fallbackSettingsSummaryExecutorDefault": "允許使用執行器預設模型", + "requireExactModelValidationTitle": "需要模型", + "requireExactModelValidation": "啟用精確模型要求前,請選擇一個具體模型。", "fallbackNoteLive": "此工作階段因設定的模型不可用而以備用模型啟動。(備用)標記僅反映目前工作階段,重新載入後不再顯示。", "modelFallbackSuffix": "(備用)", "globalSecrets": "全域金鑰", @@ -526,9 +535,9 @@ "programming": "程式設計", "promptAdd": "新增提示詞", "promptEditorAriaLabel": "提示詞編輯器", - "promptEditorBothHint": "輸入 {{placeholder}} 查看可用佔位符,或輸入 {{mention}} 查看已儲存的提示詞。", - "promptEditorPlaceholderHint": "輸入 {{placeholder}} 查看可用佔位符。", - "promptEditorPromptReferenceHint": "輸入 {{mention}} 查看已儲存的提示詞。", + "promptEditorBothHint": "輸入 {{placeholder}} 檢視可用佔位符,或輸入 {{mention}} 檢視已儲存的提示詞。", + "promptEditorPlaceholderHint": "輸入 {{placeholder}} 檢視可用佔位符。", + "promptEditorPromptReferenceHint": "輸入 {{mention}} 檢視已儲存的提示詞。", "promptCreateFailed": "無法建立提示詞", "promptDelete": "刪除提示詞", "promptDeleteDescription": "這將永久移除 <2>{{name}}。此操作無法撤銷。", @@ -770,22 +779,23 @@ "keyValueKeyPlaceholder": "鍵", "keyValueValuePlaceholder": "值", "modelVariationAdvisory": "主機目錄中有一個可能的 {{model}} 變體:{{variation}}。儲存的模型在啟動前保持不變。", - "navUnits": "組織單位", - "unitsTitle": "組織單位", - "unitsDescription": "單位包含團隊、工作區和成員。加入某個單位即可存取其下的所有工作區,包括之後建立的。", - "unitsEmpty": "尚無單位。", + "navUnits": "組織單元", + "profileStartModelNotAdvertisedOnHost": "主機探測未公佈 {{model}}。所選執行器將在啟動時決定使用的模型。", + "unitsTitle": "組織單元", + "unitsDescription": "單元包含團隊、工作區和成員。加入某個單元即可存取其下的所有工作區,包括之後建立的。", + "unitsEmpty": "尚無單元。", "unitRootBadge": "整個組織", "unitPersonalBadge": "個人", - "unitAddChild": "新增單位", + "unitAddChild": "新增單元", "unitMembers": "成員", - "unitDelete": "刪除單位", - "unitCreateTitle": "在 {{parent}} 中新增單位", - "unitCreateDescription": "部門和團隊都是單位,差別只在於所處位置。", - "unitNamePlaceholder": "平台", + "unitDelete": "刪除單元", + "unitCreateTitle": "在 {{parent}} 中新建單元", + "unitCreateDescription": "部門和團隊都是單元,區別只在於所處位置。", + "unitNamePlaceholder": "平臺", "unitCreate": "建立", "unitMembersTitle": "{{unit}} 的成員", - "unitMembersDescription": "這裡的每個人都能以你指定的角色存取該單位下的所有工作區。", - "unitNoMembers": "該單位尚無成員。", + "unitMembersDescription": "這裡的每個人都能以你指定的角色存取該單元下的所有工作區。", + "unitNoMembers": "該單元暫無成員。", "unitAddMember": "新增", "unitRemoveMember": "移除成員", "unitPickPerson": "選擇成員", diff --git a/apps/web/src/locales/zh-tw/stats.json b/apps/web/src/locales/zh-tw/stats.json index 84fb9d0b9d..d2ea71a0a0 100644 --- a/apps/web/src/locales/zh-tw/stats.json +++ b/apps/web/src/locales/zh-tw/stats.json @@ -82,10 +82,10 @@ "workload": "工作負載", "failedToLoadSection": "載入該部分失敗", "failedToLoadStats": "載入統計資料失敗", - "statsAccessDenied": "您沒有權限查看這些統計資料。", - "statsTemporarilyUnavailable": "統計資料暫時無法使用。請重試。", + "statsAccessDenied": "您無權存取這些統計資訊。", + "statsTemporarilyUnavailable": "統計資訊暫時不可用。請重試。", "retry": "重試", - "retrying": "重試中…", + "retrying": "正在重試…", "loadingStats": "正在載入統計資料…", "noGitActivity": "無 git 活動", "subtitleReady": "{{tasks}} · {{sessions}} · {{duration}}", diff --git a/apps/web/src/locales/zh-tw/system.json b/apps/web/src/locales/zh-tw/system.json index 6b4ceaa5e1..5f690161b5 100644 --- a/apps/web/src/locales/zh-tw/system.json +++ b/apps/web/src/locales/zh-tw/system.json @@ -13,7 +13,7 @@ "actionDone": "完成", "actionFailed": "失敗", "actionRunning": "執行中…", - "backupsAdminOnly": "只有管理員可以建立、下載、還原或刪除備份。", + "backupsAdminOnly": "只有管理員可以建立、下載、恢復或刪除備份。", "backupsColumnActions": "操作", "backupsColumnCreated": "建立時間", "backupsColumnKind": "型別", @@ -527,8 +527,8 @@ "storageSystemTemporaryNotApplicableDetail": "此資料夾不存在。", "storageSystemTemporaryResolvedRoot": "請求路徑 {{requested}} 解析為 {{resolved}}。", "storageSystemTemporaryRoot": "{{path}}:{{size}} · {{status}}", - "storageSystemTemporarySkippedCount_one": "已略過 {{count}} 個條目", - "storageSystemTemporarySkippedCount_other": "已略過 {{count}} 個條目", + "storageSystemTemporarySkippedCount_one": "已跳過 {{count}} 個條目", + "storageSystemTemporarySkippedCount_other": "已跳過 {{count}} 個條目", "storageTitle": "儲存", "storageToastAnalysisStarted": "已開始儲存分析", "storageToastCacheAdopted": "已接管 Go 構建快取", diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index 78aa394e37..4fc8a76473 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -39,7 +39,7 @@ "addReviewer": "+ 新增審閱人", "addShort": "+ 新增", "addSubIssue": "新增子議題", - "addToWorkspace": "加入工作區", + "addToWorkspace": "新增到工作區", "addYourCommentOrInstruction": "新增你的評論或指示…", "addedRemoved": "新增 {{added}} · 刪除 {{removed}}", "adding": "正在新增…", @@ -225,7 +225,7 @@ "cancelling": "正在取消…", "cannotMarkDoneAwaitingApprovalFrom": "無法標記為完成:正在等待 {{names}} 的批准", "cannotMarkDoneAwaitingApprovals": "無法標記為完成:正在等待批准", - "cannotMarkDoneBeforeFinalWorkflowStep": "無法標記為完成:請先將任務移至工作流的最後一步", + "cannotMarkDoneBeforeFinalWorkflowStep": "無法標記為完成:請先將任務移至工作流程的最後一步", "cannotPreviewThisFile": "無法預覽此檔案", "changeGithubIssue": "更改 GitHub 議題", "changes": "變更", @@ -955,7 +955,7 @@ "selectedAgentNotConfiguredFor": "{{agent}} 未在 {{target}} 上設定。請在「設定 → 執行器」中設定代理程式認證資訊。", "selectedAgentProfileFallback": "所選代理程式設定檔", "selectedAgentProfileFallbackInline": "所選代理程式設定檔", - "selectedAgentProfileUnavailable": "{{agent}} 無法使用。請選擇相容的代理程式設定檔。", + "selectedAgentProfileUnavailable": "{{agent}} 不可用。請選擇相容的代理程式設定檔。", "noContent": "無內容", "noDecisionYet": "尚未決定", "noDocumentsFound": "未找到檔案。", @@ -1072,7 +1072,7 @@ "plan": "計劃", "planCommentCount_one": "{{count}} 條計劃評論", "planCommentCount_other": "{{count}} 條計劃評論", - "planContentTooLarge": "您的計劃為 {{submitted}},超過了 {{limit}} 的限制。請縮短文件後重新儲存。", + "planContentTooLarge": "您的計劃為 {{submitted}},超過了 {{limit}} 的限制。請縮短檔案後重新儲存。", "planHistory": "計劃歷史", "planIsEmpty": "計劃為空", "planLoadError": "無法載入計劃。", @@ -2022,8 +2022,8 @@ "resumeFailedWorkspaceReadOnly": "工作區已以只讀模式還原", "continueOnNewBranch": "在新分支上繼續", "restoreReadOnlyWorkspace": "還原只讀工作區", - "sessionRecoveryGuardInProgress": "此工作階段的代理程式在後端重啟後仍在進行協調。這會自動清除;請稍後再試。", - "sessionRecoveryGuardUnstoppable": "後端重啟後,無法停止此工作階段先前的代理程式處理程序。請重啟後端以清除此狀態,然後再試一次。", + "sessionRecoveryGuardInProgress": "此工作階段的代理在後端重啟後仍在協調中。此狀態會自動解除,請稍後再試。", + "sessionRecoveryGuardUnstoppable": "後端重啟後無法停止此工作階段之前的代理處理程序。請重啟後端以解除此狀態,然後重試。", "gitOperationFailedGeneric": "操作失敗", "invalidGitHubUrl": "GitHub 連結無效 - 應為 github.com/owner/repo 或 .../pull/123", "invalidRegex": "正規表示式無效", @@ -2145,24 +2145,24 @@ "branchPolicyOptionSummary": "從 {{base}} 到 {{target}}", "branchPolicySelected": "{{name}}(從 {{base}})", "branchPolicyUnavailable": "基礎分支不可用。請重新整理分支或編輯此策略。", - "branchPolicyRequiresSingleRepository": "使用本機執行器時,分支策略需要單一儲存庫。", + "branchPolicyRequiresSingleRepository": "使用本地執行器時,分支策略需要單個儲存庫。", "assignedTo": "指派給", "unassigned": "未指派", "assignToMe": "指派給我", "searchPeople": "搜尋成員", - "noPeopleFound": "找不到成員", + "noPeopleFound": "未找到成員", "assignedToName": "指派給 {{name}}", "uploadConflictTitle_one": "{{count}} 個檔案已存在", "uploadConflictTitle_other": "{{count}} 個檔案已存在", "uploadConflictBody": "請選擇如何處理每個檔案。確認之前不會上傳任何內容。", - "uploadConflictApplyToAll": "套用至全部", + "uploadConflictApplyToAll": "應用於全部", "uploadConflictKeepBoth": "兩者都保留", - "uploadConflictReplace": "取代", - "uploadConflictSkip": "略過", + "uploadConflictReplace": "替換", + "uploadConflictSkip": "跳過", "uploadConflictConfirm": "上傳", "uploadFiles": "上傳檔案", "uploadFolder": "上傳資料夾", - "uploadFilesHere": "上傳檔案至此", + "uploadFilesHere": "上傳檔案到此處", "uploadComplete_one": "已上傳 {{count}} 個檔案", "uploadComplete_other": "已上傳 {{count}} 個檔案", "uploadedTo": "已上傳至 {{path}}", diff --git a/apps/web/src/locales/zh-tw/workspaces.json b/apps/web/src/locales/zh-tw/workspaces.json index 4c8150fcdb..fc8fb053c0 100644 --- a/apps/web/src/locales/zh-tw/workspaces.json +++ b/apps/web/src/locales/zh-tw/workspaces.json @@ -225,44 +225,44 @@ "branchPolicyGitflowCreated": "Gitflow 策略已建立", "branchPolicyGitflowFailed": "無法建立 Gitflow 策略。", "branchPolicySummary": "基礎:{{base}}。模板:{{template}}。提取要求目標:{{target}}。", - "defaultVisibility.description": "適用於此後建立的工作區。現有工作區維持其目前的可見性。", - "defaultVisibility.existingUnaffected": "變更此設定絕不會變更現有工作區。", + "defaultVisibility.description": "適用於此後建立的工作區。現有工作區保持其目前可見性。", + "defaultVisibility.existingUnaffected": "更改此設定絕不會更改現有工作區。", "defaultVisibility.label": "新工作區為", "defaultVisibility.savedOrg": "新工作區將對組織可見。", - "defaultVisibility.savedPrivate": "新工作區將為私人。", + "defaultVisibility.savedPrivate": "新工作區將為私有。", "defaultVisibility.title": "新工作區預設設定", "teamAccess.addMember": "新增", "teamAccess.addMemberLabel": "新增同事", - "teamAccess.addMemberPlaceholder": "選擇一位人員", - "teamAccess.description": "選擇誰可以存取此工作區,並為同事指派角色。", - "teamAccess.genericError": "無法儲存該變更。", - "teamAccess.makeOwner": "設為擁有者", - "teamAccess.memberAdded": "已新增成員。", - "teamAccess.memberRemoved": "已移除成員。", + "teamAccess.addMemberPlaceholder": "選擇一個人", + "teamAccess.description": "選擇誰可以存取此工作區,併為同事分配角色。", + "teamAccess.genericError": "無法儲存該更改。", + "teamAccess.makeOwner": "設為所有者", + "teamAccess.memberAdded": "成員已新增。", + "teamAccess.memberRemoved": "成員已移除。", "teamAccess.membersLabel": "成員", - "teamAccess.noMembers": "尚無成員。擁有者一律具有完整存取權限。", + "teamAccess.noMembers": "暫無成員。所有者始終擁有完全存取權限。", "teamAccess.noUsersToAdd": "所有人都已是成員", "teamAccess.nowOrgVisible": "此工作區現在對組織可見。", - "teamAccess.nowPrivate": "此工作區現在為私人。", - "teamAccess.ownershipTransferred": "{{name}} 現在是擁有者。", + "teamAccess.nowPrivate": "此工作區現在為私有。", + "teamAccess.ownershipTransferred": "{{name}} 現在是所有者。", "teamAccess.removeMember": "移除成員", "teamAccess.roleCollaborator": "協作者", "teamAccess.roleLabel": "角色", - "teamAccess.roleOwner": "擁有者", - "teamAccess.roleUpdated": "已更新角色。", + "teamAccess.roleOwner": "所有者", + "teamAccess.roleUpdated": "角色已更新。", "teamAccess.roleViewer": "檢視者", "teamAccess.title": "團隊存取", "teamAccess.visibilityLabel": "可見性", "teamAccess.visibilityOrg": "組織中的所有人", "teamAccess.visibilityOrgHint": "除訪客外的所有使用者都可以開啟此工作區並處理其任務。", - "teamAccess.visibilityPrivate": "私人", - "teamAccess.visibilityPrivateHint": "只有擁有者和下方列出的成員可以開啟此工作區。", + "teamAccess.visibilityPrivate": "私有", + "teamAccess.visibilityPrivateHint": "只有所有者和下方列出的成員可以開啟此工作區。", "placement.title": "位置", - "placement.description": "該工作區所屬的單位,也就決定了誰能存取它。移動它是改變存取範圍的唯一方式:角色按最高權限合併,無法就地收回。", - "placement.pick": "選擇單位", - "placement.search": "搜尋單位", - "placement.none": "找不到單位", + "placement.description": "該工作區所屬的單元,也就決定了誰能存取它。移動它是改變存取範圍的唯一方式:角色按最高權限合併,無法就地收回。", + "placement.pick": "選擇單元", + "placement.search": "搜尋單元", + "placement.none": "未找到單元", "placement.moved": "工作區已移動。", "placement.failed": "無法移動該工作區。", - "placement.ownerOnly": "僅工作區擁有者可移動" + "placement.ownerOnly": "僅工作區所有者可移動" } diff --git a/docs/decisions/2026-08-15-executor-authoritative-model-selection.md b/docs/decisions/2026-08-15-executor-authoritative-model-selection.md index 3857ef5cc6..4ef2b7e424 100644 --- a/docs/decisions/2026-08-15-executor-authoritative-model-selection.md +++ b/docs/decisions/2026-08-15-executor-authoritative-model-selection.md @@ -1,6 +1,6 @@ # ADR-2026-08-15: Let the Executor Own Model Selection -**Status:** accepted +**Status:** superseded by 2026-09-06-exact-profile-model-identity **Date:** 2026-08-15 **Area:** backend, frontend, protocol, persistence diff --git a/docs/decisions/2026-09-06-exact-profile-model-identity.md b/docs/decisions/2026-09-06-exact-profile-model-identity.md new file mode 100644 index 0000000000..86f2f22727 --- /dev/null +++ b/docs/decisions/2026-09-06-exact-profile-model-identity.md @@ -0,0 +1,44 @@ +# ADR-2026-09-06-exact-profile-model-identity: Enforce Exact Profile Model Identity + +**Status:** superseded by 2026-09-15-explicit-profile-model-strictness +**Date:** 2026-09-06 +**Area:** backend, frontend, protocol, persistence, workflow + +See [the per-profile opt-in amendment](2026-09-15-explicit-profile-model-strictness.md). +The original decision below is historical; its implicit default is superseded. + +## Context + +An executor catalog remains authoritative for whether a model can be selected, +but allowing its default after an exact profile's model is absent changes the +user's chosen runtime identity and cost policy before the first prompt. + +## Decision + +A profile with a non-empty model, `auto_fallback = false`, and no +`fallback_model` is exact. Its configured model must be advertised and applied +before inference. If that cannot be attested, the session fails before a +prompt, tool call, or agent output. + +Kandev never sends `SetModel` for an unadvertised model. An advertised explicit +fallback is the only authorized alternate model when auto fallback is off. +`auto_fallback = true` explicitly authorizes continuation on the executor +default and keeps its durable warning behavior. This same decision applies on +initial launch, context reset, and fresh workspace rebind. + +## Consequences + +Exact profiles can fail when an executor, account, or pinned runtime does not +advertise their configured model. The failure records sanitized requested, +effective-when-known, and stable mismatch-reason evidence. Existing workflow +profile precedence and intentional same-session runtime overrides remain +unchanged. + +## Alternatives Considered + +1. Continue on the executor default for every catalog mismatch. Rejected + because it silently substitutes the chosen model identity. +2. Send an unadvertised model selection request. Rejected because the executor + catalog is still the availability authority. +3. Rewrite the saved profile from an executor observation. Rejected because a + transient executor/account state must not mutate the user's profile. diff --git a/docs/decisions/2026-09-07-unique-model-variation-resolution.md b/docs/decisions/2026-09-07-unique-model-variation-resolution.md index e089a2a1bd..1a0a210d7c 100644 --- a/docs/decisions/2026-09-07-unique-model-variation-resolution.md +++ b/docs/decisions/2026-09-07-unique-model-variation-resolution.md @@ -1,9 +1,12 @@ # ADR-2026-09-07: Resolve Only One Advertised Model Variation -**Status:** proposed +**Status:** superseded by 2026-09-15-explicit-profile-model-strictness **Date:** 2026-09-07 **Area:** backend, frontend, protocol -**Amends:** `2026-08-15-executor-authoritative-model-selection` + +The historical rejection below is superseded by the +[compatible-only variation rule](2026-09-15-explicit-profile-model-strictness.md). +Explicit strict profiles continue to reject inferred variations. ## Context @@ -20,55 +23,25 @@ Variation text is provider-owned. Kandev cannot safely rank `270k`, `1m`, `fast`, or future labels. Catalog order and the current-model marker do not express user intent. -## Decision +## Rejection -Kandev recognizes a model variation only by a narrow bracketed ID shape. A bare -requested ID `x` matches an advertised ID `x[v]` when `v` is non-empty and -contains no bracket. Matching is exact and case-sensitive. Kandev treats `v` as -opaque text. +This proposal was not adopted. It would have inferred a bracketed advertised +model from a bare profile model and treated that inferred ID as an authorized +selection. That conflicts with the accepted exact-profile boundary: an +inferred ID is a different runtime identity from the configured model. -For profiles with `auto_fallback = false`, the executor-authoritative resolution -order is: +No variation-matching algorithm, inferred-selection outcome, warning reason, +or launch, reset, or workspace-rebind behavior was adopted from this proposal. +The accepted policy is recorded exclusively in +[Enforce Exact Profile Model Identity](2026-09-06-exact-profile-model-identity.md). -1. Exact requested model. -2. Advertised explicit fallback from the profile. -3. One distinct advertised variation of the requested bare model. -4. Provider current or default model, with no speculative model-selection call. +## Consequences of rejection -Kandev applies step 3 only when exactly one distinct candidate exists. Zero or -multiple candidates produce no inferred selection. An unadvertised explicit -fallback does not block step 3. - -A requested ID that contains a bracket is an exact ID only. Kandev does not -infer another variation from it. - -Profiles with `auto_fallback = true` retain the legacy path when the requested -model is absent. Kandev ignores the configured explicit fallback, does not -infer a variation, and continues with the provider current or default model. -Apply errors for an advertised model remain best-effort in that mode. - -The lifecycle stores `unique_variation` as the decision outcome and -`unique_variation_applied` as the warning reason. The effective model contains -the advertised variation. The explicit fallback field keeps its existing -meaning and stays empty when Kandev infers a variation. - -The saved profile model does not change. The rule runs for initial launch, -context reset, and workspace rebind. The executor catalog remains authoritative. -Any host-catalog result remains an advisory. - -## Consequences - -- A profile with `opus` follows `opus[1m]` when it is the only advertised - variation. -- A catalog with `opus[270k]` and `opus[1m, fast]` remains ambiguous and uses - the existing provider-default path. -- An explicit advertised fallback remains stronger than inference. -- Kandev never sends a model ID that the executor did not advertise. -- Users receive a durable warning because the effective model differs from the - saved request. -- Provider-specific ranking logic does not enter the lifecycle policy. -- Backend and frontend helpers need mirrored contract tests. The backend result - remains authoritative when their input catalogs differ. +- The historical `opus` to `opus[1m]` example does not authorize inference. +- Provider-specific variation labels and catalog order do not establish a + substitute model identity. +- Any future variation policy requires a new accepted decision that is + compatible with exact-profile identity. ## Alternatives Considered diff --git a/docs/decisions/2026-09-15-explicit-profile-model-strictness.md b/docs/decisions/2026-09-15-explicit-profile-model-strictness.md new file mode 100644 index 0000000000..7e4b11ed5a --- /dev/null +++ b/docs/decisions/2026-09-15-explicit-profile-model-strictness.md @@ -0,0 +1,51 @@ +# ADR-2026-09-15-explicit-profile-model-strictness: Make Exact Model Selection a Profile Opt-in + +**Status:** accepted +**Date:** 2026-09-15 +**Area:** backend, frontend, persistence, workflow + +## Context + +PR #3473 makes existing profiles strict when automatic fallback is false and +no explicit fallback is saved. These values previously permitted executor-default +continuation in catalog mismatch cases. Existing stored values do not prove +that a user selected strict behavior. Upgrading must not introduce that failure. + +## Decision + +Add a per-profile `require_exact_model` setting, default false for existing and +new profiles. Strictness takes priority over dormant fallback choices. Turning +it off restores those saved choices without rewriting them. Require a concrete +model when strictness is enabled. Do not add a global or workspace override. + +Compatible profiles retain the pre-PR selection/error behavior, including the +single advertised variation rule. Strict profiles never infer variations and +fail before inference when the requested model cannot be advertised and applied. +Both paths retain executor authority, unchanged saved model IDs, and durable +fallback warnings. No policy sends an unadvertised model. + +This decision supersedes +[implicit exact-profile identity](2026-09-06-exact-profile-model-identity.md). +It replaces the blanket variation rejection recorded in +[the variation decision](2026-09-07-unique-model-variation-resolution.md) with a +compatible-only rule. The implementation is tracked in the +[PR package](../plans/exact-profile-model-identity/plan.md). + +## Consequences + +Upgrades preserve working mismatch launches without enabling broader automatic +fallback. Profiles can enforce exactness independently across executors. The +additional field must traverse storage, APIs, profile copies, runtime resolution, +and all editors. Existing fallback values remain dormant while strictness is on. +Office post-start provider routing remains separately workspace-owned. + +## Alternatives Considered + +- Infer strictness from existing false/empty fields: changes existing launch + behavior and cannot distinguish an explicit decision from a default. +- Set automatic fallback true on every old profile: ignores explicit fallback + models and tolerates errors that previously stopped the launch. +- Replace current fields with an enum: adds migration scope and risks losing + existing fallback/error distinctions for this compatibility fix. +- Add global and profile settings: creates inheritance/precedence rules and + broad effects unrelated to an individual profile's identity policy. diff --git a/docs/plans/exact-profile-model-identity/plan.md b/docs/plans/exact-profile-model-identity/plan.md new file mode 100644 index 0000000000..fd7bf3293d --- /dev/null +++ b/docs/plans/exact-profile-model-identity/plan.md @@ -0,0 +1,205 @@ +--- +created: 2026-09-14 +status: implemented +requirements: + - REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001 + - REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002 +system_design: + - ../../specs/agents/system-design/no-silent-model-fallback-01.md + - ../../specs/agents/system-design/no-silent-model-fallback-02.md +legacy_specs: [] +--- +# Implementation Plan: Exact Profile Model Identity as an Explicit Opt-in + +## Overview + +Revise PR #3473 so existing remote-executor launches keep their pre-PR model +fallback behavior. Add Require exact model as a per-profile opt-in, default off. +Implement storage/API/runtime policy first, then profile editors, then end-to-end +acceptance and public documentation. All new work orders are sequential. + +This package was requested on 2026-09-15 and is implemented as a maintainer +fixup on the contributor branch. The plan, work orders, requirements, design, +tests, and public guidance are included with the implementation. + +## Baseline and completed work + +Implementation baseline: `561d2a8cf0d5c30224696304839a550952331ebe`. +Reviewed PR head: `561d2a8cf0d5c30224696304839a550952331ebe`. +The two intervening commits only retrigger CI; their tree diff is empty. +Compatibility oracle: pre-PR merge base +`ba960f973205854733e0dcd9afa355bdda3dddf6`. +The implementation was based on this contributor head and preserves the +completed transport and reuse fixes. + +Task 01 records the completed strict-by-default implementation. Preserve its +transport staging, replacement catalog event handling, single validated reuse +lookup, and durable warnings. Its old inferred strict predicate is superseded +by the new explicit field. Existing green tests do not prove upgrade compatibility. + +## Scope + +### In scope + +- False-by-default persisted `require_exact_model` through every profile path. +- Strict mode precedence over dormant fallback values; no destructive migration. +- Compatible launch/error behavior, including runtime-only unique variations. +- Shared launch/reset/rebind/recovery policy and explicit strict workflow reuse. +- Desktop/mobile profile editors, settings discovery, copy, and public guides. +- Actual store-upgrade-to-launch evidence and strict/non-strict regressions. + +### Out of scope + +Global/workspace/executor policy, runtime feature flags, provider authentication, +Office post-start routing changes, a live-session model lock, new provider APIs, +and unrelated PR cleanup. Do not recreate completed transport fixes. + +## Technical approach + +The [requirement](../../specs/agents/requirements/no-silent-model-fallback.md) +and [policy design](../../specs/agents/system-design/no-silent-model-fallback-01.md) +own the behavior matrix, migration, precedence, and transport contracts. +[Recovery and UI design](../../specs/agents/system-design/no-silent-model-fallback-02.md) +owns replacement-catalog handling, workflow eligibility, and profile surfaces. +The [ADR](../../decisions/2026-09-15-explicit-profile-model-strictness.md) +records why strictness is per profile and why old auto-fallback fields remain. + +Extend the existing profile store and DTO contract, `StartModelPolicy`, profile +resolvers, executor/backend adapters, and profile snapshots. Gate strict checks +on the new field. Preserve legacy omissions on partial update. Compatible mode +must not be implemented by setting `AutoFallback=true`: that changes fallback +priority and error tolerance. Extend existing React shared fallback components. +No new state-management or provider abstraction is needed. + +## ASCII UI preview + +### UI-01: Desktop profile, expanded Fallback settings + +Entry: Settings > Agents > profile. Default state; strictness is off. + +```text +Start model [ saved model v ] +Fallback settings v Executor default allowed ++------------------------------------------------------------+ +| Require exact model [ OFF ] | +| Stop before starting if the selected model cannot be used. | +| Fallback settings do not apply while this is on. | ++-----------------------------+------------------------------+ +| Automatic fallback [ OFF ] | Explicit fallback [ OFF ] | +| Existing help and controls | Existing help and controls | ++-----------------------------+------------------------------+ + [ Cancel ] [ Save ] +``` + +Strict on: summary becomes "Exact model required". Both fallback cards remain +visible and disabled, including their switches. Existing values remain saved. +Strict off restores their prior states. The fallback selector appears only when +its existing enable switch is on. +A strict+empty-model validation error appears beside the model field; Save +keeps the draft open. Collapsed state retains the summary and dirty indicator. + +### UI-02: Phone profile, expanded Fallback settings + +```text +< Agent profile +Start model +[ saved model v ] +Fallback settings v +Executor default allowed ++----------------------------------+ +| Require exact model [ OFF ] | +| Stop before starting if the | +| selected model cannot be used. | +| Fallback settings do not apply | +| while this is on. | ++----------------------------------+ +| Automatic fallback [ OFF ] | +| Visible helper text (i) | ++----------------------------------+ +| Explicit fallback [ OFF ] | +| Visible helper text (i) | ++----------------------------------+ +[ Cancel ] [ Save ] +``` + +The existing page/editor scrolls as one form. Help opens the shared inset +bottom drawer and returns focus on close. Switch rows and help targets have +at least 44px touch hit areas; desktop ordinary controls keep 28px sizing. +Strict-on, collapsed, and validation states have the same semantics on phone. +Long translations wrap without horizontal overflow or hiding Save. + +Order, grouping, visible disabled values, and shared state are requirements; +ASCII spacing and the illustrative model names are not pixel specifications. +UI-01/UI-02 map to AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.6 and .7. + +## Tests + +The following checks were run after implementation. + +| Acceptance | Evidence | +| --- | --- | +| 001.1/.4/.5, 002.1 | Lifecycle policy table and focused lifecycle run passed. It covers strict/compatible selection, fallback precedence, apply errors, and unique variations. | +| 001.3/.6 | SQLite migration, round-trip, explicit clear, schema replay, controller validation, and generated discovery checks passed. Postgres evidence was not run because no isolated `KANDEV_TEST_POSTGRES_DSN` was available. | +| 001.1/.3/.8 | Lifecycle reset, rebind, replacement, and profile-resolution regressions passed in the focused run. | +| 001.8 | Replacement-client catalog, cancellation, and strict reset/rebind tests passed in the focused lifecycle run. | +| 001.9 | Focused orchestrator model/profile/reuse/drift tests passed, including strict unknown drift and the validated empty candidate path. | +| 001.2/.10 | Existing desktop/mobile mismatch E2E tests passed with explicit strict fixtures and persisted compatible warnings. | +| 001.6/.7 | Frontend normalization, settings, editor, dirty/reconciliation, and CLI tests passed; typecheck, i18n checks, and targeted lint passed. | + +## E2E tests + +Extend existing specs; do not replace strict fixtures with compatible fixtures +and lose strict coverage. Set `require_exact_model=true` in strict fixtures. + +| File under apps/web/e2e/tests | Project | Acceptance and scenario | +| --- | --- | --- | +| `settings/no-silent-model-fallback.spec.ts` | chromium | 001.6/.7/.10: default off, enable/save/reload, disable restores dormant settings, strict validation keeps form open, desktop help and dimensions | +| `settings/mobile-no-silent-model-fallback.spec.ts` | mobile-chrome | 001.6/.7: same edit/save/reload flow, drawer dismiss then toggle/save, 44px hit targets, no overflow, long labels | +| `session/model-mismatch-warning.spec.ts` | chromium | 001.1/.2/.3/.4/.5: old-style profile omits field and starts on default with one reloaded warning; explicit strict profile fails before prompt; explicit fallback and auto=true remain distinct | +| `session/mobile-model-mismatch-warning.spec.ts` | mobile-chrome | 001.1/.2/.6: phone selection/launch reflects saved compatible/strict policy; warning survives reload | + +Mock E2E proves the user flow. The store-to-transport regression proves actual +migration compatibility; the Kubernetes transport test proves replacement +catalog ownership. None substitutes for the others. + +## Work orders + +- [x] [Task 01: Historical strict identity implementation](task-01-enforce-launch-identity.md) +- [x] [Task 02: Persist and apply explicit strictness](task-02-explicit-profile-policy.md) +- [x] [Task 03: Expose profile controls on desktop and phone](task-03-profile-strictness-controls.md) +- [x] [Task 04: Prove user flows and update public guides](task-04-compatibility-acceptance.md) + +## Verification results + +Implementation checks passed on 2026-09-15 unless noted below: + +- `python3 scripts/list-docs.py validate`: 269 decisions and 902 specifications. +- `python3 scripts/lint-spec-files.test.py`: 36 tests passed. +- `python3 scripts/lint-spec-files.py --all`: all specification files passed. +- `git diff --check`: passed. +- Backend focused lifecycle, settings, controller, handlers, executor, backendapp, + and orchestrator checks passed. A broader combined run reached unrelated + pre-existing orchestrator failures and an SSH close test timeout; the changed + focused tests passed. +- Frontend typecheck and 83 focused Vitest tests passed. +- Desktop settings E2E: 3 passed. Mobile settings E2E: 3 passed. +- Desktop session E2E: 2 passed. Mobile session E2E: 2 passed. +- Public docs validators and specification validators passed. +- `go run ./cmd/settings-catalog -check`, `pnpm run i18n:check`, and + `pnpm run i18n:ratchet` passed. +- Package-relative links and work-order REQ/AC references resolve. +- Catalog queries discover the amended specs and the new decision. + +The Postgres migration check remains explicitly pending until an isolated test +DSN is available. The implementation does not change the Postgres migration +contract, but SQLite and all application projections are covered. + +## Risks + +- Missing a DTO/draft/copy path can silently clear or drop strictness. +- A bulk auto-fallback migration can change fallback priorities and tolerated errors. +- Treating a recovery timeout as transport success can hide a broken session. +- Old session snapshots omit the new field; they must not imply strictness. +- Restoring compatible variation selection must never affect strict mode or edit saved IDs. +- Existing custom TUI/dynamic profiles do not have an enforceable concrete ACP model. +- Supported Postgres migration evidence requires an isolated test database; a skipped test is not a pass. diff --git a/docs/plans/exact-profile-model-identity/task-01-enforce-launch-identity.md b/docs/plans/exact-profile-model-identity/task-01-enforce-launch-identity.md new file mode 100644 index 0000000000..39fec32803 --- /dev/null +++ b/docs/plans/exact-profile-model-identity/task-01-enforce-launch-identity.md @@ -0,0 +1,53 @@ +--- +id: "01-enforce-launch-identity" +title: "Enforce exact profile model identity before inference" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001 +acceptance_criteria: + - AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.1 + - AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.2 +system_design: + - ../../specs/agents/system-design/no-silent-model-fallback-01.md +--- + +# Task 01: Enforce Exact Profile Model Identity + +## Historical scope + +This work order is complete for the original PR implementation. The 2026-09-15 +amendment in [Task 02](task-02-explicit-profile-policy.md) supersedes its implicit +strict predicate. Its transport, warning, and validated-reuse fixes remain. +The original scope below is historical, not the current compatibility contract. + +## Change + +Apply the shared start-model policy before the first inference on every +launch path: initial session start, context reset, and fresh ACP +workspace-rebind sessions. A concrete profile with a non-empty model, +`auto_fallback=false`, and no `fallback_model` must fail the launch before +a prompt or tool call when the executor does not advertise the configured +model; no speculative `SetModel` is sent for an unadvertised model. + +Workflow step entry must also isolate stale runtime identity: a parked +session whose persisted provider model disagrees with the destination +profile's configured model policy is replaced with a fresh session before +any auto-start prompt runs. Explicit `fallback_model` and `auto_fallback` +profiles remain the only authorized deviation paths, each with one durable +warning. + +## Verification + +- `go test ./internal/agent/runtime/lifecycle` covers + `TestApplyStartModelPolicy` behavior for advertised, fallback, and + unauthorized substitution cases (`start_model.go`, + `start_model_executor_authority_test.go`). +- `go test ./internal/orchestrator` covers session isolation on workflow + entry (`event_handlers_workflow.go`, + `event_handlers_workflow_profile_session_policy_resolution_test.go`, + `event_handlers_workflow_profile_session_policy_test.go`). +- Turn/message attribution and pre-prompt warnings agree on the effective + model; failures never expose credentials or provider secrets. diff --git a/docs/plans/exact-profile-model-identity/task-02-explicit-profile-policy.md b/docs/plans/exact-profile-model-identity/task-02-explicit-profile-policy.md new file mode 100644 index 0000000000..bb81d5a189 --- /dev/null +++ b/docs/plans/exact-profile-model-identity/task-02-explicit-profile-policy.md @@ -0,0 +1,130 @@ +--- +id: "02-explicit-profile-policy" +title: "Persist and apply explicit profile strictness" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001 + - REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002 +acceptance_criteria: + - AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.1 + - AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.2 + - AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.3 + - AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.4 + - AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.5 + - AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.6 + - AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.8 + - AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.9 + - AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.10 + - AC-AGENTS-NO-SILENT-MODEL-FALLBACK-002.1 +system_design: + - ../../specs/agents/system-design/no-silent-model-fallback-01.md + - ../../specs/agents/system-design/no-silent-model-fallback-02.md +--- +# Task 02: Persist and apply explicit profile strictness + +## Summary + +Deliver an independently testable backend/API policy from persisted profile to prompt. + +## In scope + +Deliver an independently testable backend/API policy from persisted profile to prompt. + +Add the additive false-default migration and profile field across storage, +DTO/controller/handler mappings, discovery schema, copy/import paths, resolvers, +internal projections, and session snapshots. Keep omitted partial updates intact. +Validate strict+empty/dynamic/passthrough without invalidating legacy profiles. +Regenerate discovery snapshots with the existing generator. + +Extend the shared policy with explicit strictness and restore compatible +selection/error behavior from the oracle. Update reset/rebind/replacement waits +and workflow drift eligibility. Preserve fresh-client-only evidence, cancellation, +validated reuse candidates, prompt ordering, and warning persistence. + +Add a real legacy-store-to-agentctl/lifecycle launch regression. Exercise normal +launch and all replacement boundaries. Do not only test synthetic policy structs. +Parameterize the real store migration/round-trip evidence for SQLite and Postgres. +Add projection tests proving true and false reach the runtime and snapshot. + +## Out of scope + +Global policy, provider authentication, Office post-start routing redesign, +unrelated cleanup, and publication. + +## Acceptance + +- A migrated old profile launches through the real lifecycle/transport boundary + under the same missing-model conditions as before. Explicit strictness stops + that launch before its prompt. Saved fallback fields remain identical. +- The complete policy matrix, partial-update/copy paths, and generated contract + pass. Strictness survives every projection, including dynamic candidate resolution. +- Recovery and workflow tests preserve existing fixes while applying the new + flag, with deterministic delayed events, cancellation, and empty reuse lookup. + + +## Verification + +Run from the repository root. Use TDD for changed logic; first record the +behavioral failure, then run the listed checks after implementation. + +```bash +(cd apps/backend && go run ./cmd/settings-catalog) +(cd apps/backend && go test ./internal/agent/settings/... ./internal/agent/runtime/agentctl ./internal/agent/runtime/lifecycle ./internal/orchestrator/... ./internal/backendapp) +(cd apps/backend && go run ./cmd/settings-catalog -check) +: "${KANDEV_TEST_POSTGRES_DSN:?Set an isolated Postgres test DSN before this check}" +(cd apps/backend && go test -count=1 ./internal/agent/settings/store -run 'TestPostgres.*(RequireExactModel|Schema)') +git diff --check +``` + +## Files likely touched + +- `apps/backend/internal/agent/settings/{models,dto,store,controller,handlers}/` +- `apps/backend/internal/agent/settings/store/sqlite_require_exact_model_test.go` (new) +- `apps/backend/internal/agent/runtime/lifecycle/{types,profile_resolver,manager_profile,start_model}.go` +- `apps/backend/internal/agent/runtime/lifecycle/{session,manager_interaction,manager_workspace_rebind,manager_kubernetes_refresh}.go` +- `apps/backend/internal/agent/runtime/lifecycle/profile_model_upgrade_test.go` (new) +- Existing lifecycle model, reset, rebind, and remote transport tests. +- `apps/backend/internal/orchestrator/event_handlers_workflow.go` and profile session policy tests. +- `apps/backend/internal/orchestrator/executor/{executor,executor_execute}.go` and tests. +- `apps/backend/internal/backendapp/adapters.go` and projection tests. +- `apps/web/lib/settings-discovery/{contract,profile-contract}.generated.json` (generator output). + +## Dependencies + +None; preserve Task 01 transport and candidate-lookup fixes. + +## Risks + +Do not convert old auto=false values into strict intent. Do not swallow +initialization/transport errors as missing catalogs. Postgres tests must actually +run with an isolated DSN. A stale cached profile must not lose true strictness. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/agents/requirements/no-silent-model-fallback.md) +- [Policy design](../../specs/agents/system-design/no-silent-model-fallback-01.md) +- [Recovery/UI design](../../specs/agents/system-design/no-silent-model-fallback-02.md) +- [Plan and regression matrix](plan.md) +- Scoped AGENTS.md, existing tests beside the affected code, and compatibility + behavior at `ba960f973205854733e0dcd9afa355bdda3dddf6`. + +## Results + +Implemented on 2026-09-15. The profile field now has an additive SQLite +migration, full DTO/controller/handler/store mappings, generated discovery +contracts, runtime lifecycle and workflow propagation, strict and compatible +selection behavior, and validation for unsupported profile kinds. Existing +fallback values remain dormant while strictness is enabled and survive an +explicit disable. + +Focused settings, lifecycle, executor, backend adapter, and orchestrator tests +passed. The broader combined backend command reached unrelated pre-existing +orchestrator failures and an SSH close test timeout. The Postgres-specific +check was skipped because `KANDEV_TEST_POSTGRES_DSN` was not available. diff --git a/docs/plans/exact-profile-model-identity/task-03-profile-strictness-controls.md b/docs/plans/exact-profile-model-identity/task-03-profile-strictness-controls.md new file mode 100644 index 0000000000..fe9096576d --- /dev/null +++ b/docs/plans/exact-profile-model-identity/task-03-profile-strictness-controls.md @@ -0,0 +1,176 @@ +--- +id: "03-profile-strictness-controls" +title: "Expose strictness in both profile editors" +status: done +wave: 2 +depends_on: ['02-explicit-profile-policy'] +plan: "plan.md" +requirements: + - REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001 +acceptance_criteria: + - AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.6 + - AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.7 + - AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.10 +system_design: + - ../../specs/agents/system-design/no-silent-model-fallback-01.md + - ../../specs/agents/system-design/no-silent-model-fallback-02.md +--- +# Task 03: Expose strictness in both profile editors + +## Summary + +Expose the saved profile setting through both existing editor paths. + +## In scope + +Expose the saved profile setting through both existing editor paths. + +Update normalized/HTTP types, serialization, draft state, dirty comparison, +reconciliation, settings store options, discovery inventory, and editor submit +payloads. Add the full-width strict row above the existing fallback cards. +Preserve dormant values, disabled states, and deterministic summaries. +Update the lighter CLI profile editor and shared fallback fields too. + +Use visible localized copy and existing error presentation for strict+empty +validation. Save errors retain the draft. Implement UI-01/UI-02 with shared state, +existing page scrolling, touch drawer help, focus return, and target sizing. +Translate en/pt-pt/zh-cn, generate zh-hk/zh-tw and pseudo; adjust summaries that +currently imply all auto=false profiles are strict. Keep host warnings advisory. + +## Out of scope + +Global policy, provider authentication, Office post-start routing redesign, +unrelated cleanup, and publication. + +## Acceptance + +- True, false, and omitted fields round-trip through both editors; Save/Cancel, + reload hydration, dirty state, and partial updates preserve the intended value. +- Strictness overrides dormant settings in summaries and disabled controls; + toggling it off restores them. Validation errors keep drafts open. +- Both editor paths satisfy UI-01/UI-02, localization, and focused component + tests. No global setting or second mobile policy is introduced. + +## ASCII UI preview + +### UI-01: Desktop profile, expanded Fallback settings + +Entry: Settings > Agents > profile. Default state; strictness is off. + +```text +Start model [ saved model v ] +Fallback settings v Executor default allowed ++------------------------------------------------------------+ +| Require exact model [ OFF ] | +| Stop before starting if the selected model cannot be used. | +| Fallback settings do not apply while this is on. | ++-----------------------------+------------------------------+ +| Automatic fallback [ OFF ] | Explicit fallback [ OFF ] | +| Existing help and controls | Existing help and controls | ++-----------------------------+------------------------------+ + [ Cancel ] [ Save ] +``` + +Strict on: summary becomes "Exact model required". Both fallback cards remain +visible and disabled, including their switches. Existing values remain saved. +Strict off restores their prior states. The fallback selector appears only when +its existing enable switch is on. +A strict+empty-model validation error appears beside the model field; Save +keeps the draft open. Collapsed state retains the summary and dirty indicator. + +### UI-02: Phone profile, expanded Fallback settings + +```text +< Agent profile +Start model +[ saved model v ] +Fallback settings v +Executor default allowed ++----------------------------------+ +| Require exact model [ OFF ] | +| Stop before starting if the | +| selected model cannot be used. | +| Fallback settings do not apply | +| while this is on. | ++----------------------------------+ +| Automatic fallback [ OFF ] | +| Visible helper text (i) | ++----------------------------------+ +| Explicit fallback [ OFF ] | +| Visible helper text (i) | ++----------------------------------+ +[ Cancel ] [ Save ] +``` + +The existing page/editor scrolls as one form. Help opens the shared inset +bottom drawer and returns focus on close. Switch rows and help targets have +at least 44px touch hit areas; desktop ordinary controls keep 28px sizing. +Strict-on, collapsed, and validation states have the same semantics on phone. +Long translations wrap without horizontal overflow or hiding Save. + +Order, grouping, visible disabled values, and shared state are requirements; +ASCII spacing and the illustrative model names are not pixel specifications. +UI-01/UI-02 map to AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.6 and .7. + +Full context: [plan previews](plan.md#ascii-ui-preview). Rendered desktop/mobile +acceptance runs in Task 04 after this work order. + +## Verification + +Run from the repository root. Use TDD for changed logic; first record the +behavioral failure, then run the listed checks after implementation. + +```bash +(cd apps && pnpm install --frozen-lockfile) +(cd apps/web && pnpm run i18n:zh-hant && pnpm run i18n:pseudo) +(cd apps/web && pnpm exec vitest run lib/api/domains/agent-profile-normalize.test.ts components/settings/profile-form-fields.test.tsx components/settings/agent-profile-dirty.test.ts components/settings/agent-profile-reconciliation.test.ts components/agent/cli-profile-editor.test.tsx lib/settings-discovery) +(cd apps/web && pnpm run typecheck && pnpm run i18n:check && pnpm run i18n:ratchet) +(cd apps/web && pnpm exec eslint components/settings/profile-model-fields.tsx components/settings/profile-form-fields.tsx components/settings/model-fallback-settings-shell.tsx components/settings/profile-capability-helpers.tsx components/settings/agent-profile-page.tsx components/settings/agent-profile-page-state.ts components/settings/agent-profile-dirty.ts components/settings/agent-profile-reconciliation.ts components/agent/cli-profile-editor.tsx components/agent/cli-profile-fallback-fields.tsx lib/types/agent-profile.ts lib/api/domains/agent-profile-normalize.ts lib/state/slices/settings/types.ts lib/settings-discovery) +git diff --check +``` + +## Files likely touched + +- `apps/web/lib/types/agent-profile.ts` +- `apps/web/lib/api/domains/agent-profile-normalize.ts` and tests. +- `apps/web/lib/state/slices/settings/types.ts` +- `apps/web/lib/settings-discovery/{profile-contract,coverage-inventory}.ts` and tests. +- `apps/web/components/settings/profile-{form-fields,model-fields,capability-helpers}.tsx` +- `apps/web/components/settings/model-fallback-settings-shell.tsx` +- `apps/web/components/settings/agent-profile-{page,page-state,dirty,reconciliation}.ts*` +- `apps/web/components/agent/cli-profile-{editor,fallback-fields}.tsx` +- Existing component tests and new `apps/web/components/settings/agent-profile-dirty.test.ts`. +- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw,pseudo}/settings.json` + +## Dependencies + +['02-explicit-profile-policy']; preserve Task 01 transport and candidate-lookup fixes. + +## Risks + +The two editor paths have separate form data; a shared shell alone does not +prove payload parity. A dormant auto=true value must not override strictness. +Record generated locale changes and avoid unrelated translation drift. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/agents/requirements/no-silent-model-fallback.md) +- [Policy design](../../specs/agents/system-design/no-silent-model-fallback-01.md) +- [Recovery/UI design](../../specs/agents/system-design/no-silent-model-fallback-02.md) +- [Plan and regression matrix](plan.md) +- Scoped AGENTS.md, existing tests beside the affected code, and compatibility + behavior at `ba960f973205854733e0dcd9afa355bdda3dddf6`. + +## Results + +Implemented on 2026-09-15. Desktop and CLI profile editors now share the +localized strictness row, preserve dormant fallback values, disable fallback +controls while strictness is enabled, and include the field in dirty state, +normalization, reconciliation, save payloads, and settings discovery. The +mobile layout keeps the existing drawer and form scroll owner. + +Typecheck, i18n checks, targeted lint, and 83 focused frontend tests passed. diff --git a/docs/plans/exact-profile-model-identity/task-04-compatibility-acceptance.md b/docs/plans/exact-profile-model-identity/task-04-compatibility-acceptance.md new file mode 100644 index 0000000000..500cb97a43 --- /dev/null +++ b/docs/plans/exact-profile-model-identity/task-04-compatibility-acceptance.md @@ -0,0 +1,144 @@ +--- +id: "04-compatibility-acceptance" +title: "Prove user flows and update public guidance" +status: done +wave: 3 +depends_on: ['03-profile-strictness-controls'] +plan: "plan.md" +requirements: + - REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001 +acceptance_criteria: + - AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.1 + - AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.2 + - AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.3 + - AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.4 + - AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.5 + - AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.6 + - AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.7 + - AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.8 + - AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.9 + - AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.10 +system_design: + - ../../specs/agents/system-design/no-silent-model-fallback-01.md + - ../../specs/agents/system-design/no-silent-model-fallback-02.md +--- +# Task 04: Prove user flows and update public guidance + +## Summary + +Complete desktop/mobile acceptance and align public guidance with the implementation. + +## In scope + +Complete desktop/mobile acceptance and align public guidance with the implementation. + +Extend all four existing specs named in the plan's E2E matrix. Keep strict +fixtures explicit and add omitted-field compatible fixtures. Edit/save/reload +through the UI; inspect visible errors and one persisted warning. Retain backend +pre-prompt assertions from Task 02; chat visibility alone does not prove no inference. +Cover both editor surfaces, dormant fallback restoration, strict+empty validation, +keyboard interactions, drawer dismissal/focus, touch bounds, and overflow. + +Update the reference sections in `docs/public/agents-and-profiles.md` and the +explanation in `docs/public/executors.md`. Explain per-profile default off, upgrade +compatibility, exact full-ID selection, and ordinary apply errors vs automatic +fallback. Search README/screenshots and linked specs/decisions for old implicit +strict claims. Keep completed transport fixes and managed-runtime documentation. + +Reconcile historical Task 01's scope notice, all new work-order results, and +specification status after actual implementation checks pass. Refresh PR head/base +read-only before handoff. Do not push, comment, or commit under this work order. + +## Out of scope + +Global policy, provider authentication, Office post-start routing redesign, +unrelated cleanup, and publication. + +## Acceptance + +- Desktop and phone users can save strictness and observe the correct launch + result; compatible warnings survive reload exactly once. Touch and keyboard + interactions meet the design and both editor paths have rendered coverage. +- Public profile/executor guidance matches the new default, fallback precedence, + and error matrix, including legacy upgrade behavior. +- Every required task check has a recorded result; pending or skipped Postgres + evidence remains explicit. Completed transport/reuse regressions still pass. + +## ASCII UI preview + +Apply [UI-01 and UI-02](plan.md#ascii-ui-preview). This work order verifies those +views and updates guide text; it does not introduce another layout. + +```text +Desktop: Strict [off/on] above [Automatic] [Explicit] cards -> Save +Phone: Strict [off/on] -> Automatic -> Explicit -> Save +Strict on: both fallback cards disabled, saved values visible. +Help: tap -> inset drawer -> dismiss -> focus returns -> save draft. +``` + +These views map to AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.6 and .7. + +## Verification + +Run from the repository root. Use TDD for changed logic; first record the +behavioral failure, then run the listed checks after implementation. + +```bash +(cd apps/web && pnpm e2e:run tests/settings/no-silent-model-fallback.spec.ts tests/session/model-mismatch-warning.spec.ts -- --retries=0) +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/settings/mobile-no-silent-model-fallback.spec.ts tests/session/mobile-model-mismatch-warning.spec.ts -- --retries=0) +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.test.py +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +## 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` +- `apps/web/e2e/tests/session/model-mismatch-warning.spec.ts` +- `apps/web/e2e/tests/session/mobile-model-mismatch-warning.spec.ts` +- `apps/web/e2e/tests/session/model-mismatch-warning-helpers.ts` +- `docs/public/agents-and-profiles.md`, `docs/public/executors.md` +- This plan, work orders, and paired requirement/design status and results. + +## Dependencies + +['03-profile-strictness-controls']; preserve Task 01 transport and candidate-lookup fixes. + +## Risks + +Use fresh managed production builds: do not pass --no-build after code changes. +Run E2E commands sequentially with the guarded worker budget. Restore shared +fixture mutations. Mock browser results cannot certify external provider auth. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/agents/requirements/no-silent-model-fallback.md) +- [Policy design](../../specs/agents/system-design/no-silent-model-fallback-01.md) +- [Recovery/UI design](../../specs/agents/system-design/no-silent-model-fallback-02.md) +- [Plan and regression matrix](plan.md) +- Scoped AGENTS.md, existing tests beside the affected code, and compatibility + behavior at `ba960f973205854733e0dcd9afa355bdda3dddf6`. + +## Results + +Implemented on 2026-09-15. Public agent/profile and executor guidance now +describes compatible-by-default behavior, explicit strict opt-in, fallback +precedence, host-probe advisories, and upgrade compatibility. The existing +desktop and mobile settings and session mismatch specs passed with fresh +production builds: + +- desktop settings: 3 passed; +- mobile settings: 3 passed; +- desktop session mismatch: 2 passed; +- mobile session mismatch: 2 passed. + +Public-doc and specification validators passed. The Postgres check remains +pending until an isolated test database is available. diff --git a/docs/public/agents-and-profiles.md b/docs/public/agents-and-profiles.md index bcfa608a5d..801f899017 100644 --- a/docs/public/agents-and-profiles.md +++ b/docs/public/agents-and-profiles.md @@ -161,6 +161,8 @@ Select an agent, create a profile, then open **Settings > Agents > _Agent_ > _Pr | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Name | Label shown in workflow, session, and automation selectors. | | Model | Requested through ACP when the agent supports model selection. Leaving it unset uses the agent's default where the form allows that. | +| Require exact model | Per-profile opt-in. When enabled, Kandev stops before inference unless the executor advertises and accepts the saved model. It disables fallback controls without erasing their saved values. | +| Fallback settings | Compatible profiles can use an advertised explicit fallback or automatic provider-default continuation. If the saved model is absent and exactly one bracketed variation is advertised, Kandev can use that variation with a warning. | | Mode | Requested with ACP `session/set_mode`. The choices come from the installed agent. | | Configuration options | Dynamic ACP values requested with `session/set_config_option`. | | CLI flags | Enabled entries are tokenized and appended to the ACP launch command. | @@ -187,31 +189,6 @@ change makes a saved option value unsupported, Kandev removes that value after a successful resolution; a failed resolution keeps the draft unchanged so you can retry it. -### Model IDs and executor catalogs - -The host model probe is an editing hint. The selected executor owns the model -catalog at launch. Kandev resolves a saved model in this order: - -1. Use the exact requested model when the executor advertises it. -2. Use the advertised explicit fallback when the requested model is absent. -3. Use one unique bracketed variation when the requested model is absent and - the executor advertises exactly one matching ID. -4. Use the agent's current or default model when no earlier choice applies. - -Profiles with automatic fallback enabled keep the legacy behavior when the -saved model is absent: Kandev ignores the explicit fallback and does not infer -a variation. It uses the agent's current or default model instead. - -For example, a saved `opus` model can launch as `opus[1m]` when that is the -only advertised `opus[...]` ID. If the executor advertises both -`opus[270k]` and `opus[1m, fast]`, Kandev does not choose either variation. -Variation text is opaque and model IDs remain case-sensitive. - -Kandev shows a warning when the launch result differs from the saved model. -The saved profile remains `opus`; Kandev does not rewrite it after applying a -unique variation or using the agent default. Recheck the executor catalog when -the warning repeats after credentials, copied configuration, or agent updates. - ### Use a dynamic profile > [!EXPERIMENTAL] @@ -267,22 +244,33 @@ stale browser action does not replace a newer route decision. The model list shown while editing a profile comes from a host probe. It is an editing hint, not a launch gate. A profile remains selectable when its saved -model is missing from that host list. Profile selectors do not show a model -warning for this difference. Inspect the model list in profile settings for -discovery details. Authentication, installation, and probe-failure indicators -remain visible on profile selectors. +model is missing from that host list. Profile selectors show an amber warning +icon for this difference. On a desktop pointer, hover or focus the icon to +read its tooltip. On a touch device, select the icon to open its warning +drawer. Inspect the model list in profile settings for discovery details. +Authentication, installation, and probe-failure indicators remain visible on +profile selectors. -At task launch, the selected executor's ACP catalog is authoritative. For -profiles without automatic fallback, Kandev follows the four-step order above. -For profiles with automatic fallback enabled, an absent saved model causes no -model request, and Kandev ignores the explicit fallback and any variation. -Kandev stores one warning in task chat with the requested model and the -effective model when known. The warning also identifies the agent and executor -and asks you to check credentials, copied configuration, and the agent version. +At task launch, the selected executor's ACP catalog is authoritative. Kandev +sends the requested model only when the executor advertises it. Profiles are +compatible by default, so an unavailable saved model can use an advertised +explicit fallback, one unique bracketed variation, or the executor's current +or default model. Kandev records one warning with the requested and effective +models when it continues with a different model. Automatic fallback allows +provider-default continuation and ignores the saved explicit fallback. -The saved profile model is not changed. Optional portable configuration can -copy selected allowlisted files into a remote executor, but it cannot guarantee -that the host and executor expose the same model catalog. +Enable **Require exact model** when the profile must keep the saved model. The +executor must advertise and accept that model before the first prompt. An empty +or unsupported catalog, an unavailable model, or a failed apply stops the +session before inference. Kandev never sends an unadvertised model and never +rewrites the saved profile model. + +The host model list is only an editing hint. A missing host-probe model keeps a +profile selectable and shows an advisory warning; the executor catalog decides +the launch result. Upgrades and omitted API fields keep existing profiles +compatible, with strictness off until a user enables it. Optional portable +configuration can copy selected allowlisted files to a remote executor, but it +cannot guarantee equal host and executor model catalogs. ### Monitor capability and subscription status diff --git a/docs/public/executors.md b/docs/public/executors.md index 13c4e0c4d1..07e644d774 100644 --- a/docs/public/executors.md +++ b/docs/public/executors.md @@ -101,32 +101,24 @@ Review the selected bundles before saving the profile. ### Model selection in remote executors The host model probe helps edit a profile, but it is not the launch authority. -At launch, the selected executor's advertised ACP catalog decides the model. -For profiles without automatic fallback, Kandev applies this deterministic -order: +At launch, the selected executor's advertised ACP catalog decides whether +Kandev sends the saved model. Profiles are compatible by default. When the +saved model is absent, Kandev can use an advertised explicit fallback, one +unique bracketed variation, or the executor's current or default model. An +automatic-fallback profile continues with the provider default and ignores its +saved explicit fallback. Kandev writes one warning to task chat when it uses a +different model. -1. An exact advertised model ID. -2. An advertised explicit fallback. -3. One unique advertised bracketed variation of the saved model. -4. The agent's current or default model. +Enable **Require exact model** on a profile when a substitution is not allowed. +The executor must advertise and accept the saved model before the first prompt. +An unavailable model, empty or unsupported catalog, or failed apply stops the +session before inference. The session error reports the requested model, the +effective model when known, and a stable reason. Kandev does not send an +unadvertised model or rewrite the saved profile model. -For profiles with automatic fallback enabled, an absent saved model keeps the -legacy behavior: Kandev ignores the explicit fallback and does not infer a -variation. It uses the agent's current or default model instead. - -For example, a saved `opus` model uses `opus[1m]` when that is the only -advertised variation. With both `opus[270k]` and `opus[1m, fast]`, Kandev -does not infer a choice and uses the agent's current or default model instead. -It treats model IDs as case-sensitive and variation text as opaque. - -Kandev writes one warning to task chat when the effective model differs from -the saved model. The warning can list the requested model, effective model, -agent, executor, and executor profile. It also tells you to check executor -credentials, copied agent configuration, and the agent version. Kandev does -not rewrite the saved profile model, including after applying a unique -variation. -Portable configuration can improve parity, but it does not guarantee equal -host and executor model catalogs. +A missing host-probe model remains an advisory warning and does not disable +profile selection. Portable configuration can improve parity, but it does not +guarantee equal host and executor model catalogs. ### Script behavior is runtime-specific diff --git a/docs/specs/agents/requirements/no-silent-model-fallback.md b/docs/specs/agents/requirements/no-silent-model-fallback.md index ee7d647477..6f7bf20fae 100644 --- a/docs/specs/agents/requirements/no-silent-model-fallback.md +++ b/docs/specs/agents/requirements/no-silent-model-fallback.md @@ -5,131 +5,107 @@ created: 2026-08-23 owners: - kandev --- - # No Silent Model Fallback Requirements ## Overview -Kandev applies an agent profile model against the model catalog from the -selected executor. A different effective model must be deterministic and -visible to the user. The agent system owns this contract because it owns -profiles, ACP session startup, and model-selection warnings. +Profiles retain their model choices when executor capabilities differ from the +host. Users can require exact selection for an individual profile. Upgrading +Kandev must not turn existing fallback behavior into strict launch failures. + +The agent system owns this contract because it owns persisted profile policy. +Task and Office execution consume that policy. This specification amends +PR #3473 and is implemented by the profile policy and recovery designs. ## Terminology -- **Requested model:** The model ID stored in the agent profile. -- **Explicit fallback:** The optional fallback model ID stored in the profile. -- **Bare model ID:** A model ID that contains no `[` or `]` character. -- **Model variation:** An advertised model ID with the exact form - `[]`. The variant contains no bracket - character. Its content is otherwise opaque. -- **Unique variation:** The only distinct advertised variation for a requested - bare model ID. +- **Strict profile:** a profile with Require exact model explicitly enabled. +- **Compatible profile:** a profile with Require exact model disabled or absent. +- **Requested model:** the effective model for the launch, including an + intentional session override where the existing session contract permits it. +- **Fallback:** a different model selected or retained for the current launch. ## Requirements -### REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001: Use executor-authoritative model selection +### REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001: Explicit strictness and visible fallback -**Intent:** Keep task launch operational without hiding a difference between -the saved model and the effective executor model. +**Intent:** Prevent unwanted substitutions when explicitly requested, while +preserving working executor launches for existing profiles. -**User story:** As a user, I want Kandev to use the selected executor's model -catalog and report any fallback, so that I can understand which model runs. +- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.1:** When Require exact model is + enabled, the executor shall advertise and apply the requested model before + the initial prompt. Otherwise the attempt shall fail before inference. + A fallback setting or advertised variation shall not bypass this rule. +- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.2:** When a compatible profile uses + a different model, the session shall show one durable warning for that + selection decision. It shall include the requested and effective model when known. + Reload and event replay shall not duplicate the warning. +- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.3:** Existing profiles and newly + created profiles shall have Require exact model off unless explicitly set. + An upgrade shall preserve the model, fallback model, automatic fallback, + and compatible launch outcomes. An absent setting shall not imply strictness. +- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.4:** For compatible profiles, an + advertised requested model shall take priority. With automatic fallback off, + an advertised explicit fallback shall take priority over variation matching. + If no alternate selection is available, the session shall continue on the + executor current/default model with a warning. An empty catalog or unsupported + model-selection method shall also permit this continuation. +- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.5:** Compatible profiles shall retain + existing apply-error behavior. An ordinary error applying an advertised model + shall fail unless automatic fallback is enabled. Automatic fallback shall + continue with a warning and ignore the configured explicit fallback. + Initialization failures, disconnections, and cancellation shall remain errors. +- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.6:** Require exact model shall be a + per-profile setting in Fallback settings, available on desktop and mobile. + It shall default off. Enabling it shall disable the fallback controls without + erasing their values. Disabling it shall restore their previous behavior. + Save, reload, duplication, and supported profile export/import shall preserve + the setting. Omitted fields in partial updates shall preserve a saved choice. +- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.7:** A collapsed Fallback settings + section shall summarize the effective policy and expose unsaved changes. + Strictness shall be keyboard and touch operable with visible explanatory copy. + Phone help shall use the existing drawer interaction, with reachable touch + targets, focus return, and no horizontal document overflow. +- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.8:** Launch, reset, fresh workspace + rebind, and replacement-executor recovery shall apply the same profile policy. + Replacement selection shall use only replacement-session evidence. Cancellation + shall stop the attempt. A strict attempt without a ready replacement catalog + shall fail; an initialized compatible attempt may continue with a warning. +- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.9:** Across different workflow steps, + an explicitly strict destination shall replace a session whose effective model + is different or unknown. Compatible destinations shall not force replacement + solely because the effective model differs. Same-step intentional overrides + shall keep their existing behavior. A validated empty reuse lookup shall create + a fresh session without an unvalidated second lookup. +- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.10:** A missing host-probe model shall + remain an advisory and shall not disable profile selection. Runtime selection + shall not send an unadvertised model or rewrite the saved profile model. + Enabling strictness without a concrete model shall produce a validation error. -#### Acceptance criteria +### REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002: Compatible advertised variation selection -- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.1:** When the executor advertises - the requested model, the system shall apply that exact model. -- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.2:** When the requested model is - absent and the executor advertises the configured explicit fallback, the - system shall apply the explicit fallback. -- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.3:** When no permitted advertised - model applies, the system shall make no speculative model-selection call and - shall continue with the provider current or default model. -- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.4:** When the effective model differs - from the requested model, the system shall persist one structured warning - that identifies the requested and effective models when known. -- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.5:** The system shall not rewrite the - saved profile model from an executor model-selection decision. +**Intent:** Preserve the pre-PR compatible selection order without treating a +variation as an exact identity. This amendment restores the scoped behavior +removed by the strict-by-default PR. -### REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002: Resolve one advertised model variation +- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-002.1:** This rule applies when strictness + and automatic fallback are off. The bare requested ID must be absent, and no + advertised explicit fallback must be available. Under these conditions, exactly + one distinct advertised bracketed variation shall be selectable with a warning. Zero or multiple matches, malformed + variations, and already-bracketed requests shall not authorize a guessed ID; + the session shall use the executor default with a warning. Saved IDs shall + remain unchanged. Strict profiles shall never infer a variation. -**Intent:** Preserve a user's bare model choice when a CLI replaces that model -ID with one unambiguous bracketed variation. +## Exclusions -**User story:** As a user, I want `opus` to resolve to `opus[1m]` when that is -the only advertised variation, so that a provider catalog change does not move -my session to an unrelated default model. +No global, workspace, executor, environment, or release-toggle setting. No +provider switching caused by this new field. Office post-start routing remains +workspace-owned. No retroactive interruption or model change of running turns. +No guarantee that invalid credentials, missing executables, or network failures +can launch successfully. No new global enforcement system for model costs. -#### Acceptance criteria +## System design and delivery -- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-002.1:** When automatic fallback is - disabled, the exact requested model is absent, and an advertised explicit - fallback exists, the system shall apply the explicit fallback before it - considers an inferred variation. Automatic-fallback profiles shall retain - their legacy no-selection behavior for an absent requested model. -- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-002.2:** When no advertised explicit - fallback exists and the catalog contains exactly one distinct variation of a - requested bare model ID, the system shall apply that advertised variation. -- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-002.3:** When the catalog contains zero - or more than one distinct variation, the system shall not infer a model. -- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-002.4:** When the requested ID contains a - bracket, the system shall treat it as an exact ID and shall not infer another - variation. -- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-002.5:** Matching shall be case-sensitive - and shall not parse, rank, or assign meaning to variation text. -- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-002.6:** When Kandev applies a unique - variation, it shall keep the saved model unchanged and shall persist a - warning that identifies the inferred effective model. -- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-002.7:** The host profile probe shall - present unique-variation resolution as an advisory in the profile editor. - It shall not become the launch authority or add a model warning to a profile - selector. It shall not disable the profile. -- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-002.8:** The same resolution order shall - apply at initial launch, context reset, and workspace rebind. - -### REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-003: Show model warnings at the point of action - -**Intent:** Let users select a saved profile without treating a host discovery -difference as evidence of an executor failure. - -#### Acceptance criteria - -- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.1:** A host model-catalog difference - shall not add an icon, message, tooltip, or help action to a profile selector. - This applies to its options and its selected label, including missing models, - one or several model variations, and empty or pending catalogs. -- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.2:** On desktop and mobile, an - otherwise eligible profile shall remain selectable by keyboard or pointer. - On touch devices, tapping its row shall select it without opening model help. -- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.3:** Existing authentication, - installation, and capability-probe failure indicators shall remain visible. - Their presence shall not depend on whether the saved model is advertised. -- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.4:** The profile editor shall retain - its missing-model treatment, unique-variation advisory, and fallback controls. -- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.5:** When the executor applies the - requested model successfully, a host catalog difference shall not cause a - model-selection warning in task chat. Actual fallback warnings shall retain - the persistence and visibility defined by requirement 001. -- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.6:** Selecting a profile shall not - rewrite its saved model, reasoning configuration, or fallback settings. - -## Compatibility - -The September 2026 selector amendment removes host model advisories from -profile selection. It preserves executor model selection and profile editing. -Delivery is tracked in the [implementation package](../../../plans/profile-selector-model-warnings/plan.md). - -## Out of scope - -- Ranking multiple variations by context size, speed, price, or display order. -- Parsing provider-specific variation syntax beyond the bracketed ID shape. -- Inferring a base model from an already bracketed request. -- Rewriting the profile model to the current executor's advertised variation. -- Changing Office post-start provider routing or mid-turn model switching. -- Changing host discovery refresh, cache invalidation, or ACP model normalization. - -## System design - -The technical source is split into [part 1](../system-design/no-silent-model-fallback-01.md) -and [part 2](../system-design/no-silent-model-fallback-02.md). +- [Policy and persistence](../system-design/no-silent-model-fallback-01.md) +- [Recovery and profile surfaces](../system-design/no-silent-model-fallback-02.md) +- [PR implementation package](../../../plans/exact-profile-model-identity/plan.md) diff --git a/docs/specs/agents/system-design/no-silent-model-fallback-01.md b/docs/specs/agents/system-design/no-silent-model-fallback-01.md index 436c9fd7de..5ad259088c 100644 --- a/docs/specs/agents/system-design/no-silent-model-fallback-01.md +++ b/docs/specs/agents/system-design/no-silent-model-fallback-01.md @@ -4,640 +4,146 @@ system: agents requirements: - REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001 - REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002 - - REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-003 created: 2026-08-23 owners: - kandev --- -# No Silent Model Fallback System Design Part 1 +# No Silent Model Fallback: Policy and Persistence -## Purpose and boundaries +## Scope and mapping -This design defines executor-authoritative model fallback, including safe -resolution from a bare requested model to one advertised bracketed variation. +This design amends the strict-by-default implementation in PR #3473. The agents +system owns profile policy; lifecycle applies it using executor evidence. +[Part 2](no-silent-model-fallback-02.md) covers recovery and rendered surfaces. -## Requirement mapping - -| Requirement | Design section | +| Requirement | Design sections | | --- | --- | -| `REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001` | [Migrated source detail](#migrated-source-detail) | -| `REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002` | [Unique model-variation resolution](#unique-model-variation-resolution) | -| `REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-003` | [Profile selection and warning placement](#8-profile-selection-and-warning-placement) | - -## Migrated source detail - -**Status**: implemented (executor-authority amendment 2026-08-15) -**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 host and executor can advertise different model catalogs. -Kandev must keep the task operational and explain the effective model.** - -## Goals - -- A configured model that the executor does not advertise is never applied. - The agent continues with its current or default model. -- Every default or explicit fallback creates a persisted warning in task chat. - The warning survives reload and shows the effective model when known. -- A Claude model that is valid for the user's account can start in a cold, - isolated executor even when the initial Claude ACP model list omits it. -- "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 remain selectable when the host probe does not advertise the saved - model. The host probe is an editing hint, not an executor launch gate. -- A new optional per-profile **agent fallback model** allows an explicit, - single-model automatic switch when the start model becomes unavailable at - session start. Kandev applies it only when the executor advertises it. - 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). A failed apply continues with the - agent default and creates a persisted warning. 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. -- Requiring equal model catalogs on the Kandev host and executor. -- Requiring portable configuration copying before a task can start. -- 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`. -- **Default-on-mismatch mode**: profile has `auto_fallback = false` and no - `fallback_model`. If the executor omits the start model, Kandev skips model - selection and the agent uses its default. Kandev persists a warning. -- **Fallback-model mode**: profile has `auto_fallback = false` and a - non-empty `fallback_model`. The only permitted automatic switch is to - that single model when the executor advertises it. Otherwise, the agent uses - its default and Kandev persists a warning. -- **Auto-fallback mode**: profile has `auto_fallback = true`. Legacy - behavior (session-start best-effort; office routing re-dispatch to next - candidate). Every session-start deviation creates a warning. - `fallback_model` is ignored. -- **Cold isolated executor**: a non-standalone executor that does not share the - host probe's Claude settings or model cache. Docker, remote Docker, Sprites, - and SSH executors can have this property. - -## Behavior Matrix - -Per agent profile, one of three modes (precedence: `auto_fallback` wins -over `fallback_model`): - -| Scenario | Default-on-mismatch | Fallback-model | Auto-fallback | -|---|---|---|---| -| Session start, start model not advertised | Do not call `SetModel`. Continue on the agent default and persist a warning. | If the fallback is advertised, apply it and persist a warning. Otherwise, use the agent default and persist a warning. | Do not call `SetModel`. Continue on the agent default and persist a warning. | -| Session start, advertised start model fails to apply | Fail explicitly. | Fail explicitly. | Continue on the agent default and persist a warning. | -| Session start, model selection unsupported | Continue on the agent default and persist a warning. | Same | Same | -| 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 default-on-mismatch: `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 selectable without a host model advisory. | Same. | Same. | -| 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`) do not stop -the launch. The agent uses its default and Kandev persists a warning. - -The executor ACP catalog is authoritative for launch. -The host probe remains an editing hint and does not block profile selection. - -## Unique model-variation resolution - -### Matching contract - -ACP model IDs remain opaque and case-sensitive. Kandev recognizes only this -structural relationship: - -```text -[] -``` - -The requested ID must contain no `[` or `]` character. The advertised ID must -start with the complete requested ID followed by `[`, end with `]`, and contain -a non-empty variant with no bracket character. Kandev does not trim, parse, or -rank the variant. - -Examples: - -| Requested | Advertised candidates | Result | -| --- | --- | --- | -| `opus` | `opus[1m]` | Apply `opus[1m]`. | -| `opus` | `opus[270k]`, `opus[1m, fast]` | Do not infer. | -| `opus[1m]` | `opus[270k]` | Do not infer. | -| `opus` | `myopus[1m]`, `opus-extra[1m]` | Do not infer. | -| `opus` | `opus[]`, `opus[1m][fast]` | Do not infer. | - -Duplicate catalog rows with the same model ID count as one distinct candidate. -This rule keeps the outcome stable when an agent repeats a catalog entry. - -### Resolution order - -For profiles with `auto_fallback = false`, the lifecycle applies this order -after ACP initialization: - -1. Apply the exact requested model when advertised. -2. Apply the configured explicit fallback when advertised. -3. Apply the requested model's unique advertised variation when one exists. -4. Otherwise, make no model-selection call and keep the provider current or - default model. - -An unadvertised explicit fallback does not block step 3. More than one distinct -variation blocks inference even when one candidate appears first or is the -provider current model. - -When `auto_fallback = true`, the legacy path takes precedence when the -requested model is absent: Kandev ignores the explicit fallback and does not -infer a variation. It makes no model-selection call and continues on the -provider current or default model with a warning. Apply errors for an -advertised model remain best-effort in that mode. - -`applyStartModelPolicy` remains the single runtime owner. A pure helper returns -the unique candidate or no candidate. Initial launch, context reset, and -workspace rebind continue to call the same policy. - -### Decision and warning contract - -The typed decision adds the outcome `unique_variation` and the stable reason -`unique_variation_applied`. `EffectiveModel` contains the advertised variation. -`FallbackModel` remains reserved for a configured explicit fallback and is not -overloaded with an inferred value. - -Kandev calls `SetModel` only with the candidate's exact advertised ID. A failed -call follows the existing advertised-model rule: fail the operation unless -legacy `auto_fallback` permits provider-default continuation. A -method-not-supported response also follows the current provider-default path. - -The persisted `model_selection_warning` event uses the new reason and existing -requested and effective model fields. The profile remains unchanged. The -decision ID continues to include the requested model, reason, and effective -model, so the warning stays idempotent for one session decision. - -### Host advisory and mobile behavior - -The frontend uses a small pure helper with the same case-sensitive matching -table. This calculation is advisory because the host catalog can differ from -the selected executor catalog. - -When the host advertises one variation, the task profile option remains -selectable without a host model advisory. The profile editor keeps the saved -bare ID visible and describes the unique -host variation instead of reporting that the model is simply gone. Selecting -the advertised variation remains an explicit way to update the profile. - -When the host advertises zero or multiple variations, the profile editor's -missing-model presentation remains. The runtime can still reach a different decision from -the selected executor catalog. - -The task-create selector has no host model tooltip or help drawer. -The profile editor uses its existing responsive selector. The durable -chat warning uses the existing status-message layout on desktop and mobile. -This change adds no nested scroll area or hover-only information. - -### Tests and operational evidence - -Backend table tests cover exact precedence, explicit-fallback precedence, -one variation, duplicate rows, multiple variations, bracketed requests, -case differences, malformed IDs, method-not-supported, and apply errors. -Existing launch, reset, rebind, warning persistence, and reload tests cover the -new decision outcome. - -Frontend unit tests mirror the matching table and cover the task picker, -profile editor, and localized status warning. Existing desktop and mobile model -mismatch E2E flows add the unique and ambiguous catalog cases. - -Structured lifecycle logs record the requested model, effective model, outcome, -and distinct candidate count. They do not record credentials or configuration -file contents. - -No database migration is required. - -## Related decisions - -- [Let the Executor Own Model Selection](../../../decisions/2026-08-15-executor-authoritative-model-selection.md) -- [Resolve Only One Advertised Model Variation](../../../decisions/2026-09-07-unique-model-variation-resolution.md) - -## 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.", -"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: executor-authoritative model application - -In `internal/agent/runtime/lifecycle/session.go`, the start-model policy uses -the executor session state after `InitializeSession`. - -The policy returns a typed model decision with these values: - -- Requested model. -- Advertised fallback model, when one is applied. -- Effective model, when the agent reports it. -- Outcome and warning reason. -- Whether Kandev called `SetModel`. - -The policy applies this order: - -1. If the start model is advertised, call `SetModel(start_model)`. -2. If the start model is absent and the explicit fallback is advertised, call `SetModel(fallback_model)`. -3. Otherwise, do not call `SetModel` and continue with the agent default. - -An empty advertised list follows step 3. -It does not authorize a speculative `SetModel` request. - -If an advertised start or fallback model fails to apply, fail the launch. -Transport and protocol errors remain explicit. - -If `auto_fallback` is enabled, an apply error remains best-effort. -The launch continues with the agent default and persists a warning. - -If `sessionmodel.IsMethodNotFound(err)` is true, continue with the agent default. -Persist a warning because the profile requested a model that Kandev could not apply. - -The start-model policy owns every model-selection attempt. -Later profile or configuration layers must not repeat a handled attempt. - -The same policy applies after a context reset. -A reset cannot send a model that the new executor session does not advertise. - -### 3a. Persisted model-selection warning - -If Kandev does not apply the requested model, it emits a provider-neutral -`session_model_selection_warning` event. - -The orchestrator persists one `status` message for the session-start decision. -The message uses `metadata.variant = "warning"` and -`metadata.kind = "model_selection_warning"`. - -The metadata contains the reason, requested model, effective model, fallback -model, agent ID, executor type, and executor profile ID. - -The message content is structured and localized by the frontend status renderer. -If the agent reports no effective model, the UI shows -`provider default, model not reported`. - -The warning includes this remediation guidance: - -- Inspect the executor credentials. -- Inspect copied agent configuration. -- Inspect the agent version in the executor. - -Message persistence is best-effort and does not stop the agent launch. -Persistence errors are logged without model data from configuration files. - -The persistence path uses an idempotency key for the session-start decision. -Reconnect, replay, and browser reload do not create duplicate warning messages. - -The existing ephemeral `session.model_fallback` signal becomes a compatibility -projection of the provider-neutral decision event. - -### 3b. Claude ACP model exposure in cold executors - -The host probe can advertise a Claude model from its warm settings or model -cache. A cold isolated executor can start with only the bridge's baseline model -list. This difference does not prove that the configured model is unavailable -to the user's account. - -Before the initial Claude ACP process starts in a non-standalone executor, the -lifecycle exposes the effective start model through Claude's documented launch -environment. The effective model is the request model override when present. -Otherwise, it is the profile start model. The lifecycle exposes an explicit -profile fallback as a selectable custom model when `auto_fallback` is false. -During workspace recovery, a persisted session runtime model is treated as the -selected model for launch-environment derivation and takes precedence over the -profile start model. - -Provider launch environment values use fill-missing precedence. Request, -executor, and profile environment values remain authoritative. The lifecycle -does not replace an existing `ANTHROPIC_MODEL` or -`ANTHROPIC_CUSTOM_MODEL_OPTION` value. - -This behavior is an optional agent capability. Other agents receive no Claude -environment values. A standalone Claude process keeps the current host behavior -because it shares the settings and cache used by the probe. - -SSH keeps its credential allowlist unchanged, but projects these non-secret -Claude model values into the remote agent controller so its child process sees -the same launch environment. Recovery fails before environment construction if -the execution profile cannot be resolved, rather than silently dropping its -model and environment values. - -Kandev does not relax model validation after the ACP session starts. -If Claude still omits the model, the executor-authoritative policy uses the default and warns. - -Kandev does not copy the bridge private cache. -The separate portable-configuration feature can copy `settings.json` after explicit user selection. - -### 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. -Kandev does not send an unadvertised model, and it persists a warning. -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 - 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 (executor default, 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 selection and warning placement - -`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`) derives profile labels and eligibility without -comparing the saved model with the host model catalog. Remove -`advertisedModelIDs`, `ModelProbeWarning`, and `ModelProbeWarningIndicator` from -this module, plus imports used only by those helpers. The hook no longer needs -`useAvailableAgents` solely to produce model advisories. - -Both `renderLabel` and `renderTriggerLabel` use the same profile presentation. -Keep the option interface compatible with its consumers. Preserve profile -eligibility, recent-use ordering, names, logos, and CLI passthrough indicators. -Keep `getCapabilityWarning` and its authentication, installation, and probe -failure states. Those states describe agent health rather than a model mismatch. - -Consumers include task creation, new subtasks, new sessions, quick chat, -automation configuration, and Office setup. All receive this behavior from the -shared hook. Do not add consumer-specific warning suppression flags. - -Retain profile model/configuration fields and the editor's -`findUniqueModelVariation`. After checking references, remove these unused -selector keys from every locale: -`profileStartModelNotAdvertisedOnHost` and -`profileStartModelUniqueVariationOnHost`. - -`apps/web/app/office/setup/agent-profile-setup-controls.tsx` -(`useSelectableProfileOptions`) retains its Office eligibility filtering around -the shared profile options. - -#### Desktop and mobile composition - -Keep the touch-usable combobox and shared selection logic. A row tap completes -the temporary profile choice without another help drawer or an empty hit area. -Preserve keyboard selection, focus return, picker-owned scrolling, and safe-area -constraints. Assert no document horizontal overflow. - -Use `e2e/tests/settings/mobile-no-silent-model-fallback.spec.ts` as the shipped -flow and `components/task/mobile/mobile-picker-sheet.tsx` as the curated -temporary-choice precedent. No picker-shell replacement is needed. - -#### Rationale and compatibility - -A host observation cannot establish the executor outcome. A neutral icon or -better ID comparison would still report valid catalog differences during -selection. Keep the existing executor-authority ADR and runtime behavior. -This change needs no resolver, cache, schema, or new ADR. Explain warning -placement in `docs/public/agents-and-profiles.md` when implementation ships. - -The persisted `model_selection_warning` chat message renders through -`apps/web/components/task/chat/messages/status-message.tsx`. -The renderer uses localized copy and structured metadata. - -The message appears in the normal chat flow on desktop and mobile. -It does not require a new drawer or a hover-only control. - -### 9. i18n - -New keys in `apps/web/src/locales/{en,pseudo}/settings.json` (camelCase, -`settings:` namespace), cover the gone-model hint and fallback controls. - -New keys in the chat namespace cover the persisted model-selection warning. -No added or edited line contains hardcoded user copy. - -## 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: an unadvertised start model causes no `SetModel` call. - The session continues with the reported current or default model. -- Runtime session start: an advertised explicit fallback is applied. - An unadvertised fallback causes no call and the agent default remains active. -- Runtime session start: an empty catalog and method-not-supported both continue - with the agent default and create a model-selection warning. -- Runtime session start: an advertised model apply error fails explicitly. - Auto-fallback keeps best-effort behavior and creates a warning. -- Runtime session start: each decision produces at most one model-selection - attempt with no profile-layer retry. -- Runtime launch environment: a non-standalone Claude launch exposes the - effective start model before ACP initialization. It exposes an explicit - fallback only when auto fallback is off. Request, executor, and profile - environment values take precedence. Standalone Claude launches and other - agents receive no new environment values. -- 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). -- Model-decision event and warning metadata tests. -- Orchestrator persistence tests for message type, warning metadata, - idempotency, reload hydration, and persistence errors. - -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`: every otherwise eligible host-mismatch profile - remains selectable with no model advisory in either label. Cover canonical - IDs, legacy bracketed IDs, unique and multiple variations, and empty catalogs. -- Capability health warnings, recent-use ordering, disabled-profile filtering, - and saved profile values remain unchanged by selector rendering. -- 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. -- Status message: structured model-selection metadata renders localized - requested, effective, agent, executor, and remediation text. - -E2E (Playwright, `apps/web/e2e`): - -- Mock backend (`KANDEV_E2E_MOCK=true`): create a profile whose start model - is not in the host catalog. Make sure that the task-create picker keeps the - profile selectable without a host model warning in the option or selected - label. Select the profile with keyboard on desktop and a row tap on mobile. -- Launch a profile whose executor advertises its exact model while the host - omits it. Verify the selected model, no model-selection warning, and unchanged - profile values after reload. -- Launch with an executor catalog that omits the profile model. Make sure that - no model-selection call occurs, the task continues, and chat shows one warning. -- Reload the task page. Make sure that the warning remains in chat. -- 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 use `auto_fallback = 0` and no fallback model. -This state means default-on-mismatch behavior. - -If the executor omits the saved model, the agent uses its default and Kandev -persists a warning. The saved profile model does not change. +| REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001 | Persistence, policy, warnings, boundaries | +| REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002 | Compatible selection order | + +## Persistence and API + +Add `RequireExactModel bool` to `settings/models.AgentProfile`, represented as +`require_exact_model` in JSON/storage and `requireExactModel` in normalized web +profile data. Use an additive `agent_profiles.require_exact_model INTEGER NOT +NULL DEFAULT 0` migration through the existing dialect-aware migration path in +`settings/store/sqlite.go`. Cover SQLite and Postgres. Do not rewrite existing +`model`, `fallback_model`, or `auto_fallback` columns or reinterpret their values. +New profiles and seeds also default false. Reopening the store is idempotent. + +Wire create/read/update, scans/inserts, clone/duplicate, reconciler preservation, +profile export/import, and the canonical settings discovery contract. Use pointer +presence on partial updates, including the enabled-only fast path. An omitted +field preserves an existing value; explicit false clears strictness. Create and +legacy import without the field use false. Old clients must not accidentally +clear an existing true value when editing another field. The response includes +an explicit boolean. Use the existing profile contract generator for snapshots. + +Validation uses the merged profile on updates: strictness requires a non-empty +model on a concrete ACP profile. A dynamic selector or terminal-passthrough +profile cannot provide this guarantee and rejects an explicit strict request; +existing such profiles remain valid with false. Dynamic selection passes the +chosen concrete candidate's policy through existing execution resolution. +Do not redesign dynamic eligibility or Office routing. + +Saved fallback choices may coexist with strictness as dormant values. Runtime +precedence is `require_exact_model` first, then existing `auto_fallback`, then +explicit fallback/compatible selection. This preserves values across toggling +and avoids destructive normalization. UI prevents simultaneous active modes; +API precedence makes combinations deterministic without extra booleans. + +## Profile transport + +Propagate the field through `settings/dto`, handlers/controller mappings, +`lifecycle.AgentProfileInfo`, `profile_resolver.go`, both policy constructors in +`manager_profile.go`, and `StartModelPolicy`. Propagate it through the internal +`orchestrator/executor.AgentProfileInfo` projection and `backendapp/adapters.go`. +Include it in the existing profile snapshot produced by +`executor.resolveAgentProfileSnapshot`; missing legacy snapshot fields mean +false. A snapshot is evidence, not a new competing source of profile policy. +Keep current profile-resolution timing: saving affects the next launch/reset/ +rebind or cross-step decision, not an already-running turn. Preserve existing +model override precedence within a session. + +Frontend mappings include `lib/types/agent-profile.ts`, API normalization and +serialization, settings store option conversion, editor drafts, dirty checks, +reconciliation, and settings discovery. Trace every existing `auto_fallback` +producer/consumer to check whether it also carries the new profile field. +Do not add strictness to provider wire protocols: the host owns this policy. + +## Shared policy + +`applyStartModelPolicy` remains the single owner of `SetModel` decisions. +Never send an ID absent from the current executor catalog. Later config layers +must not repeat a handled selection. Exactness compares full IDs, including +provider prefixes and bracketed suffixes. + +For strict profiles, ignore dormant fallback choices. Require a non-empty +requested ID, advertise it, and apply it before ready/initial prompt. Empty +catalog, absent requested ID, unsupported selection, or selection error fails +with sanitized evidence. Runtime guards also reject strict+empty if storage +validation was bypassed. An intentional session override supplies the effective +requested ID under the existing override contract; this setting is not a lock +against explicit user model changes. + +### Compatible selection order + +Use merge base `ba960f973205854733e0dcd9afa355bdda3dddf6` as the compatibility +oracle for selection, not the PR's new strict tests: + +1. Empty requested model means no explicit selection and no mismatch warning. +2. Apply the requested model if advertised. +3. With automatic fallback on and the requested model absent, continue on the + executor current/default model. Ignore saved explicit fallback and variations. +4. Otherwise apply an advertised explicit fallback when available. +5. Otherwise resolve one distinct advertised bracketed variation of a bare ID. + Restore the removed `uniqueAdvertisedModelVariation` runtime behavior only + here. Deduplicate matching IDs; reject empty/nested brackets, ambiguous + matches, and already-bracketed requests. Do not rank suffixes or catalog order. +6. Otherwise keep the executor current/default model and warn. + +An empty catalog follows step 6. Unsupported model selection retains the +provider default and warns for compatible profiles. Other advertised apply +errors still fail unless existing automatic fallback permits continuation. +Do not catch initialization failure, transport disconnect, or cancellation as a +catalog mismatch. The setting does not make all failures recoverable. + +| Runtime case | Strict on | Strict off, auto off | Strict off, auto on | +| --- | --- | --- | --- | +| Requested advertised and applies | Requested | Requested | Requested | +| Requested absent, fallback advertised | Error | Explicit fallback | Executor default | +| No fallback, unique variation | Error | Variation | Executor default | +| Missing/empty catalog or no match | Error | Executor default | Executor default | +| Selection method unsupported | Error | Executor default | Executor default | +| Ordinary advertised apply error | Error | Error | Executor default | +| Initialization fails/cancelled | Error | Error | Error | + +The stricter PR behavior remains valuable when selected explicitly. Its tests +must set the new field instead of relying on `auto_fallback=false`. + +## Warnings and existing boundaries + +Reuse `ModelSelectionDecision`, the lifecycle warning event, and the persisted +`status` message with `metadata.kind=model_selection_warning`. Restore the +`unique_variation` decision outcome and existing effective-model warning path +for compatible variation selection. Preserve one warning per selection decision, +requested/effective model attribution, unknown-effective-model text, and reload +idempotency. Failed strict attempts emit errors, not successful fallback notices. +No credentials or provider configuration content belongs in metadata. + +Keep gone saved models visible and unselectable in editors without rewriting +profile values. Host probes remain advisory. Do not restore editor-side ID +replacement to achieve runtime compatibility. Office post-start routing remains +owned by workspace policy; never repurpose `auto_fallback` or strictness as a +new workspace routing gate. Existing authorization and secret sanitization apply. + +Preserve existing provider-specific launch preparation, including cold Claude +model exposure and request/executor/profile environment precedence. Dormant +fallback choices must not authorize strict selection. This package adds no +provider-specific environment contract, cache copying, or unadvertised selection. + +## Decision + +[Explicit per-profile exact model policy](../../../decisions/2026-09-15-explicit-profile-model-strictness.md) +supersedes the implicit strict default and records why a global setting and a +bulk automatic-fallback migration are not used. diff --git a/docs/specs/agents/system-design/no-silent-model-fallback-02.md b/docs/specs/agents/system-design/no-silent-model-fallback-02.md index a3d87cd48a..828c38db1c 100644 --- a/docs/specs/agents/system-design/no-silent-model-fallback-02.md +++ b/docs/specs/agents/system-design/no-silent-model-fallback-02.md @@ -3,73 +3,108 @@ status: current system: agents requirements: - REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001 - - REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002 - - REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-003 created: 2026-08-23 owners: - kandev --- -# No Silent Model Fallback System Design Part 2 +# No Silent Model Fallback: Recovery and Profile Surfaces -## Purpose and boundaries +## Mapping and scope -This design records risks for executor-authoritative model fallback and unique -variation resolution. +This design completes [the policy design](no-silent-model-fallback-01.md). +REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001 acceptance criteria .6/.7 map to profile +surfaces, .8/.9 to recovery/workflow, and .2/.10 to warnings/advisories. -## Requirement mapping +## Recovery and session ownership -| Requirement | Design section | -| --- | --- | -| `REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001` | [Migrated source detail](#migrated-source-detail) | -| `REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002` | [Risks & Open Questions](#risks--open-questions) | -| `REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-003` | [Risks & Open Questions](#risks--open-questions) | +Initial launch, context reset (`manager_interaction.go`), workspace rebind +(`manager_workspace_rebind.go`), and Kubernetes replacement +(`manager_kubernetes_refresh.go`) use the same resolved `StartModelPolicy`. +Preserve completed PR transport staging and delayed `session_models` handling. +A strict replacement waits for its own usable catalog, including an async +catalog after an empty session response. Timeout with no usable catalog fails +before inference. Never select from the previous published client on timeout. -## Migrated source detail +A compatible replacement may continue after a bounded catalog wait when its +session initialized successfully but advertised no usable models. Pass only +replacement evidence (including absent/empty state) to the shared policy and +persist its warning. Distinguish catalog absence from initialization/stream +failure and context cancellation. Cancellation always stops the attempt. +An empty non-nil snapshot is not proof of catalog readiness. Keep replacement +publication, rollback, and conversation identity ordering already fixed by the PR. -## Risks & Open Questions +In `sessionHasUnauthorizedExactModelDrift`, apply exact-model eligibility only +when the destination profile explicitly requires exactness. Dormant auto/fallback +values cannot authorize a different model for strict destinations. Unknown or +missing effective model also requires a fresh cross-step strict session. +Compatible destinations keep normal workflow reuse policy; model drift alone +is no longer a replacement reason. Profile changes and other existing session +eligibility rules still apply. Same-step explicit overrides remain intact. -- **Behavior change for existing profiles**: an unadvertised start model no - longer stops the launch. Kandev uses the executor agent default and persists - an actionable warning. -- **Office post-start fallback remains workspace-routing-governed**: a - default-mode 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). - The profile editor uses it only as a hint. Profile selectors do not render - host model advisories. The executor session catalog owns the launch decision. -- **Cold Claude model lists**: a valid restricted model can be absent from a - cold bridge's initial list. Pre-session exposure lets the bridge include and - select the configured model. If the bridge still omits it, Kandev uses the - agent default and persists a warning. -- **Context reset model changes**: this amendment covers the model selected - before the initial process starts. It does not restart a live ACP bridge to - expose a newly selected hidden model during context reset. -- **Office vs. kanban surfaces**: both share the same agent-profile rows. - The shared selector behavior covers kanban task creation and Office setup. - 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. -- **Provider-specific labels remain opaque**: `1m`, `270k`, and `fast` have no - Kandev-defined order. More than one variation therefore fails closed to the - existing provider-default path. -- **Host and executor decisions can differ**: a unique host variation is only - an advisory. The selected executor can advertise zero, one, or multiple - variations at launch. -- **Bracketed requests do not drift sideways**: a request such as `opus[1m]` - does not infer `opus[270k]`. The user must select another explicit model. -- **Legacy automatic fallback remains unchanged**: when `auto_fallback` is - enabled and the requested model is absent, Kandev does not apply an explicit - fallback or infer a variation. It continues with the provider default. -- **Removal must be narrow**: authentication, missing-CLI, and failed-probe - indicators remain. Profile-editor advisories and persisted task warnings - retain their existing behavior. -- **Evidence has limits**: a model warning does not prove a cache defect. - Discovery refresh or normalization changes require separate root-cause - evidence; this amendment does not change those contracts. +Retain the validated candidate passed to the switch path and the one-lookup +empty-candidate fix. Profile-read errors do not silently downgrade strictness. +Test all callers of the shared drift check, including human-to-work transitions. +A new conversation must not inherit a rejected provider resume identity. + +## Profile surfaces + +Entry point: Settings > Agents > concrete agent profile > Fallback settings. +Also update the shared controls used by `components/agent/cli-profile-editor.tsx`. +The setting applies to that profile on any executor. There is no global, +workspace, or executor override and no added item under System Feature Toggles. + +Extend `ModelFallbackSection` and `ModelFallbackSettingsShell`: + +- Place Require exact model first, as a full-width row in the expanded section. +- Show a visible helper: "Stop before starting if the selected model cannot be + used. Fallback settings do not apply while this is on." +- Strict on disables automatic and explicit fallback controls without clearing + them. Strict off restores the old automatic/explicit interaction. A dormant + auto=true must not win in the summary or control disabled state. +- Keep the disclosure initially collapsed, with effective summaries such as + "Exact model required", "Automatic fallback", "Fallback: {model}", or + "Executor default allowed". Summaries and helper copy must agree with the + compatible selection/error matrix, including default continuation when an + explicit fallback is also absent. +- Include the new field in dirty state, Save/Cancel, optimistic reconciliation, + normalization, and reload hydration. Updating only strictness must not be + mistaken for an enabled-only edit. Empty-model validation keeps the draft open. +- Keep the saved start model unchanged. An unavailable host model does not + disable the strict switch if the profile already has a concrete saved ID. +- Use `t()` and settings namespace keys for all copy. Translate English, + Portuguese, Simplified Chinese, and generated Traditional Chinese catalogs; + regenerate pseudo. Do not publish copy that describes every auto=false profile + as strict or promises a configured fallback is always the final alternative. + +## Mobile contract + +Nearest shipped exemplar: `ModelFallbackSettingsShell` with `FallbackOptionHelp`. +Reuse its shared form state, collapsed disclosure, and `useTouchDrawer` help. +Desktop keeps the strict row above two fallback columns. Phone uses one ordered +vertical flow: strictness, automatic fallback, explicit fallback, existing Save. +No separate phone policy or copied business logic. + +This is an occasional settings edit; it remains inline on the profile page. +Use an inset bottom drawer only for supplementary help. The existing page or +editor scroll container remains the sole form scroll owner. Shared drawer +primitives handle dynamic viewport height and safe-area spacing. Close help +returns focus to its trigger without closing the form or losing draft values. + +Ordinary desktop controls keep 28px sizing; touch row/help hit targets are at +least 44px (both dimensions for icon buttons), scoped to phone/coarse pointers. +Measure actual bounds and hit testing. Check long translated labels, disabled +states, keyboard toggle/save, document overflow, and coarse-pointer tablets. +The plan's UI-01/UI-02 previews define order and grouping, not pixel geometry. + +## Verification and remaining limits + +Use real store migrations for upgrade evidence, policy unit tests for the full +matrix, and lifecycle transport tests for delayed replacement catalogs and +cancellation. Pair model-policy tests with an actual migrated profile launch; +a fabricated zero-value policy alone does not prove upgrade compatibility. +Desktop and mobile E2E cover saved toggles, launch continuation/error, drawer +interaction, and persisted warnings. A mock E2E catalog mismatch does not prove +provider credentials or every external executor integration. + +Keep public docs updates in the implementation work orders. The existing public +pages now describe the implemented compatible-by-default policy.