From cd1d19ae651d6930dd35b51a52b795825be5edc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sat, 8 Aug 2026 17:13:50 +0100 Subject: [PATCH] feat: resolve model-dependent provider options (#2404) --- apps/backend/cmd/mock-agent/main.go | 60 +- .../cmd/mock-agent/session_config_test.go | 49 ++ .../internal/agent/hostutility/manager.go | 55 +- .../agent/hostutility/model_config_cache.go | 128 +++++ .../agent/hostutility/model_config_test.go | 371 ++++++++++++ .../internal/agent/hostutility/public.go | 153 +++++ .../internal/agent/hostutility/types.go | 20 + .../agent/settings/controller/agent_config.go | 45 ++ .../settings/controller/agent_config_test.go | 105 ++++ .../settings/controller/agent_discovery.go | 31 +- .../controller/agent_discovery_test.go | 20 + .../agent/settings/controller/controller.go | 12 +- .../internal/agent/settings/dto/dto.go | 18 + .../agent/settings/handlers/handlers.go | 31 +- .../handlers/model_config_handlers_test.go | 87 +++ .../agentctl/server/utility/acp_executor.go | 371 ++++++++++-- .../utility/acp_probe_capabilities_test.go | 539 +++++++++++++++++- .../server/utility/model_apply_test.go | 54 +- .../internal/agentctl/server/utility/types.go | 14 +- .../settings/agent-profile-page.tsx | 48 +- .../model-config-resolution-status.test.tsx | 40 ++ .../model-config-resolution-status.tsx | 50 ++ .../settings/profile-capability-status.tsx | 78 +++ .../settings/profile-form-fields.test.tsx | 241 +++++++- .../settings/profile-form-fields.tsx | 209 +++---- .../settings/profile-model-config.test.ts | 27 + .../settings/profile-model-config.ts | 31 + .../use-session-config-model-options.ts | 57 ++ .../use-workflow-draft-contributor.test.ts | 10 + .../use-workflow-draft-contributor.ts | 24 +- .../web/components/settings/workflow-card.tsx | 8 + .../workflow-pipeline-editor-panels.tsx | 7 +- .../settings/workflow-pipeline-editor.tsx | 3 + .../workflow-session-config-editor.test.tsx | 362 +++++++++++- .../workflow-session-config-editor.tsx | 27 +- .../workflow-session-config-rule-card.tsx | 197 ++++++- .../tests/chat/model-selector-error.spec.ts | 2 +- .../tests/settings/agent-profile-acp.spec.ts | 37 +- ...bile-agent-profile-config-selector.spec.ts | 18 +- .../workflow/mobile-workflow-settings.spec.ts | 4 +- .../tests/workflow/workflow-settings.spec.ts | 38 +- .../domains/settings/use-available-agents.ts | 45 ++ .../settings/use-dynamic-models.test.ts | 197 ++++++- .../domains/settings/use-dynamic-models.ts | 312 +++++++++- .../use-profile-model-capabilities.ts | 70 +++ apps/web/lib/api/domains/settings-api.test.ts | 29 + apps/web/lib/api/domains/settings-api.ts | 13 + apps/web/lib/types/http-agents.ts | 15 + apps/web/src/locales/en/agents.json | 4 + apps/web/src/locales/pseudo/agents.json | 4 + apps/web/src/locales/pt-pt/agents.json | 4 + apps/web/src/locales/zh-cn/agents.json | 4 + ...el-aware-provider-capability-resolution.md | 106 ++++ docs/decisions/INDEX.md | 1 + docs/plans/dynamic-provider-options/plan.md | 315 ++++++++++ .../task-01-model-aware-probe.md | 58 ++ .../task-02-resolution-cache-api.md | 58 ++ .../task-03-resolution-hook.md | 58 ++ .../task-04-settings-integration.md | 63 ++ .../task-05-e2e-docs.md | 62 ++ docs/public/agents-and-profiles.md | 6 + docs/public/tasks-and-workflows.md | 6 + docs/specs/INDEX.md | 1 + docs/specs/agents/dynamic-provider-options.md | 258 +++++++++ docs/specs/workflow-session-settings/spec.md | 4 +- 65 files changed, 5073 insertions(+), 301 deletions(-) create mode 100644 apps/backend/internal/agent/hostutility/model_config_cache.go create mode 100644 apps/backend/internal/agent/hostutility/model_config_test.go create mode 100644 apps/backend/internal/agent/settings/controller/agent_config_test.go create mode 100644 apps/backend/internal/agent/settings/handlers/model_config_handlers_test.go create mode 100644 apps/web/components/settings/model-config-resolution-status.test.tsx create mode 100644 apps/web/components/settings/model-config-resolution-status.tsx create mode 100644 apps/web/components/settings/profile-capability-status.tsx create mode 100644 apps/web/components/settings/profile-model-config.test.ts create mode 100644 apps/web/components/settings/profile-model-config.ts create mode 100644 apps/web/components/settings/use-session-config-model-options.ts create mode 100644 apps/web/hooks/domains/settings/use-profile-model-capabilities.ts create mode 100644 docs/decisions/2026-08-07-model-aware-provider-capability-resolution.md create mode 100644 docs/plans/dynamic-provider-options/plan.md create mode 100644 docs/plans/dynamic-provider-options/task-01-model-aware-probe.md create mode 100644 docs/plans/dynamic-provider-options/task-02-resolution-cache-api.md create mode 100644 docs/plans/dynamic-provider-options/task-03-resolution-hook.md create mode 100644 docs/plans/dynamic-provider-options/task-04-settings-integration.md create mode 100644 docs/plans/dynamic-provider-options/task-05-e2e-docs.md create mode 100644 docs/specs/agents/dynamic-provider-options.md diff --git a/apps/backend/cmd/mock-agent/main.go b/apps/backend/cmd/mock-agent/main.go index 8cbe5e5f01..3d650a2aeb 100644 --- a/apps/backend/cmd/mock-agent/main.go +++ b/apps/backend/cmd/mock-agent/main.go @@ -167,13 +167,35 @@ func mockSessionModes() *acp.SessionModeState { } func mockSessionConfigOptions() []acp.SessionConfigOption { + return mockSessionConfigOptionsForModel("mock-fast") +} + +func mockSessionConfigOptionsForModel(model string) []acp.SessionConfigOption { modelCat := acp.SessionConfigOptionCategoryModel modeCat := acp.SessionConfigOptionCategoryMode thoughtCat := acp.SessionConfigOptionCategoryThoughtLevel + effortID := acp.SessionConfigId("effort") + effortName := "Effort" + effortDescription := "Controls how much reasoning the mock model uses" + effortValue := acp.SessionConfigValueId("medium") + effortOptions := acp.SessionConfigSelectOptionsUngrouped{ + {Value: "low", Name: "Low", Description: ptr("Faster responses with less reasoning")}, + {Value: "medium", Name: "Medium", Description: ptr("Balanced speed and reasoning")}, + {Value: "high", Name: "High", Description: ptr("More reasoning for complex tasks")}, + } + if model == "mock-smart" { + effortDescription = "Controls the reasoning depth for the smart mock model" + effortValue = "high" + effortOptions = acp.SessionConfigSelectOptionsUngrouped{ + {Value: "low", Name: "Low", Description: ptr("Use less reasoning")}, + {Value: "high", Name: "High", Description: ptr("Use deeper reasoning")}, + {Value: "max", Name: "Max", Description: ptr("Use maximum reasoning")}, + } + } return []acp.SessionConfigOption{ {Select: &acp.SessionConfigOptionSelect{ Category: &modelCat, - CurrentValue: "mock-fast", + CurrentValue: acp.SessionConfigValueId(model), Id: "model", Name: "Model", Options: acp.SessionConfigSelectOptions{Ungrouped: &acp.SessionConfigSelectOptionsUngrouped{ @@ -195,16 +217,12 @@ func mockSessionConfigOptions() []acp.SessionConfigOption { }}, {Select: &acp.SessionConfigOptionSelect{ Category: &thoughtCat, - CurrentValue: "medium", - Id: "effort", - Name: "Effort", - Description: ptr("Controls how much reasoning the mock model uses"), - Options: acp.SessionConfigSelectOptions{Ungrouped: &acp.SessionConfigSelectOptionsUngrouped{ - {Value: "low", Name: "Low", Description: ptr("Faster responses with less reasoning")}, - {Value: "medium", Name: "Medium", Description: ptr("Balanced speed and reasoning")}, - {Value: "high", Name: "High", Description: ptr("More reasoning for complex tasks")}, - }}, - Type: "select", + CurrentValue: effortValue, + Id: effortID, + Name: effortName, + Description: ptr(effortDescription), + Options: acp.SessionConfigSelectOptions{Ungrouped: &effortOptions}, + Type: "select", }}, } } @@ -338,10 +356,30 @@ func (a *mockAgent) SetSessionConfigOption(_ context.Context, req acp.SetSession if !found { return acp.SetSessionConfigOptionResponse{}, fmt.Errorf("unknown mock config option %q", req.ValueId.ConfigId) } + if req.ValueId.ConfigId == "model" { + modeValue := mockConfigOptionValue(options, "mode") + options = mockSessionConfigOptionsForModel(string(req.ValueId.Value)) + for i := range options { + if options[i].Select != nil && options[i].Select.Id == "mode" && modeValue != "" { + if mockConfigOptionContainsValue(options[i].Select.Options, modeValue) { + options[i].Select.CurrentValue = modeValue + } + } + } + } a.sessionConfig[req.ValueId.SessionId] = options return acp.SetSessionConfigOptionResponse{ConfigOptions: cloneSessionConfigOptions(options)}, nil } +func mockConfigOptionValue(options []acp.SessionConfigOption, id acp.SessionConfigId) acp.SessionConfigValueId { + for _, option := range options { + if option.Select != nil && option.Select.Id == id { + return option.Select.CurrentValue + } + } + return "" +} + func mockConfigOptionContainsValue(options acp.SessionConfigSelectOptions, value acp.SessionConfigValueId) bool { if options.Ungrouped != nil { for _, option := range *options.Ungrouped { diff --git a/apps/backend/cmd/mock-agent/session_config_test.go b/apps/backend/cmd/mock-agent/session_config_test.go index 58ef95e98f..e1d22c9fca 100644 --- a/apps/backend/cmd/mock-agent/session_config_test.go +++ b/apps/backend/cmd/mock-agent/session_config_test.go @@ -67,6 +67,55 @@ func TestSetSessionConfigOptionRejectsUnknownValue(t *testing.T) { } } +func TestSetSessionConfigOptionModelChangesAvailableOptions(t *testing.T) { + agent := &mockAgent{ + sessions: make(map[acp.SessionId]bool), + sessionConfig: make(map[acp.SessionId][]acp.SessionConfigOption), + commandsEmitted: make(map[acp.SessionId]bool), + } + session, err := agent.NewSession(context.Background(), acp.NewSessionRequest{}) + if err != nil { + t.Fatalf("NewSession: %v", err) + } + + response, err := agent.SetSessionConfigOption(context.Background(), acp.SetSessionConfigOptionRequest{ + ValueId: &acp.SetSessionConfigOptionValueId{ + SessionId: session.SessionId, + ConfigId: "model", + Value: "mock-smart", + }, + }) + if err != nil { + t.Fatalf("set model: %v", err) + } + + values := make(map[string]string) + for _, option := range response.ConfigOptions { + if option.Select != nil { + values[string(option.Select.Id)] = string(option.Select.CurrentValue) + } + } + if values["model"] != "mock-smart" { + t.Fatalf("model = %q, want mock-smart", values["model"]) + } + if values["effort"] != "high" { + t.Fatalf("effort = %q, want high for mock-smart", values["effort"]) + } + var hasMax, hasMedium bool + for _, option := range response.ConfigOptions { + if option.Select == nil || option.Select.Id != "effort" || option.Select.Options.Ungrouped == nil { + continue + } + for _, choice := range *option.Select.Options.Ungrouped { + hasMax = hasMax || choice.Value == "max" + hasMedium = hasMedium || choice.Value == "medium" + } + } + if !hasMax || hasMedium { + t.Fatalf("response = %#v, want smart-only effort choices", response.ConfigOptions) + } +} + func TestSessionConfigIsIsolatedAcrossNewSessions(t *testing.T) { agent := &mockAgent{ sessions: make(map[acp.SessionId]bool), diff --git a/apps/backend/internal/agent/hostutility/manager.go b/apps/backend/internal/agent/hostutility/manager.go index 92d7369d2b..4e68f4eec7 100644 --- a/apps/backend/internal/agent/hostutility/manager.go +++ b/apps/backend/internal/agent/hostutility/manager.go @@ -44,12 +44,16 @@ type Manager struct { parentTmpDir string cache *cache + modelCache *modelConfigCache - mu sync.RWMutex - instances map[string]*instance // keyed by agent type - createGroup singleflight.Group - startCancel context.CancelFunc - stopped bool + mu sync.RWMutex + instances map[string]*instance // keyed by agent type + createGroup singleflight.Group + modelGroup singleflight.Group + modelGenerationMu sync.Mutex + modelGenerations map[string]uint64 + startCancel context.CancelFunc + stopped bool } // instance is a single warm agentctl instance bound to an agent type. @@ -69,13 +73,15 @@ func NewManager( log *logger.Logger, ) *Manager { return &Manager{ - registry: reg, - controlHost: controlHost, - controlPort: controlPort, - controlClient: controlClient, - log: log.WithFields(zap.String("component", "host-utility")), - cache: newCache(), - instances: make(map[string]*instance), + registry: reg, + controlHost: controlHost, + controlPort: controlPort, + controlClient: controlClient, + log: log.WithFields(zap.String("component", "host-utility")), + cache: newCache(), + modelCache: newModelConfigCache(), + instances: make(map[string]*instance), + modelGenerations: make(map[string]uint64), } } @@ -146,6 +152,9 @@ func (m *Manager) Start(ctx context.Context) error { // Only dirs owned by this process are removed; other kandev processes' dirs // are untouched. func (m *Manager) Stop(ctx context.Context) { + if m.modelCache != nil { + m.modelCache.clear() + } m.mu.Lock() m.stopped = true cancel := m.startCancel @@ -594,31 +603,15 @@ func (m *Manager) probeWithCommand( ID: m.ID, Name: m.Name, Description: m.Description, Meta: m.Meta, }) } - for _, opt := range resp.ConfigOptions { - choices := make([]ConfigOptionChoice, 0, len(opt.Options)) - for _, choice := range opt.Options { - choices = append(choices, ConfigOptionChoice{ - Value: choice.Value, - Name: choice.Name, - Description: choice.Description, - }) - } - caps.ConfigOptions = append(caps.ConfigOptions, ConfigOption{ - Type: opt.Type, - ID: opt.ID, - Name: opt.Name, - Description: opt.Description, - CurrentValue: opt.CurrentValue, - Category: opt.Category, - Options: choices, - }) - } + caps.ConfigOptions = configOptionsFromProbe(resp.ConfigOptions) for _, c := range resp.Commands { caps.Commands = append(caps.Commands, Command{Name: c.Name, Description: c.Description}) } return caps } +const modelConfigResolveTimeout = 60 * time.Second + func buildProbeRequest( inst *instance, ia agents.InferenceAgent, diff --git a/apps/backend/internal/agent/hostutility/model_config_cache.go b/apps/backend/internal/agent/hostutility/model_config_cache.go new file mode 100644 index 0000000000..142f9da7ca --- /dev/null +++ b/apps/backend/internal/agent/hostutility/model_config_cache.go @@ -0,0 +1,128 @@ +package hostutility + +import ( + "encoding/json" + "sort" + "sync" + "time" +) + +const ( + modelConfigCacheTTL = 5 * time.Minute + modelConfigCacheMaxEntries = 128 +) + +type modelConfigCacheEntry struct { + resolution ModelConfigResolution + expiresAt time.Time +} + +type modelConfigCache struct { + mu sync.RWMutex + items map[string]modelConfigCacheEntry +} + +func newModelConfigCache() *modelConfigCache { + return &modelConfigCache{items: make(map[string]modelConfigCacheEntry)} +} + +func (c *modelConfigCache) get(key string, now time.Time) (ModelConfigResolution, bool) { + c.mu.RLock() + entry, ok := c.items[key] + c.mu.RUnlock() + if !ok { + return ModelConfigResolution{}, false + } + if !now.Before(entry.expiresAt) { + c.mu.Lock() + if current, exists := c.items[key]; exists && !now.Before(current.expiresAt) { + delete(c.items, key) + } + c.mu.Unlock() + return ModelConfigResolution{}, false + } + return cloneModelConfigResolution(entry.resolution), true +} + +func (c *modelConfigCache) set(key string, resolution ModelConfigResolution, now time.Time) { + c.mu.Lock() + for existingKey, entry := range c.items { + if !now.Before(entry.expiresAt) { + delete(c.items, existingKey) + } + } + if _, exists := c.items[key]; !exists && len(c.items) >= modelConfigCacheMaxEntries { + var oldestKey string + var oldestExpiry time.Time + for existingKey, entry := range c.items { + if oldestKey == "" || entry.expiresAt.Before(oldestExpiry) { + oldestKey = existingKey + oldestExpiry = entry.expiresAt + } + } + if oldestKey != "" { + delete(c.items, oldestKey) + } + } + c.items[key] = modelConfigCacheEntry{ + resolution: cloneModelConfigResolution(resolution), + expiresAt: now.Add(modelConfigCacheTTL), + } + c.mu.Unlock() +} + +func (c *modelConfigCache) invalidateAgent(agentType string) { + c.mu.Lock() + for key, entry := range c.items { + if entry.resolution.AgentType == agentType { + delete(c.items, key) + } + } + c.mu.Unlock() +} + +func (c *modelConfigCache) clear() { + c.mu.Lock() + c.items = make(map[string]modelConfigCacheEntry) + c.mu.Unlock() +} + +type modelConfigCacheOption struct { + ID string `json:"id"` + Value string `json:"value"` +} + +type modelConfigCacheKeyData struct { + AgentType string `json:"agent_type"` + Model string `json:"model"` + Mode string `json:"mode,omitempty"` + ConfigOptions []modelConfigCacheOption `json:"config_options,omitempty"` +} + +func modelConfigCacheKey(agentType string, req ModelConfigResolutionRequest) string { + keys := make([]string, 0, len(req.ConfigOptions)) + for key := range req.ConfigOptions { + keys = append(keys, key) + } + sort.Strings(keys) + options := make([]modelConfigCacheOption, 0, len(keys)) + for _, key := range keys { + options = append(options, modelConfigCacheOption{ID: key, Value: req.ConfigOptions[key]}) + } + data, _ := json.Marshal(modelConfigCacheKeyData{ + AgentType: agentType, + Model: req.Model, + Mode: req.Mode, + ConfigOptions: options, + }) + return string(data) +} + +func cloneModelConfigResolution(in ModelConfigResolution) ModelConfigResolution { + out := in + out.ConfigOptions = append([]ConfigOption(nil), in.ConfigOptions...) + for i := range out.ConfigOptions { + out.ConfigOptions[i].Options = append([]ConfigOptionChoice(nil), in.ConfigOptions[i].Options...) + } + return out +} diff --git a/apps/backend/internal/agent/hostutility/model_config_test.go b/apps/backend/internal/agent/hostutility/model_config_test.go new file mode 100644 index 0000000000..d35dc5cbd0 --- /dev/null +++ b/apps/backend/internal/agent/hostutility/model_config_test.go @@ -0,0 +1,371 @@ +package hostutility + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/kandev/kandev/internal/agent/registry" + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility" + "github.com/stretchr/testify/require" +) + +func TestModelConfigCacheKeyCanonicalizesOptionMap(t *testing.T) { + first := ModelConfigResolutionRequest{ + Model: "model", + Mode: "build", + ConfigOptions: map[string]string{ + "zeta": "last", + "alpha": "first", + }, + } + second := ModelConfigResolutionRequest{ + Model: "model", + Mode: "build", + ConfigOptions: map[string]string{ + "alpha": "first", + "zeta": "last", + }, + } + + require.Equal(t, modelConfigCacheKey("agent", first), modelConfigCacheKey("agent", second)) + require.NotEqual(t, modelConfigCacheKey("other-agent", first), modelConfigCacheKey("agent", second)) +} + +func TestModelConfigCacheExpiresAndClonesValues(t *testing.T) { + cache := newModelConfigCache() + now := time.Unix(100, 0) + resolution := ModelConfigResolution{ + AgentType: "agent", + Model: "model", + Status: StatusOK, + ConfigOptions: []ConfigOption{{ + ID: "effort", + Options: []ConfigOptionChoice{{Value: "high"}}, + }}, + } + cache.set("key", resolution, now) + + got, ok := cache.get("key", now.Add(time.Second)) + require.True(t, ok) + got.ConfigOptions[0].Options[0].Value = "mutated" + + again, ok := cache.get("key", now.Add(time.Second)) + require.True(t, ok) + require.Equal(t, "high", again.ConfigOptions[0].Options[0].Value) + + _, ok = cache.get("key", now.Add(modelConfigCacheTTL)) + require.False(t, ok) +} + +func TestModelConfigCacheSetSweepsExpiredEntriesAndCapsSize(t *testing.T) { + cache := newModelConfigCache() + now := time.Unix(100, 0) + cache.set("expired", ModelConfigResolution{AgentType: "agent"}, now.Add(-modelConfigCacheTTL)) + cache.set("fresh", ModelConfigResolution{AgentType: "agent"}, now) + + cache.mu.RLock() + if _, ok := cache.items["expired"]; ok { + cache.mu.RUnlock() + t.Fatal("expired entry remains after a set") + } + cache.mu.RUnlock() + + for i := 0; i < modelConfigCacheMaxEntries+1; i++ { + cache.set("key-"+fmt.Sprint(i), ModelConfigResolution{AgentType: "agent"}, now) + } + + cache.mu.RLock() + defer cache.mu.RUnlock() + if len(cache.items) > modelConfigCacheMaxEntries { + t.Fatalf("cache size = %d, want at most %d", len(cache.items), modelConfigCacheMaxEntries) + } +} + +func TestResolveModelConfigSendsSelectedModel(t *testing.T) { + log := newTestLogger(t) + reg := registry.NewRegistry(log) + const agentType = "dynamic-acp" + agent := &installedInferenceAgent{id: agentType} + require.NoError(t, reg.Register(agent)) + + var requested agentctlutil.ProbeRequest + probeCalls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + w.WriteHeader(http.StatusOK) + case "/api/v1/inference/probe": + probeCalls++ + require.NoError(t, json.NewDecoder(r.Body).Decode(&requested)) + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(agentctlutil.ProbeResponse{ + Success: true, + ConfigOptions: []agentctlutil.ProbeConfigOption{{ + ID: "reasoning_effort", + Name: "Reasoning effort", + Type: "select", + CurrentValue: "high", + }}, + })) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + host, port := serverHostPort(t, server) + + mgr := NewManager(reg, host, port, nil, log) + mgr.instances[agentType] = &instance{ + agentType: agentType, + workDir: t.TempDir(), + client: agentctl.NewClient(host, port, log), + } + + resolved, err := mgr.ResolveModelConfig(context.Background(), agentType, ModelConfigResolutionRequest{ + Model: "model-with-effort", + }) + require.NoError(t, err) + require.Equal(t, StatusOK, resolved.Status) + require.Equal(t, "model-with-effort", resolved.Model) + require.Len(t, resolved.ConfigOptions, 1) + require.Equal(t, "reasoning_effort", resolved.ConfigOptions[0].ID) + + require.Equal(t, "model-with-effort", requested.Model) + + second, err := mgr.ResolveModelConfig(context.Background(), agentType, ModelConfigResolutionRequest{ + Model: "model-with-effort", + }) + require.NoError(t, err) + require.Equal(t, resolved.ConfigOptions[0].ID, second.ConfigOptions[0].ID) + require.Equal(t, 1, probeCalls) +} + +func TestResolveModelConfigDeduplicatesConcurrentProbes(t *testing.T) { + log := newTestLogger(t) + reg := registry.NewRegistry(log) + const agentType = "concurrent-dynamic-acp" + require.NoError(t, reg.Register(&installedInferenceAgent{id: agentType})) + + probeStarted := make(chan struct{}) + releaseProbe := make(chan struct{}) + var probeCalls atomic.Int32 + var startOnce sync.Once + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + w.WriteHeader(http.StatusOK) + case "/api/v1/inference/probe": + probeCalls.Add(1) + startOnce.Do(func() { close(probeStarted) }) + <-releaseProbe + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(agentctlutil.ProbeResponse{ + Success: true, + })) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + host, port := serverHostPort(t, server) + + mgr := NewManager(reg, host, port, nil, log) + mgr.instances[agentType] = &instance{ + agentType: agentType, + workDir: t.TempDir(), + client: agentctl.NewClient(host, port, log), + } + + request := ModelConfigResolutionRequest{Model: "shared-model"} + firstContext, cancelFirst := context.WithCancel(context.Background()) + defer cancelFirst() + results := make(chan ModelConfigResolution, 2) + errors := make(chan error, 2) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + resolved, err := mgr.ResolveModelConfig(firstContext, agentType, request) + results <- resolved + errors <- err + }() + + <-probeStarted + wg.Add(1) + go func() { + defer wg.Done() + resolved, err := mgr.ResolveModelConfig(context.Background(), agentType, request) + results <- resolved + errors <- err + }() + cancelFirst() + close(releaseProbe) + wg.Wait() + close(results) + close(errors) + for err := range errors { + require.NoError(t, err) + } + for range results { + } + require.Equal(t, int32(1), probeCalls.Load()) +} + +func TestResolveModelConfigRefreshStartsNewGenerationAndWinsOverOlderProbe(t *testing.T) { + log := newTestLogger(t) + reg := registry.NewRegistry(log) + const agentType = "refresh-generation-acp" + require.NoError(t, reg.Register(&installedInferenceAgent{id: agentType})) + + firstStarted := make(chan struct{}) + secondStarted := make(chan struct{}) + releaseFirst := make(chan struct{}) + releaseSecond := make(chan struct{}) + var probeCalls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + w.WriteHeader(http.StatusOK) + case "/api/v1/inference/probe": + call := probeCalls.Add(1) + switch call { + case 1: + close(firstStarted) + <-releaseFirst + case 2: + close(secondStarted) + <-releaseSecond + } + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(agentctlutil.ProbeResponse{ + Success: true, + ConfigOptions: []agentctlutil.ProbeConfigOption{{ + ID: "generation", + CurrentValue: fmt.Sprint(call), + }}, + })) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + host, port := serverHostPort(t, server) + + mgr := NewManager(reg, host, port, nil, log) + mgr.instances[agentType] = &instance{ + agentType: agentType, + workDir: t.TempDir(), + client: agentctl.NewClient(host, port, log), + } + + request := ModelConfigResolutionRequest{Model: "refresh-model"} + firstResult := make(chan ModelConfigResolution, 1) + firstError := make(chan error, 1) + go func() { + resolved, err := mgr.ResolveModelConfig(context.Background(), agentType, request) + firstResult <- resolved + firstError <- err + }() + <-firstStarted + + refreshResult := make(chan ModelConfigResolution, 1) + refreshError := make(chan error, 1) + go func() { + resolved, err := mgr.ResolveModelConfig(context.Background(), agentType, ModelConfigResolutionRequest{ + Model: request.Model, + Refresh: true, + }) + refreshResult <- resolved + refreshError <- err + }() + + select { + case <-secondStarted: + case <-time.After(2 * time.Second): + t.Fatal("refresh joined the older in-flight probe") + } + close(releaseSecond) + select { + case err := <-refreshError: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("refresh probe did not finish") + } + refreshed := <-refreshResult + require.Equal(t, "2", refreshed.ConfigOptions[0].CurrentValue) + + close(releaseFirst) + require.NoError(t, <-firstError) + <-firstResult + + cached, err := mgr.ResolveModelConfig(context.Background(), agentType, request) + require.NoError(t, err) + require.Equal(t, "2", cached.ConfigOptions[0].CurrentValue) + require.Equal(t, int32(2), probeCalls.Load()) +} + +func TestResolveModelConfigDoesNotCacheFailures(t *testing.T) { + log := newTestLogger(t) + reg := registry.NewRegistry(log) + const agentType = "failure-dynamic-acp" + require.NoError(t, reg.Register(&installedInferenceAgent{id: agentType})) + var probeCalls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + w.WriteHeader(http.StatusOK) + case "/api/v1/inference/probe": + call := probeCalls.Add(1) + w.Header().Set("Content-Type", "application/json") + if call == 1 { + require.NoError(t, json.NewEncoder(w).Encode(agentctlutil.ProbeResponse{ + Success: false, + Error: "provider failed", + })) + return + } + require.NoError(t, json.NewEncoder(w).Encode(agentctlutil.ProbeResponse{Success: true})) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + host, port := serverHostPort(t, server) + + mgr := NewManager(reg, host, port, nil, log) + mgr.instances[agentType] = &instance{ + agentType: agentType, + workDir: t.TempDir(), + client: agentctl.NewClient(host, port, log), + } + request := ModelConfigResolutionRequest{Model: "retry-model"} + + first, err := mgr.ResolveModelConfig(context.Background(), agentType, request) + require.NoError(t, err) + require.Equal(t, StatusFailed, first.Status) + second, err := mgr.ResolveModelConfig(context.Background(), agentType, request) + require.NoError(t, err) + require.Equal(t, StatusOK, second.Status) + require.Equal(t, int32(2), probeCalls.Load()) +} + +func TestModelConfigCacheInvalidatesOneAgent(t *testing.T) { + cache := newModelConfigCache() + now := time.Unix(100, 0) + cache.set("agent-key", ModelConfigResolution{AgentType: "agent", Status: StatusOK}, now) + cache.set("other-key", ModelConfigResolution{AgentType: "other", Status: StatusOK}, now) + + cache.invalidateAgent("agent") + _, ok := cache.get("agent-key", now.Add(time.Second)) + require.False(t, ok) + _, ok = cache.get("other-key", now.Add(time.Second)) + require.True(t, ok) +} diff --git a/apps/backend/internal/agent/hostutility/public.go b/apps/backend/internal/agent/hostutility/public.go index 0f82957ee4..958233dd7f 100644 --- a/apps/backend/internal/agent/hostutility/public.go +++ b/apps/backend/internal/agent/hostutility/public.go @@ -26,10 +26,127 @@ func (m *Manager) Get(agentType string) (AgentCapabilities, bool) { return m.cache.get(agentType) } +func (m *Manager) invalidateModelConfigCache(agentType string) uint64 { + if m == nil { + return 0 + } + if m.modelCache != nil { + m.modelCache.invalidateAgent(agentType) + } + m.modelGenerationMu.Lock() + defer m.modelGenerationMu.Unlock() + if m.modelGenerations == nil { + m.modelGenerations = make(map[string]uint64) + } + m.modelGenerations[agentType]++ + return m.modelGenerations[agentType] +} + +func (m *Manager) modelConfigGeneration(agentType string) uint64 { + m.modelGenerationMu.Lock() + defer m.modelGenerationMu.Unlock() + return m.modelGenerations[agentType] +} + +// ResolveModelConfig applies a model in a sessionless ACP probe and returns the +// provider's complete configuration-option snapshot. Successful resolutions +// are cached for a short time and identical concurrent requests share one +// probe. +func (m *Manager) ResolveModelConfig( + ctx context.Context, + agentType string, + req ModelConfigResolutionRequest, +) (ModelConfigResolution, error) { + if m == nil || m.modelCache == nil { + return ModelConfigResolution{}, errors.New("host utility manager not configured") + } + if req.Model == "" { + return ModelConfigResolution{}, errors.New("model is required") + } + + key := modelConfigCacheKey(agentType, req) + generation := m.modelConfigGeneration(agentType) + if req.Refresh { + generation = m.invalidateModelConfigCache(agentType) + } + if !req.Refresh { + if cached, ok := m.modelCache.get(key, time.Now()); ok { + return cached, nil + } + } + + flightKey := fmt.Sprintf("%d:%s", generation, key) + value, err, _ := m.modelGroup.Do(flightKey, func() (interface{}, error) { + return m.resolveModelConfigFlight(ctx, agentType, req, key, generation) + }) + if err != nil { + return ModelConfigResolution{}, err + } + return value.(ModelConfigResolution), nil +} + +func (m *Manager) resolveModelConfigFlight( + ctx context.Context, + agentType string, + req ModelConfigResolutionRequest, + key string, + generation uint64, +) (interface{}, error) { + if !req.Refresh { + if cached, ok := m.modelCache.get(key, time.Now()); ok { + return cached, nil + } + } + + // All work inside the shared flight uses a detached request context. + // A caller disconnecting must not cancel the probe for other waiters; + // the bounded timeout remains the lifetime limit for the shared work. + probeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), modelConfigResolveTimeout) + defer cancel() + inst, ia, err := m.getInstance(probeCtx, agentType) + if err != nil { + return nil, err + } + cfg := ia.InferenceConfig() + if cfg == nil || !cfg.Supported { + return nil, errors.New("inference config not available") + } + + probeReq := buildProbeRequest(inst, ia, req.Refresh, agents.Command{}) + probeReq.Model = req.Model + probeReq.Mode = req.Mode + probeReq.ConfigOptions = cloneStringMap(req.ConfigOptions) + resp, err := inst.client.Probe(probeCtx, probeReq) + if err != nil { + return nil, err + } + + resolution := ModelConfigResolution{ + AgentType: agentType, + Model: req.Model, + Status: StatusFailed, + } + if !resp.Success { + resolution.Error = resp.Error + if isAuthError(resp.Error) { + resolution.Status = StatusAuthRequired + } + return resolution, nil + } + + resolution.Status = StatusOK + resolution.ConfigOptions = configOptionsFromProbe(resp.ConfigOptions) + if m.modelConfigGeneration(agentType) == generation { + m.modelCache.set(key, resolution, time.Now()) + } + return resolution, nil +} + // Refresh re-probes the given agent type, refreshes the cache, and returns the // new capabilities. If the warm instance is missing (never bootstrapped or // crashed), it is lazily recreated. func (m *Manager) Refresh(ctx context.Context, agentType string) (AgentCapabilities, error) { + m.invalidateModelConfigCache(agentType) // Publish "probing" so callers polling the cache see in-flight state. m.cache.set(AgentCapabilities{ AgentType: agentType, @@ -64,6 +181,7 @@ func (m *Manager) RefreshWithCommand( agentType string, command agents.Command, ) (AgentCapabilities, error) { + m.invalidateModelConfigCache(agentType) inst, ia, err := m.getInstance(ctx, agentType) if err != nil { return AgentCapabilities{}, err @@ -148,3 +266,38 @@ func (m *Manager) resolveModel(agentType, explicit string, _ agents.InferenceAge } return "" } + +func cloneStringMap(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for key, value := range in { + out[key] = value + } + return out +} + +func configOptionsFromProbe(options []agentctlutil.ProbeConfigOption) []ConfigOption { + out := make([]ConfigOption, 0, len(options)) + for _, option := range options { + choices := make([]ConfigOptionChoice, 0, len(option.Options)) + for _, choice := range option.Options { + choices = append(choices, ConfigOptionChoice{ + Value: choice.Value, + Name: choice.Name, + Description: choice.Description, + }) + } + out = append(out, ConfigOption{ + Type: option.Type, + ID: option.ID, + Name: option.Name, + Description: option.Description, + CurrentValue: option.CurrentValue, + Category: option.Category, + Options: choices, + }) + } + return out +} diff --git a/apps/backend/internal/agent/hostutility/types.go b/apps/backend/internal/agent/hostutility/types.go index c127cc7c52..123e0bdfa7 100644 --- a/apps/backend/internal/agent/hostutility/types.go +++ b/apps/backend/internal/agent/hostutility/types.go @@ -109,6 +109,26 @@ type ConfigOptionChoice struct { Description string `json:"description,omitempty"` } +// ModelConfigResolutionRequest identifies the provider state to resolve. +// Model is required. Mode and ConfigOptions are optional future-dependent +// inputs for providers whose options depend on more than the model. +type ModelConfigResolutionRequest struct { + Model string + Mode string + ConfigOptions map[string]string + Refresh bool +} + +// ModelConfigResolution is the provider's complete option snapshot for one +// model-resolution context. +type ModelConfigResolution struct { + AgentType string + Model string + Status Status + ConfigOptions []ConfigOption + Error string +} + // PromptResult is returned from ExecutePrompt and RawPrompt calls. type PromptResult struct { Response string diff --git a/apps/backend/internal/agent/settings/controller/agent_config.go b/apps/backend/internal/agent/settings/controller/agent_config.go index ae805d2239..52bf2a11b7 100644 --- a/apps/backend/internal/agent/settings/controller/agent_config.go +++ b/apps/backend/internal/agent/settings/controller/agent_config.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "os/exec" + "strings" "time" "github.com/kandev/kandev/internal/agent/agents" @@ -321,3 +322,47 @@ func (c *Controller) FetchDynamicModels(ctx context.Context, agentName string, r } return resp, nil } + +// ResolveAgentModelConfig returns the complete provider configuration-option +// snapshot after applying a selected model in a sessionless ACP probe. +func (c *Controller) ResolveAgentModelConfig( + ctx context.Context, + agentName string, + req dto.ResolveAgentModelConfigRequest, +) (*dto.AgentModelConfigResponse, error) { + if _, ok := c.agentRegistry.Get(agentName); !ok { + return nil, fmt.Errorf("%w: %s", ErrAgentNotFound, agentName) + } + if strings.TrimSpace(req.Model) == "" { + return nil, ErrModelRequired + } + resp := &dto.AgentModelConfigResponse{ + AgentName: agentName, + Model: req.Model, + Status: string(hostutility.StatusNotConfigured), + ConfigOptions: []dto.ConfigOptionDTO{}, + } + if c.hostUtility == nil { + return resp, nil + } + + resolution, err := c.hostUtility.ResolveModelConfig(ctx, agentName, hostutility.ModelConfigResolutionRequest{ + Model: req.Model, + Mode: req.Mode, + ConfigOptions: req.ConfigOptions, + Refresh: req.Refresh, + }) + if err != nil { + return nil, err + } + resp.Status = string(resolution.Status) + resp.ConfigOptions = configOptionDTOs(resolution.ConfigOptions) + if resolution.Error != "" { + message := "model option resolution failed" + if resolution.Status == hostutility.StatusAuthRequired { + message = "agent authentication is required" + } + resp.Error = &message + } + return resp, nil +} diff --git a/apps/backend/internal/agent/settings/controller/agent_config_test.go b/apps/backend/internal/agent/settings/controller/agent_config_test.go new file mode 100644 index 0000000000..14fb2d2d31 --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/agent_config_test.go @@ -0,0 +1,105 @@ +package controller + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/hostutility" + "github.com/kandev/kandev/internal/agent/settings/dto" +) + +func TestResolveAgentModelConfigRequiresModel(t *testing.T) { + ctrl := newTestController(map[string]agents.Agent{ + "test-agent": &testAgent{id: "test-agent"}, + }) + + _, err := ctrl.ResolveAgentModelConfig(context.Background(), "test-agent", dto.ResolveAgentModelConfigRequest{}) + if !errors.Is(err, ErrModelRequired) { + t.Fatalf("ResolveAgentModelConfig() error = %v, want ErrModelRequired", err) + } +} + +func TestResolveAgentModelConfigWithoutHostUtilityReturnsNotConfigured(t *testing.T) { + ctrl := newTestController(map[string]agents.Agent{ + "test-agent": &testAgent{id: "test-agent"}, + }) + + resp, err := ctrl.ResolveAgentModelConfig(context.Background(), "test-agent", dto.ResolveAgentModelConfigRequest{ + Model: "model", + }) + if err != nil { + t.Fatalf("ResolveAgentModelConfig() error = %v", err) + } + if resp.Status != "not_configured" { + t.Fatalf("status = %q, want not_configured", resp.Status) + } + if len(resp.ConfigOptions) != 0 { + t.Fatalf("config options = %#v, want empty", resp.ConfigOptions) + } +} + +type fakeModelConfigHostUtility struct { + resolution hostutility.ModelConfigResolution +} + +func (f *fakeModelConfigHostUtility) Get(string) (hostutility.AgentCapabilities, bool) { + return hostutility.AgentCapabilities{}, true +} + +func (f *fakeModelConfigHostUtility) Refresh(context.Context, string) (hostutility.AgentCapabilities, error) { + return hostutility.AgentCapabilities{}, nil +} + +func (f *fakeModelConfigHostUtility) ResolveModelConfig( + context.Context, + string, + hostutility.ModelConfigResolutionRequest, +) (hostutility.ModelConfigResolution, error) { + return f.resolution, nil +} + +func TestResolveAgentModelConfigSanitizesProviderFailure(t *testing.T) { + ctrl := newTestController(map[string]agents.Agent{ + "test-agent": &testAgent{id: "test-agent"}, + }) + ctrl.hostUtility = &fakeModelConfigHostUtility{resolution: hostutility.ModelConfigResolution{ + Status: hostutility.StatusFailed, + Error: "provider stderr includes token=secret-value", + }} + + resp, err := ctrl.ResolveAgentModelConfig(context.Background(), "test-agent", dto.ResolveAgentModelConfigRequest{ + Model: "model", + }) + if err != nil { + t.Fatalf("ResolveAgentModelConfig() error = %v", err) + } + if resp.Error == nil || *resp.Error != "model option resolution failed" { + t.Fatalf("error = %v, want sanitized failure", resp.Error) + } + if strings.Contains(*resp.Error, "secret-value") { + t.Fatalf("sanitized error contains provider output: %q", *resp.Error) + } +} + +func TestResolveAgentModelConfigMapsAuthenticationRequired(t *testing.T) { + ctrl := newTestController(map[string]agents.Agent{ + "test-agent": &testAgent{id: "test-agent"}, + }) + ctrl.hostUtility = &fakeModelConfigHostUtility{resolution: hostutility.ModelConfigResolution{ + Status: hostutility.StatusAuthRequired, + Error: "provider asks for login", + }} + + resp, err := ctrl.ResolveAgentModelConfig(context.Background(), "test-agent", dto.ResolveAgentModelConfigRequest{ + Model: "model", + }) + if err != nil { + t.Fatalf("ResolveAgentModelConfig() error = %v", err) + } + if resp.Error == nil || *resp.Error != "agent authentication is required" { + t.Fatalf("error = %v, want authentication message", resp.Error) + } +} diff --git a/apps/backend/internal/agent/settings/controller/agent_discovery.go b/apps/backend/internal/agent/settings/controller/agent_discovery.go index 56959a59fc..73401996d5 100644 --- a/apps/backend/internal/agent/settings/controller/agent_discovery.go +++ b/apps/backend/internal/agent/settings/controller/agent_discovery.go @@ -15,6 +15,7 @@ import ( "github.com/kandev/kandev/internal/agent/hostutility" "github.com/kandev/kandev/internal/agent/settings/dto" "github.com/kandev/kandev/internal/agent/settings/models" + "github.com/kandev/kandev/pkg/agent" ) func (c *Controller) ListDiscovery(ctx context.Context) (*dto.ListDiscoveryResponse, error) { @@ -199,16 +200,15 @@ func buildLoginCommandDTO(ag agents.Agent) *dto.LoginCommandDTO { } // buildModelConfigFromHostUtility reads cached ACP probe data for the agent -// type and produces a ModelConfigDTO with models, modes, and status. Agents -// not in the probe cache (e.g. the mock agent used in E2E tests, which -// doesn't speak ACP through its binary) fall back to `SupportsDynamicModels: -// false` with an empty model list — callers render the profile's stored -// model as a plain string rather than offering a dropdown. +// type and produces a ModelConfigDTO with models, modes, and status. The +// capability cache can be empty while the asynchronous host-utility probe +// is still starting, so the dynamic-support flag comes from the registered +// agent capability rather than from cache presence. func (c *Controller) buildModelConfigFromHostUtility(agentID string) dto.ModelConfigDTO { // Always initialize slices so JSON marshals as [] not null — the // frontend uses .some()/.find() on these without null checks. cfg := dto.ModelConfigDTO{ - SupportsDynamicModels: false, + SupportsDynamicModels: c.agentSupportsDynamicModelConfig(agentID), AvailableModels: []dto.ModelEntryDTO{}, AvailableModes: []dto.ModeEntryDTO{}, } @@ -254,6 +254,25 @@ func (c *Controller) buildModelConfigFromHostUtility(agentID string) dto.ModelCo return cfg } +func (c *Controller) agentSupportsDynamicModelConfig(agentID string) bool { + if c.agentRegistry == nil { + return false + } + ag, ok := c.agentRegistry.Get(agentID) + if !ok { + return false + } + ia, ok := ag.(agents.InferenceAgent) + if !ok { + return false + } + if cfg := ia.InferenceConfig(); cfg == nil || !cfg.Supported { + return false + } + runtime := ag.Runtime() + return runtime != nil && runtime.Protocol == agent.ProtocolACP +} + func configOptionDTOs(options []hostutility.ConfigOption) []dto.ConfigOptionDTO { out := make([]dto.ConfigOptionDTO, 0, len(options)) for _, opt := range options { diff --git a/apps/backend/internal/agent/settings/controller/agent_discovery_test.go b/apps/backend/internal/agent/settings/controller/agent_discovery_test.go index 18c8b23f62..9efea2cf37 100644 --- a/apps/backend/internal/agent/settings/controller/agent_discovery_test.go +++ b/apps/backend/internal/agent/settings/controller/agent_discovery_test.go @@ -84,6 +84,26 @@ func TestUnavailableManagedAgentOmitsRuntimeMetadata(t *testing.T) { } } +func TestAvailableACPInferenceAgentIsDynamicBeforeProbeSnapshot(t *testing.T) { + ag := agents.NewMockAgent() + ag.SetEnabled(true) + ctrl := newTestController(map[string]agents.Agent{ag.ID(): ag}) + + item := ctrl.buildAvailableAgentDTO( + context.Background(), + ag, + discovery.Availability{Name: ag.ID(), Available: true}, + time.Now(), + ) + + if !item.ModelConfig.SupportsDynamicModels { + t.Fatal("supports_dynamic_models = false, want true before the probe snapshot is cached") + } + if item.ModelConfig.Status != "not_configured" { + t.Fatalf("status = %q, want not_configured without a host utility", item.ModelConfig.Status) + } +} + func TestConfigOptionDTOsPreserveDescriptions(t *testing.T) { dtos := configOptionDTOs([]hostutility.ConfigOption{{ Type: "select", diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go index 6771898372..3e8928cd13 100644 --- a/apps/backend/internal/agent/settings/controller/controller.go +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -60,7 +60,7 @@ type Controller struct { automationDeps AutomationDependencyChecker mcpService *mcpconfig.Service modelCache *modelfetcher.Cache - hostUtility *hostutility.Manager + hostUtility hostUtilityProvider jobStore *JobStore updateJobStore *AgentUpdateJobStore runtimeUpdater RuntimeUpdater @@ -185,6 +185,16 @@ type RoutingTierDependencyChecker interface { ListRoutingTierReferencesByAgentProfile(ctx context.Context, profileID string) ([]RoutingTierReference, error) } +type hostUtilityProvider interface { + Get(agentType string) (hostutility.AgentCapabilities, bool) + Refresh(ctx context.Context, agentType string) (hostutility.AgentCapabilities, error) + ResolveModelConfig( + ctx context.Context, + agentType string, + req hostutility.ModelConfigResolutionRequest, + ) (hostutility.ModelConfigResolution, error) +} + func NewController(repo store.Repository, discoveryRegistry *discovery.Registry, agentRegistry *registry.Registry, sessionChecker SessionChecker, log *logger.Logger, ) *Controller { return &Controller{ diff --git a/apps/backend/internal/agent/settings/dto/dto.go b/apps/backend/internal/agent/settings/dto/dto.go index f2d94fa909..b2186d42d5 100644 --- a/apps/backend/internal/agent/settings/dto/dto.go +++ b/apps/backend/internal/agent/settings/dto/dto.go @@ -338,6 +338,24 @@ type DynamicModelsResponse struct { Error *string `json:"error"` } +// ResolveAgentModelConfigRequest selects the provider context to resolve. +type ResolveAgentModelConfigRequest struct { + Model string `json:"model"` + Mode string `json:"mode,omitempty"` + ConfigOptions map[string]string `json:"config_options,omitempty"` + Refresh bool `json:"refresh,omitempty"` +} + +// AgentModelConfigResponse is the complete provider option snapshot for one +// selected model. +type AgentModelConfigResponse struct { + AgentName string `json:"agent_name"` + Model string `json:"model"` + Status string `json:"status"` + ConfigOptions []ConfigOptionDTO `json:"config_options"` + Error *string `json:"error"` +} + // ModeEntryDTO is a single ACP session mode advertised by an agent. type ModeEntryDTO struct { ID string `json:"id"` diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go index ca8ec1f827..4a2aed5db6 100644 --- a/apps/backend/internal/agent/settings/handlers/handlers.go +++ b/apps/backend/internal/agent/settings/handlers/handlers.go @@ -62,6 +62,7 @@ func (h *Handlers) registerHTTP(router *gin.Engine) { api.POST("/agents/:id/profiles", h.interlock, h.httpCreateProfile) api.GET("/agents/:id/logo", h.httpGetAgentLogo) api.GET("/agent-models/:agentName", h.httpGetAgentModels) + api.POST("/agent-models/:agentName/resolve", h.httpResolveAgentModelConfig) api.POST("/agent-command-preview/:agentName", h.httpPreviewAgentCommand) api.POST("/agent-install/:agentName", h.interlock, h.httpInstallAgent) api.GET("/agent-update/:agentName/preview", h.httpPreviewAgentUpdate) @@ -712,7 +713,7 @@ func (h *Handlers) httpGetAgentModels(c *gin.Context) { resp, err := h.controller.FetchDynamicModels(c.Request.Context(), agentName, refresh) if err != nil { - if strings.Contains(err.Error(), "not found") { + if errors.Is(err, controller.ErrAgentNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"}) return } @@ -723,3 +724,31 @@ func (h *Handlers) httpGetAgentModels(c *gin.Context) { c.JSON(http.StatusOK, resp) } + +func (h *Handlers) httpResolveAgentModelConfig(c *gin.Context) { + agentName := strings.TrimSpace(c.Param("agentName")) + if agentName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "agent name is required"}) + return + } + var req dto.ResolveAgentModelConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid model resolution request"}) + return + } + resp, err := h.controller.ResolveAgentModelConfig(c.Request.Context(), agentName, req) + if err != nil { + if errors.Is(err, controller.ErrModelRequired) { + c.JSON(http.StatusBadRequest, gin.H{"error": "model is required"}) + return + } + if errors.Is(err, controller.ErrAgentNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"}) + return + } + h.logger.Error("failed to resolve agent model options", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve agent model options"}) + return + } + c.JSON(http.StatusOK, resp) +} diff --git a/apps/backend/internal/agent/settings/handlers/model_config_handlers_test.go b/apps/backend/internal/agent/settings/handlers/model_config_handlers_test.go new file mode 100644 index 0000000000..4abc569af5 --- /dev/null +++ b/apps/backend/internal/agent/settings/handlers/model_config_handlers_test.go @@ -0,0 +1,87 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/hostutility" + "github.com/kandev/kandev/internal/agent/registry" + "github.com/kandev/kandev/internal/agent/settings/controller" + "github.com/kandev/kandev/internal/common/logger" +) + +func newModelConfigRouter(t *testing.T) (*gin.Engine, *registry.Registry) { + t.Helper() + gin.SetMode(gin.TestMode) + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatalf("NewLogger: %v", err) + } + reg := registry.NewRegistry(log) + ctrl := controller.NewController(nil, nil, reg, nil, log) + ctrl.SetHostUtility(hostutility.NewManager(reg, "127.0.0.1", 0, nil, log)) + router := gin.New() + RegisterRoutes(router, ctrl, nil, log, "test-interlock") + return router, reg +} + +func TestResolveAgentModelConfigEndpointRejectsInvalidJSON(t *testing.T) { + router, _ := newModelConfigRouter(t) + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v1/agent-models/test-agent/resolve", strings.NewReader("{")) + request.Header.Set("Content-Type", "application/json") + router.ServeHTTP(response, request) + + if response.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } +} + +func TestResolveAgentModelConfigEndpointReturnsNotFoundForMissingAgent(t *testing.T) { + router, _ := newModelConfigRouter(t) + response := httptest.NewRecorder() + request := httptest.NewRequest( + http.MethodPost, + "/api/v1/agent-models/missing-agent/resolve", + strings.NewReader(`{"model":"model"}`), + ) + request.Header.Set("Content-Type", "application/json") + router.ServeHTTP(response, request) + + if response.Code != http.StatusNotFound { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } +} + +func TestResolveAgentModelConfigEndpointDoesNotClassifyNonInferenceAgentAsNotFound(t *testing.T) { + router, reg := newModelConfigRouter(t) + requireNoError(t, reg.Register(agents.NewTUIAgent(agents.TUIAgentConfig{ + AgentID: "terminal-agent", + AgentName: "Terminal", + Command: "terminal-agent", + }))) + + response := httptest.NewRecorder() + request := httptest.NewRequest( + http.MethodPost, + "/api/v1/agent-models/terminal-agent/resolve", + strings.NewReader(`{"model":"model"}`), + ) + request.Header.Set("Content-Type", "application/json") + router.ServeHTTP(response, request) + + if response.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } +} + +func requireNoError(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/apps/backend/internal/agentctl/server/utility/acp_executor.go b/apps/backend/internal/agentctl/server/utility/acp_executor.go index 6732e45dd3..6e3a9878d7 100644 --- a/apps/backend/internal/agentctl/server/utility/acp_executor.go +++ b/apps/backend/internal/agentctl/server/utility/acp_executor.go @@ -31,6 +31,7 @@ const ( acpCommandTerminateGrace = 250 * time.Millisecond acpCommandForceKillGrace = 500 * time.Millisecond acpCommandPollInterval = 25 * time.Millisecond + acpProbeConfigWait = 1 * time.Second ) // ACPInferenceExecutor executes one-shot prompts using the ACP protocol. @@ -225,7 +226,7 @@ func (e *ACPInferenceExecutor) executeACPSession( return "", fmt.Errorf("ACP model selection failed: %w", err) } } - if err := applySessionConfigOptions(ctx, conn, string(sessionID), modelConfigOptions); err != nil { + if _, err := applySessionConfigOptions(ctx, conn, string(sessionID), modelConfigOptions); err != nil { return "", fmt.Errorf("ACP model config selection failed: %w", err) } @@ -262,7 +263,24 @@ func applySessionModel( model string, configOptions []acp.SessionConfigOption, ) (sessionmodel.Method, error) { - return sessionmodel.ApplySDKFromACP(ctx, conn, string(sessionID), model, configOptions) + method, _, err := applySessionModelWithConfigOptions( + ctx, conn, sessionID, model, configOptions, + ) + return method, err +} + +func applySessionModelWithConfigOptions( + ctx context.Context, + conn sessionmodel.SDKConn, + sessionID acp.SessionId, + model string, + configOptions []acp.SessionConfigOption, +) (sessionmodel.Method, []acp.SessionConfigOption, error) { + return sessionmodel.ApplySDKWithConfigOptions(ctx, conn, sessionmodel.Request{ + SessionID: string(sessionID), + ModelID: model, + ConfigOptions: sessionmodel.FromACP(configOptions), + }) } func applySessionConfigOptions( @@ -270,22 +288,96 @@ func applySessionConfigOptions( conn sessionmodel.SDKConn, sessionID string, options map[string]string, -) error { +) ([]acp.SessionConfigOption, error) { + configOptions, _, err := applySessionConfigOptionsWithBoundary( + ctx, conn, sessionID, options, nil, + ) + return configOptions, err +} + +// applySessionConfigOptionsWithBoundary applies a batch of option mutations +// and returns the notification version captured immediately before the final +// mutation. A snapshot from an earlier mutation is not authoritative for the +// batch, so probe callers use that boundary when they wait for a notification. +func applySessionConfigOptionsWithBoundary( + ctx context.Context, + conn sessionmodel.SDKConn, + sessionID string, + options map[string]string, + state *acpProbeNotificationState, +) ([]acp.SessionConfigOption, int, error) { if len(options) == 0 { - return nil + return nil, 0, nil } keys := make([]string, 0, len(options)) for key := range options { keys = append(keys, key) } sort.Strings(keys) - applier := sessionmodel.SDKApplier{Conn: conn} + var configOptions []acp.SessionConfigOption for _, key := range keys { - if err := applier.SetConfigOption(ctx, sessionID, key, options[key]); err != nil { - return fmt.Errorf("set %q: %w", key, err) + previousVersion := 0 + if state != nil { + previousVersion = state.currentConfigUpdateVersion() + } + response, err := conn.SetSessionConfigOption(ctx, acp.SetSessionConfigOptionRequest{ + ValueId: &acp.SetSessionConfigOptionValueId{ + SessionId: acp.SessionId(sessionID), + ConfigId: acp.SessionConfigId(key), + Value: acp.SessionConfigValueId(options[key]), + }, + }) + if err != nil { + return nil, 0, fmt.Errorf("set %q: %w", key, err) + } + configOptions = response.ConfigOptions + if key == keys[len(keys)-1] { + return configOptions, previousVersion, nil } } - return nil + return configOptions, 0, nil +} + +func filterRequestedConfigOptions( + requested map[string]string, + available []acp.SessionConfigOption, +) map[string]string { + if len(requested) == 0 || len(available) == 0 { + return nil + } + availableValues := make(map[string]map[string]struct{}, len(available)) + for _, option := range available { + if option.Select == nil { + continue + } + values := make(map[string]struct{}) + for _, choice := range selectOptionsUngrouped(option.Select.Options) { + values[string(choice.Value)] = struct{}{} + } + availableValues[string(option.Select.Id)] = values + } + filtered := make(map[string]string, len(requested)) + for id, value := range requested { + values, ok := availableValues[id] + if !ok { + continue + } + // Some providers expose a select option without enumerating choices. + // Keep the persisted value in that case. When choices are present, + // discard values from a previous model that the new model does not + // support. + if len(values) == 0 { + filtered[id] = value + continue + } + if _, ok := values[value]; ok { + filtered[id] = value + } + } + if len(filtered) == 0 { + return nil + } + return filtered } // toACPMcpServers converts the cross-process DTO list into the ACP SDK shape. @@ -407,7 +499,9 @@ func (e *ACPInferenceExecutor) Probe(ctx context.Context, req *ProbeRequest) (*P } defer cleanupACPCommand(ctx, cmd, lifecycle, e.logger) - resp, err := e.probeACPSession(ctx, stdin, stdout, workDir, req.AgentID) + resp, err := e.probeACPSessionWithContext( + ctx, stdin, stdout, workDir, req.AgentID, req.Model, req.Mode, req.ConfigOptions, + ) if err != nil { // The tail goes to the log rather than into the response: handlers // deliberately keep subprocess diagnostics and tmp paths out of client @@ -537,40 +631,203 @@ func isOpenCodeModelID(id string) bool { return strings.Contains(id, "/") && !strings.ContainsAny(id, " \t\r") } -// probeACPSession performs initialize + session/new and returns the parsed -// capabilities, without sending any prompt or running session/prompt. After -// session/new, it briefly drains out-of-band notifications to capture the -// `available_commands_update` notification which some agents emit post-session. -func (e *ACPInferenceExecutor) probeACPSession( +type acpProbeNotificationState struct { + mu sync.Mutex + commands []ProbeCommand + configOptions []acp.SessionConfigOption + configUpdateVersion int + gotCommands chan struct{} + gotConfigOptions chan struct{} +} + +func newACPProbeNotificationState() *acpProbeNotificationState { + return &acpProbeNotificationState{ + gotCommands: make(chan struct{}, 1), + gotConfigOptions: make(chan struct{}, 1), + } +} + +func (s *acpProbeNotificationState) handle(n acp.SessionNotification) { + if update := n.Update.ConfigOptionUpdate; update != nil { + s.mu.Lock() + s.configOptions = append([]acp.SessionConfigOption(nil), update.ConfigOptions...) + s.configUpdateVersion++ + s.mu.Unlock() + select { + case s.gotConfigOptions <- struct{}{}: + default: + } + } + if update := n.Update.AvailableCommandsUpdate; update != nil { + s.mu.Lock() + s.commands = s.commands[:0] + for _, command := range update.AvailableCommands { + s.commands = append(s.commands, ProbeCommand{ + Name: command.Name, + Description: command.Description, + }) + } + s.mu.Unlock() + select { + case s.gotCommands <- struct{}{}: + default: + } + } +} + +func (s *acpProbeNotificationState) currentConfigUpdateVersion() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.configUpdateVersion +} + +func (s *acpProbeNotificationState) waitForConfigUpdate( + ctx context.Context, + previousVersion int, +) ([]acp.SessionConfigOption, bool, error) { + timer := time.NewTimer(acpProbeConfigWait) + defer timer.Stop() + for { + select { + case <-s.gotConfigOptions: + s.mu.Lock() + if s.configUpdateVersion > previousVersion { + updated := append([]acp.SessionConfigOption(nil), s.configOptions...) + s.mu.Unlock() + return updated, true, nil + } + s.mu.Unlock() + case <-timer.C: + return nil, false, nil + case <-ctx.Done(): + return nil, false, ctx.Err() + } + } +} + +func (s *acpProbeNotificationState) commandsSnapshot() []ProbeCommand { + s.mu.Lock() + defer s.mu.Unlock() + return append([]ProbeCommand(nil), s.commands...) +} + +func waitForProbeCommands(ctx context.Context, state *acpProbeNotificationState) { + select { + case <-state.gotCommands: + case <-time.After(1 * time.Second): + case <-ctx.Done(): + } +} + +type acpSessionModeSetter interface { + SetSessionMode(context.Context, acp.SetSessionModeRequest) (acp.SetSessionModeResponse, error) +} + +func applyProbeMode( + ctx context.Context, + conn acpSessionModeSetter, + sessionID acp.SessionId, + mode string, + state *acpProbeNotificationState, +) ([]acp.SessionConfigOption, error) { + previousVersion := state.currentConfigUpdateVersion() + if _, err := conn.SetSessionMode(ctx, acp.SetSessionModeRequest{ + SessionId: sessionID, + ModeId: acp.SessionModeId(mode), + }); err != nil { + return nil, fmt.Errorf("ACP session/set_mode failed: %w", err) + } + updated, received, err := state.waitForConfigUpdate(ctx, previousVersion) + if err != nil { + return nil, err + } + if !received { + return nil, nil + } + return updated, nil +} + +func applyProbeModel( + ctx context.Context, + conn sessionmodel.SDKConn, + sessionID acp.SessionId, + model string, + configOptions []acp.SessionConfigOption, + state *acpProbeNotificationState, +) ([]acp.SessionConfigOption, error) { + previousVersion := state.currentConfigUpdateVersion() + method, returnedConfigOptions, err := applySessionModelWithConfigOptions( + ctx, conn, sessionID, model, configOptions, + ) + if err != nil { + return nil, fmt.Errorf("ACP model selection failed: %w", err) + } + if method == sessionmodel.MethodNone { + return nil, fmt.Errorf("ACP provider does not support model selection") + } + if returnedConfigOptions != nil { + return returnedConfigOptions, nil + } + updated, received, err := state.waitForConfigUpdate(ctx, previousVersion) + if err != nil { + return nil, err + } + if !received { + return nil, fmt.Errorf("ACP model selection returned no configuration options") + } + return updated, nil +} + +func applyProbeConfigOptions( + ctx context.Context, + conn sessionmodel.SDKConn, + sessionID acp.SessionId, + requested map[string]string, + available []acp.SessionConfigOption, + state *acpProbeNotificationState, +) ([]acp.SessionConfigOption, error) { + requested = filterRequestedConfigOptions(requested, available) + if len(requested) == 0 { + return nil, nil + } + returnedConfigOptions, previousVersion, err := applySessionConfigOptionsWithBoundary( + ctx, conn, string(sessionID), requested, state, + ) + if err != nil { + return nil, fmt.Errorf("ACP model config selection failed: %w", err) + } + if returnedConfigOptions != nil { + return returnedConfigOptions, nil + } + updated, received, err := state.waitForConfigUpdate(ctx, previousVersion) + if err != nil { + return nil, err + } + if !received { + return nil, fmt.Errorf("ACP model config selection returned no configuration options") + } + return updated, nil +} + +// probeACPSessionWithContext performs a model-aware probe with optional mode +// and configuration selections. The returned options are always the latest +// complete snapshot observed from the provider. +func (e *ACPInferenceExecutor) probeACPSessionWithContext( ctx context.Context, stdin io.Writer, stdout io.Reader, workDir string, agentID string, + model string, + mode string, + requestedConfigOptions map[string]string, ) (*ProbeResponse, error) { - var mu sync.Mutex - var commands []ProbeCommand - gotCommands := make(chan struct{}, 1) - updateHandler := func(n acp.SessionNotification) { - if n.Update.AvailableCommandsUpdate == nil { - return - } - mu.Lock() - commands = commands[:0] - for _, c := range n.Update.AvailableCommandsUpdate.AvailableCommands { - commands = append(commands, ProbeCommand{Name: c.Name, Description: c.Description}) - } - mu.Unlock() - select { - case gotCommands <- struct{}{}: - default: - } - } + updates := newACPProbeNotificationState() client := acpclient.NewClient( acpclient.WithLogger(e.logger), acpclient.WithWorkspaceRoot(workDir), - acpclient.WithUpdateHandler(updateHandler), + acpclient.WithUpdateHandler(updates.handle), ) conn := acp.NewClientSideConnection(client, stdin, stdout) @@ -604,20 +861,48 @@ func (e *ACPInferenceExecutor) probeACPSession( return nil, describeACPFailure(ctx, "session/new", err) } - // Wait up to 1s for the available_commands_update notification. Agents - // that don't advertise commands (or push them later) simply yield an - // empty Commands slice. - select { - case <-gotCommands: - case <-time.After(1 * time.Second): - case <-ctx.Done(): + // Mode can change the provider's available options. Apply it before the + // model and use the resulting notification, when present, as the next + // model-selection snapshot. + if mode != "" { + updated, err := applyProbeMode(ctx, conn, sessionResp.SessionId, mode, updates) + if err != nil { + return nil, err + } + if updated != nil { + sessionResp.ConfigOptions = updated + } } + if model != "" { + updated, err := applyProbeModel( + ctx, conn, sessionResp.SessionId, model, sessionResp.ConfigOptions, updates, + ) + if err != nil { + return nil, err + } + sessionResp.ConfigOptions = updated + } + if updated, err := applyProbeConfigOptions( + ctx, + conn, + sessionResp.SessionId, + requestedConfigOptions, + sessionResp.ConfigOptions, + updates, + ); err != nil { + return nil, err + } else if updated != nil { + sessionResp.ConfigOptions = updated + } + + // Agents that don't advertise commands (or push them later) simply yield + // an empty Commands slice. + waitForProbeCommands(ctx, updates) + out := buildInitProbeFields(initResp) applySessionProbeFields(out, sessionResp, agentID) - mu.Lock() - out.Commands = append([]ProbeCommand(nil), commands...) - mu.Unlock() + out.Commands = updates.commandsSnapshot() return out, nil } diff --git a/apps/backend/internal/agentctl/server/utility/acp_probe_capabilities_test.go b/apps/backend/internal/agentctl/server/utility/acp_probe_capabilities_test.go index c682c5d5d1..4f8b3102cb 100644 --- a/apps/backend/internal/agentctl/server/utility/acp_probe_capabilities_test.go +++ b/apps/backend/internal/agentctl/server/utility/acp_probe_capabilities_test.go @@ -3,6 +3,7 @@ package utility import ( "context" "io" + "strings" "sync" "testing" "time" @@ -84,8 +85,8 @@ func probeHandshakeMeta(t *testing.T, agentID string) map[string]any { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - if _, err := e.probeACPSession(ctx, c2aW, a2cR, t.TempDir(), agentID); err != nil { - t.Fatalf("probeACPSession(%q): %v", agentID, err) + if _, err := e.probeACPSessionWithContext(ctx, c2aW, a2cR, t.TempDir(), agentID, "", "", nil); err != nil { + t.Fatalf("probeACPSessionWithContext(%q): %v", agentID, err) } return fake.captured().ClientCapabilities.Meta } @@ -148,3 +149,537 @@ func TestProbeACPSession_LeavesOtherAgentsHandshakeUnchanged(t *testing.T) { }) } } + +func TestProbeACPSessionWithContextRejectsProviderWithoutModelSelection(t *testing.T) { + c2aR, c2aW := io.Pipe() + a2cR, a2cW := io.Pipe() + t.Cleanup(func() { + _ = c2aR.Close() + _ = c2aW.Close() + _ = a2cR.Close() + _ = a2cW.Close() + }) + + fake := &probeCaptureAgent{} + acp.NewAgentSideConnection(fake, a2cW, c2aR) + e := NewACPInferenceExecutor(zap.NewNop()) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := e.probeACPSessionWithContext( + ctx, c2aW, a2cR, t.TempDir(), "provider-without-models", "model", "", nil, + ) + if err == nil || !strings.Contains(err.Error(), "provider does not support model selection") { + t.Fatalf("error = %v, want unsupported model selection", err) + } +} + +func TestProbeACPSessionWithContextTimesOutWithoutConfigSnapshot(t *testing.T) { + c2aR, c2aW := io.Pipe() + a2cR, a2cW := io.Pipe() + t.Cleanup(func() { + _ = c2aR.Close() + _ = c2aW.Close() + _ = a2cR.Close() + _ = a2cW.Close() + }) + + fake := &probeNoConfigResponseAgent{probeCaptureAgent: &probeCaptureAgent{}} + acp.NewAgentSideConnection(fake, a2cW, c2aR) + e := NewACPInferenceExecutor(zap.NewNop()) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := e.probeACPSessionWithContext( + ctx, c2aW, a2cR, t.TempDir(), "provider-without-config-response", "model", "", nil, + ) + if err == nil || !strings.Contains(err.Error(), "returned no configuration options") { + t.Fatalf("error = %v, want missing configuration snapshot", err) + } +} + +func TestProbeACPSessionWithModel_UsesReturnedConfigOptions(t *testing.T) { + c2aR, c2aW := io.Pipe() + a2cR, a2cW := io.Pipe() + t.Cleanup(func() { + _ = c2aR.Close() + _ = c2aW.Close() + _ = a2cR.Close() + _ = a2cW.Close() + }) + + fake := &probeModelConfigAgent{probeCaptureAgent: &probeCaptureAgent{}} + acp.NewAgentSideConnection(fake, a2cW, c2aR) + + e := NewACPInferenceExecutor(zap.NewNop()) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + resp, err := e.probeACPSessionWithContext( + ctx, c2aW, a2cR, t.TempDir(), "opencode-acp", "model-with-effort", "", nil, + ) + if err != nil { + t.Fatalf("probeACPSessionWithContext(): %v", err) + } + + var found bool + for _, option := range resp.ConfigOptions { + if option.ID == "reasoning_effort" { + found = true + break + } + } + if !found { + t.Fatalf("config options = %#v, want reasoning_effort", resp.ConfigOptions) + } +} + +func TestProbeACPSessionWithModel_UsesConfigUpdateNotification(t *testing.T) { + c2aR, c2aW := io.Pipe() + a2cR, a2cW := io.Pipe() + t.Cleanup(func() { + _ = c2aR.Close() + _ = c2aW.Close() + _ = a2cR.Close() + _ = a2cW.Close() + }) + + fake := &probeNotificationConfigAgent{probeCaptureAgent: &probeCaptureAgent{}} + fake.conn = acp.NewAgentSideConnection(fake, a2cW, c2aR) + + e := NewACPInferenceExecutor(zap.NewNop()) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + resp, err := e.probeACPSessionWithContext( + ctx, c2aW, a2cR, t.TempDir(), "opencode-acp", "legacy-model-with-effort", "", nil, + ) + if err != nil { + t.Fatalf("probeACPSessionWithContext(): %v (calls: %v)", err, fake.recordedCalls()) + } + + var found bool + for _, option := range resp.ConfigOptions { + if option.ID == "reasoning_effort" { + found = true + break + } + } + if !found { + t.Fatalf("config options = %#v, want reasoning_effort", resp.ConfigOptions) + } +} + +func TestProbeACPSessionWithContextReturnsSnapshotAfterRequestedOptions(t *testing.T) { + c2aR, c2aW := io.Pipe() + a2cR, a2cW := io.Pipe() + t.Cleanup(func() { + _ = c2aR.Close() + _ = c2aW.Close() + _ = a2cR.Close() + _ = a2cW.Close() + }) + + fake := &probeRequestedConfigAgent{probeCaptureAgent: &probeCaptureAgent{}} + acp.NewAgentSideConnection(fake, a2cW, c2aR) + + e := NewACPInferenceExecutor(zap.NewNop()) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + resp, err := e.probeACPSessionWithContext( + ctx, + c2aW, + a2cR, + t.TempDir(), + "opencode-acp", + "model-with-effort", + "build", + map[string]string{"reasoning_effort": "low"}, + ) + if err != nil { + t.Fatalf("probeACPSessionWithContext(): %v", err) + } + + for _, option := range resp.ConfigOptions { + if option.ID == "reasoning_effort" { + if option.CurrentValue != "low" { + t.Fatalf("reasoning_effort current value = %q, want low", option.CurrentValue) + } + return + } + } + t.Fatalf("config options = %#v, want reasoning_effort", resp.ConfigOptions) +} + +func TestProbeACPSessionWithContextAppliesModeBeforeModelAndOptions(t *testing.T) { + c2aR, c2aW := io.Pipe() + a2cR, a2cW := io.Pipe() + t.Cleanup(func() { + _ = c2aR.Close() + _ = c2aW.Close() + _ = a2cR.Close() + _ = a2cW.Close() + }) + + fake := &probeModeConfigAgent{probeCaptureAgent: &probeCaptureAgent{}} + fake.conn = acp.NewAgentSideConnection(fake, a2cW, c2aR) + + e := NewACPInferenceExecutor(zap.NewNop()) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + resp, err := e.probeACPSessionWithContext( + ctx, + c2aW, + a2cR, + t.TempDir(), + "opencode-acp", + "model-with-effort", + "smart", + map[string]string{"reasoning_effort": "low"}, + ) + if err != nil { + t.Fatalf("probeACPSessionWithContext(): %v (calls: %v)", err, fake.recordedCalls()) + } + if got, want := strings.Join(fake.recordedCalls(), ","), "mode,model,config"; got != want { + t.Fatalf("ACP mutation order = %q, want %q", got, want) + } + + for _, option := range resp.ConfigOptions { + if option.ID == "reasoning_effort" { + if option.CurrentValue != "low" { + t.Fatalf("reasoning_effort current value = %q, want low", option.CurrentValue) + } + return + } + } + t.Fatalf("config options = %#v, want reasoning_effort", resp.ConfigOptions) +} + +func TestProbeACPSessionWithContextRequiresSnapshotAfterFinalConfigMutation(t *testing.T) { + c2aR, c2aW := io.Pipe() + a2cR, a2cW := io.Pipe() + t.Cleanup(func() { + _ = c2aR.Close() + _ = c2aW.Close() + _ = a2cR.Close() + _ = a2cW.Close() + }) + + fake := &probeTwoConfigAgent{probeCaptureAgent: &probeCaptureAgent{}} + acp.NewAgentSideConnection(fake, a2cW, c2aR) + + e := NewACPInferenceExecutor(zap.NewNop()) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := e.probeACPSessionWithContext( + ctx, + c2aW, + a2cR, + t.TempDir(), + "opencode-acp", + "model-with-options", + "", + map[string]string{"first": "one", "second": "two"}, + ) + if err == nil || !strings.Contains(err.Error(), "model config selection returned no configuration options") { + t.Fatalf("error = %v, want missing final configuration snapshot", err) + } +} + +func TestFilterRequestedConfigOptionsUsesResolvedSnapshot(t *testing.T) { + category := acp.SessionConfigOptionCategoryThoughtLevel + got := filterRequestedConfigOptions( + map[string]string{"effort": "high", "reasoning_effort": "max"}, + []acp.SessionConfigOption{{Select: &acp.SessionConfigOptionSelect{ + Id: "reasoning_effort", + Category: &category, + }}}, + ) + if len(got) != 1 || got["reasoning_effort"] != "max" { + t.Fatalf("filtered options = %#v, want only reasoning_effort=max", got) + } +} + +func TestFilterRequestedConfigOptionsDropsValuesMissingFromResolvedSnapshot(t *testing.T) { + category := acp.SessionConfigOptionCategoryThoughtLevel + got := filterRequestedConfigOptions( + map[string]string{"effort": "medium"}, + []acp.SessionConfigOption{{Select: &acp.SessionConfigOptionSelect{ + Id: "effort", + Category: &category, + Options: acp.SessionConfigSelectOptions{ + Ungrouped: &acp.SessionConfigSelectOptionsUngrouped{ + {Value: "low"}, + {Value: "high"}, + {Value: "max"}, + }, + }, + }}}, + ) + if len(got) != 0 { + t.Fatalf("filtered options = %#v, want stale effort value removed", got) + } +} + +type probeModelConfigAgent struct { + *probeCaptureAgent +} + +type probeNoConfigResponseAgent struct { + *probeCaptureAgent +} + +func (*probeNoConfigResponseAgent) NewSession(context.Context, acp.NewSessionRequest) (acp.NewSessionResponse, error) { + return acp.NewSessionResponse{ + SessionId: acp.SessionId("probe-session"), + ConfigOptions: probeModelConfigOptions("default-model"), + }, nil +} + +func (*probeNoConfigResponseAgent) SetSessionConfigOption(context.Context, acp.SetSessionConfigOptionRequest) (acp.SetSessionConfigOptionResponse, error) { + return acp.SetSessionConfigOptionResponse{}, nil +} + +func (*probeModelConfigAgent) NewSession(context.Context, acp.NewSessionRequest) (acp.NewSessionResponse, error) { + return acp.NewSessionResponse{ + SessionId: acp.SessionId("probe-session"), + ConfigOptions: probeModelConfigOptions("default-model"), + }, nil +} + +func (*probeModelConfigAgent) SetSessionConfigOption(context.Context, acp.SetSessionConfigOptionRequest) (acp.SetSessionConfigOptionResponse, error) { + return acp.SetSessionConfigOptionResponse{ + ConfigOptions: probeModelConfigOptions("model-with-effort"), + }, nil +} + +type probeNotificationConfigAgent struct { + *probeCaptureAgent + conn *acp.AgentSideConnection + mu sync.Mutex + calls []string +} + +type probeRequestedConfigAgent struct { + *probeCaptureAgent +} + +type probeTwoConfigAgent struct { + *probeCaptureAgent +} + +type probeModeConfigAgent struct { + *probeCaptureAgent + conn *acp.AgentSideConnection + mu sync.Mutex + calls []string +} + +func (a *probeNotificationConfigAgent) record(call string) { + a.mu.Lock() + a.calls = append(a.calls, call) + a.mu.Unlock() +} + +func (a *probeNotificationConfigAgent) recordedCalls() []string { + a.mu.Lock() + defer a.mu.Unlock() + return append([]string(nil), a.calls...) +} + +func (a *probeModeConfigAgent) record(call string) { + a.mu.Lock() + a.calls = append(a.calls, call) + a.mu.Unlock() +} + +func (a *probeModeConfigAgent) recordedCalls() []string { + a.mu.Lock() + defer a.mu.Unlock() + return append([]string(nil), a.calls...) +} + +func (*probeModeConfigAgent) NewSession(context.Context, acp.NewSessionRequest) (acp.NewSessionResponse, error) { + return acp.NewSessionResponse{ + SessionId: acp.SessionId("probe-session"), + ConfigOptions: probeModelConfigOptions("default-model"), + }, nil +} + +func (a *probeModeConfigAgent) SetSessionMode( + ctx context.Context, + request acp.SetSessionModeRequest, +) (acp.SetSessionModeResponse, error) { + a.record("mode") + options := probeModelConfigOptions("default-model") + options[1].Select.Options = acp.SessionConfigSelectOptions{ + Ungrouped: &acp.SessionConfigSelectOptionsUngrouped{ + {Name: "Low", Value: "low"}, + {Name: "High", Value: "high"}, + }, + } + return acp.SetSessionModeResponse{}, a.conn.SessionUpdate(ctx, acp.SessionNotification{ + SessionId: request.SessionId, + Update: acp.SessionUpdate{ + ConfigOptionUpdate: &acp.SessionConfigOptionUpdate{ConfigOptions: options}, + }, + }) +} + +func (a *probeModeConfigAgent) SetSessionConfigOption( + _ context.Context, + request acp.SetSessionConfigOptionRequest, +) (acp.SetSessionConfigOptionResponse, error) { + if request.ValueId.ConfigId == "model" { + a.record("model") + } else { + a.record("config") + } + options := probeModelConfigOptions("model-with-effort") + options[1].Select.Options = acp.SessionConfigSelectOptions{ + Ungrouped: &acp.SessionConfigSelectOptionsUngrouped{ + {Name: "Low", Value: "low"}, + {Name: "High", Value: "high"}, + }, + } + if request.ValueId.ConfigId == "reasoning_effort" { + options[1].Select.CurrentValue = request.ValueId.Value + } + return acp.SetSessionConfigOptionResponse{ConfigOptions: options}, nil +} + +func (*probeRequestedConfigAgent) NewSession(context.Context, acp.NewSessionRequest) (acp.NewSessionResponse, error) { + return acp.NewSessionResponse{ + SessionId: acp.SessionId("probe-session"), + ConfigOptions: probeModelConfigOptions("default-model"), + }, nil +} + +func (*probeTwoConfigAgent) NewSession(context.Context, acp.NewSessionRequest) (acp.NewSessionResponse, error) { + return acp.NewSessionResponse{ + SessionId: acp.SessionId("probe-session"), + ConfigOptions: probeTwoConfigOptions("default-model"), + }, nil +} + +func (*probeTwoConfigAgent) SetSessionConfigOption( + _ context.Context, + request acp.SetSessionConfigOptionRequest, +) (acp.SetSessionConfigOptionResponse, error) { + if request.ValueId.ConfigId == "model" { + return acp.SetSessionConfigOptionResponse{ + ConfigOptions: probeTwoConfigOptions(string(request.ValueId.Value)), + }, nil + } + if request.ValueId.ConfigId == "first" { + return acp.SetSessionConfigOptionResponse{ + ConfigOptions: probeTwoConfigOptions("first-applied"), + }, nil + } + return acp.SetSessionConfigOptionResponse{}, nil +} + +func (*probeRequestedConfigAgent) SetSessionConfigOption( + _ context.Context, + request acp.SetSessionConfigOptionRequest, +) (acp.SetSessionConfigOptionResponse, error) { + options := probeModelConfigOptions("model-with-effort") + options[1].Select.Options = acp.SessionConfigSelectOptions{ + Ungrouped: &acp.SessionConfigSelectOptionsUngrouped{ + {Name: "Low", Value: "low"}, + {Name: "High", Value: "high"}, + }, + } + if request.ValueId != nil && request.ValueId.ConfigId == "reasoning_effort" { + options[1].Select.CurrentValue = request.ValueId.Value + } + return acp.SetSessionConfigOptionResponse{ConfigOptions: options}, nil +} + +func (*probeNotificationConfigAgent) NewSession(context.Context, acp.NewSessionRequest) (acp.NewSessionResponse, error) { + return acp.NewSessionResponse{ + SessionId: acp.SessionId("probe-session"), + ConfigOptions: probeModelConfigOptions("default-model"), + }, nil +} + +func (a *probeNotificationConfigAgent) SetSessionConfigOption(ctx context.Context, req acp.SetSessionConfigOptionRequest) (acp.SetSessionConfigOptionResponse, error) { + a.record("config") + err := a.conn.SessionUpdate(ctx, acp.SessionNotification{ + SessionId: req.ValueId.SessionId, + Update: acp.SessionUpdate{ + ConfigOptionUpdate: &acp.SessionConfigOptionUpdate{ + ConfigOptions: probeModelConfigOptions(string(req.ValueId.Value)), + }, + }, + }) + return acp.SetSessionConfigOptionResponse{}, err +} + +func probeModelConfigOptions(model string) []acp.SessionConfigOption { + modelCategory := acp.SessionConfigOptionCategoryModel + effortCategory := acp.SessionConfigOptionCategory("reasoning_effort") + return []acp.SessionConfigOption{ + {Select: &acp.SessionConfigOptionSelect{ + Id: "model", + Category: &modelCategory, + Type: "select", + CurrentValue: acp.SessionConfigValueId(model), + Options: acp.SessionConfigSelectOptions{ + Ungrouped: &acp.SessionConfigSelectOptionsUngrouped{ + {Name: model, Value: acp.SessionConfigValueId(model)}, + }, + }, + }}, + {Select: &acp.SessionConfigOptionSelect{ + Id: "reasoning_effort", + Category: &effortCategory, + Type: "select", + CurrentValue: "high", + Options: acp.SessionConfigSelectOptions{ + Ungrouped: &acp.SessionConfigSelectOptionsUngrouped{ + {Name: "High", Value: "high"}, + }, + }, + }}, + } +} + +func probeTwoConfigOptions(model string) []acp.SessionConfigOption { + modelCategory := acp.SessionConfigOptionCategoryModel + optionCategory := acp.SessionConfigOptionCategoryThoughtLevel + return []acp.SessionConfigOption{ + {Select: &acp.SessionConfigOptionSelect{ + Id: "model", + Category: &modelCategory, + Type: "select", + CurrentValue: acp.SessionConfigValueId(model), + Options: acp.SessionConfigSelectOptions{ + Ungrouped: &acp.SessionConfigSelectOptionsUngrouped{ + {Name: model, Value: acp.SessionConfigValueId(model)}, + }, + }, + }}, + {Select: &acp.SessionConfigOptionSelect{ + Id: "first", + Category: &optionCategory, + Type: "select", + CurrentValue: "one", + Options: acp.SessionConfigSelectOptions{ + Ungrouped: &acp.SessionConfigSelectOptionsUngrouped{{Name: "One", Value: "one"}}, + }, + }}, + {Select: &acp.SessionConfigOptionSelect{ + Id: "second", + Category: &optionCategory, + Type: "select", + CurrentValue: "two", + Options: acp.SessionConfigSelectOptions{ + Ungrouped: &acp.SessionConfigSelectOptionsUngrouped{{Name: "Two", Value: "two"}}, + }, + }}, + } +} diff --git a/apps/backend/internal/agentctl/server/utility/model_apply_test.go b/apps/backend/internal/agentctl/server/utility/model_apply_test.go index f44ab664a2..557c2b08b5 100644 --- a/apps/backend/internal/agentctl/server/utility/model_apply_test.go +++ b/apps/backend/internal/agentctl/server/utility/model_apply_test.go @@ -47,11 +47,63 @@ func TestApplySessionModel_UsesConfigOptionWhenSessionAdvertisesModelOption(t *t } } +func TestApplySessionModelWithConfigOptions_ReturnsProviderSnapshot(t *testing.T) { + t.Parallel() + + modelCat := acp.SessionConfigOptionCategoryModel + effortCat := acp.SessionConfigOptionCategory("reasoning_effort") + configOptions := []acp.SessionConfigOption{ + {Select: &acp.SessionConfigOptionSelect{ + Id: "model", + Category: &modelCat, + Type: "select", + }}, + {Select: &acp.SessionConfigOptionSelect{ + Id: "reasoning_effort", + Category: &effortCat, + Type: "select", + }}, + } + conn := &fakeModelConnWithConfig{configOptions: configOptions} + + method, got, err := applySessionModelWithConfigOptions( + context.Background(), conn, "sess-1", "model-with-effort", []acp.SessionConfigOption{ + {Select: &acp.SessionConfigOptionSelect{ + Id: "model", + Category: &modelCat, + Type: "select", + }}, + }, + ) + + if err != nil { + t.Fatalf("applySessionModelWithConfigOptions() error = %v", err) + } + if method != "session/set_config_option" { + t.Fatalf("method = %q, want session/set_config_option", method) + } + if !reflect.DeepEqual(got, configOptions) { + t.Fatalf("config options = %#v, want %#v", got, configOptions) + } +} + +type fakeModelConnWithConfig struct { + configOptions []acp.SessionConfigOption +} + +func (f *fakeModelConnWithConfig) SetSessionConfigOption(_ context.Context, _ acp.SetSessionConfigOptionRequest) (acp.SetSessionConfigOptionResponse, error) { + return acp.SetSessionConfigOptionResponse{ConfigOptions: f.configOptions}, nil +} + +func (f *fakeModelConnWithConfig) UnstableSetSessionModel(_ context.Context, _ acp.UnstableSetSessionModelRequest) (acp.UnstableSetSessionModelResponse, error) { + return acp.UnstableSetSessionModelResponse{}, nil +} + func TestApplySessionConfigOptions_SortsAndAppliesValues(t *testing.T) { t.Parallel() conn := &fakeModelConn{} - err := applySessionConfigOptions(context.Background(), conn, "sess-1", map[string]string{ + _, err := applySessionConfigOptions(context.Background(), conn, "sess-1", map[string]string{ "fast": "true", "effort": "high", }) diff --git a/apps/backend/internal/agentctl/server/utility/types.go b/apps/backend/internal/agentctl/server/utility/types.go index 896b1d5808..762a3b61e4 100644 --- a/apps/backend/internal/agentctl/server/utility/types.go +++ b/apps/backend/internal/agentctl/server/utility/types.go @@ -60,6 +60,18 @@ type ProbeRequest struct { // AgentID is the agent to probe (e.g., "claude-acp", "codex-acp"). AgentID string `json:"agent_id" binding:"required"` + // Model optionally asks the probe to apply a model after session/new and + // return the provider's resulting configuration options. + Model string `json:"model,omitempty"` + + // Mode optionally selects a session mode before the probe returns its + // resolved configuration options. + Mode string `json:"mode,omitempty"` + + // ConfigOptions optionally applies additional select values before the + // probe returns its resolved configuration options. + ConfigOptions map[string]string `json:"config_options,omitempty"` + // Refresh asks agent-specific discovery fallbacks to invalidate their own // model caches. ACP session discovery itself always starts a fresh process. Refresh bool `json:"refresh,omitempty"` @@ -102,7 +114,7 @@ type ProbeResponse struct { CurrentModeID string `json:"current_mode_id,omitempty"` // ConfigOptions are select-style session options advertised by session/new. - ConfigOptions []ProbeConfigOption `json:"config_options,omitempty"` + ConfigOptions []ProbeConfigOption `json:"config_options"` // Commands are the slash commands the agent advertises via the // `available_commands_update` session notification (drained briefly diff --git a/apps/web/components/settings/agent-profile-page.tsx b/apps/web/components/settings/agent-profile-page.tsx index e40b86a550..0fa7130ff4 100644 --- a/apps/web/components/settings/agent-profile-page.tsx +++ b/apps/web/components/settings/agent-profile-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback } from "react"; +import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import Link from "@/components/routing/app-link"; import { useParams } from "@/lib/routing/client-router"; @@ -73,6 +73,16 @@ type ProfileEditorHeaderProps = { onEnabledChange: (enabled: boolean) => void; }; +function profileSaveInvalidReason( + profileName: string, + modelConfigResolutionPending: boolean, + translate: (key: string) => string, +): string | undefined { + if (!profileName.trim()) return translate("agents:profileNameRequired"); + if (modelConfigResolutionPending) return translate("agents:resolvingModelOptions"); + return undefined; +} + function ProfileEditorHeader({ agentName, agentDisplayName, @@ -142,6 +152,7 @@ type ProfileSettingsCardProps = { modelConfig: ModelConfig; permissionSettings: Record; passthroughConfig: PassthroughConfig | null; + onModelConfigResolutionPendingChange: (pending: boolean) => void; }; function ProfileSettingsCard({ @@ -153,6 +164,7 @@ function ProfileSettingsCard({ modelConfig, permissionSettings, passthroughConfig, + onModelConfigResolutionPendingChange, }: ProfileSettingsCardProps) { const { t } = useTranslation(); const handleFormChange = (patch: Partial) => { @@ -201,6 +213,7 @@ function ProfileSettingsCard({ permissionSettings={permissionSettings} passthroughConfig={passthroughConfig} agentName={agent.name} + onModelConfigResolutionPendingChange={onModelConfigResolutionPendingChange} lockPassthrough={Boolean(agent.tui_config)} hideCustomCLIFlags /> @@ -259,6 +272,7 @@ type ProfileEditorBodyProps = { secrets: { id: string; name: string }[]; initialMcpConfig?: AgentProfileMcpConfig | null; onToastError: (error: unknown) => void; + onModelConfigResolutionPendingChange: (pending: boolean) => void; }; function ProfileEditorBody({ @@ -273,6 +287,7 @@ function ProfileEditorBody({ secrets, initialMcpConfig, onToastError, + onModelConfigResolutionPendingChange, }: ProfileEditorBodyProps) { return ( <> @@ -285,6 +300,7 @@ function ProfileEditorBody({ modelConfig={modelConfig} permissionSettings={permissionSettings} passthroughConfig={passthroughConfig} + onModelConfigResolutionPendingChange={onModelConfigResolutionPendingChange} /> state.settingsAgents.items); const syncAgentsToStore = useSyncAgentsToStore(); const { items: secrets } = useSecrets(); @@ -370,20 +387,12 @@ function ProfileEditor({ id: `agent-profile:${draft.id}`, revision: JSON.stringify(draft), isDirty, - canSave: Boolean(draft.name.trim()), - invalidReason: draft.name.trim() ? undefined : t("agents:profileNameRequired"), + canSave: Boolean(draft.name.trim()) && !modelConfigResolutionPending, + invalidReason: profileSaveInvalidReason(draft.name, modelConfigResolutionPending, t), save: handleSave, discard: () => setDraft(savedProfile), }); - const { - requestDelete, - showDeleteConfirm, - setShowDeleteConfirm, - handleDeleteProfile, - conflict, - setConflict, - handleForceDelete, - } = useProfileDelete(agent, draft, settingsAgents, syncAgentsToStore, toast); + const deleteState = useProfileDelete(agent, draft, settingsAgents, syncAgentsToStore, toast); return (
@@ -415,17 +424,18 @@ function ProfileEditor({ variant: "error", }) } + onModelConfigResolutionPendingChange={setModelConfigResolutionPending} /> - +
); diff --git a/apps/web/components/settings/model-config-resolution-status.test.tsx b/apps/web/components/settings/model-config-resolution-status.test.tsx new file mode 100644 index 0000000000..4db4ed4f40 --- /dev/null +++ b/apps/web/components/settings/model-config-resolution-status.test.tsx @@ -0,0 +1,40 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ModelConfigResolutionStatus } from "./model-config-resolution-status"; + +afterEach(cleanup); + +describe("ModelConfigResolutionStatus", () => { + it("shows a localized loading state", () => { + render( + {})} + />, + ); + + expect(screen.getByTestId("model-config-resolution-loading")).toBeTruthy(); + expect(screen.getByText("Loading model options…")).toBeTruthy(); + }); + + it("shows a localized failure and retries resolution", () => { + const onRetry = vi.fn(async () => {}); + render( + , + ); + + expect(screen.getByTestId("model-config-resolution-error")).toBeTruthy(); + expect(screen.getByText("Model options could not be loaded.")).toBeTruthy(); + expect(screen.queryByText("raw provider stderr")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(onRetry).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/components/settings/model-config-resolution-status.tsx b/apps/web/components/settings/model-config-resolution-status.tsx new file mode 100644 index 0000000000..5e78291206 --- /dev/null +++ b/apps/web/components/settings/model-config-resolution-status.tsx @@ -0,0 +1,50 @@ +import { useTranslation } from "react-i18next"; +import { Button } from "@kandev/ui/button"; +import type { CapabilityStatus } from "@/lib/types/http"; + +export function ModelConfigResolutionStatus({ + status, + error, + isLoading, + onRetry, +}: { + status: CapabilityStatus | undefined; + error: string | null; + isLoading: boolean; + onRetry: () => Promise; +}) { + const { t } = useTranslation(); + if (isLoading || status === "probing") { + return ( +

+ {t("agents:resolvingModelOptions")} +

+ ); + } + + if (status !== "failed" && status !== "auth_required" && !error) { + return null; + } + + return ( +
+ + {status === "auth_required" + ? t("agents:modelOptionsAuthenticationRequired") + : t("agents:modelOptionsResolutionFailed")} + + +
+ ); +} diff --git a/apps/web/components/settings/profile-capability-status.tsx b/apps/web/components/settings/profile-capability-status.tsx new file mode 100644 index 0000000000..df23a30dd8 --- /dev/null +++ b/apps/web/components/settings/profile-capability-status.tsx @@ -0,0 +1,78 @@ +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import { IconAlertCircle, IconRefresh } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import type { ModelConfig } from "@/lib/types/http"; + +const CAPABILITY_STATUS_KEYS: Record = { + probing: "agents:capabilityProbing", + auth_required: "agents:capabilityAuthRequired", + not_installed: "agents:capabilityNotInstalled", + failed: "agents:capabilityProbeFailed", +}; + +function capabilityStatusMessage(t: TFunction, status: ModelConfig["status"]): string | null { + const key = status ? CAPABILITY_STATUS_KEYS[status] : undefined; + return key ? t(key) : null; +} + +export function CapabilityStatusMessage({ status }: { status: ModelConfig["status"] }) { + const { t } = useTranslation(); + const msg = capabilityStatusMessage(t, status); + if (!msg) return null; + return ( +

+ {msg} +

+ ); +} + +export function RefreshCapabilitiesButton({ + onRefresh, + isLoading, + error, +}: { + onRefresh: () => Promise; + isLoading: boolean; + error: string | null; +}) { + const { t } = useTranslation(); + return ( +
+ + + + + +

{t("agents:refreshCapabilitiesTooltip")}

+
+
+ {error && ( + + +
+ +
+
+ +

{t("agents:failedToRefresh", { error })}

+
+
+ )} +
+ ); +} diff --git a/apps/web/components/settings/profile-form-fields.test.tsx b/apps/web/components/settings/profile-form-fields.test.tsx index 3959a65bf1..855a20d4ca 100644 --- a/apps/web/components/settings/profile-form-fields.test.tsx +++ b/apps/web/components/settings/profile-form-fields.test.tsx @@ -1,9 +1,42 @@ -import { cleanup, render, screen } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { TooltipProvider } from "@kandev/ui/tooltip"; +import { SettingsSaveProvider, useSettingsSaveContributor } from "./settings-save-provider"; +import { resolveAgentModelConfig } from "@/lib/api/domains/settings-api"; +import { __resetModelConfigResolutionCache } from "@/hooks/domains/settings/use-dynamic-models"; import { ProfileFormFields, type ProfileFormData } from "./profile-form-fields"; import type { ModelConfig } from "@/lib/types/http"; +vi.mock("@/lib/api/domains/settings-api", () => ({ + fetchDynamicModels: vi.fn(async () => ({ + agent_name: "opencode", + status: "ok", + models: [ + { id: "model-a", name: "Model A" }, + { id: "model-b", name: "Model B" }, + ], + modes: [], + commands: [], + error: null, + })), + resolveAgentModelConfig: vi.fn(async (_agentName: string, request: { model: string }) => ({ + agent_name: "opencode", + model: request.model, + status: "ok", + config_options: [ + { + type: "select", + id: reasoningEffortOptionId, + name: "Reasoning effort", + current_value: "max", + options: [{ value: "max", name: "Max" }], + }, + ], + error: null, + })), +})); + afterEach(cleanup); const modelConfig: ModelConfig = { @@ -12,6 +45,9 @@ const modelConfig: ModelConfig = { supports_dynamic_models: false, }; +const reasoningEffortOptionId = "reasoning_effort"; +const profileStartModelSettingsLabel = "Profile start model settings"; + function formData(overrides: Partial = {}): ProfileFormData { return { name: "Profile", @@ -24,13 +60,17 @@ function formData(overrides: Partial = {}): ProfileFormData { } as ProfileFormData; } -function renderForm(profile: ProfileFormData) { +function renderForm( + profile: ProfileFormData, + config: ModelConfig = modelConfig, + onChange: (patch: Partial) => void = vi.fn(), +) { return render( ) => void, +) { + function StatefulForm() { + const [currentProfile, setCurrentProfile] = useState(profile); + return ( + { + onChange(patch); + setCurrentProfile((current) => ({ ...current, ...patch })); + }} + modelConfig={config} + permissionSettings={{}} + passthroughConfig={null} + agentName="mock-agent" + /> + ); + } + + return render( + + + , + ); +} + describe("ProfileFormFields command prefix visibility", () => { it("shows the command prefix field for an ACP (non-passthrough) profile", () => { renderForm(formData({ cli_passthrough: false })); @@ -52,3 +121,167 @@ describe("ProfileFormFields command prefix visibility", () => { expect(screen.queryByTestId("command-prefix-input")).toBeNull(); }); }); + +describe("ProfileFormFields model options", () => { + it("loads model-specific options in the profile model selector", async () => { + const dynamicModelConfig: ModelConfig = { + default_model: "model-a", + current_model_id: "model-a", + available_models: [{ id: "model-a", name: "Model A" }], + config_options: [], + supports_dynamic_models: true, + }; + + renderForm(formData({ model: "model-a" }), dynamicModelConfig); + await waitFor(() => + expect(screen.getByRole("button", { name: profileStartModelSettingsLabel })).toBeTruthy(), + ); + fireEvent.click(screen.getByRole("button", { name: profileStartModelSettingsLabel })); + + expect( + await screen.findByTestId(`config-option-trigger-${reasoningEffortOptionId}`), + ).toBeTruthy(); + }); + + it("preserves a saved option value during the initial resolution", async () => { + const onChange = vi.fn(); + const dynamicModelConfig: ModelConfig = { + default_model: "model-a", + current_model_id: "model-a", + available_models: [{ id: "model-a", name: "Model A" }], + config_options: [ + { + type: "select", + id: reasoningEffortOptionId, + name: "Reasoning effort", + current_value: "medium", + options: [{ value: "medium", name: "Medium" }], + }, + ], + supports_dynamic_models: true, + }; + + renderForm( + formData({ model: "model-a", config_options: { [reasoningEffortOptionId]: "medium" } }), + dynamicModelConfig, + onChange, + ); + + await waitFor(() => + expect(screen.getByRole("button", { name: profileStartModelSettingsLabel })).toBeTruthy(), + ); + fireEvent.click(screen.getByRole("button", { name: profileStartModelSettingsLabel })); + expect( + await screen.findByTestId(`config-option-trigger-${reasoningEffortOptionId}`), + ).toBeTruthy(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("removes a saved option value after the user changes the model", async () => { + const onChange = vi.fn(); + const dynamicModelConfig: ModelConfig = { + default_model: "model-a", + current_model_id: "model-a", + available_models: [ + { id: "model-a", name: "Model A" }, + { id: "model-b", name: "Model B" }, + ], + config_options: [ + { + type: "select", + id: reasoningEffortOptionId, + name: "Reasoning effort", + current_value: "medium", + options: [{ value: "medium", name: "Medium" }], + }, + ], + supports_dynamic_models: true, + }; + + renderStatefulForm( + formData({ model: "model-a", config_options: { [reasoningEffortOptionId]: "medium" } }), + dynamicModelConfig, + onChange, + ); + + await waitFor(() => + expect(screen.getByRole("button", { name: profileStartModelSettingsLabel })).toBeTruthy(), + ); + fireEvent.click(screen.getByRole("button", { name: profileStartModelSettingsLabel })); + expect( + await screen.findByTestId(`config-option-trigger-${reasoningEffortOptionId}`), + ).toBeTruthy(); + fireEvent.click(await screen.findByText("Model B")); + + await waitFor(() => expect(onChange).toHaveBeenCalledWith({ config_options: {} })); + }); +}); + +describe("ProfileFormFields save coordination", () => { + it("blocks the coordinated profile save while model options are resolving", async () => { + __resetModelConfigResolutionCache(); + let resolveResponse: ((value: unknown) => void) | undefined; + const response = new Promise((resolve) => { + resolveResponse = resolve; + }); + vi.mocked(resolveAgentModelConfig).mockReturnValueOnce(response as never); + const save = vi.fn(); + const dynamicModelConfig: ModelConfig = { + default_model: "model-a", + current_model_id: "model-a", + available_models: [{ id: "model-a", name: "Model A" }], + config_options: [], + supports_dynamic_models: true, + }; + + function SaveHarness() { + const [pending, setPending] = useState(false); + useSettingsSaveContributor({ + id: "profile:model-config", + revision: 1, + isDirty: true, + canSave: !pending, + invalidReason: pending ? "Loading model options" : undefined, + save, + discard: vi.fn(), + }); + return ( + + ); + } + + render( + + + + + , + ); + + const saveButton = await screen.findByRole("button", { name: "Save changes" }); + await waitFor(() => expect(saveButton.hasAttribute("disabled")).toBe(true)); + fireEvent.click(saveButton); + expect(save).not.toHaveBeenCalled(); + + await act(async () => { + resolveResponse?.({ + agent_name: "mock-agent", + model: "model-a", + status: "ok", + config_options: [], + error: null, + }); + }); + await waitFor(() => expect(saveButton.hasAttribute("disabled")).toBe(false)); + fireEvent.click(saveButton); + await waitFor(() => expect(save).toHaveBeenCalledOnce()); + }); +}); diff --git a/apps/web/components/settings/profile-form-fields.tsx b/apps/web/components/settings/profile-form-fields.tsx index fee7a99595..2b26f51562 100644 --- a/apps/web/components/settings/profile-form-fields.tsx +++ b/apps/web/components/settings/profile-form-fields.tsx @@ -1,25 +1,22 @@ "use client"; -import { useId } from "react"; +import { useEffect, useId } from "react"; import { useTranslation } from "react-i18next"; -import type { TFunction } from "i18next"; -import { IconAlertCircle, IconAlertTriangle, IconRefresh } from "@tabler/icons-react"; +import { IconAlertTriangle } from "@tabler/icons-react"; import { NoAuthPanel, ProbingPanel } from "@/components/settings/profile-status-panels"; import { Button } from "@kandev/ui/button"; import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; import { Skeleton } from "@kandev/ui/skeleton"; import { Switch } from "@kandev/ui/switch"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { ModeCombobox } from "@/components/settings/mode-combobox"; import { configOptionToModelOptions, isModelConfigOption, ModelConfigSelector, type SelectConfigOption, - usableConfigOptions, } from "@/components/model-config-selector"; -import { useAgentCapabilities } from "@/hooks/domains/settings/use-dynamic-models"; +import { useProfileModelCapabilities } from "@/hooks/domains/settings/use-profile-model-capabilities"; import { PERMISSION_APPLY_AGENTCTL_AUTO_APPROVE, PERMISSION_KEYS, @@ -28,16 +25,23 @@ import { } from "@/lib/agent-permissions"; import { CLIFlagsField } from "@/components/settings/cli-flags-field"; import { CommandPrefixField } from "@/components/settings/command-prefix-field"; +import { ModelConfigResolutionStatus } from "@/components/settings/model-config-resolution-status"; +import { + CapabilityStatusMessage, + RefreshCapabilitiesButton, +} from "@/components/settings/profile-capability-status"; import { CommandsButton, findActiveMode, profileModeIsDirty, profileModelIsDirty, } from "@/components/settings/profile-capability-helpers"; +import { modelConfigOptions } from "@/components/settings/profile-model-config"; import type { CLIFlag, CommandEntry, ModelConfig, + ConfigOptionEntry, ModeEntry, ModelEntry, PermissionSetting, @@ -67,6 +71,7 @@ export type ProfileFormFieldsProps = { variant?: "default" | "compact"; hideNameField?: boolean; lockPassthrough?: boolean; + onModelConfigResolutionPendingChange?: (pending: boolean) => void; /** * When true, the custom-flag list + Add form on CLIFlagsField is * hidden. Curated predefined toggles still render. Used by the @@ -247,80 +252,6 @@ function PermissionToggles({ ); } -// The probe `status` values are the wire enum; only the messages are copy, so -// they travel as catalog keys and resolve at render. -const CAPABILITY_STATUS_KEYS: Record = { - probing: "agents:capabilityProbing", - auth_required: "agents:capabilityAuthRequired", - not_installed: "agents:capabilityNotInstalled", - failed: "agents:capabilityProbeFailed", -}; - -function capabilityStatusMessage(t: TFunction, status: ModelConfig["status"]): string | null { - const key = status ? CAPABILITY_STATUS_KEYS[status] : undefined; - return key ? t(key) : null; -} - -function CapabilityStatusMessage({ status }: { status: ModelConfig["status"] }) { - const { t } = useTranslation(); - const msg = capabilityStatusMessage(t, status); - if (!msg) return null; - return ( -

- {msg} -

- ); -} - -function RefreshCapabilitiesButton({ - onRefresh, - isLoading, - error, -}: { - onRefresh: () => Promise; - isLoading: boolean; - error: string | null; -}) { - const { t } = useTranslation(); - return ( -
- - - - - -

{t("agents:refreshCapabilitiesTooltip")}

-
-
- {error && ( - - -
- -
-
- -

{t("agents:failedToRefresh", { error })}

-
-
- )} -
- ); -} - function ModelPicker({ profile, models, @@ -389,19 +320,6 @@ function ModePicker({ ); } -function modelConfigOptions(modelConfig: ModelConfig): SelectConfigOption[] { - return usableConfigOptions( - modelConfig.config_options?.map((option) => ({ - type: option.type, - id: option.id, - name: option.name, - currentValue: option.current_value, - category: option.category, - options: option.options, - })), - ); -} - type CapabilitiesRowProps = { profile: ProfileFormData; models: ModelEntry[]; @@ -416,11 +334,50 @@ type CapabilitiesRowProps = { onRefresh: () => Promise; error: string | null; modelConfig: ModelConfig; + resolvedConfigOptions?: ConfigOptionEntry[]; + configStatus: ModelConfig["status"]; + configError: string | null; + configIsLoading: boolean; + onRetryConfig: () => Promise; agentName: string; baselineProfile?: ProfileFormData; }; -function CapabilitiesRow({ +function CapabilitiesRow(props: CapabilitiesRowProps) { + const { t } = useTranslation(); + const gapCls = props.isCompact ? "space-y-1.5" : "space-y-2"; + + if (props.isLoading && props.models.length === 0) { + return ( +
+ + +
+ ); + } + + if (props.status === "probing") { + return ; + } + if (props.status === "auth_required" || props.status === "not_installed") { + return ( + + ); + } + + return ; +} + +function CapabilitiesRowContent({ profile, models, modes, @@ -434,41 +391,22 @@ function CapabilitiesRow({ onRefresh, error, modelConfig, - agentName, + resolvedConfigOptions, + configStatus, + configError, + configIsLoading, + onRetryConfig, baselineProfile, }: CapabilitiesRowProps) { const { t } = useTranslation(); const hasModes = modes.length > 0; - const configOptions = modelConfigOptions(modelConfig); + const configOptions = modelConfigOptions( + resolvedConfigOptions ? { ...modelConfig, config_options: resolvedConfigOptions } : modelConfig, + ); const activeMode = findActiveMode(modes, profile.mode, currentModeId); const labelCls = isCompact ? "text-xs text-muted-foreground" : undefined; const gapCls = isCompact ? "space-y-1.5" : "space-y-2"; - if (isLoading && models.length === 0) { - return ( -
- - -
- ); - } - - if (status === "probing") { - return ; - } - if (status === "auth_required" || status === "not_installed") { - return ( - - ); - } - return (
@@ -485,6 +423,12 @@ function CapabilitiesRow({ configOptions={configOptions} onChange={onChange} /> +
{hasModes && (
{ + onModelConfigResolutionPendingChange?.(isConfigResolutionPending); + }, [isConfigResolutionPending, onModelConfigResolutionPendingChange]); return (
@@ -590,9 +548,14 @@ export function ProfileFormFields({ onChange={onChange} isCompact={isCompact} isLoading={caps.isLoading} - onRefresh={caps.refresh} + onRefresh={refresh} error={caps.error} modelConfig={modelConfig} + resolvedConfigOptions={resolvedConfigOptions} + configStatus={configStatus} + configError={configError} + configIsLoading={configIsLoading} + onRetryConfig={refreshModelConfig} baselineProfile={baselineProfile} /> diff --git a/apps/web/components/settings/profile-model-config.test.ts b/apps/web/components/settings/profile-model-config.test.ts new file mode 100644 index 0000000000..b1d3ce32f9 --- /dev/null +++ b/apps/web/components/settings/profile-model-config.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { reconcileConfigOptionValues } from "./profile-model-config"; + +describe("reconcileConfigOptionValues", () => { + it("keeps supported values and removes values not advertised by the snapshot", () => { + expect( + reconcileConfigOptionValues({ effort: "medium", safe: "on", unknown: "value" }, [ + { + type: "select", + id: "effort", + name: "Effort", + current_value: "high", + options: [ + { value: "low", name: "Low" }, + { value: "high", name: "High" }, + ], + }, + { + type: "select", + id: "safe", + name: "Safe mode", + current_value: "on", + }, + ]), + ).toEqual({ safe: "on" }); + }); +}); diff --git a/apps/web/components/settings/profile-model-config.ts b/apps/web/components/settings/profile-model-config.ts new file mode 100644 index 0000000000..260781bfc8 --- /dev/null +++ b/apps/web/components/settings/profile-model-config.ts @@ -0,0 +1,31 @@ +import { usableConfigOptions, type SelectConfigOption } from "@/components/model-config-selector"; +import type { ConfigOptionEntry, ModelConfig } from "@/lib/types/http"; + +export function reconcileConfigOptionValues( + values: Record | undefined, + options: ConfigOptionEntry[] | undefined, +): Record { + const optionById = new Map((options ?? []).map((option) => [option.id, option])); + return Object.entries(values ?? {}).reduce>((result, [id, value]) => { + const option = optionById.get(id); + if (!option) return result; + if (option.options?.length && !option.options.some((choice) => choice.value === value)) { + return result; + } + result[id] = value; + return result; + }, {}); +} + +export function modelConfigOptions(modelConfig: ModelConfig): SelectConfigOption[] { + return usableConfigOptions( + modelConfig.config_options?.map((option) => ({ + type: option.type, + id: option.id, + name: option.name, + currentValue: option.current_value, + category: option.category, + options: option.options, + })), + ); +} diff --git a/apps/web/components/settings/use-session-config-model-options.ts b/apps/web/components/settings/use-session-config-model-options.ts new file mode 100644 index 0000000000..2534da550e --- /dev/null +++ b/apps/web/components/settings/use-session-config-model-options.ts @@ -0,0 +1,57 @@ +import { useResolvedModelConfig } from "@/hooks/domains/settings/use-dynamic-models"; +import type { AvailableAgent } from "@/lib/types/http"; +import type { ConfigureSessionRule } from "@/lib/types/workflow-actions"; +import { modelConfigOptions } from "./workflow-session-config-shared"; + +function selectedModelForRule( + selectedAgent: AvailableAgent | undefined, + rule: ConfigureSessionRule, +): string | undefined { + if (rule.operation !== "set") return undefined; + return ( + rule.model || + selectedAgent?.model_config?.current_model_id || + selectedAgent?.model_config?.default_model || + selectedAgent?.model_config?.available_models?.[0]?.id + ); +} + +function modelResolutionEnabled( + selectedAgent: AvailableAgent | undefined, + rule: ConfigureSessionRule, +): boolean { + return rule.operation === "set" && !!selectedAgent?.model_config?.supports_dynamic_models; +} + +function modelConfigWithResolvedOptions( + selectedAgent: AvailableAgent | undefined, + configOptions: ReturnType["configOptions"], +) { + if (!selectedAgent?.model_config) return undefined; + return { ...selectedAgent.model_config, config_options: configOptions }; +} + +export function useSessionConfigModelOptions( + selectedAgent: AvailableAgent | undefined, + rule: ConfigureSessionRule, +) { + const selectedModel = selectedModelForRule(selectedAgent, rule); + const resolvedModelConfig = useResolvedModelConfig(selectedAgent?.name, selectedModel, { + initialConfigOptions: selectedAgent?.model_config?.config_options, + enabled: modelResolutionEnabled(selectedAgent, rule), + }); + + return { + configOptions: modelConfigOptions( + modelConfigWithResolvedOptions(selectedAgent, resolvedModelConfig.configOptions), + rule, + ), + configStatus: resolvedModelConfig.status, + configError: resolvedModelConfig.error, + configIsLoading: resolvedModelConfig.isLoading, + isConfigResolutionPending: resolvedModelConfig.isResolutionPending, + refreshConfig: resolvedModelConfig.refresh, + isConfigResolvedForRequest: resolvedModelConfig.isResolvedForRequest, + resolvedConfigOptions: resolvedModelConfig.configOptions, + }; +} diff --git a/apps/web/components/settings/use-workflow-draft-contributor.test.ts b/apps/web/components/settings/use-workflow-draft-contributor.test.ts index 40d557f13a..23e6fb5f56 100644 --- a/apps/web/components/settings/use-workflow-draft-contributor.test.ts +++ b/apps/web/components/settings/use-workflow-draft-contributor.test.ts @@ -64,12 +64,14 @@ function renderContributor({ savedWorkflowSteps = workflowSteps, guardMutation = vi.fn(async ({ operation }: { operation: () => Promise }) => operation()), onDeleteWorkflow = vi.fn(async () => undefined), + isSessionConfigResolutionPending = false, }: { draftWorkflow?: Workflow; workflowSteps?: WorkflowStep[]; savedWorkflowSteps?: WorkflowStep[]; guardMutation?: ReturnType; onDeleteWorkflow?: () => Promise; + isSessionConfigResolutionPending?: boolean; } = {}) { const setWorkflowSteps = vi.fn(); const setSavedWorkflowSteps = vi.fn(); @@ -87,6 +89,7 @@ function renderContributor({ onWorkflowSaved: vi.fn(), onDiscardWorkflow, onDeleteWorkflow, + isSessionConfigResolutionPending, }), ); return { ...view, setWorkflowSteps, onDiscardWorkflow, onDeleteWorkflow }; @@ -105,6 +108,13 @@ beforeEach(() => { }); describe("useWorkflowDraftContributor", () => { + it("blocks coordinated saves while session model options are resolving", () => { + renderContributor({ isSessionConfigResolutionPending: true }); + + expect(contributor().canSave).toBe(false); + expect(contributor().invalidReason).toBe("Loading model options…"); + }); + it("deletes a partially persisted temporary workflow when discarding", async () => { const draftWorkflow = workflow("temp-workflow-1"); const steps = [step("temp-step-1", draftWorkflow.id)]; diff --git a/apps/web/components/settings/use-workflow-draft-contributor.ts b/apps/web/components/settings/use-workflow-draft-contributor.ts index 41e62b4d7f..28dd43aa08 100644 --- a/apps/web/components/settings/use-workflow-draft-contributor.ts +++ b/apps/web/components/settings/use-workflow-draft-contributor.ts @@ -17,6 +17,16 @@ import type { useWorkflowMutationGuard } from "./workflow-mutation-guard"; const TEMP_WORKFLOW_PREFIX = "temp-workflow-"; +function workflowSaveInvalidReason( + workflowName: string, + modelConfigResolutionPending: boolean, + translate: (key: string) => string, +): string | undefined { + if (!workflowName.trim()) return translate("workflows:workflowNameIsRequired"); + if (modelConfigResolutionPending) return translate("agents:resolvingModelOptions"); + return undefined; +} + type WorkflowSavedParams = { clientWorkflow: Workflow; submittedWorkflow: Workflow; @@ -38,6 +48,7 @@ type WorkflowDraftContributorArgs = { onWorkflowSaved: (params: WorkflowSavedParams) => void; onDiscardWorkflow: () => void; onDeleteWorkflow: () => Promise; + isSessionConfigResolutionPending?: boolean; }; function useWorkflowDraftPersistence(args: WorkflowDraftContributorArgs) { @@ -91,7 +102,14 @@ function useWorkflowDraftPersistence(args: WorkflowDraftContributorArgs) { } export function useWorkflowDraftContributor(args: WorkflowDraftContributorArgs) { - const { workflow, workflowSteps, savedWorkflowSteps, mutationGuard, toast } = args; + const { + workflow, + workflowSteps, + savedWorkflowSteps, + mutationGuard, + toast, + isSessionConfigResolutionPending = false, + } = args; const persistence = useWorkflowDraftPersistence(args); const removingDraftRef = useRef(false); const [isRemovingDraft, setIsRemovingDraft] = useState(false); @@ -102,8 +120,8 @@ export function useWorkflowDraftContributor(args: WorkflowDraftContributorArgs) order: 100, revision: persistence.revision, isDirty: args.isWorkflowDirty || stepsDirty, - canSave: workflow.name.trim().length > 0, - invalidReason: workflow.name.trim() ? undefined : t("workflows:workflowNameIsRequired"), + canSave: workflow.name.trim().length > 0 && !isSessionConfigResolutionPending, + invalidReason: workflowSaveInvalidReason(workflow.name, isSessionConfigResolutionPending, t), save: async (submittedRevision) => { if (!workflow.id.startsWith(TEMP_WORKFLOW_PREFIX)) { await persistence.persistSubmittedDraft(submittedRevision); diff --git a/apps/web/components/settings/workflow-card.tsx b/apps/web/components/settings/workflow-card.tsx index a407e93c27..1a48942537 100644 --- a/apps/web/components/settings/workflow-card.tsx +++ b/apps/web/components/settings/workflow-card.tsx @@ -239,6 +239,7 @@ type WorkflowCardBodyProps = { handleReorderWorkflowSteps: (steps: WorkflowStep[]) => Promise; }; readOnly: boolean; + onSessionConfigResolutionPendingChange: (pending: boolean) => void; }; function WorkflowNameField({ @@ -289,6 +290,7 @@ function WorkflowCardBody({ isImproveWorkspace, stepActions, readOnly, + onSessionConfigResolutionPendingChange, }: WorkflowCardBodyProps) { const { t } = useTranslation(); const healthyProfiles = useHealthyAgentProfiles(workflow.agent_profile_id); @@ -360,6 +362,7 @@ function WorkflowCardBody({ onRemoveStep={stepActions.handleRemoveWorkflowStep} onReorderSteps={stepActions.handleReorderWorkflowSteps} readOnly={mutationPending || readOnly} + onSessionConfigResolutionPendingChange={onSessionConfigResolutionPendingChange} /> )}
@@ -453,6 +456,7 @@ function useWorkflowCardState(props: WorkflowCardProps) { } = useWorkflowSteps(workflow.id, initialWorkflowSteps, toast); const isNewWorkflow = workflow.id.startsWith(TEMP_WORKFLOW_PREFIX); const mutationGuard = useWorkflowMutationGuard(workflowSteps); + const [sessionConfigResolutionPending, setSessionConfigResolutionPending] = useState(false); const stepActions = useWorkflowStepActions({ workflow, isNewWorkflow, @@ -479,6 +483,7 @@ function useWorkflowCardState(props: WorkflowCardProps) { onWorkflowSaved: props.onWorkflowSaved, onDiscardWorkflow: props.onDiscardWorkflow, onDeleteWorkflow: props.onDeleteWorkflow, + isSessionConfigResolutionPending: sessionConfigResolutionPending, }); const wfDeleteHandlers = useWorkflowDeleteHandlers({ workflow, @@ -516,6 +521,8 @@ function useWorkflowCardState(props: WorkflowCardProps) { stepDeleteHandlers, stepsForStepMigration, ...workflowDraft, + sessionConfigResolutionPending, + setSessionConfigResolutionPending, }; } @@ -544,6 +551,7 @@ export function WorkflowCard(props: WorkflowCardProps) { isImproveWorkspace={props.isImproveWorkspace} stepActions={s.stepActions} readOnly={s.readOnly} + onSessionConfigResolutionPendingChange={s.setSessionConfigResolutionPending} /> ) => void; onRemove: () => void; readOnly?: boolean; + onSessionConfigResolutionPendingChange?: (pending: boolean) => void; }; export function StepConfigPanel({ @@ -523,6 +524,7 @@ export function StepConfigPanel({ onUpdate, onRemove, readOnly = false, + onSessionConfigResolutionPendingChange, }: StepConfigPanelProps) { const [localName, setLocalName] = useState(step.name); const [localPrompt, setLocalPrompt] = useState(step.prompt ?? ""); @@ -563,7 +565,10 @@ export function StepConfigPanel({ toggleOnEnterAction={actions.toggleOnEnterAction} readOnly={readOnly} /> - + void; onReorderSteps: (steps: WorkflowStep[]) => void; readOnly?: boolean; + onSessionConfigResolutionPendingChange?: (pending: boolean) => void; }; // --- Helpers --- @@ -278,6 +279,7 @@ export function WorkflowPipelineEditor({ onRemoveStep, onReorderSteps, readOnly = false, + onSessionConfigResolutionPendingChange, }: WorkflowPipelineEditorProps) { const [selectedStepId, setSelectedStepId] = useState(null); const [prevStepCount, setPrevStepCount] = useState(steps.length); @@ -364,6 +366,7 @@ export function WorkflowPipelineEditor({ onUpdate={(updates) => onUpdateStep(selectedStep.id, updates)} onRemove={() => handleRemoveStep(selectedStep.id)} readOnly={readOnly} + onSessionConfigResolutionPendingChange={onSessionConfigResolutionPendingChange} /> )}
diff --git a/apps/web/components/settings/workflow-session-config-editor.test.tsx b/apps/web/components/settings/workflow-session-config-editor.test.tsx index 0a8c415c59..8347e31118 100644 --- a/apps/web/components/settings/workflow-session-config-editor.test.tsx +++ b/apps/web/components/settings/workflow-session-config-editor.test.tsx @@ -1,10 +1,18 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import type { WorkflowStep } from "@/lib/types/http"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useState } from "react"; +import type { Workflow, WorkflowStep } from "@/lib/types/http"; +import { resolveAgentModelConfig } from "@/lib/api/domains/settings-api"; +import { __resetModelConfigResolutionCache } from "@/hooks/domains/settings/use-dynamic-models"; +import { SettingsSaveProvider } from "./settings-save-provider"; +import { persistWorkflowDraft } from "./workflow-card-actions"; +import { useWorkflowDraftContributor } from "./use-workflow-draft-contributor"; import { SessionConfigEditor, SessionConfigToggle } from "./workflow-session-config-editor"; const CODEX_AGENT = "codex"; const GROK_AGENT = "grok"; +const DYNAMIC_ONLY_AGENT = "dynamic-only"; +const DYNAMIC_ONLY_MODEL = "dynamic-model"; const EFFORT_OPTION = "reasoning_effort"; const MODEL_ID = "gpt-5.6-sol"; @@ -17,6 +25,13 @@ vi.mock("@/hooks/domains/settings/use-healthy-agent-profiles", () => ({ agent_name: CODEX_AGENT, cli_passthrough: false, }, + { + id: "dynamic-only-profile", + label: "Dynamic only • Default", + agent_id: "dynamic-only-agent", + agent_name: DYNAMIC_ONLY_AGENT, + cli_passthrough: false, + }, ], })); @@ -29,7 +44,10 @@ vi.mock("@/hooks/domains/settings/use-available-agents", () => ({ model_config: { default_model: MODEL_ID, current_model_id: MODEL_ID, - available_models: [{ id: MODEL_ID, name: "5.6 Sol" }], + available_models: [ + { id: MODEL_ID, name: "5.6 Sol" }, + { id: "model-b", name: "Model B" }, + ], config_options: [ { type: "select", @@ -56,11 +74,83 @@ vi.mock("@/hooks/domains/settings/use-available-agents", () => ({ supports_dynamic_models: true, }, }, + { + name: DYNAMIC_ONLY_AGENT, + display_name: "Dynamic only", + model_config: { + default_model: DYNAMIC_ONLY_MODEL, + current_model_id: DYNAMIC_ONLY_MODEL, + available_models: [], + config_options: [], + supports_dynamic_models: true, + }, + }, ], }), })); -afterEach(cleanup); +vi.mock("@/lib/api/domains/settings-api", () => ({ + resolveAgentModelConfig: vi.fn(async (_agentName: string, request: { model: string }) => ({ + agent_name: "codex", + model: request.model, + status: "ok", + config_options: [ + { + type: "select", + id: "reasoning_effort", + name: "Effort", + current_value: "high", + options: [ + { value: "high", name: "High" }, + { value: "max", name: "Max" }, + ], + }, + ], + error: null, + })), +})); + +vi.mock("./workflow-card-actions", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + persistWorkflowDraft: vi.fn( + async (input: { workflow: Workflow; draftSteps: WorkflowStep[] }) => ({ + workflow: input.workflow, + steps: input.draftSteps, + }), + ), + }; +}); + +afterEach(() => { + cleanup(); + __resetModelConfigResolutionCache(); +}); + +beforeEach(() => { + vi.mocked(resolveAgentModelConfig).mockReset(); + vi.mocked(resolveAgentModelConfig).mockImplementation( + async (_agentName: string, request: { model: string }) => ({ + agent_name: CODEX_AGENT, + model: request.model, + status: "ok", + config_options: [ + { + type: "select", + id: EFFORT_OPTION, + name: "Effort", + current_value: "high", + options: [ + { value: "high", name: "High" }, + { value: "max", name: "Max" }, + ], + }, + ], + error: null, + }), + ); +}); function step(id: string, position: number, events?: WorkflowStep["events"]): WorkflowStep { return { @@ -92,6 +182,64 @@ function SessionConfigEditorHarness({ ); } +const testWorkflow = { + id: "workflow-1", + workspace_id: "workspace-1", + name: "Workflow", + created_at: "", + updated_at: "", +} as Workflow; + +function WorkflowSaveHarness({ initialStep }: { initialStep: WorkflowStep }) { + const [currentStep, setCurrentStep] = useState(initialStep); + const [workflowSteps, setWorkflowSteps] = useState([initialStep]); + const [savedWorkflowSteps, setSavedWorkflowSteps] = useState([initialStep]); + const [isSessionConfigResolutionPending, setSessionConfigResolutionPending] = useState(false); + + useWorkflowDraftContributor({ + workflow: testWorkflow, + isWorkflowDirty: true, + workflowSteps, + savedWorkflowSteps, + setWorkflowSteps, + setSavedWorkflowSteps, + mutationGuard: { + guardMutation: async ({ operation }: { operation: () => Promise }) => operation(), + } as never, + toast: vi.fn(), + onWorkflowSaved: vi.fn(), + onDiscardWorkflow: vi.fn(), + onDeleteWorkflow: async () => undefined, + isSessionConfigResolutionPending, + }); + + const onUpdate = (updates: Partial) => { + setCurrentStep((current) => ({ ...current, ...updates })); + setWorkflowSteps((steps) => steps.map((step) => ({ ...step, ...updates }))); + }; + + return ( + + ); +} + +function configuredStep(agentName: string, model: string): WorkflowStep { + return step("configured-step", 0, { + on_enter: [ + { + type: "configure_session", + config: { rules: [{ agent_name: agentName, operation: "set", model }] }, + }, + ], + }); +} + describe("SessionConfigEditor", () => { it("keeps conditional settings hidden until the header toggle is enabled", () => { const onUpdate = vi.fn(); @@ -188,3 +336,207 @@ describe("SessionConfigEditor", () => { expect(screen.queryByTestId("work-session-config-editor")).toBeNull(); }); }); + +describe("SessionConfigRuleCard model options", () => { + it("loads model-specific options in a workflow session override", async () => { + const currentStep = step("work", 0, { + on_enter: [ + { + type: "configure_session", + config: { rules: [{ agent_name: CODEX_AGENT, operation: "set", model: MODEL_ID }] }, + }, + ], + }); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Settings for codex" })); + expect(await screen.findByTestId("config-option-trigger-reasoning_effort")).toBeTruthy(); + }); + + it("preserves a saved option value during the initial resolution", async () => { + const onUpdate = vi.fn(); + const currentStep = step("work", 0, { + on_enter: [ + { + type: "configure_session", + config: { + rules: [ + { + agent_name: CODEX_AGENT, + operation: "set", + model: MODEL_ID, + config_options: { reasoning_effort: "medium" }, + }, + ], + }, + }, + ], + }); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Settings for codex" })); + expect(await screen.findByTestId("config-option-trigger-reasoning_effort")).toBeTruthy(); + expect(onUpdate).not.toHaveBeenCalled(); + }); +}); + +describe("SessionConfigRuleCard save coordination", () => { + it("blocks the coordinated workflow save until model options finish resolving", async () => { + let resolveSecondResponse: ((value: unknown) => void) | undefined; + const secondResponse = new Promise((resolve) => { + resolveSecondResponse = resolve; + }); + vi.mocked(resolveAgentModelConfig) + .mockResolvedValueOnce({ + agent_name: CODEX_AGENT, + model: MODEL_ID, + status: "ok", + config_options: [], + error: null, + }) + .mockReturnValueOnce(secondResponse as never); + + const initialStep = configuredStep(CODEX_AGENT, MODEL_ID); + render( + + + , + ); + + const saveButton = await screen.findByRole("button", { name: "Save changes" }); + await waitFor(() => expect(saveButton.hasAttribute("disabled")).toBe(false)); + + fireEvent.click(screen.getByRole("button", { name: "Settings for codex" })); + fireEvent.click(await screen.findByText("Model B")); + await waitFor(() => expect(saveButton.hasAttribute("disabled")).toBe(true)); + + fireEvent.click(saveButton); + expect(persistWorkflowDraft).not.toHaveBeenCalled(); + + await act(async () => { + resolveSecondResponse?.({ + agent_name: CODEX_AGENT, + model: "model-b", + status: "ok", + config_options: [], + error: null, + }); + }); + await waitFor(() => expect(saveButton.hasAttribute("disabled")).toBe(false)); + + fireEvent.click(saveButton); + await waitFor(() => expect(persistWorkflowDraft).toHaveBeenCalledOnce()); + }); +}); + +describe("SessionConfigRuleCard resolution status", () => { + it("shows loading status when a dynamic-only agent has no baseline options", async () => { + let resolveResponse: ((value: unknown) => void) | undefined; + const response = new Promise((resolve) => { + resolveResponse = resolve; + }); + vi.mocked(resolveAgentModelConfig).mockReturnValueOnce(response as never); + + render( + , + ); + + expect(await screen.findByTestId("model-config-resolution-loading")).toBeTruthy(); + expect( + screen.getByText( + "Model options are unavailable for this agent. Refresh agent capabilities before saving this condition.", + ), + ).toBeTruthy(); + + await act(async () => { + resolveResponse?.({ + agent_name: DYNAMIC_ONLY_AGENT, + model: DYNAMIC_ONLY_MODEL, + status: "failed", + config_options: [], + error: "provider unavailable", + }); + }); + }); + + it("shows a retryable failure and loads options after retry for a dynamic-only agent", async () => { + vi.mocked(resolveAgentModelConfig) + .mockResolvedValueOnce({ + agent_name: DYNAMIC_ONLY_AGENT, + model: DYNAMIC_ONLY_MODEL, + status: "failed", + config_options: [], + error: "provider unavailable", + }) + .mockResolvedValueOnce({ + agent_name: DYNAMIC_ONLY_AGENT, + model: DYNAMIC_ONLY_MODEL, + status: "ok", + config_options: [ + { + type: "select", + id: EFFORT_OPTION, + name: "Effort", + current_value: "max", + options: [{ value: "max", name: "Max" }], + }, + ], + error: null, + }); + + render( + , + ); + + expect(await screen.findByTestId("model-config-resolution-error")).toBeTruthy(); + expect(screen.getByText("Model options could not be loaded.")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + await waitFor(() => expect(resolveAgentModelConfig).toHaveBeenCalledTimes(2)); + fireEvent.click(screen.getByRole("button", { name: "Settings for dynamic-only" })); + expect(await screen.findByTestId(`config-option-trigger-${EFFORT_OPTION}`)).toBeTruthy(); + }); + + it("shows an authentication retry status for a dynamic-only agent", async () => { + vi.mocked(resolveAgentModelConfig).mockResolvedValueOnce({ + agent_name: DYNAMIC_ONLY_AGENT, + model: DYNAMIC_ONLY_MODEL, + status: "auth_required", + config_options: [], + error: "authentication required", + }); + + render( + , + ); + + expect(await screen.findByTestId("model-config-resolution-error")).toBeTruthy(); + expect(screen.getByText("Sign in to this agent to load model options.")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy(); + }); +}); diff --git a/apps/web/components/settings/workflow-session-config-editor.tsx b/apps/web/components/settings/workflow-session-config-editor.tsx index b0742e4a9d..456f04d742 100644 --- a/apps/web/components/settings/workflow-session-config-editor.tsx +++ b/apps/web/components/settings/workflow-session-config-editor.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { IconPlus } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; @@ -34,6 +34,7 @@ type SessionConfigEditorProps = { steps: WorkflowStep[]; onUpdate: (updates: Partial) => void; readOnly: boolean; + onResolutionPendingChange?: (pending: boolean) => void; }; export function SessionConfigToggle({ @@ -94,6 +95,7 @@ export function SessionConfigEditor({ steps, onUpdate, readOnly, + onResolutionPendingChange, }: SessionConfigEditorProps) { const profiles = useHealthyAgentProfiles(step.agent_profile_id); const availableAgents = useAvailableAgents(); @@ -107,6 +109,22 @@ export function SessionConfigEditor({ const isDirty = isWorkflowStepValueDirty(step, savedStep, (item) => JSON.stringify(configureSessionAction(item)?.config?.rules ?? []), ); + const [pendingRuleKeys, setPendingRuleKeys] = useState>(() => new Set()); + const updateRuleResolutionPending = useCallback((key: string, pending: boolean) => { + setPendingRuleKeys((current) => { + const next = new Set(current); + if (pending) next.add(key); + else next.delete(key); + if (next.size === current.size && [...next].every((item) => current.has(item))) { + return current; + } + return next; + }); + }, []); + + useEffect(() => { + onResolutionPendingChange?.(pendingRuleKeys.size > 0); + }, [onResolutionPendingChange, pendingRuleKeys]); const updateRules = (nextRules: ConfigureSessionRule[]) => { onUpdate(withConfigureSessionRules(step, nextRules)); @@ -166,6 +184,7 @@ export function SessionConfigEditor({ fixedProfile={!!step.agent_profile_id} onChooseCarryRule={createCarryRule} onUpdateRules={updateRules} + onResolutionPendingChange={updateRuleResolutionPending} /> ); @@ -182,6 +201,7 @@ function SessionConfigBody({ fixedProfile, onChooseCarryRule, onUpdateRules, + onResolutionPendingChange, }: { step: WorkflowStep; action: ReturnType; @@ -196,6 +216,7 @@ function SessionConfigBody({ operation: ConfigureSessionOperation, ) => void; onUpdateRules: (rules: ConfigureSessionRule[]) => void; + onResolutionPendingChange: (key: string, pending: boolean) => void; }) { const { t } = useTranslation(); return ( @@ -223,6 +244,7 @@ function SessionConfigBody({ fixedProfile={fixedProfile} onChooseCarryRule={onChooseCarryRule} onUpdateRules={onUpdateRules} + onResolutionPendingChange={onResolutionPendingChange} /> )} @@ -238,6 +260,7 @@ function SessionConfigRuleList({ fixedProfile, onChooseCarryRule, onUpdateRules, + onResolutionPendingChange, }: { rules: ConfigureSessionRule[]; warnings: SessionConfigCarryWarning[]; @@ -250,6 +273,7 @@ function SessionConfigRuleList({ operation: ConfigureSessionOperation, ) => void; onUpdateRules: (rules: ConfigureSessionRule[]) => void; + onResolutionPendingChange: (key: string, pending: boolean) => void; }) { const { t } = useTranslation(); return ( @@ -277,6 +301,7 @@ function SessionConfigRuleList({ )} availableAgents={availableAgents} readOnly={readOnly} + onResolutionPendingChange={onResolutionPendingChange} onChange={(nextRule) => { if ( nextRule.agent_name !== rule.agent_name && diff --git a/apps/web/components/settings/workflow-session-config-rule-card.tsx b/apps/web/components/settings/workflow-session-config-rule-card.tsx index 7b75363e79..04babdaaa9 100644 --- a/apps/web/components/settings/workflow-session-config-rule-card.tsx +++ b/apps/web/components/settings/workflow-session-config-rule-card.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useRef } from "react"; import { IconTrash } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { Button } from "@kandev/ui/button"; @@ -11,34 +12,22 @@ import { ModelConfigSelector, type SelectConfigOption, } from "@/components/model-config-selector"; +import { ModelConfigResolutionStatus } from "@/components/settings/model-config-resolution-status"; import type { AvailableAgent } from "@/lib/types/http"; import type { ConfigureSessionOperation, ConfigureSessionRule } from "@/lib/types/workflow-actions"; +import { reconcileConfigOptionValues } from "./profile-model-config"; +import { useSessionConfigModelOptions } from "./use-session-config-model-options"; import { type AgentChoice, defaultModelForAgent, - modelConfigOptions, operationRule, } from "./workflow-session-config-shared"; -export function SessionConfigRuleCard({ - rule, - index, - choices, - availableAgents, - readOnly, - onChange, - onRemove, -}: { - rule: ConfigureSessionRule; - index: number; - choices: AgentChoice[]; - availableAgents: AvailableAgent[]; - readOnly: boolean; - onChange: (rule: ConfigureSessionRule) => void; - onRemove: () => void; -}) { - const selectedAgent = availableAgents.find((agent) => agent.name === rule.agent_name); - const configOptions = modelConfigOptions(selectedAgent?.model_config, rule); +function modelSelectionForRule( + rule: ConfigureSessionRule, + configOptions: SelectConfigOption[], + selectedAgent: AvailableAgent | undefined, +) { const modelConfigOption = configOptions.find(isModelConfigOption); const modelOptions = modelConfigOption ? configOptionToModelOptions(modelConfigOption) @@ -49,7 +38,51 @@ export function SessionConfigRuleCard({ usageMultiplier: typeof model.meta?.copilotUsage === "string" ? model.meta.copilotUsage : undefined, })); - const currentModel = currentModelForRule(rule, modelConfigOption, selectedAgent); + + return { + modelOptions, + currentModel: currentModelForRule(rule, modelConfigOption, selectedAgent), + }; +} + +export function SessionConfigRuleCard({ + rule, + index, + choices, + availableAgents, + readOnly, + onChange, + onRemove, + onResolutionPendingChange, +}: { + rule: ConfigureSessionRule; + index: number; + choices: AgentChoice[]; + availableAgents: AvailableAgent[]; + readOnly: boolean; + onChange: (rule: ConfigureSessionRule) => void; + onRemove: () => void; + onResolutionPendingChange: (key: string, pending: boolean) => void; +}) { + const selectedAgent = availableAgents.find((agent) => agent.name === rule.agent_name); + const { + configOptions, + configStatus, + configError, + configIsLoading, + isResolutionPending, + refreshConfig, + modelOptions, + currentModel, + handleRuleChange, + } = useSessionConfigRuleModelState({ + rule, + index, + selectedAgent, + readOnly, + onChange, + onResolutionPendingChange, + }); return (
)}
); } +function useSessionConfigRuleModelState({ + rule, + index, + selectedAgent, + readOnly, + onChange, + onResolutionPendingChange, +}: { + rule: ConfigureSessionRule; + index: number; + selectedAgent: AvailableAgent | undefined; + readOnly: boolean; + onChange: (rule: ConfigureSessionRule) => void; + onResolutionPendingChange: (key: string, pending: boolean) => void; +}) { + const { + configOptions, + configStatus, + configError, + configIsLoading, + isConfigResolutionPending, + refreshConfig, + isConfigResolvedForRequest, + resolvedConfigOptions, + } = useSessionConfigModelOptions(selectedAgent, rule); + const selectedRuleModel = rule.operation === "set" ? rule.model : undefined; + const initialRuleModel = useRef(selectedRuleModel); + const hasUserSelectedModel = useRef(false); + const pendingKey = `${rule.agent_name}:${index}`; + const currentConfigOptions = rule.operation === "set" ? rule.config_options : undefined; + + const reconciledConfigOptions = reconcileConfigOptionValues( + currentConfigOptions, + resolvedConfigOptions, + ); + const needsReconciliation = + !readOnly && + rule.operation === "set" && + configStatus === "ok" && + isConfigResolvedForRequest && + JSON.stringify(reconciledConfigOptions) !== JSON.stringify(currentConfigOptions ?? {}); + const isResolutionPending = isConfigResolutionPending || needsReconciliation; + + useEffect(() => { + onResolutionPendingChange(pendingKey, isResolutionPending); + return () => onResolutionPendingChange(pendingKey, false); + }, [isResolutionPending, onResolutionPendingChange, pendingKey]); + + useEffect(() => { + if (selectedRuleModel !== initialRuleModel.current) { + hasUserSelectedModel.current = true; + } + }, [selectedRuleModel]); + + useEffect(() => { + if ( + readOnly || + !hasUserSelectedModel.current || + rule.operation !== "set" || + configStatus !== "ok" || + !isConfigResolvedForRequest + ) { + return; + } + const nextConfigOptions = reconciledConfigOptions; + if (JSON.stringify(nextConfigOptions) === JSON.stringify(currentConfigOptions ?? {})) { + return; + } + const nextRule = { ...rule }; + if (Object.keys(nextConfigOptions).length > 0) { + nextRule.config_options = nextConfigOptions; + } else { + delete nextRule.config_options; + } + onChange(nextRule); + }, [configStatus, isConfigResolvedForRequest, onChange, readOnly, reconciledConfigOptions, rule]); + + const { modelOptions, currentModel } = modelSelectionForRule(rule, configOptions, selectedAgent); + const handleRuleChange = (nextRule: ConfigureSessionRule) => { + const nextModel = nextRule.operation === "set" ? nextRule.model : undefined; + if (nextModel !== selectedRuleModel) { + hasUserSelectedModel.current = true; + } + onChange(nextRule); + }; + + return { + configOptions, + configStatus, + configError, + configIsLoading, + isResolutionPending, + refreshConfig, + modelOptions, + currentModel, + handleRuleChange, + }; +} + function SessionConfigRuleHeader({ rule, index, @@ -183,6 +320,11 @@ function SessionConfigRuleSettings({ modelOptions, currentModel, configOptions, + configStatus, + configError, + configIsLoading, + isConfigResolutionPending, + onRetryConfig, readOnly, onChange, }: { @@ -190,6 +332,11 @@ function SessionConfigRuleSettings({ modelOptions: { id: string; name: string; description?: string; usageMultiplier?: string }[]; currentModel: string | null; configOptions: SelectConfigOption[]; + configStatus: ReturnType["configStatus"]; + configError: string | null; + configIsLoading: boolean; + isConfigResolutionPending: boolean; + onRetryConfig: () => Promise; readOnly: boolean; onChange: (rule: ConfigureSessionRule) => void; }) { @@ -218,6 +365,12 @@ function SessionConfigRuleSettings({ {t("workflows:sessionConfigModelOptionsUnavailable")}

)} +
); } diff --git a/apps/web/e2e/tests/chat/model-selector-error.spec.ts b/apps/web/e2e/tests/chat/model-selector-error.spec.ts index cc58963c7e..783079c4ff 100644 --- a/apps/web/e2e/tests/chat/model-selector-error.spec.ts +++ b/apps/web/e2e/tests/chat/model-selector-error.spec.ts @@ -442,7 +442,7 @@ test.describe("Chat model selector — persistence", () => { await trigger.click(); await testPage.getByTestId("config-option-trigger-effort").click(); await expect( - testPage.getByText("Controls how much reasoning the mock model uses", { exact: true }), + testPage.getByText("Controls the reasoning depth for the smart mock model", { exact: true }), ).toBeVisible(); await testPage.getByRole("button", { name: "Low", exact: true }).click(); await expect diff --git a/apps/web/e2e/tests/settings/agent-profile-acp.spec.ts b/apps/web/e2e/tests/settings/agent-profile-acp.spec.ts index 877e78b2ee..65f2ee8140 100644 --- a/apps/web/e2e/tests/settings/agent-profile-acp.spec.ts +++ b/apps/web/e2e/tests/settings/agent-profile-acp.spec.ts @@ -21,7 +21,10 @@ test.describe("Agent profile — ACP-first", () => { test.setTimeout(60_000); const { agents } = await apiClient.listAgents(); - const agent = agents[0]; + // Provider registration order is not a stable contract. The mock agent is + // the fixture that advertises deterministic ACP modes, so select it by id + // instead of relying on the first row returned by the API. + const agent = agents.find((item) => item.name === "mock-agent") ?? agents[0]; const profileId = agent.profiles[0].id; await testPage.goto(`/settings/agents/${agent.name}/profiles/${profileId}`); @@ -134,6 +137,12 @@ test.describe("Agent profile — ACP-first", () => { // Shared profile selectors retain the all-values summary; baseline // compaction applies only to task chat. await expect(selector).toHaveText("Mock Fast / High", { timeout: 10_000 }); + await expect(testPage.getByTestId("model-config-resolution-loading")).toBeHidden({ + timeout: 15_000, + }); + await expect(testPage.getByTestId("profile-refresh-capabilities")).toBeEnabled({ + timeout: 15_000, + }); await selector.click(); await expect( @@ -145,6 +154,30 @@ test.describe("Agent profile — ACP-first", () => { await testPage.getByRole("button", { name: "Low", exact: true }).click(); await expect(selector).toHaveText("Mock Fast / Low"); + // Changing the model must replace the option snapshot. mock-smart + // exposes Max while mock-fast exposes Medium, so this also proves the + // profile selector does not keep the previous model's option list. + await testPage.getByRole("option", { name: /Mock Smart/ }).click(); + await expect(testPage.getByTestId("model-config-resolution-loading")).toBeHidden({ + timeout: 15_000, + }); + await expect(selector).toContainText("Mock Smart", { timeout: 10_000 }); + await expect(testPage.getByTestId("config-option-trigger-effort")).toBeVisible(); + await testPage.getByTestId("config-option-trigger-effort").click(); + await expect(testPage.getByRole("button", { name: "Max", exact: true })).toBeVisible(); + await testPage.getByRole("button", { name: "Max", exact: true }).click(); + await expect(selector).toHaveText("Mock Smart / Max"); + + // Restore the original model before exercising persistence below. + await testPage.getByRole("option", { name: /Mock Fast/ }).click(); + await expect(testPage.getByTestId("model-config-resolution-loading")).toBeHidden({ + timeout: 15_000, + }); + await testPage.getByTestId("config-option-trigger-effort").click(); + await testPage.getByRole("button", { name: "Low", exact: true }).click(); + await expect(selector).toHaveText("Mock Fast / Low"); + await selector.click(); + const saveButton = testPage.getByRole("button", { name: /^Save( changes)?$/i }).first(); await expect(saveButton).toBeEnabled({ timeout: 10_000 }); await saveButton.click(); @@ -181,7 +214,7 @@ test.describe("Agent profile — ACP-first", () => { // 1. Get mock agent and create a profile with mode "plan-mock" const { agents } = await apiClient.listAgents(); - const agent = agents[0]; + const agent = agents.find((item) => item.name === "mock-agent") ?? agents[0]; const profile = await apiClient.createAgentProfile(agent.id, "Mode Test Profile", { model: "mock-fast", mode: "plan-mock", diff --git a/apps/web/e2e/tests/settings/mobile-agent-profile-config-selector.spec.ts b/apps/web/e2e/tests/settings/mobile-agent-profile-config-selector.spec.ts index 1a3ba3d2a5..4afc266e39 100644 --- a/apps/web/e2e/tests/settings/mobile-agent-profile-config-selector.spec.ts +++ b/apps/web/e2e/tests/settings/mobile-agent-profile-config-selector.spec.ts @@ -35,12 +35,26 @@ test.describe("Mobile agent profile config selector", () => { await testPage.goto(`/settings/agents/${agent.name}/profiles/${profile.id}`); const selector = testPage.getByRole("button", { name: "Profile start model settings" }); await expect(selector).toBeVisible({ timeout: 15_000 }); + await expect(testPage.getByTestId("model-config-resolution-loading")).toBeHidden({ + timeout: 15_000, + }); + await expect(testPage.getByTestId("profile-refresh-capabilities")).toBeEnabled({ + timeout: 15_000, + }); await selector.click(); + await testPage.getByRole("option", { name: /Mock Smart/ }).tap(); + await expect(testPage.getByTestId("model-config-resolution-loading")).toBeHidden({ + timeout: 15_000, + }); + await expect(selector).toContainText("Mock Smart", { timeout: 10_000 }); const effortTrigger = testPage.getByTestId("config-option-trigger-effort"); await expect(effortTrigger).toBeVisible(); await effortTrigger.click(); - await testPage.getByRole("button", { name: "High", exact: true }).click(); - await expect(selector).toContainText("High"); + await expect(testPage.getByRole("button", { name: "Max", exact: true })).toBeVisible({ + timeout: 10_000, + }); + await testPage.getByRole("button", { name: "Max", exact: true }).tap(); + await expect(selector).toHaveText("Mock Smart / Max"); } finally { await apiClient.deleteAgentProfile(profile.id, true); } diff --git a/apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts b/apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts index 3a0cce0b98..efde164b00 100644 --- a/apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts +++ b/apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts @@ -38,7 +38,7 @@ test.describe("Workflow settings on mobile", () => { await settings.tap(); await testPage.getByText("Mock Smart", { exact: true }).tap(); await testPage.getByTestId("config-option-trigger-effort").tap(); - await testPage.getByRole("button", { name: "High", exact: true }).tap(); + await testPage.getByRole("button", { name: "Max", exact: true }).tap(); await testPage.keyboard.press("Escape"); await prCapture.screenshot("mobile-original-session-editor", { caption: "Mobile workflow step editor with touch-sized original-session controls.", @@ -65,7 +65,7 @@ test.describe("Workflow settings on mobile", () => { { operation: "set", model: "mock-smart", - config_options: { effort: "high" }, + config_options: { effort: "max" }, }, ], }, diff --git a/apps/web/e2e/tests/workflow/workflow-settings.spec.ts b/apps/web/e2e/tests/workflow/workflow-settings.spec.ts index 2b5c2c4428..d820c200bb 100644 --- a/apps/web/e2e/tests/workflow/workflow-settings.spec.ts +++ b/apps/web/e2e/tests/workflow/workflow-settings.spec.ts @@ -81,17 +81,37 @@ test.describe("Workflow settings", () => { seedData, prCapture, }) => { + // The host-utility probe runs asynchronously during backend startup. Wait + // for the deterministic mock provider used by this spec instead of + // selecting whichever provider happens to be first in the response. + await expect + .poll( + async () => { + const available = await testPage.request.get( + `${backend.baseUrl}/api/v1/agents/available`, + ); + if (!available.ok()) return false; + const payload = (await available.json()) as { + agents?: Array<{ + name: string; + model_config?: { config_options?: Array<{ id: string }> }; + }>; + }; + const mock = payload.agents?.find((item) => item.name === "mock-agent"); + return Boolean( + mock?.model_config?.config_options?.some((option) => option.id === "effort"), + ); + }, + { timeout: 20_000, intervals: [250, 500, 1_000] }, + ) + .toBe(true); + const available = await testPage.request.get(`${backend.baseUrl}/api/v1/agents/available`); expect(available.ok()).toBe(true); const availablePayload = (await available.json()) as { - agents?: Array<{ - name: string; - model_config?: { config_options?: Array<{ id: string }> }; - }>; + agents?: Array<{ name: string }>; }; - const agent = availablePayload.agents?.find((item) => - item.model_config?.config_options?.some((option) => option.id === "effort"), - ); + const agent = availablePayload.agents?.find((item) => item.name === "mock-agent"); expect(agent).toBeDefined(); const workflow = await apiClient.createWorkflow( @@ -124,7 +144,7 @@ test.describe("Workflow settings", () => { await settings.click(); await testPage.getByText("Mock Smart", { exact: true }).click(); await testPage.getByTestId("config-option-trigger-effort").click(); - await testPage.getByRole("button", { name: "High", exact: true }).click(); + await testPage.getByRole("button", { name: "Max", exact: true }).click(); await testPage.keyboard.press("Escape"); await prCapture.screenshot("desktop-original-session-editor", { caption: "Workflow step editor with a conditional original-session model and effort rule.", @@ -141,7 +161,7 @@ test.describe("Workflow settings", () => { agent_name: agent!.name, operation: "set", model: "mock-smart", - config_options: { effort: "high" }, + config_options: { effort: "max" }, }, ], }, diff --git a/apps/web/hooks/domains/settings/use-available-agents.ts b/apps/web/hooks/domains/settings/use-available-agents.ts index bfe911a000..84ef05e2a0 100644 --- a/apps/web/hooks/domains/settings/use-available-agents.ts +++ b/apps/web/hooks/domains/settings/use-available-agents.ts @@ -1,6 +1,19 @@ import { useEffect } from "react"; import { useAppStore } from "@/components/state-provider"; import { listAvailableAgents } from "@/lib/api"; +import type { AvailableAgent } from "@/lib/types/http"; + +const CAPABILITY_REVALIDATION_DELAY_MS = 250; +const CAPABILITY_REVALIDATION_MAX_ATTEMPTS = 240; +let capabilityRevalidation: Promise | null = null; + +function hasPendingDynamicCapabilities(agents: AvailableAgent[]): boolean { + return agents.some( + (agent) => + agent.model_config.supports_dynamic_models && + (agent.model_config.status === "not_configured" || agent.model_config.status === "probing"), + ); +} export function useAvailableAgents(enabled = true) { const availableAgents = useAppStore((state) => state.availableAgents); @@ -24,5 +37,37 @@ export function useAvailableAgents(enabled = true) { setAvailableAgentsLoading, ]); + useEffect(() => { + if ( + !enabled || + !availableAgents.loaded || + availableAgents.loading || + !hasPendingDynamicCapabilities(availableAgents.items) || + capabilityRevalidation + ) { + return; + } + + capabilityRevalidation = (async () => { + for (let attempt = 0; attempt <= CAPABILITY_REVALIDATION_MAX_ATTEMPTS; attempt += 1) { + const response = await listAvailableAgents({ cache: "no-store" }); + setAvailableAgents(response.agents, response.tools ?? []); + if (!hasPendingDynamicCapabilities(response.agents)) return; + if (attempt === CAPABILITY_REVALIDATION_MAX_ATTEMPTS) return; + await new Promise((resolve) => setTimeout(resolve, CAPABILITY_REVALIDATION_DELAY_MS)); + } + })() + .catch(() => undefined) + .finally(() => { + capabilityRevalidation = null; + }); + }, [ + availableAgents.items, + availableAgents.loaded, + availableAgents.loading, + enabled, + setAvailableAgents, + ]); + return availableAgents; } diff --git a/apps/web/hooks/domains/settings/use-dynamic-models.test.ts b/apps/web/hooks/domains/settings/use-dynamic-models.test.ts index ca093a60a7..533c9353a2 100644 --- a/apps/web/hooks/domains/settings/use-dynamic-models.test.ts +++ b/apps/web/hooks/domains/settings/use-dynamic-models.test.ts @@ -1,15 +1,26 @@ import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { DynamicModelsResponse, ModelConfig } from "@/lib/types/http"; +import type { + AgentModelConfigResponse, + ConfigOptionEntry, + DynamicModelsResponse, + ModelConfig, +} from "@/lib/types/http"; const fetchDynamicModelsMock = vi.fn(); +const resolveAgentModelConfigMock = vi.fn(); vi.mock("@/lib/api/domains/settings-api", () => ({ fetchDynamicModels: (...args: unknown[]) => fetchDynamicModelsMock(...args), + resolveAgentModelConfig: (...args: unknown[]) => resolveAgentModelConfigMock(...args), })); -import { useAgentCapabilities } from "./use-dynamic-models"; +import { + invalidateModelConfigResolutionCache, + useAgentCapabilities, + useResolvedModelConfig, +} from "./use-dynamic-models"; const initialConfig: ModelConfig = { default_model: "", @@ -34,9 +45,24 @@ function response(status: DynamicModelsResponse["status"]): DynamicModelsRespons afterEach(() => { cleanup(); + invalidateModelConfigResolutionCache(); fetchDynamicModelsMock.mockReset(); + resolveAgentModelConfigMock.mockReset(); }); +function resolvedResponse( + model: string, + configOptions: ConfigOptionEntry[], +): AgentModelConfigResponse { + return { + agent_name: "opencode", + model, + status: "ok", + config_options: configOptions, + error: null, + }; +} + describe("useAgentCapabilities", () => { it("exposes the status returned by a forced capability refresh", async () => { fetchDynamicModelsMock @@ -146,4 +172,171 @@ describe("useAgentCapabilities", () => { await waitFor(() => expect(result.current.error).toBe("probe timed out")); }); + + it("polls a pending bootstrap until the capability snapshot is ready", async () => { + fetchDynamicModelsMock + .mockResolvedValueOnce(response("not_configured")) + .mockResolvedValueOnce(response("probing")) + .mockResolvedValueOnce({ + ...response("ok"), + modes: [{ id: "plan", name: "Plan" }], + }); + + const { result } = renderHook(() => useAgentCapabilities("grok-acp", initialConfig)); + + await waitFor( + () => { + expect(result.current.modes).toEqual([{ id: "plan", name: "Plan" }]); + expect(result.current.isLoading).toBe(false); + }, + { timeout: 2_000 }, + ); + expect(fetchDynamicModelsMock).toHaveBeenCalledTimes(3); + }); +}); + +describe("useResolvedModelConfig", () => { + it("loads options for the selected model and ignores an older response", async () => { + let resolveFirst: ((response: AgentModelConfigResponse) => void) | undefined; + let resolveSecond: ((response: AgentModelConfigResponse) => void) | undefined; + resolveAgentModelConfigMock.mockImplementation((_, request: { model: string }) => { + if (request.model === "model-a") { + return new Promise((resolve) => { + resolveFirst = resolve; + }); + } + return new Promise((resolve) => { + resolveSecond = resolve; + }); + }); + + const baseline: ConfigOptionEntry[] = [ + { type: "select", id: "effort", name: "Effort", current_value: "low", options: [] }, + ]; + const { result, rerender } = renderHook( + ({ model }) => + useResolvedModelConfig("opencode", model, { + mode: "build", + configOptions: { effort: "low" }, + initialConfigOptions: baseline, + }), + { initialProps: { model: "model-a" } }, + ); + + await waitFor(() => expect(resolveAgentModelConfigMock).toHaveBeenCalledTimes(1)); + expect(resolveAgentModelConfigMock).toHaveBeenLastCalledWith("opencode", { + model: "model-a", + mode: "build", + config_options: { effort: "low" }, + }); + + rerender({ model: "model-b" }); + await waitFor(() => expect(resolveAgentModelConfigMock).toHaveBeenCalledTimes(2)); + + const secondOptions: ConfigOptionEntry[] = [ + { + type: "select", + id: "reasoning_effort", + name: "Reasoning effort", + current_value: "max", + options: [], + }, + ]; + await act(async () => { + resolveSecond?.(resolvedResponse("model-b", secondOptions)); + }); + expect(result.current.configOptions).toEqual(secondOptions); + + await act(async () => { + resolveFirst?.(resolvedResponse("model-a", baseline)); + }); + expect(result.current.configOptions).toEqual(secondOptions); + }); +}); + +describe("useResolvedModelConfig draft updates", () => { + it("does not re-resolve for an equivalent draft option object", async () => { + const configOptions: ConfigOptionEntry[] = [ + { + type: "select", + id: "effort", + name: "Effort", + current_value: "low", + options: [ + { value: "low", name: "Low" }, + { value: "high", name: "High" }, + ], + }, + ]; + fetchDynamicModelsMock.mockResolvedValue(response("ok")); + resolveAgentModelConfigMock.mockResolvedValue( + resolvedResponse("profile-draft-model", configOptions), + ); + + const { rerender } = renderHook( + ({ draft }) => + useResolvedModelConfig("profile-draft-agent", "profile-draft-model", { + configOptions: draft, + initialConfigOptions: configOptions, + }), + { initialProps: { draft: { effort: "low" } } }, + ); + + await waitFor(() => expect(resolveAgentModelConfigMock).toHaveBeenCalledTimes(1)); + + rerender({ draft: { effort: "low" } }); + await act(async () => Promise.resolve()); + + expect(resolveAgentModelConfigMock).toHaveBeenCalledTimes(1); + expect(resolveAgentModelConfigMock).toHaveBeenCalledWith("profile-draft-agent", { + model: "profile-draft-model", + config_options: { effort: "low" }, + }); + }); +}); + +describe("useResolvedModelConfig cache invalidation", () => { + const cacheRefreshModel = "cache-refresh-model"; + + it("invalidates model option resolutions when capabilities refresh", async () => { + const firstOptions: ConfigOptionEntry[] = [ + { type: "select", id: "effort", name: "Effort", current_value: "low", options: [] }, + ]; + const secondOptions: ConfigOptionEntry[] = [ + { type: "select", id: "effort", name: "Effort", current_value: "high", options: [] }, + ]; + fetchDynamicModelsMock.mockResolvedValue(response("ok")); + resolveAgentModelConfigMock + .mockResolvedValueOnce(resolvedResponse(cacheRefreshModel, firstOptions)) + .mockResolvedValueOnce(resolvedResponse(cacheRefreshModel, secondOptions)); + + const modelConfig: ModelConfig = { + ...initialConfig, + current_model_id: cacheRefreshModel, + default_model: cacheRefreshModel, + }; + const cacheRefreshAgent = "cache-refresh-agent"; + const firstResolution = renderHook(() => + useResolvedModelConfig(cacheRefreshAgent, cacheRefreshModel, { + initialConfigOptions: firstOptions, + }), + ); + await waitFor(() => expect(resolveAgentModelConfigMock).toHaveBeenCalledTimes(1)); + + const capabilities = renderHook(() => useAgentCapabilities(cacheRefreshAgent, modelConfig)); + await waitFor(() => expect(fetchDynamicModelsMock).toHaveBeenCalledTimes(1)); + await act(async () => { + await capabilities.result.current.refresh(); + }); + + const secondResolution = renderHook(() => + useResolvedModelConfig(cacheRefreshAgent, cacheRefreshModel, { + initialConfigOptions: firstOptions, + }), + ); + await waitFor(() => + expect(secondResolution.result.current.configOptions).toEqual(secondOptions), + ); + expect(firstResolution.result.current.configOptions).toEqual(firstOptions); + }); }); diff --git a/apps/web/hooks/domains/settings/use-dynamic-models.ts b/apps/web/hooks/domains/settings/use-dynamic-models.ts index 47df885955..836c474b4d 100644 --- a/apps/web/hooks/domains/settings/use-dynamic-models.ts +++ b/apps/web/hooks/domains/settings/use-dynamic-models.ts @@ -1,12 +1,15 @@ -import { useState, useEffect, useCallback } from "react"; -import { fetchDynamicModels } from "@/lib/api/domains/settings-api"; +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { fetchDynamicModels, resolveAgentModelConfig } from "@/lib/api/domains/settings-api"; import type { + AgentModelConfigResponse, CommandEntry, + ConfigOptionEntry, ModeEntry, ModelEntry, CapabilityStatus, DynamicModelsResponse, ModelConfig, + ResolveAgentModelConfigRequest, } from "@/lib/types/http"; // Set from an async catch rather than rendered as a literal, so this uses the // module-level `t`, which resolves at call time. The message surfaces in the @@ -25,6 +28,186 @@ type UseAgentCapabilitiesState = { refresh: () => Promise; }; +type UseResolvedModelConfigOptions = { + mode?: string; + configOptions?: Record; + initialConfigOptions?: ConfigOptionEntry[]; + enabled?: boolean; +}; + +type UseResolvedModelConfigState = { + configOptions: ConfigOptionEntry[]; + status: CapabilityStatus | undefined; + isResolvedForRequest: boolean; + isResolutionPending: boolean; + isLoading: boolean; + error: string | null; + refresh: () => Promise; +}; + +type CachedModelConfigResolution = { + response: AgentModelConfigResponse; + expiresAt: number; +}; + +const MODEL_CONFIG_RESOLUTION_TTL_MS = 5 * 60 * 1000; +const CAPABILITY_PENDING_POLL_DELAY_MS = 250; +const CAPABILITY_PENDING_MAX_ATTEMPTS = 240; +const modelConfigResolutionCache = new Map(); +const modelConfigResolutionInflight = new Map>(); +let modelConfigResolutionGeneration = 0; + +export function invalidateModelConfigResolutionCache(agentName?: string): void { + modelConfigResolutionGeneration += 1; + if (!agentName) { + modelConfigResolutionCache.clear(); + modelConfigResolutionInflight.clear(); + return; + } + for (const key of modelConfigResolutionCache.keys()) { + if (modelConfigResolutionKeyAgent(key) === agentName) { + modelConfigResolutionCache.delete(key); + } + } + for (const key of modelConfigResolutionInflight.keys()) { + if (modelConfigResolutionKeyAgent(key) === agentName) { + modelConfigResolutionInflight.delete(key); + } + } +} + +export function __resetModelConfigResolutionCache(): void { + modelConfigResolutionGeneration += 1; + modelConfigResolutionCache.clear(); + modelConfigResolutionInflight.clear(); +} + +function modelConfigResolutionKeyAgent(key: string): string | undefined { + try { + const parsed = JSON.parse(key); + return Array.isArray(parsed) && typeof parsed[0] === "string" ? parsed[0] : undefined; + } catch { + return undefined; + } +} + +function stableRecordKey(values: Record | undefined): string { + return JSON.stringify( + Object.entries(values ?? {}).sort(([left], [right]) => left.localeCompare(right)), + ); +} + +function modelConfigResolutionKey( + agentName: string, + request: ResolveAgentModelConfigRequest, +): string { + return JSON.stringify([ + agentName, + request.model, + request.mode ?? "", + stableRecordKey(request.config_options), + ]); +} + +function cloneModelConfigResponse(response: AgentModelConfigResponse): AgentModelConfigResponse { + return { + ...response, + config_options: (response.config_options ?? []).map((option) => ({ + ...option, + options: option.options?.map((item) => ({ ...item })), + })), + }; +} + +async function fetchResolvedModelConfig( + agentName: string, + request: ResolveAgentModelConfigRequest, + forceRefresh: boolean, +): Promise { + const key = modelConfigResolutionKey(agentName, request); + if (!forceRefresh) { + const cached = modelConfigResolutionCache.get(key); + if (cached && cached.expiresAt > Date.now()) { + return cloneModelConfigResponse(cached.response); + } + if (cached) { + modelConfigResolutionCache.delete(key); + } + } + + const existing = modelConfigResolutionInflight.get(key); + if (existing) return existing.then(cloneModelConfigResponse); + + const generation = modelConfigResolutionGeneration; + const payload = forceRefresh ? { ...request, refresh: true } : request; + const promise = resolveAgentModelConfig(agentName, payload).then((response) => { + const cloned = cloneModelConfigResponse(response); + if (cloned.status === "ok" && generation === modelConfigResolutionGeneration) { + modelConfigResolutionCache.set(key, { + response: cloned, + expiresAt: Date.now() + MODEL_CONFIG_RESOLUTION_TTL_MS, + }); + } + return cloned; + }); + modelConfigResolutionInflight.set(key, promise); + void promise.then( + () => { + if (modelConfigResolutionInflight.get(key) === promise) { + modelConfigResolutionInflight.delete(key); + } + }, + () => { + if (modelConfigResolutionInflight.get(key) === promise) { + modelConfigResolutionInflight.delete(key); + } + }, + ); + return promise.then(cloneModelConfigResponse); +} + +function isCapabilityProbePending(status: CapabilityStatus): boolean { + return status === "not_configured" || status === "probing"; +} + +async function fetchCapabilitiesUntilSettled( + agentName: string, + forceRefresh: boolean, +): Promise { + for (let attempt = 0; ; attempt += 1) { + const response = await fetchDynamicModels(agentName, { refresh: forceRefresh }); + if (forceRefresh || !isCapabilityProbePending(response.status)) { + return response; + } + if (attempt >= CAPABILITY_PENDING_MAX_ATTEMPTS) { + return response; + } + await new Promise((resolve) => setTimeout(resolve, CAPABILITY_PENDING_POLL_DELAY_MS)); + } +} + +type ModelConfigResolutionResult = + | { kind: "response"; response: AgentModelConfigResponse } + | { kind: "error"; error: string }; + +async function resolveModelConfig( + agentName: string, + request: ResolveAgentModelConfigRequest, + forceRefresh: boolean, +): Promise { + try { + return { + kind: "response", + response: await fetchResolvedModelConfig(agentName, request, forceRefresh), + }; + } catch (err) { + return { + kind: "error", + error: err instanceof Error ? err.message : t("agents:failedToFetchCapabilities"), + }; + } +} + /** * useAgentCapabilities fetches the full ACP probe cache for an agent * (models, modes, current defaults) and keeps it in sync. Refresh triggers @@ -49,24 +232,28 @@ export function useAgentCapabilities( const hasManualRefresh = manualRefreshAgentName === agentName; const [isLoading, setIsLoading] = useState(supportsDynamicModels && !!agentName); const [error, setError] = useState(null); + const capabilityRequestSequence = useRef(0); const fetchCaps = useCallback( async (forceRefresh: boolean) => { if (!agentName || !supportsDynamicModels) { return; } + const sequence = ++capabilityRequestSequence.current; setIsLoading(true); + if (forceRefresh) { + invalidateModelConfigResolutionCache(agentName); + } setError(null); try { - const response: DynamicModelsResponse = await fetchDynamicModels(agentName, { - refresh: forceRefresh, - }); + const response = await fetchCapabilitiesUntilSettled(agentName, forceRefresh); + if (sequence !== capabilityRequestSequence.current) return; setStatus(response.status); setError(response.error ?? null); if (forceRefresh) { setManualRefreshAgentName(agentName); } - if (response.status !== "failed") { + if (response.status === "ok") { setModels(response.models ?? []); setModes(response.modes ?? []); setCommands(response.commands ?? []); @@ -74,9 +261,12 @@ export function useAgentCapabilities( setCurrentModeId(response.current_mode_id); } } catch (err) { + if (sequence !== capabilityRequestSequence.current) return; setError(err instanceof Error ? err.message : t("agents:failedToFetchCapabilities")); } finally { - setIsLoading(false); + if (sequence === capabilityRequestSequence.current) { + setIsLoading(false); + } } }, [agentName, supportsDynamicModels], @@ -92,6 +282,9 @@ export function useAgentCapabilities( if (supportsDynamicModels && agentName) { void fetchCaps(false); } + return () => { + capabilityRequestSequence.current += 1; + }; }, [agentName, supportsDynamicModels, fetchCaps]); const refresh = useCallback(() => fetchCaps(true), [fetchCaps]); @@ -108,3 +301,108 @@ export function useAgentCapabilities( refresh, }; } + +/** + * Resolve the complete provider option snapshot for the selected model. + * + * The response is model-aware. A provider can therefore return a different + * option list for every model without adding provider-specific frontend code. + */ +export function useResolvedModelConfig( + agentName: string | undefined, + model: string | undefined, + options: UseResolvedModelConfigOptions = {}, +): UseResolvedModelConfigState { + const { mode, configOptions, initialConfigOptions = [], enabled = true } = options; + const configOptionsKey = stableRecordKey(configOptions); + const initialConfigOptionsKey = JSON.stringify(initialConfigOptions); + const stableConfigOptions = useMemo(() => configOptions, [configOptionsKey]); + const stableInitialConfigOptions = useMemo(() => initialConfigOptions, [initialConfigOptionsKey]); + const request = useMemo(() => { + if (!model) return null; + return { + model, + ...(mode ? { mode } : {}), + ...(stableConfigOptions && Object.keys(stableConfigOptions).length > 0 + ? { config_options: stableConfigOptions } + : {}), + }; + }, [mode, model, stableConfigOptions]); + const requestKey = useMemo( + () => (request ? modelConfigResolutionKey(agentName ?? "", request) : undefined), + [agentName, request], + ); + const [resolvedOptions, setResolvedOptions] = useState( + stableInitialConfigOptions, + ); + const [status, setStatus] = useState(); + const [resolvedRequestKey, setResolvedRequestKey] = useState(); + const [settledRequestKey, setSettledRequestKey] = useState(); + const [isLoading, setIsLoading] = useState(Boolean(enabled && agentName && request)); + const [error, setError] = useState(null); + const requestSequence = useRef(0); + + const run = useCallback( + async (forceRefresh: boolean) => { + const sequence = ++requestSequence.current; + if (!enabled || !agentName || !request) { + setResolvedOptions(stableInitialConfigOptions); + setStatus(undefined); + setResolvedRequestKey(undefined); + setSettledRequestKey(undefined); + setError(null); + setIsLoading(false); + return; + } + + setResolvedRequestKey(undefined); + setSettledRequestKey(undefined); + setResolvedOptions(stableInitialConfigOptions); + setStatus(forceRefresh ? "probing" : undefined); + setError(null); + setIsLoading(true); + const result = await resolveModelConfig(agentName, request, forceRefresh); + if (sequence !== requestSequence.current) return; + if (result.kind === "error") { + setStatus("failed"); + setError(result.error); + setResolvedOptions(stableInitialConfigOptions); + if (requestKey) setSettledRequestKey(requestKey); + } else { + const response = result.response; + setStatus(response.status); + setError(response.error ?? null); + setResolvedOptions( + response.status === "ok" ? (response.config_options ?? []) : stableInitialConfigOptions, + ); + if (response.status === "ok" && requestKey) { + setResolvedRequestKey(requestKey); + } + if (requestKey) setSettledRequestKey(requestKey); + } + setIsLoading(false); + }, + [agentName, enabled, request, requestKey, stableInitialConfigOptions], + ); + + useEffect(() => { + void run(false); + return () => { + requestSequence.current += 1; + }; + }, [run]); + + const refresh = useCallback(() => run(true), [run]); + + return { + configOptions: resolvedOptions, + status, + isResolvedForRequest: requestKey !== undefined && resolvedRequestKey === requestKey, + isResolutionPending: Boolean( + enabled && agentName && requestKey && (isLoading || settledRequestKey !== requestKey), + ), + isLoading, + error, + refresh, + }; +} diff --git a/apps/web/hooks/domains/settings/use-profile-model-capabilities.ts b/apps/web/hooks/domains/settings/use-profile-model-capabilities.ts new file mode 100644 index 0000000000..864aec3cf2 --- /dev/null +++ b/apps/web/hooks/domains/settings/use-profile-model-capabilities.ts @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useRef } from "react"; +import { useAgentCapabilities, useResolvedModelConfig } from "./use-dynamic-models"; +import type { ModelConfig } from "@/lib/types/http"; +import { reconcileConfigOptionValues } from "@/components/settings/profile-model-config"; + +type ProfileModelSelection = { + model: string; + mode: string; + config_options?: Record; +}; + +export function useProfileModelCapabilities( + agentName: string, + profile: ProfileModelSelection, + modelConfig: ModelConfig, + onChange?: (patch: { config_options: Record }) => void, +) { + const capabilities = useAgentCapabilities(agentName, modelConfig); + const selectedModel = + profile.model || + capabilities.currentModelId || + modelConfig.current_model_id || + modelConfig.default_model; + const initialProfileModel = useRef(profile.model); + const hasUserSelectedModel = useRef(false); + const resolvedModelConfig = useResolvedModelConfig(agentName, selectedModel, { + initialConfigOptions: modelConfig.config_options, + enabled: modelConfig.supports_dynamic_models, + }); + + useEffect(() => { + if (profile.model !== initialProfileModel.current) { + hasUserSelectedModel.current = true; + } + }, [profile.model]); + + const shouldReconcile = + Boolean(onChange) && + hasUserSelectedModel.current && + resolvedModelConfig.status === "ok" && + resolvedModelConfig.isResolvedForRequest; + const nextConfigOptions = shouldReconcile + ? reconcileConfigOptionValues(profile.config_options, resolvedModelConfig.configOptions) + : (profile.config_options ?? {}); + const needsReconciliation = + shouldReconcile && + JSON.stringify(nextConfigOptions) !== JSON.stringify(profile.config_options ?? {}); + + useEffect(() => { + if (!onChange || !shouldReconcile || !needsReconciliation) { + return; + } + onChange({ config_options: nextConfigOptions }); + }, [needsReconciliation, onChange, profile.config_options, nextConfigOptions, shouldReconcile]); + + const refresh = useCallback(async () => { + await Promise.all([capabilities.refresh(), resolvedModelConfig.refresh()]); + }, [capabilities.refresh, resolvedModelConfig.refresh]); + + return { + capabilities, + configOptions: resolvedModelConfig.configOptions, + configStatus: resolvedModelConfig.status, + configError: resolvedModelConfig.error, + configIsLoading: resolvedModelConfig.isLoading, + isConfigResolutionPending: resolvedModelConfig.isResolutionPending || needsReconciliation, + refreshModelConfig: resolvedModelConfig.refresh, + refresh, + }; +} diff --git a/apps/web/lib/api/domains/settings-api.test.ts b/apps/web/lib/api/domains/settings-api.test.ts index 22a67b1030..643a704b53 100644 --- a/apps/web/lib/api/domains/settings-api.test.ts +++ b/apps/web/lib/api/domains/settings-api.test.ts @@ -7,6 +7,7 @@ vi.mock("@/lib/config", () => ({ import { fetchMessageQueueSettings, fetchSleepInhibitionSettings, + resolveAgentModelConfig, startHostShell, updateMessageQueueSettings, updateSleepInhibitionSettings, @@ -135,3 +136,31 @@ describe("startHostShell", () => { expect(JSON.parse(String(fetchCall?.[1]?.body))).toEqual({ cols: 80, rows: 24 }); }); }); + +describe("resolveAgentModelConfig", () => { + it("posts the selected provider context", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse({ + agent_name: "opencode", + model: "opencode/gpt-5.6", + status: "ok", + config_options: [], + error: null, + }), + ); + + await resolveAgentModelConfig("opencode", { + model: "opencode/gpt-5.6", + mode: "build", + config_options: { reasoning_effort: "high" }, + }); + + expect(lastCall().url).toBe("http://api.test/api/v1/agent-models/opencode/resolve"); + expect(lastCall().init?.method).toBe("POST"); + expect(JSON.parse(String(lastCall().init?.body))).toEqual({ + model: "opencode/gpt-5.6", + mode: "build", + config_options: { reasoning_effort: "high" }, + }); + }); +}); diff --git a/apps/web/lib/api/domains/settings-api.ts b/apps/web/lib/api/domains/settings-api.ts index aa545107d4..4a9939d018 100644 --- a/apps/web/lib/api/domains/settings-api.ts +++ b/apps/web/lib/api/domains/settings-api.ts @@ -19,6 +19,8 @@ import type { UserSettingsResponse, UserSettingsUpdatePayload, DynamicModelsResponse, + AgentModelConfigResponse, + ResolveAgentModelConfigRequest, } from "@/lib/types/http"; import type { MessageQueueSettingsPatch, @@ -349,6 +351,17 @@ export async function fetchDynamicModels( return fetchJson(url, options); } +export async function resolveAgentModelConfig( + agentName: string, + payload: ResolveAgentModelConfigRequest, + options?: ApiRequestOptions, +): Promise { + return fetchJson(`/api/v1/agent-models/${agentName}/resolve`, { + ...options, + init: { method: "POST", body: JSON.stringify(payload), ...(options?.init ?? {}) }, + }); +} + // Editors export async function listEditors(options?: ApiRequestOptions) { return fetchJson("/api/v1/editors", options); diff --git a/apps/web/lib/types/http-agents.ts b/apps/web/lib/types/http-agents.ts index 8c58230f67..37b9c05c92 100644 --- a/apps/web/lib/types/http-agents.ts +++ b/apps/web/lib/types/http-agents.ts @@ -159,6 +159,21 @@ export type DynamicModelsResponse = { error: string | null; }; +export type ResolveAgentModelConfigRequest = { + model: string; + mode?: string; + config_options?: Record; + refresh?: boolean; +}; + +export type AgentModelConfigResponse = { + agent_name: string; + model: string; + status: CapabilityStatus; + config_options: ConfigOptionEntry[]; + error: string | null; +}; + export type PermissionSetting = { supported: boolean; default: boolean; diff --git a/apps/web/src/locales/en/agents.json b/apps/web/src/locales/en/agents.json index 33f55e3ac2..11f44adb2a 100644 --- a/apps/web/src/locales/en/agents.json +++ b/apps/web/src/locales/en/agents.json @@ -94,6 +94,10 @@ "failedToCreateAgent": "Failed to create agent", "failedToDeleteProfile": "Failed to delete profile", "failedToFetchCapabilities": "Failed to fetch capabilities", + "modelOptionsAuthenticationRequired": "Sign in to this agent to load model options.", + "modelOptionsResolutionFailed": "Model options could not be loaded.", + "resolvingModelOptions": "Loading model options…", + "retryModelOptions": "Retry", "failedToLoadCommandPreview": "Failed to load command preview", "failedToLoadMcpConfig": "Failed to load MCP config", "failedToRefresh": "Failed to refresh: {{error}}", diff --git a/apps/web/src/locales/pseudo/agents.json b/apps/web/src/locales/pseudo/agents.json index 6dfce12107..f4ad4daab9 100644 --- a/apps/web/src/locales/pseudo/agents.json +++ b/apps/web/src/locales/pseudo/agents.json @@ -94,6 +94,10 @@ "failedToCreateAgent": "Ƒàĩĺēď ţō ćŕēàţē àĝēńţ", "failedToDeleteProfile": "Ƒàĩĺēď ţō ďēĺēţē ƥŕōƒĩĺē", "failedToFetchCapabilities": "Ƒàĩĺēď ţō ƒēţćĥ ćàƥàƀĩĺĩţĩēś", + "modelOptionsAuthenticationRequired": "Śĩĝń ĩń ţō ţĥĩś àĝēńţ ţō ĺōàď ḿōďēĺ ōƥţĩōńś.", + "modelOptionsResolutionFailed": "Ḿōďēĺ ōƥţĩōńś ćōũĺď ńōţ ƀē ĺōàďēď.", + "resolvingModelOptions": "Ľōàďĩńĝ ḿōďēĺ ōƥţĩōńś…", + "retryModelOptions": "Ŕēţŕŷ", "failedToLoadCommandPreview": "Ƒàĩĺēď ţō ĺōàď ćōḿḿàńď ƥŕēvĩēŵ", "failedToLoadMcpConfig": "Ƒàĩĺēď ţō ĺōàď ḾĆƤ ćōńƒĩĝ", "failedToRefresh": "Ƒàĩĺēď ţō ŕēƒŕēśĥ: {{error}}", diff --git a/apps/web/src/locales/pt-pt/agents.json b/apps/web/src/locales/pt-pt/agents.json index 7261e3a83d..ca310fe513 100644 --- a/apps/web/src/locales/pt-pt/agents.json +++ b/apps/web/src/locales/pt-pt/agents.json @@ -94,6 +94,10 @@ "failedToCreateAgent": "Não foi possível criar o agente", "failedToDeleteProfile": "Não foi possível eliminar o perfil", "failedToFetchCapabilities": "Não foi possível obter as capacidades", + "modelOptionsAuthenticationRequired": "Inicie sessão neste agente para carregar as opções do modelo.", + "modelOptionsResolutionFailed": "Não foi possível carregar as opções do modelo.", + "resolvingModelOptions": "A carregar as opções do modelo…", + "retryModelOptions": "Tentar novamente", "failedToLoadCommandPreview": "Não foi possível carregar a pré-visualização do comando", "failedToLoadMcpConfig": "Não foi possível carregar a configuração MCP", "failedToRefresh": "Não foi possível atualizar: {{error}}", diff --git a/apps/web/src/locales/zh-cn/agents.json b/apps/web/src/locales/zh-cn/agents.json index 8a8f203210..046dd0e561 100644 --- a/apps/web/src/locales/zh-cn/agents.json +++ b/apps/web/src/locales/zh-cn/agents.json @@ -94,6 +94,10 @@ "failedToCreateAgent": "创建代理失败", "failedToDeleteProfile": "删除配置失败", "failedToFetchCapabilities": "获取能力失败", + "modelOptionsAuthenticationRequired": "请登录此代理以加载模型选项。", + "modelOptionsResolutionFailed": "无法加载模型选项。", + "resolvingModelOptions": "正在加载模型选项…", + "retryModelOptions": "重试", "failedToLoadCommandPreview": "加载命令预览失败", "failedToLoadMcpConfig": "加载 MCP 配置失败", "failedToRefresh": "刷新失败:{{error}}", diff --git a/docs/decisions/2026-08-07-model-aware-provider-capability-resolution.md b/docs/decisions/2026-08-07-model-aware-provider-capability-resolution.md new file mode 100644 index 0000000000..cae88b354a --- /dev/null +++ b/docs/decisions/2026-08-07-model-aware-provider-capability-resolution.md @@ -0,0 +1,106 @@ +--- +status: accepted +date: 2026-08-07 +area: backend, frontend, protocol +--- + +# Model-aware provider capability resolution + +## Context + +ACP providers can change their selectable configuration options after a model +changes. OpenCode is an example: the live task selector receives a +`reasoning_effort` option for some selected models, while the settings pages +currently read one agent-level capability snapshot taken before a model is +selected. The workflow session override editor and the OpenCode agent profile +editor therefore cannot show model-dependent options. The shared selector is +already generic; the missing capability is a model-aware source of truth. + +The same problem will occur for future providers and for future option types, +so provider names and option identifiers must not be embedded in the settings +UI. + +## Decision + +Add a sessionless, model-aware capability resolver behind the existing host +utility ACP boundary. + +- Keep the existing agent-level capability discovery as the fast baseline for + agent status, model lists, modes, commands, and options available for the + provider's default session. +- When a settings surface selects a model, resolve that model in a short-lived + ACP probe session. Apply the model through the generic ACP/session-model + machinery, capture the provider's complete resulting config-option snapshot, + and normalize it through the shared `internal/agentctl/acpcompat` boundary. +- Treat the provider's returned snapshot as authoritative for that resolution + context. The frontend renders arbitrary ACP `select` options and choices; it + does not map OpenCode, Grok, or any other provider by name. +- Expose resolution through an explicit HTTP endpoint with a request context + containing the model and future-dependent inputs such as mode and selected + config options. Keep the existing baseline models endpoint unchanged. +- Cache successful and unsupported resolutions in memory for a short bounded + TTL, keyed by agent type and the canonical resolution context. Deduplicate + concurrent requests and invalidate the cache when the provider capability + baseline is refreshed. Do not persist probe results in the database. +- A resolution response replaces the current option snapshot for the selected + context. If resolution fails, settings retain the existing draft/persisted + values and do not invent new controls; the user can retry or save unchanged + data. + +The initial settings implementation uses the selected model as the only +context input. The API and cache key are shaped so that mode and selected +options can become dependencies without another provider-specific design. + +## Consequences + +Positive consequences: + +- Chat, workflow settings, and agent profile settings can share one provider + capability contract. +- A provider can add or remove options dynamically without a frontend release. +- The expensive part of discovery is bounded by caching and single-flight + deduplication. +- Provider compatibility quirks remain in the ACP compatibility package rather + than leaking into React components. + +Costs and risks: + +- Selecting a model in settings may require a short asynchronous probe before + dependent controls appear. +- A provider that cannot return a complete post-model snapshot will show a + degraded discovery state rather than being guessed at by the UI. +- The backend must carefully bound probe time and process resources because a + settings page can request several models in succession. +- A model change may make previously selected option identifiers invalid; the + editable draft must reconcile them only after a successful authoritative + snapshot, while preserving persisted data when discovery fails. + +## Alternatives considered + +### Keep one static capability snapshot per agent + +This is simple and preserves the current API, but it cannot represent options +whose choices depend on the selected model. It was the behavior observed on +both settings surfaces, so it is insufficient. + +### Probe every model during initial discovery + +This would make the UI immediately complete, but multiplies provider process +startup and ACP calls by the number of models, delays the baseline response, +and still needs a context model for mode- or option-dependent capabilities. + +### Hard-code provider-specific option maps in the frontend + +This would make OpenCode work quickly but duplicates provider behavior, goes +stale as providers change, and violates the generic ACP capability boundary. + +### Reuse the active task session for settings discovery + +Settings pages may be opened without a task, and mutating an active task to +discover options would create user-visible side effects. A sessionless probe +keeps discovery isolated. + +## Follow-up + +The behavioral contract and implementation work are specified in +[`Dynamic Provider Model Options`](../specs/agents/dynamic-provider-options.md). diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 2bc40f6f07..37f67bca54 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -128,4 +128,5 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-08-05-server-owned-quick-terminal-descriptors | [Server-Owned Quick Terminal Descriptors](2026-08-05-server-owned-quick-terminal-descriptors.md) | accepted | backend, frontend, protocol, security | 2026-08-05 | | 2026-08-05-nested-submodules-as-repository-scopes | [Model Nested Submodules as Repository Scopes](2026-08-05-nested-submodules-as-repository-scopes.md) | accepted | backend, frontend, protocol | 2026-08-05 | | 2026-08-05-homebrew-remote-helper-audit | [Preserve Remote Helpers in Homebrew Installs](2026-08-05-homebrew-remote-helper-audit.md) | accepted | infra, workflow | 2026-08-05 | +| 2026-08-07-model-aware-provider-capability-resolution | [Model-aware provider capability resolution](2026-08-07-model-aware-provider-capability-resolution.md) | accepted | backend, frontend, protocol | 2026-08-07 | | 2026-08-07-claude-allowlist-label-bridge | [Use the Claude Allowlist as a Trusted Preview Gate](2026-08-07-claude-allowlist-label-bridge.md) | accepted | infra, workflow, security | 2026-08-07 | diff --git a/docs/plans/dynamic-provider-options/plan.md b/docs/plans/dynamic-provider-options/plan.md new file mode 100644 index 0000000000..9ddbe2cce0 --- /dev/null +++ b/docs/plans/dynamic-provider-options/plan.md @@ -0,0 +1,315 @@ +--- +spec: docs/specs/agents/dynamic-provider-options.md +decision: docs/decisions/2026-08-07-model-aware-provider-capability-resolution.md +created: 2026-08-07 +status: complete +--- + +# Implementation Plan: Dynamic Provider Model Options + +## Overview + +Add a model-aware, provider-neutral capability resolver for settings surfaces +that do not have a live ACP session. The resolver will use the existing host +utility agentctl process, apply a requested model through generic ACP session +model logic, and return the provider's complete normalized configuration-option +snapshot. Workflow session overrides and agent profile settings will consume +the same frontend hook and reconcile their draft option maps against the +returned snapshot. + +The existing baseline agent capability endpoint remains responsible for the +model list, agent status, modes, commands, and default-session options. The +live chat selector remains session-authoritative. No database migration or +provider-specific frontend mapping is planned. + +## Architecture + +### Baseline versus resolved capabilities + +`AvailableAgent.model_config` remains a fast baseline. It is used to render the +model picker immediately and to preserve a usable selector while a dependent +resolution is loading. It must not be treated as the final option set for a +selected model. + +The new resolver accepts an agent name and a canonical context containing the +selected model, with optional mode and selected config options. It starts or +uses the existing host utility ACP boundary, performs a model change in the +probe session, waits for the provider's complete response or bounded config +update notification, and normalizes the result through +`internal/agentctl/acpcompat`. The returned option list is a full replacement +for the current context, including an intentional empty list. + +The provider response is authoritative. The UI renders arbitrary ACP select +options and choices through `ModelConfigSelector`; it does not know that an +option came from OpenCode or that it is called reasoning effort. + +### Resolution API and cache + +Add `POST /api/v1/agent-models/:agentName/resolve`. The existing GET endpoint +continues to return the baseline dynamic model response. The POST body has a +required `model` and optional `mode`, `config_options`, and `refresh` fields. +The response returns the agent name, requested model, status, complete +normalized `config_options`, and a sanitized error when resolution is not +authoritative. + +Host utility resolution is cached in memory by agent type plus canonicalized +context. Use a bounded TTL (five minutes unless existing runtime limits require +a shorter value), single-flight deduplication for identical concurrent keys, +and invalidation on baseline refresh. Cache failures only when they represent a +stable unsupported result; transient process/timeout failures should be +retryable and must not poison the cache indefinitely. The cache is runtime-only +and is safe to lose on restart. + +### Settings behavior + +The workflow rule card and profile form request resolution whenever their +selected model/context changes. The hook must provide baseline options, +resolved options, loading, error, and retry state while protecting against +out-of-order responses. A successful snapshot replaces, rather than merges +with, the previous option list. The editable `config_options` map drops keys +not present in a successful snapshot before the next save. A failed resolution +does not rewrite persisted data or silently remove saved values. + +The shared selector stays generic. If a small loading/error affordance is +needed, add it as an optional status prop with localized copy. The chat path is +not changed because its live session already emits authoritative updates. + +### Probe compatibility + +The utility probe must use the same client capability metadata and ACP +compatibility normalization as live sessions. The model application path must +support typed config-option model changes and legacy model methods through the +existing `sessionmodel` abstraction. Provider-specific quirks remain in +`internal/agentctl/acpcompat`; no OpenCode branch belongs in settings or the +host utility cache. + +### Mock provider contract + +Make the mock ACP agent model-dependent for E2E. Its initial session should +advertise a small baseline, and applying one model should return no extra +options while applying another should return an effort-like select option. +This keeps tests provider-neutral while proving that settings resolve after a +model change instead of accidentally relying on static `AvailableAgent` +data. + +## Backend + +### Model-aware utility probe + +Extend the utility probe request/result and ACP executor flow so a probe can +carry a model-resolution context. After `session/new`, apply the requested +model, capture the complete response config or a bounded configuration-update +notification, and pass it through the existing config-option conversion and +ACP dialect normalization helpers. Keep baseline probing behavior unchanged +when no model context is supplied. + +Add focused coverage for response snapshots, notification fallback, unsupported +model application, provider normalization, and the existing client capability +metadata. Reuse `sessionmodel.ApplySDKWithConfigOptions` where its returned +config is authoritative instead of duplicating ACP request logic. + +### Host utility resolution service + +Add typed resolution inputs/results to the host utility package, a separate +runtime cache, canonical cache-key construction, TTL handling, single-flight +deduplication, refresh invalidation, and a bounded context timeout. Expose a +narrow public manager method for the settings controller. Do not persist +resolved capabilities or attach them to a task session. + +### Settings HTTP contract + +Add DTOs for the resolver request and response, controller validation, and the +POST route beside the existing dynamic-model route. Reuse the existing agent +availability and authorization checks and sanitize probe failures consistently +with the current settings APIs. Preserve the baseline GET response for existing +callers. + +## Frontend + +### API and hook + +Extend `settings-api.ts` and `http-agents.ts` with the resolver request/response +contract. Extend `use-dynamic-models.ts` with a reusable model-resolution hook +or resolver returned by the existing hook. It must: + +- key requests by agent, model, mode, and canonical option context; +- share in-flight and cached results across workflow/profile consumers; +- ignore stale responses after a newer model selection; +- replace the option snapshot atomically; +- reconcile draft option maps only after a successful response; and +- keep persisted values readable and retryable when discovery fails. + +Add pure unit coverage for canonical context, reconciliation, stale responses, +cache reuse, refresh, and error recovery. + +### Workflow editor + +Wire `WorkflowSessionConfigRuleCard` to resolve options for the rule's selected +model. Keep the current static model list and rule serialization. On a +successful model-dependent response, render every returned select option and +save only the reconciled map. Preserve existing read-only and existing-rule +failure behavior. + +### Agent profile editor + +Wire `ProfileFormFields` to the same resolver using the profile's current +model. Keep model/mode/command discovery on the existing baseline hook. Ensure +changing a profile model refreshes dependent options, removes only +successfully-invalidated draft keys, and saves arbitrary resolved options. + +All new loading, retry, and failure text must use i18next keys and be added to +the affected locale catalogs. Keep controls within the existing mobile +one-column layout and touch-target conventions. + +## Tests + +Backend unit/integration coverage must include: + +- utility model application returning complete config snapshots; +- config-update notification fallback and bounded timeout; +- shared ACP dialect normalization; +- host utility cache key, TTL, refresh invalidation, and single-flight behavior; +- controller request validation and sanitized errors; and +- resolver HTTP response shape while the baseline GET contract remains stable. + +Frontend unit/component coverage must include: + +- resolver API request/response mapping; +- dynamic option replacement rather than stale merging; +- model-switch reconciliation and preservation on failure; +- latest-request-wins behavior; +- workflow rule rendering and serialization; +- profile form rendering and save behavior; and +- generic unknown option identifiers and choices. + +## E2E Tests and Documentation + +Update the mock ACP provider and existing settings specs so the selected model, +not the static boot snapshot, is what causes the dependent option to appear. +Cover both desktop flows: + +- workflow session override selects a model and saves a dynamic option; +- OpenCode/ACP profile selects a model, saves a dynamic option, reloads, and + displays it. + +Cover the mobile workflow path with touch interaction, one-column layout, and +no document-level horizontal overflow. Keep the existing managed Playwright +runner and project split; do not run the mobile spec through desktop Chromium. + +Update `docs/public/tasks-and-workflows.md` with the provider-neutral behavior, +model-dependent option discovery, and failure/retry expectations if the public +documentation coverage inventory requires the change. Keep the spec and ADR as +the detailed contract. + +## Waves + +Wave 1: + +- [x] [task-01-model-aware-probe](task-01-model-aware-probe.md) + +Wave 2 (depends on the probe contract): + +- [x] [task-02-resolution-cache-api](task-02-resolution-cache-api.md) + +Wave 3 (depends on the HTTP contract): + +- [x] [task-03-resolution-hook](task-03-resolution-hook.md) + +Wave 4 (depends on the hook): + +- [x] [task-04-settings-integration](task-04-settings-integration.md) + +Wave 5 (depends on the settings behavior): + +- [x] [task-05-e2e-docs](task-05-e2e-docs.md) + +The dependency chain is complete. The implementation uses a model-only request +from the current settings consumers, while the backend keeps the optional mode +and option context available for providers that need it later. Save +contributors remain blocked until a selected model has an authoritative +resolution and any stale draft options have been reconciled. Failed discovery +keeps the draft readable and exposes a localized retry action. + +## Verification + +Targeted backend checks: + +```bash +cd apps/backend && go test ./internal/agentctl/server/utility ./internal/agentctl/acpcompat ./internal/agent/hostutility ./internal/agent/settings/controller ./internal/agent/settings/handlers +``` + +Targeted frontend checks: + +```bash +cd apps && pnpm --filter @kandev/web exec vitest run hooks/domains/settings/use-dynamic-models.test.ts components/settings/profile-form-fields.test.tsx components/settings/workflow-session-config-editor.test.tsx components/settings/use-workflow-draft-contributor.test.ts components/settings/model-config-resolution-status.test.tsx components/settings/profile-model-config.test.ts components/model-config-selector.test.tsx --reporter=dot +cd apps/web && pnpm run typecheck +cd apps/web && pnpm run i18n:check +cd apps/web && pnpm run i18n:ratchet +``` + +Managed desktop and mobile E2E checks: + +```bash +cd apps/web && pnpm e2e:run --host --project chromium tests/settings/agent-profile-acp.spec.ts tests/workflow/workflow-settings.spec.ts +cd apps/web && pnpm e2e:run --host --project mobile-chrome tests/workflow/mobile-workflow-settings.spec.ts +``` + +Public docs checks when the public page changes: + +```bash +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +``` + +Verification Results: + +- Backend build passed; focused backend packages: 307 tests passed across five + packages. +- Frontend focused unit tests: 42 tests passed across the seven listed files; + typecheck, ESLint, i18n checks, and i18n ratchet passed. +- Managed E2E passed: 19 Chromium desktop tests and 3 mobile Chromium tests + across profile and workflow settings. The tests also cover startup capability + revalidation, so provider registration order and probe timing are not test + assumptions. +- Public documentation checks were not required because this change updates + the implementation plan and workflow spec, not `docs/public/**`. + +## Review follow-up + +The implementation completed the following review work: + +- [x] Make the current settings resolver model-only. Do not send editable + option values or mode as context from the profile and workflow consumers. +- [x] Remove probe mutations that are applied after the returned snapshot, or + implement and test final-snapshot capture for a future multi-input contract. +- [x] Show localized resolution loading/failure state and provide retry in both + settings surfaces. +- [x] Test controller error sanitization and authentication status mapping. +- [x] Reconcile only after a user model/context change so opening a saved + profile does not create unsaved changes. +- [x] Complete the recommended cache, context-cancellation, conversion-helper, + wrapper, mock-agent, and missing probe/host-utility test cleanup. +- [x] Remove E2E force-click workarounds after the selector no longer remounts + for each option edit, then rerun desktop and mobile E2E. +- [x] Block profile and workflow saves during model-option reconciliation, + require a snapshot after the final ACP option mutation, isolate forced + refresh generations, use typed missing-agent errors, and keep zero-baseline + resolution status visible. +- [x] Mark ACP inference agents as dynamic before their first cache snapshot, + poll pending capability probes with a bounded retry, and make settings E2E + fixtures independent of provider registration order. + +## Risks and open questions + +- Some ACP providers may advertise model changes only through delayed + notifications. The probe needs a bounded wait that is long enough for normal + providers but cannot hold a settings request indefinitely. +- Some providers may require a mode or prior option before returning the final + snapshot. The API accepts those inputs now, but the first UI should only send + values it can prove are current. +- Model resolution can be expensive for large model lists. The TTL, + single-flight cache, and refresh semantics should be measured with the mock + provider before broadening the UI to prefetch. +- If a provider cannot produce a complete post-model snapshot, the UI must + remain safe and understandable without implying that the baseline is + authoritative for the selected model. diff --git a/docs/plans/dynamic-provider-options/task-01-model-aware-probe.md b/docs/plans/dynamic-provider-options/task-01-model-aware-probe.md new file mode 100644 index 0000000000..e5a75798ef --- /dev/null +++ b/docs/plans/dynamic-provider-options/task-01-model-aware-probe.md @@ -0,0 +1,58 @@ +--- +id: dynamic-provider-options-01 +title: Add model-aware capability probe +status: done +wave: 1 +depends_on: [] +plan: docs/plans/dynamic-provider-options/plan.md +spec: docs/specs/agents/dynamic-provider-options.md +--- + +# Add model-aware capability probe + +## Scope + +Extend the sessionless ACP utility probe so it can apply a requested model and +return the provider's complete normalized configuration-option snapshot. Keep +the existing no-context probe behavior unchanged. + +## Acceptance conditions + +1. A model-aware probe applies the requested model through the generic ACP + session-model path and returns the full post-model config-option snapshot. +2. A provider that reports the snapshot through a config-update notification is + handled within a bounded wait; unsupported or incomplete responses are + reported without being presented as authoritative. +3. Utility tests cover response snapshots, notification fallback, model + application failure, and ACP compatibility normalization. + +## Files + +- `apps/backend/internal/agentctl/server/utility/types.go` +- `apps/backend/internal/agentctl/server/utility/acp_executor.go` +- `apps/backend/internal/agentctl/server/utility/model_apply_test.go` +- `apps/backend/internal/agentctl/server/utility/acp_probe_capabilities_test.go` +- `apps/backend/internal/agentctl/server/utility/acp_executor_test.go` +- `apps/backend/internal/agentctl/acpcompat/` (only the shared normalization + helper files that need extraction or reuse) + +## Dependencies and inputs + +- Existing ACP probe/session initialization and `sessionmodel` APIs. +- Existing `ClientCapabilityMeta` and ACP dialect normalization. +- The dynamic-provider-options spec and ADR. + +## Output contract + +Expose a utility-level model-resolution request/result that the host utility +manager can call without knowing provider-specific option identifiers. The +result distinguishes a complete empty snapshot from an error or incomplete +provider response. + +## Checks + +```bash +cd apps/backend && go test ./internal/agentctl/server/utility ./internal/agentctl/acpcompat +``` + +Results: `cd apps/backend && go test ./internal/agentctl/server/utility -count=1` — passed. diff --git a/docs/plans/dynamic-provider-options/task-02-resolution-cache-api.md b/docs/plans/dynamic-provider-options/task-02-resolution-cache-api.md new file mode 100644 index 0000000000..7f688c6c98 --- /dev/null +++ b/docs/plans/dynamic-provider-options/task-02-resolution-cache-api.md @@ -0,0 +1,58 @@ +--- +id: dynamic-provider-options-02 +title: Add resolution cache API +status: done +wave: 2 +depends_on: + - dynamic-provider-options-01 +plan: docs/plans/dynamic-provider-options/plan.md +spec: docs/specs/agents/dynamic-provider-options.md +--- + +# Add resolution cache API + +## Scope + +Expose the model-aware probe through host utility resolution, a bounded +runtime-only cache, and the authenticated agent-settings HTTP endpoint. + +## Acceptance conditions + +1. Identical agent/context requests share cached or in-flight work, refresh + bypasses/invalidate the entry, and the cache cannot persist data to SQLite. +2. `POST /api/v1/agent-models/:agentName/resolve` validates the request, + returns a complete normalized snapshot on success, and sanitizes failures. +3. Existing `GET /api/v1/agent-models/:agentName` behavior remains compatible. + +## Files + +- `apps/backend/internal/agent/hostutility/types.go` +- `apps/backend/internal/agent/hostutility/cache.go` +- `apps/backend/internal/agent/hostutility/manager.go` +- `apps/backend/internal/agent/hostutility/public.go` +- `apps/backend/internal/agent/hostutility/manager_test.go` +- `apps/backend/internal/agent/settings/dto/dto.go` +- `apps/backend/internal/agent/settings/controller/agent_config.go` +- `apps/backend/internal/agent/settings/controller/agent_config_test.go` +- `apps/backend/internal/agent/settings/handlers/handlers.go` +- `apps/backend/internal/agent/settings/handlers/handlers_test.go` + +## Dependencies and inputs + +- `dynamic-provider-options-01` utility request/result. +- Existing host utility availability, refresh, authorization, and settings + route conventions. + +## Output contract + +Provide a typed controller method and HTTP route matching the spec's request +context and response shape. Cache key construction must be deterministic for +maps, include future context fields, and have explicit TTL/invalidation tests. + +## Checks + +```bash +cd apps/backend && go test ./internal/agent/hostutility ./internal/agent/settings/controller ./internal/agent/settings/handlers +``` + +Results: `cd apps/backend && go test ./internal/agentctl/server/utility ./internal/agent/hostutility ./internal/agent/settings/controller ./internal/agent/settings/handlers -count=1` — passed. diff --git a/docs/plans/dynamic-provider-options/task-03-resolution-hook.md b/docs/plans/dynamic-provider-options/task-03-resolution-hook.md new file mode 100644 index 0000000000..bc861c6ca8 --- /dev/null +++ b/docs/plans/dynamic-provider-options/task-03-resolution-hook.md @@ -0,0 +1,58 @@ +--- +id: dynamic-provider-options-03 +title: Add model resolution hook +status: done +wave: 3 +depends_on: + - dynamic-provider-options-02 +plan: docs/plans/dynamic-provider-options/plan.md +spec: docs/specs/agents/dynamic-provider-options.md +--- + +# Add model resolution hook + +## Scope + +Add the frontend API/types and a reusable model-aware resolution hook that +shares requests, replaces option snapshots atomically, reconciles drafts only +after success, and ignores stale responses. + +## Acceptance conditions + +1. The hook sends the selected model/context to the resolver endpoint and + exposes loading, resolved options, sanitized error, retry, and refresh + state. +2. A newer model selection prevents an older response from changing the + current options; a successful snapshot never merges stale option IDs. +3. Resolver failure preserves the existing draft/persisted values and does not + create unverified option controls. + +## Files + +- `apps/web/lib/api/domains/settings-api.ts` +- `apps/web/lib/types/http-agents.ts` +- `apps/web/hooks/domains/settings/use-dynamic-models.ts` +- `apps/web/hooks/domains/settings/use-dynamic-models.test.ts` +- `apps/web/lib/agent-config/` (only if a pure reconciliation helper is + extracted; add its focused test beside it) + +## Dependencies and inputs + +- `dynamic-provider-options-02` HTTP contract. +- Existing `ModelConfig`, `ConfigOptionEntry`, `fetchDynamicModels`, and + settings API error conventions. + +## Output contract + +Return a provider-neutral hook/helper consumable by both profile and workflow +settings. It must accept a stable agent/model/context identity and make the +complete option snapshot plus reconciliation behavior available without +duplicating fetch logic in either component. + +## Checks + +```bash +cd apps && pnpm --filter @kandev/web exec vitest run hooks/domains/settings/use-dynamic-models.test.ts --reporter=dot +``` + +Results: `cd apps && pnpm --filter @kandev/web exec vitest run hooks/domains/settings/use-dynamic-models.test.ts components/settings/profile-model-config.test.ts` — passed. diff --git a/docs/plans/dynamic-provider-options/task-04-settings-integration.md b/docs/plans/dynamic-provider-options/task-04-settings-integration.md new file mode 100644 index 0000000000..45832300b7 --- /dev/null +++ b/docs/plans/dynamic-provider-options/task-04-settings-integration.md @@ -0,0 +1,63 @@ +--- +id: dynamic-provider-options-04 +title: Integrate settings selectors +status: done +wave: 4 +depends_on: + - dynamic-provider-options-03 +plan: docs/plans/dynamic-provider-options/plan.md +spec: docs/specs/agents/dynamic-provider-options.md +--- + +# Integrate settings selectors + +## Scope + +Wire the shared model-resolution behavior into workflow session rules and ACP +agent profile settings while preserving existing persistence, read-only, and +generic selector behavior. + +## Acceptance conditions + +1. Workflow rules display and save arbitrary provider-returned options after a + model change, including replacement/removal of options that the new model + does not advertise. +2. ACP agent profile settings use the same resolver and save/reload the same + dynamic options without provider-specific branches. +3. Loading, retry, and error states are localized; mobile layouts retain + touch-sized controls and no horizontal overflow. + +## Files + +- `apps/web/components/settings/workflow-session-config-rule-card.tsx` +- `apps/web/components/settings/workflow-session-config-shared.ts` +- `apps/web/components/settings/workflow-session-config-editor.test.tsx` +- `apps/web/components/settings/profile-form-fields.tsx` +- `apps/web/components/settings/profile-form-fields.test.tsx` +- `apps/web/components/model-config-selector.tsx` (only if an optional + resolver status affordance is required) +- `apps/web/public/locales/` affected locale catalogs + +## Dependencies and inputs + +- `dynamic-provider-options-03` hook and reconciliation contract. +- Existing workflow rule serializer, profile save coordinator, and + `ModelConfigSelector` select-option behavior. +- Mobile-parity requirements for the existing settings layouts. + +## Output contract + +Both settings surfaces consume the same provider-neutral resolved snapshot. +Persisted config maps are not mutated by a failed discovery request; successful +model changes save only values valid for the returned snapshot. + +## Checks + +```bash +cd apps && pnpm --filter @kandev/web exec vitest run components/settings/profile-form-fields.test.tsx components/settings/workflow-session-config-editor.test.tsx components/model-config-selector.test.tsx --reporter=dot +cd apps/web && pnpm run typecheck +cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet +``` + +Results: profile/workflow component tests, frontend typecheck, ESLint, i18n +check, and i18n ratchet — passed. diff --git a/docs/plans/dynamic-provider-options/task-05-e2e-docs.md b/docs/plans/dynamic-provider-options/task-05-e2e-docs.md new file mode 100644 index 0000000000..17e5175648 --- /dev/null +++ b/docs/plans/dynamic-provider-options/task-05-e2e-docs.md @@ -0,0 +1,62 @@ +--- +id: dynamic-provider-options-05 +title: Verify dynamic option flows +status: done +wave: 5 +depends_on: + - dynamic-provider-options-04 +plan: docs/plans/dynamic-provider-options/plan.md +spec: docs/specs/agents/dynamic-provider-options.md +--- + +# Verify dynamic option flows + +## Scope + +Make the mock ACP provider expose model-dependent options, update desktop and +mobile Playwright coverage, and keep public workflow documentation aligned. + +## Acceptance conditions + +1. The mock provider's dependent option appears only after the selected model is + resolved, and desktop workflow/profile tests save and reload that option. +2. Mobile workflow coverage configures the resolved option through touch, + retains the existing one-column layout, and reports no horizontal overflow. +3. Public documentation and its validators describe the dynamic capability + behavior without adding provider-specific UI assumptions. + +## Files + +- `apps/backend/cmd/mock-agent/main.go` +- `apps/web/e2e/tests/settings/agent-profile-acp.spec.ts` +- `apps/web/e2e/tests/workflow/workflow-settings.spec.ts` +- `apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts` +- `apps/web/e2e/pages/workflow-settings-page.ts` (only if the page object needs + a dynamic-option helper) +- `docs/public/tasks-and-workflows.md` +- `docs/public/agents-and-profiles.md` + +## Dependencies and inputs + +- `dynamic-provider-options-04` settings integration. +- Existing mock-agent fixture contract and managed Playwright projects. +- Public docs validator and documentation coverage inventory. + +## Output contract + +End-to-end evidence must exercise a model-dependent provider response rather +than a static capability option. Keep the desktop and mobile specs in their +existing project paths. + +## Checks + +```bash +cd apps/web && pnpm e2e:run --project chromium tests/settings/agent-profile-acp.spec.ts tests/workflow/workflow-settings.spec.ts +cd apps/web && pnpm e2e:run --project mobile-chrome tests/workflow/mobile-workflow-settings.spec.ts +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +``` + +Results: managed desktop and mobile profile/workflow model-option flows — +passed. `node --test scripts/validate-public-docs.test.mjs` and +`node scripts/validate-public-docs.mjs` — passed. diff --git a/docs/public/agents-and-profiles.md b/docs/public/agents-and-profiles.md index e7c7202cfe..2fd1f4db3a 100644 --- a/docs/public/agents-and-profiles.md +++ b/docs/public/agents-and-profiles.md @@ -80,6 +80,12 @@ Select an agent, create a profile, then open **Settings > Agents > _Agent_ > _Pr Model, mode, command, and configuration choices are probed from the locally installed CLI and cached. The managed **Update agent** action refreshes them automatically; after other CLI changes, refresh the profile manually. Probe status can report **auth required**, **not installed**, **not configured**, or **failed**; a saved model name does not prove that the current provider account can use it. +Configuration options are resolved for the model selected in the profile. An +agent can therefore show a different option set for each model. If a model +change makes a saved option value unsupported, Kandev removes that value after +a successful resolution; a failed resolution keeps the draft unchanged so you +can retry it. + ### Monitor capability and subscription status Use the profile refresh control after installing, authenticating, or upgrading an agent. A manual refresh updates both the advertised models, modes, and commands and the visible capability status, so an old failure banner does not remain authoritative after the local CLI recovers. diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md index 2541d480fa..228e5a52db 100644 --- a/docs/public/tasks-and-workflows.md +++ b/docs/public/tasks-and-workflows.md @@ -315,6 +315,12 @@ data later becomes unavailable. The editor uses the same model and ACP option picker as the chat input, so provider-specific models and options are selected from the agent's advertised capabilities. +The model and option list is resolved for the selected model. Providers can +therefore expose different options for different models, and the list can +change after a model selection. Kandev removes saved option values only after +a successful provider response; if discovery fails, the current draft remains +available and can be retried. + Each rule can **Set** a model and any selected options, **Keep** the settings already active, or **Restore original** to reapply the immutable model and option values captured when the original session finished initializing, after diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 9fb7086a5c..215bfaccf4 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -101,6 +101,7 @@ Roles, governance gates, and granular permissions that apply across human users |---|---| | [runtime-updates](agents/runtime-updates.md) | approved | | [profile-disable](agents/profile-disable.md) | draft | +| [dynamic-provider-options](agents/dynamic-provider-options.md) | shipped | | [roles](agents/roles.md) | shipped | | [governance](agents/governance.md) | shipped | | [granular-permissions](agents/granular-permissions.md) | draft | diff --git a/docs/specs/agents/dynamic-provider-options.md b/docs/specs/agents/dynamic-provider-options.md new file mode 100644 index 0000000000..8faa5a11ad --- /dev/null +++ b/docs/specs/agents/dynamic-provider-options.md @@ -0,0 +1,258 @@ +--- +status: shipped +created: 2026-08-07 +owner: kandev +--- + +# Dynamic Provider Model Options + +## Why + +Provider capabilities are not always properties of an agent family alone. +Some ACP providers change the available configuration options when a model is +selected. OpenCode currently demonstrates this split: the task chat selector +receives model-dependent options such as reasoning effort from the live +session, while the workflow session override editor and the OpenCode agent +profile editor only have the agent-level capability snapshot. The settings +surfaces therefore show the model but omit valid dependent options. + +Kandev needs one provider-neutral contract that works for OpenCode and future +providers without maintaining a list of provider-specific option names. + +## What + +Kandev resolves model-dependent ACP configuration options on demand for the +settings surfaces that do not have a live task session: + +- The existing agent-level capability response remains the baseline for agent + availability, model lists, modes, commands, and options available before a + model is selected. +- After a user selects a model in the workflow session override editor or an + agent profile editor, Kandev requests a model-aware capability snapshot from + the provider through a sessionless ACP probe. +- The probe applies the requested model using generic ACP session-model logic, + captures the complete post-model `config_options` response or update, and + applies the existing ACP compatibility normalization. +- The response is a complete snapshot for that resolution context. It may + contain zero, one, or many arbitrary ACP `select` options and their choices. + The UI does not recognize provider-specific option identifiers. +- The settings pages share the resolver and its short-lived in-memory cache. + The live chat selector remains session-authoritative and is not changed by + this feature. +- The initial UI sends the model as the dependency context. The contract also + accepts mode and currently selected options so providers with additional + dependencies can be supported without changing the endpoint shape. + +## Scenarios + +### Model adds an option in workflow settings + +Given an OpenCode agent family whose default model advertises only model and +mode, when a workflow author selects a model that supports reasoning effort, +then the workflow rule editor loads and displays the provider-supplied +reasoning-effort select and its choices. The option appears without an +OpenCode-specific frontend mapping. + +### Model adds an option in an agent profile + +Given the OpenCode agent profile settings page, when the profile model changes +to a model with additional selectable options, then the profile editor uses +the same resolved snapshot as workflow settings and allows those options to be +selected and saved. + +### Model removes an option + +Given a draft with a selected option for the current model, when the author +selects a model whose authoritative snapshot does not advertise that option, +then the editor removes that option from the editable configuration before a +subsequent save. The model remains selected and the provider's current options +are shown. + +### Provider adds arbitrary future options + +Given a provider returns a new ACP `select` option with an identifier and +choices unknown to Kandev, when the model is resolved, then the shared selector +renders that option using its generic label, value, and choice data. No code +change is required for the new identifier. + +### Resolution fails + +Given a model selection whose capability probe times out, is unavailable, or +returns no complete post-model option snapshot, when the settings page receives +the failure, then it keeps already persisted and currently selected values, +does not offer unverified new options, shows a retryable discovery error, and +allows a model-only save when the rest of the form is valid. + +### Responses arrive out of order + +Given an author selects model A and then quickly selects model B, when the +resolution for A completes after the resolution for B, then the editor ignores +A's result and only displays options for B. + +### Shared resolution work + +Given workflow and profile settings request the same agent, model, and context +within the cache TTL, when both pages resolve capabilities, then Kandev +deduplicates the backend work and returns the same snapshot. A refresh request +or baseline capability refresh invalidates the cached result. + +### Mobile settings + +Given a phone viewport, when the author selects a model and configures the +resolved options in either settings flow, then controls remain in a one-column +touch-friendly layout, use the existing settings scroll owner, and do not +create document-level horizontal overflow. + +## Data model + +No durable database schema is added. + +The existing profile and workflow data continue to store the selected model +and `config_options` map. A successful resolution reconciles the editable map +against the returned option identifiers before save. A failed resolution does +not rewrite persisted profile or workflow data. + +The runtime-only resolution context is: + +```json +{ + "agent_name": "opencode-acp", + "model": "opencode-go/glm-5.2", + "mode": "build", + "config_options": { + "reasoning_effort": "high" + } +} +``` + +The initial frontend sends only `model`; `mode` and `config_options` are +optional inputs for providers whose capabilities depend on more than the +model. Cache keys canonicalize map ordering and include every supplied +context field. + +## API surface + +The existing baseline endpoint remains unchanged: + +`GET /api/v1/agent-models/:agentName` + +Add a model-aware resolver endpoint: + +`POST /api/v1/agent-models/:agentName/resolve` + +Request body: + +```json +{ + "model": "opencode-go/glm-5.2", + "mode": "build", + "config_options": { + "reasoning_effort": "high" + }, + "refresh": false +} +``` + +`model` is required for the first version. `mode`, `config_options`, and +`refresh` are optional. + +Successful response: + +```json +{ + "agent_name": "opencode-acp", + "model": "opencode-go/glm-5.2", + "status": "ok", + "config_options": [ + { + "id": "reasoning_effort", + "name": "Reasoning effort", + "type": "select", + "current_value": "high", + "options": ["low", "medium", "high", "max"] + } + ], + "error": null +} +``` + +The exact wire field names follow the existing `ConfigOptionEntry` DTO shape; +the important contract is that `config_options` is a complete normalized +snapshot, not a delta. Errors use the existing agent-settings error/status +conventions and never expose provider secrets or raw process output. + +## State machine + +Each settings surface follows this state machine for the selected resolution +context: + +| State | Event | Result | +| --- | --- | --- | +| baseline | model selected | Show baseline options and start one resolver request. | +| resolving | matching response | Replace options with the complete snapshot and reconcile the draft. | +| resolving | stale response | Ignore it and keep the newer model's state. | +| resolving | timeout/error | Keep existing values, show retryable error, and do not add controls. | +| resolved | same context requested | Reuse the runtime cache. | +| resolved | refresh or baseline invalidation | Request a fresh snapshot. | + +An option snapshot is replaced atomically. The frontend must not merge an old +model's choices with a new model's choices. + +## Permissions + +The resolver uses the same authenticated agent-settings route and workspace +authorization as the existing dynamic-model endpoint. It does not create a +task session, change an agent profile, or mutate provider configuration. + +## Failure modes + +- If the agent is unavailable, the API returns the existing unavailable/error + status and the UI keeps the baseline or last-known values. +- If the probe cannot apply the requested model, no partial option snapshot is + returned as authoritative. +- If the provider applies the model but supplies neither a complete response + nor a bounded configuration-update notification, the result is degraded and + the UI does not guess at model-specific options. +- Probe execution has a bounded timeout. Concurrent identical requests share + one in-flight resolution; distinct contexts do not block indefinitely behind + one another. +- A stale browser response cannot overwrite a newer model selection. +- If a previously saved option is no longer advertised after a successful + resolution, it is removed from the next editable save for that model. If + discovery fails, it remains in persisted data and is not silently deleted. +- Provider errors are sanitized before reaching the HTTP response or UI. + +## Persistence guarantees + +- No resolved capability snapshot is persisted in SQLite or workflow JSON. +- Existing profile and workflow `config_options` persistence remains the source + of truth for user selections. +- Runtime cache entries may disappear on process restart without affecting + saved settings. +- A successful settings save stores the selected model and the reconciled + option map. A failed discovery never rewrites saved data by itself. + +## Out of scope + +- Hard-coding OpenCode, Grok, or any provider's option names in React code. +- Changing the live task chat selector's session event protocol. +- Resolving every model eagerly during initial agent discovery. +- Persisting provider capability snapshots or introducing a database cache. +- Supporting non-ACP provider-specific configuration controls through this + contract. +- Changing the semantics of workflow runtime application or profile loading. + +## Success criteria + +- The workflow session override editor displays model-dependent ACP select + options returned by a provider, including OpenCode reasoning effort. +- The OpenCode agent profile settings page displays and saves the same resolved + options. +- A newly added arbitrary select option renders without provider-specific UI + code. +- A model change never displays options from a previous model after an + out-of-order response. +- Resolver failures preserve saved data and provide a retryable, localized + status. +- Desktop and mobile E2E coverage verifies workflow and profile behavior, with + touch sizing and overflow assertions for the mobile workflow editor. diff --git a/docs/specs/workflow-session-settings/spec.md b/docs/specs/workflow-session-settings/spec.md index 1902418ea5..67ce43650b 100644 --- a/docs/specs/workflow-session-settings/spec.md +++ b/docs/specs/workflow-session-settings/spec.md @@ -116,6 +116,8 @@ The backend validates the action on workflow-step create/update and import. Inva Existing agent capability responses provide the editor's family names, models, and ACP select options. Existing task-session model/configuration mechanisms apply and persist runtime changes. +When an ACP provider's options depend on the selected model, the editor uses the model-aware capability contract in [Dynamic Provider Model Options](../agents/dynamic-provider-options.md) to replace the baseline option snapshot before saving a rule. + ## State machine For each original agent family on each possible workflow path, the editor and runtime use these effective states: @@ -144,7 +146,7 @@ The same users who can edit a workspace workflow can create, change, or remove c - If the session is not running but can be launched or resumed, Kandev records the requested overrides first so initialization applies them before the next prompt. - If `restore_original` has no trustworthy original effective snapshot, Kandev changes nothing, warns that restoration is unavailable for that legacy or incomplete session, and continues the step. - If an option captured in the original snapshot is no longer advertised, restoration skips that option, restores the remaining fields, and reports the skipped option in the warning. -- Capability discovery failures keep the conditional editor readable, show the existing agent capability error, and prevent saving a new `set` rule whose values cannot be selected. Existing persisted rules remain visible and removable. +- Capability discovery keeps the conditional editor readable and shows a localized, retryable status. While discovery is pending, coordinated save is blocked. After a failed discovery, the author may save the selected model without new or unverified option values; existing persisted rules remain visible, and failed discovery does not remove their saved option values. ## Persistence guarantees