fix(agents): enforce exact profile model identity (#3473)

Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Alassane
2026-09-15 18:11:43 -04:00
committed by GitHub
parent 4ef775afc9
commit 739eabc49a
141 changed files with 4136 additions and 2338 deletions
@@ -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)
+4 -3
View File
@@ -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.
+3
View File
@@ -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)
@@ -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()
@@ -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 {
@@ -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
@@ -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) {
@@ -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)
@@ -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 {
@@ -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,
}
}
@@ -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")
}
@@ -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
@@ -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),
@@ -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 {
@@ -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,
@@ -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
@@ -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)
}
}
@@ -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",
@@ -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)
}
@@ -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)
}
@@ -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
@@ -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
@@ -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")
@@ -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)
}
})
}
}
@@ -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,
}
}
@@ -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,
}
}
@@ -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)
@@ -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.
@@ -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."},
@@ -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)
}
@@ -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
}
@@ -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
@@ -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)
@@ -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")
}
}
@@ -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) {
@@ -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.
@@ -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,
@@ -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 }
@@ -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
@@ -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 {
@@ -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{}
@@ -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)
@@ -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
@@ -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,
@@ -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),
+2
View File
@@ -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<string, string>;
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<string, string>;
allow_indexing?: boolean;
@@ -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({});
@@ -38,6 +38,7 @@ export function toAgentProfilePatch(patch: Partial<ProfileFormData>): Partial<Ag
if (patch.model !== undefined) next.model = patch.model;
if (patch.fallback_model !== undefined) next.fallbackModel = patch.fallback_model;
if (patch.auto_fallback !== undefined) next.autoFallback = patch.auto_fallback;
if (patch.require_exact_model !== undefined) next.requireExactModel = patch.require_exact_model;
if (patch.mode !== undefined) next.mode = patch.mode;
if (patch.config_options !== undefined) next.configOptions = patch.config_options;
if (patch.allow_indexing !== undefined) next.allowIndexing = patch.allow_indexing;
@@ -193,6 +193,51 @@ describe("CliProfileEditor fallback settings", () => {
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(
<StateProvider
initialState={{
settingsAgents: { items: [] },
availableAgents: {
items: [baseAvailableAgent],
tools: [],
loaded: true,
loading: false,
},
}}
>
<CliProfileEditor mode="edit" profile={profile} onSaved={vi.fn()} />
</StateProvider>,
);
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", () => {
@@ -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}
/>
</div>
);
@@ -573,6 +584,7 @@ async function saveExistingProfile(id: string, form: FormState): Promise<AgentPr
model: form.model,
fallback_model: form.fallbackModel ?? "",
auto_fallback: form.autoFallback,
require_exact_model: form.requireExactModel,
mode: form.mode || undefined,
allow_indexing: form.allowIndexing,
auto_approve: form.autoApprove,
@@ -588,6 +600,7 @@ async function saveNewProfile(form: FormState, settingsAgents: Agent[]): Promise
model: form.model,
fallback_model: form.fallbackModel ?? "",
auto_fallback: form.autoFallback,
require_exact_model: form.requireExactModel,
mode: form.mode || undefined,
allow_indexing: form.allowIndexing,
auto_approve: form.autoApprove,
@@ -10,6 +10,101 @@ import {
ModelFallbackSettingsShell,
} from "@/components/settings/model-fallback-settings-shell";
function ExactModelOption({
requireExactModel,
onChange,
}: {
requireExactModel: boolean;
onChange: (value: boolean) => void;
}) {
const { t } = useTranslation();
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1">
<Label>{t("settings:requireExactModel")}</Label>
<FallbackOptionHelp kind="strict" />
</div>
<Switch
checked={requireExactModel}
onCheckedChange={onChange}
aria-label={t("settings:requireExactModel")}
/>
</div>
<p className="text-xs text-muted-foreground">{t("settings:requireExactModelHelper")}</p>
</div>
);
}
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 (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1">
<Label className={fallbackModelGone ? "text-destructive" : undefined}>
{t("settings:agentFallback")}
</Label>
<FallbackOptionHelp kind="explicit" />
</div>
<Switch
checked={fallbackEnabled}
disabled={autoFallback || requireExactModel}
onCheckedChange={(checked) => {
setOptedIn(checked);
if (!checked) onFallbackModelChange("");
}}
aria-label={t("settings:agentFallback")}
/>
</div>
{fallbackEnabled && (
<ModelCombobox
value={fallbackModel}
onChange={onFallbackModelChange}
models={
fallbackModelGone
? [
...availableModels,
{
id: fallbackModel,
name: `${fallbackModel} (${t("settings:startModelUnavailable")})`,
disabled: true,
},
]
: availableModels
}
currentModelId={currentModelId}
placeholder={t("settings:agentFallbackPlaceholder")}
disabled={autoFallback || requireExactModel}
/>
)}
<p className="text-xs text-muted-foreground">
{autoFallback || requireExactModel
? t("settings:agentFallbackDisabledHelper")
: t("settings:agentFallbackHelper")}
</p>
</div>
);
}
// 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 (
<ModelFallbackSettingsShell
autoFallback={autoFallback}
requireExactModel={requireExactModel}
strictOption={
<ExactModelOption
requireExactModel={requireExactModel}
onChange={onRequireExactModelChange}
/>
}
fallbackModel={fallbackModel}
automaticOption={
<div className="space-y-2">
@@ -48,6 +149,7 @@ export function ModelFallbackFields({
</div>
<Switch
checked={autoFallback}
disabled={requireExactModel}
onCheckedChange={onAutoFallbackChange}
aria-label={t("settings:autoFallback")}
/>
@@ -56,51 +158,15 @@ export function ModelFallbackFields({
</div>
}
explicitOption={
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1">
<Label className={fallbackModelGone ? "text-destructive" : undefined}>
{t("settings:agentFallback")}
</Label>
<FallbackOptionHelp kind="explicit" />
</div>
<Switch
checked={fallbackEnabled}
disabled={autoFallback}
onCheckedChange={(checked) => {
setOptedIn(checked);
if (!checked) onFallbackModelChange("");
}}
aria-label={t("settings:agentFallback")}
/>
</div>
{fallbackEnabled && (
<ModelCombobox
value={fallbackModel}
onChange={onFallbackModelChange}
models={
fallbackModelGone
? [
...availableModels,
{
id: fallbackModel,
name: `${fallbackModel} (${t("settings:startModelUnavailable")})`,
disabled: true,
},
]
: availableModels
}
currentModelId={currentModelId}
placeholder={t("settings:agentFallbackPlaceholder")}
disabled={autoFallback}
/>
)}
<p className="text-xs text-muted-foreground">
{autoFallback
? t("settings:agentFallbackDisabledHelper")
: t("settings:agentFallbackHelper")}
</p>
</div>
<ExplicitFallbackOption
availableModels={availableModels}
fallbackModel={fallbackModel}
fallbackModelGone={fallbackModelGone}
autoFallback={autoFallback}
requireExactModel={requireExactModel}
currentModelId={currentModelId}
onFallbackModelChange={onFallbackModelChange}
/>
}
/>
);
@@ -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),
@@ -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),
@@ -78,6 +78,27 @@ type ProfileEditorProps = {
initialMcpConfig?: AgentProfileMcpConfig | null;
};
function toProfileFormData(
profile: AgentProfile,
permissionSettings: Record<string, PermissionSetting>,
): 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<ProfileFormData>) => {
onDraftChange(toAgentProfilePatch(patch));
};
const permissionValues = profilePermissionValues(draft, permissionSettings);
const savedPermissionValues = profilePermissionValues(savedProfile, permissionSettings);
return (
<SettingsCard
@@ -256,32 +275,8 @@ function ProfileSettingsCard({
</CardHeader>
<CardContent className="space-y-4">
<ProfileFormFields
profile={{
name: draft.name,
model: draft.model,
fallback_model: draft.fallbackModel ?? "",
auto_fallback: draft.autoFallback ?? false,
mode: draft.mode ?? "",
config_options: draft.configOptions ?? {},
auto_approve: permissionValues.auto_approve,
allow_indexing: permissionValues.allow_indexing,
cli_passthrough: draft.cliPassthrough,
cli_flags: draft.cliFlags ?? [],
command_prefix: draft.commandPrefix ?? "",
}}
baselineProfile={{
name: savedProfile.name,
model: savedProfile.model,
fallback_model: savedProfile.fallbackModel ?? "",
auto_fallback: savedProfile.autoFallback ?? false,
mode: savedProfile.mode ?? "",
config_options: savedProfile.configOptions ?? {},
auto_approve: savedPermissionValues.auto_approve,
allow_indexing: savedPermissionValues.allow_indexing,
cli_passthrough: savedProfile.cliPassthrough,
cli_flags: savedProfile.cliFlags ?? [],
command_prefix: savedProfile.commandPrefix ?? "",
}}
profile={toProfileFormData(draft, permissionSettings)}
baselineProfile={toProfileFormData(savedProfile, permissionSettings)}
onChange={handleFormChange}
modelConfig={modelConfig}
permissionSettings={permissionSettings}
@@ -6,6 +6,7 @@ const EDITABLE_FIELDS = [
"model",
"fallbackModel",
"autoFallback",
"requireExactModel",
"mode",
"configOptions",
"allowIndexing",
@@ -4,12 +4,22 @@ import { ModelFallbackSettingsShell } from "./model-fallback-settings-shell";
afterEach(cleanup);
function renderShell({ autoFallback = false, fallbackModel = "", isDirty = false } = {}) {
const exactModelSummary = "Exact model required";
const fallbackSettingsTrigger = "profile-fallback-settings-trigger";
function renderShell({
autoFallback = false,
fallbackModel = "",
isDirty = false,
requireExactModel = false,
} = {}) {
return render(
<ModelFallbackSettingsShell
autoFallback={autoFallback}
fallbackModel={fallbackModel}
isDirty={isDirty}
requireExactModel={requireExactModel}
strictOption={<span>Strict option</span>}
automaticOption={<span>Automatic option</span>}
explicitOption={<span>Explicit option</span>}
/>,
@@ -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",
);
});
});
@@ -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 (
<Collapsible
@@ -153,6 +167,14 @@ export function ModelFallbackSettingsShell({
className="grid min-w-0 grid-cols-1 gap-3 md:grid-cols-2"
data-testid="profile-fallback-settings-grid"
>
{strictOption && (
<div
className="min-w-0 rounded-md border border-border/70 bg-muted/10 p-3 md:col-span-2"
data-testid="profile-require-exact-model-option"
>
{strictOption}
</div>
)}
<div
className="min-w-0 rounded-md border border-border/70 bg-muted/10 p-3"
data-testid="profile-auto-fallback-option"
@@ -93,6 +93,15 @@ export function profileAutoFallbackIsDirty(
);
}
export function profileRequireExactModelIsDirty(
profile: ProfileFormData,
baseline?: ProfileFormData,
): boolean {
return Boolean(
baseline && (profile.require_exact_model ?? false) !== (baseline.require_exact_model ?? false),
);
}
export function profileModeIsDirty(profile: ProfileFormData, baseline?: ProfileFormData): boolean {
return Boolean(baseline && profile.mode !== baseline.mode);
}
@@ -150,15 +150,13 @@ describe("ProfileFormFields no-silent-model-fallback rows", () => {
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();
@@ -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<string, string>;
cli_passthrough: boolean;
@@ -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<string, string>;
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 && (
<p className="text-xs text-muted-foreground" data-testid="profile-model-variation-advisory">
{t("settings:modelVariationAdvisory", {
model: profile.model,
variation: uniqueVariation,
})}
</p>
)}
</div>
);
}
@@ -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 (
<ModelFallbackSettingsShell
autoFallback={autoFallback}
requireExactModel={requireExactModel}
strictOption={
<div className={gapCls}>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1">
<SettingsFieldLabel className={labelCls}>
{t("settings:requireExactModel")}
</SettingsFieldLabel>
<FallbackOptionHelp kind="strict" />
</div>
<Switch
checked={requireExactModel}
onCheckedChange={(checked) => onChange({ require_exact_model: checked })}
aria-label={t("settings:requireExactModel")}
/>
</div>
<SettingsFieldDescription>
{t("settings:requireExactModelHelper")}
</SettingsFieldDescription>
</div>
}
fallbackModel={profile.fallback_model ?? ""}
isDirty={isDirty}
automaticOption={
@@ -316,6 +327,7 @@ export function ModelFallbackSection({
<Switch
checked={autoFallback}
onCheckedChange={(checked) => onChange({ auto_fallback: checked })}
disabled={requireExactModel}
aria-label={t("settings:autoFallback")}
/>
</div>
@@ -330,7 +342,7 @@ export function ModelFallbackSection({
baselineProfile={baselineProfile}
labelCls={labelCls}
gapCls={gapCls}
disabled={autoFallback}
disabled={autoFallback || requireExactModel}
onChange={onChange}
/>
}
@@ -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(
<TooltipProvider>
<div data-testid="option-label">{option.renderLabel()}</div>
<div data-testid="selected-label">{option.renderTriggerLabel?.()}</div>
<div>{result.current[0]?.renderTriggerLabel?.()}</div>
</TooltipProvider>,
);
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(
<TooltipProvider>
<div>{option.renderLabel()}</div>
<div>{option.renderTriggerLabel?.()}</div>
</TooltipProvider>,
);
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(<OptionsProbe profiles={[profile]} />);
const initialLabel = screen.getByTestId("option-0").innerHTML;
setAvailableAgents([AGENT_WITH_GPT]);
rerender(<OptionsProbe profiles={[profile]} />);
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(
<TooltipProvider>
<div>{option.renderLabel()}</div>
<div>{option.renderTriggerLabel?.()}</div>
</TooltipProvider>,
);
expect(screen.getAllByTitle("Agent unavailable")).toHaveLength(2);
expect(screen.queryByTestId(MODEL_PROBE_WARNING_TEST_ID)).toBeNull();
});
@@ -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 = (
<button
type="button"
className="inline-flex min-h-0 min-w-8 shrink-0 cursor-help items-center justify-center rounded-sm border-0 bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring [@media(pointer:coarse)]:min-h-11 [@media(pointer:coarse)]:min-w-11"
aria-label={note}
aria-expanded={usesTouchDrawer ? drawerOpen : undefined}
aria-haspopup={usesTouchDrawer ? "dialog" : undefined}
data-testid="agent-profile-model-probe-warning"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<IconAlertTriangle className="size-3.5 text-amber-500" aria-hidden />
</button>
);
if (usesTouchDrawer) {
return (
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen}>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent style={{ zIndex: 80 }}>
<DrawerHeader>
<DrawerTitle className="sr-only">{note}</DrawerTitle>
<DrawerDescription>{note}</DrawerDescription>
</DrawerHeader>
</DrawerContent>
</Drawer>
);
}
return (
<Tooltip>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent side="top" style={{ zIndex: 80 }}>
{note}
</TooltipContent>
</Tooltip>
);
}
function ModelProbeWarningIndicator({ note }: { note: string }) {
return <IconAlertTriangle className="size-3.5 text-amber-500" title={note} aria-hidden />;
}
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) => (
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex shrink-0 items-center justify-between gap-2">
<span className="flex shrink-0 items-center gap-1.5">
@@ -176,6 +243,7 @@ export function useAgentProfileOptions(
{warning && (
<warning.Icon className={`size-3.5 ${warning.color}`} title={warning.title} />
)}
{modelProbeWarning}
</span>
<span className="flex shrink-0 items-center gap-1.5">
{isPassthrough && (
@@ -198,11 +266,15 @@ export function useAgentProfileOptions(
label: profile.label,
disabled: undefined,
disabledReason: undefined,
renderLabel: renderProfileLabel,
renderTriggerLabel: renderProfileLabel,
renderLabel: () =>
renderProfileLabel(modelProbeNote ? <ModelProbeWarning note={modelProbeNote} /> : null),
renderTriggerLabel: () =>
renderProfileLabel(
modelProbeNote ? <ModelProbeWarningIndicator note={modelProbeNote} /> : null,
),
};
});
}, [agentProfiles, availableAgents.items, context, dynamicRoutingEnabled, recentProfileIds, t]);
}, [agentProfiles, availableAgents, context, dynamicRoutingEnabled, recentProfileIds, t]);
}
export function useExecutorOptions(executors: Executor[]): OptionItem[] {
@@ -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,
@@ -180,11 +180,15 @@ function modelSelectionReasonLabel(reason: string | undefined): string {
const keyByReason: Record<string, string> = {
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);
+5
View File
@@ -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<string, string>;
@@ -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<string, string>;
cli_passthrough?: boolean;
@@ -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(() => {});
}
});
});
@@ -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<AgentProfile> {
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(
@@ -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(() => {});
}
@@ -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);
}
});
});
@@ -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);
}
});
});
@@ -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);
}
});
});
@@ -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);
}
});
});
@@ -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);
}
@@ -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);
});
@@ -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,
});
});
@@ -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);
-57
View File
@@ -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();
});
});
-17
View File
@@ -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;
}
@@ -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",
@@ -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",
@@ -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",
@@ -21,6 +21,7 @@ export const PROFILE_EDITABLE_FIELD_PATHS = [
"model",
"fallback_model",
"auto_fallback",
"require_exact_model",
"mode",
"config_options",
"allow_indexing",
@@ -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,
+3
View File
@@ -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<string, string>;
allow_indexing: boolean;
+3
View File
@@ -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;
+10
View File
@@ -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",
+10
View File
@@ -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": "Ƥŕōḿƥţ ēďĩţōŕ",
+10
View File
@@ -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.",
+10
View File
@@ -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": "尚无单元。",
+4 -4
View File
@@ -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": "權杖已建立",
+39 -39
View File
@@ -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": "只有管理員可以更改代理和代理設定檔。"
}
+1 -1
View File
@@ -16,7 +16,7 @@
"joinThisKandevDeployment": "加入此 Kandev 部署。",
"joining": "正在加入…",
"orContinueWith": "或使用以下方式繼續",
"organizationUnavailable": "您的組織目前無法使用。請聯絡您的 Kandev 管理員。",
"organizationUnavailable": "您的組織目前不可用。請聯繫您的 Kandev 管理員。",
"password": "密碼",
"setUpKandev": "設定 Kandev",
"setupFailed": "初始化失敗。請重試。",
+15 -15
View File
@@ -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",
+1 -1
View File
@@ -149,7 +149,7 @@
"placeholderDescriptionBody": "描述正文",
"placeholderIdDescription": "工作項或拉取要求 ID",
"placeholderProjectDescription": "項目名稱",
"placeholderSourceBranchDescription": "來源分支",
"placeholderSourceBranchDescription": "源分支",
"placeholderStateDescription": "目前狀態",
"placeholderTargetBranchDescription": "目標分支",
"placeholderTypeDescription": "工作項類型",
+39 -39
View File
@@ -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": "工作區位置",
+1 -1
View File
@@ -98,7 +98,7 @@
"quickChatDialogIntro": "與代理程式開始快速對話,無需建立正式任務。",
"quickChatIntro": "就想法、問題或程式碼庫與代理程式聊天。",
"quickChatIntroSecondary": "快速聊天獨立於任務看板。",
"quickChatTabOrderSaveFailed": "無法儲存快速聊天分頁順序。",
"quickChatTabOrderSaveFailed": "無法儲存快速聊天標籤頁順序。",
"quickChatTabOrderSaveFailedDescription": "目前順序會保留在此處。重新載入可恢復上次儲存的順序。",
"renameChat": "重命名聊天",
"renameSavedOnThisDeviceOnly": "重命名僅儲存在此設備上",
+1 -1
View File
@@ -78,7 +78,7 @@
"commandGoToStats": "前往統計",
"commandGoToStatsKeywords": "統計, 數據分析, 分析, 指標",
"commandGoToThreads": "前往對話流",
"commandGoToThreadsKeywords": "對話流, 代理程式, 即時, 欄",
"commandGoToThreadsKeywords": "對話流, 代理程式, 實時, 列",
"commandGroupActions": "操作",
"commandGroupAgent": "代理程式",
"commandGroupCommands": "命令",
+1 -1
View File
@@ -402,7 +402,7 @@
"prTaskStatusCi": "CI",
"prTaskStatusMerge": "合併",
"prTaskStatusNumber": "PR #{{number}}",
"prTaskStatusRepositoryNumber": "{{repository}} 拉取請求 #{{number}}",
"prTaskStatusRepositoryNumber": "{{repository}} 拉取要求 #{{number}}",
"prTaskStatusReview": "審閱",
"prTaskStatusState": "狀態",
"prepareAppOnGithub": "在 GitHub 上準備應用",
+2 -2
View File
@@ -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 參數的完整重定向網址。"

Some files were not shown because too many files have changed in this diff Show More